Skip to content

fix(gguf/gqa): correct Qwen3.5/3.8 hybrid export (MTP blocks, M-RoPE interleave, partial-RoPE dim) - #522

Open
justinchuby wants to merge 5 commits into
mainfrom
sapper/qwen38-27b
Open

fix(gguf/gqa): correct Qwen3.5/3.8 hybrid export (MTP blocks, M-RoPE interleave, partial-RoPE dim)#522
justinchuby wants to merge 5 commits into
mainfrom
sapper/qwen38-27b

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Summary

Three GGUF→ONNX export bugs surfaced while converting the hybrid Qwen3.8-27B (Gated DeltaNet + GQA, partial RoPE 64/256) 4-bit GGUF to an int4 model. Each produced a silently-wrong model (garbage tokens) rather than a hard failure. All three also affect the sibling Qwen3.5 family.

1. MTP / nextn block-count (_config_mapping.py)

GGUF block_count includes the trailing Multi-Token-Prediction ("nextn") block, so num_hidden_layers was one too high. The base decode model doesn't build the MTP head and its weights are skipped during mapping, leaving an extra decoder layer whose linear-attn/GQA initializers have no backing GGUF weights → _check_weights save invariant fails. Fix: subtract <arch>.nextn_predict_layers.

2. rope_interleave from M-RoPE sections (_config_mapping.py)

rope.dimension_sections encodes Qwen-VL M-RoPE section sizes (e.g. [11,11,10,0]); it does not select GPT-J adjacent-pair rotation. Deriving rope_interleave from section presence set rotary_interleaved=1 on the exported GQA/RotaryEmbedding, corrupting RoPE (Qwen uses split-half / NEOX rotate_half). Only deepseek4 genuinely needs the flat flag.

3. rotary_embedding_dim dropped in GQA fusion (_group_query_attention.py)

RotaryAttentionToGQA fuses external RotaryEmbedding + Attention into a GroupQueryAttention with do_rotary=1, but never copied the source rotary_embedding_dim. Partial-RoPE models (Qwen3.5/3.8 rotate only 64 of 256 head elements) defaulted the fused GQA to the full head_dim, reading past the partial cos/sin cache → garbage from every full-attention layer. Fix: read it off the q RotaryEmbedding node and forward it when nonzero.

Verification

  • Verified on Qwen3.8-27B-Q4_0 GGUF export: exported GQA nodes now carry rotary_interleaved=0 and rotary_embedding_dim=64 (were 1 / absent before), and decoder layer count is correct (65→64, MTP block excluded).
  • Added regression tests: TestQwen35MtpBlockExclusion, TestQwen35RopeInterleave, test_partial_rotary_embedding_dim_propagated, test_full_rotary_omits_rotary_embedding_dim.
  • pytest src/mobius/integrations/gguf/_config_mapping_test.py src/mobius/rewrite_rules/_group_query_attention_test.py53 passed.

Note (out of scope)

Even with all three fixes the 27B GGUF int4 model still decodes incoherently, which points to a separate remaining gap in the GGUF Gated-DeltaNet in_proj handling (the direct attn_qkv→in_proj_qkv / ssm_alpha,ssm_beta→in_proj_a,in_proj_b map is shape-correct but the fused-projection ordering vs the HF in_proj_qkvz/in_proj_ba split path is unverified). The only coherence-validated DeltaNet path today is the safetensors builder. Tracking separately; these three fixes are correct and independently testable.

justinchuby and others added 2 commits August 20, 2026 04:57
…terleave)

Two independent GGUF→config bugs surfaced while exporting the hybrid
Qwen3.8-27B (Gated DeltaNet + GQA) model:

1. MTP/nextn block-count. GGUF's `block_count` includes the trailing
   Multi-Token-Prediction ("nextn") block(s), so `num_hidden_layers`
   was one too high. The base decode model does not build the MTP head
   and its weights are skipped during tensor mapping, leaving an extra
   decoder layer whose linear-attention/GQA initializers have no backing
   GGUF weights and tripping the `_check_weights` save invariant. Subtract
   `<arch>.nextn_predict_layers` from the decoder layer count.

2. rope_interleave from M-RoPE sections. `rope.dimension_sections`
   encodes Qwen-VL M-RoPE *section* sizes (e.g. [11,11,10,0]); it does
   NOT select GPT-J adjacent-pair rotation. Deriving `rope_interleave`
   from section presence set `rotary_interleaved=1` on the exported
   GQA/RotaryEmbedding, corrupting RoPE (Qwen uses split-half/NEOX).
   Only `deepseek4` genuinely needs the flat interleave flag.

Adds regression tests for both.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… (partial RoPE)

The RotaryAttentionToGQA rewrite fuses an external RotaryEmbedding +
Attention into a GroupQueryAttention with do_rotary=1, but never copied
the source RotaryEmbedding's `rotary_embedding_dim`. Partial-RoPE models
(Qwen3.5/3.8 rotate only the first 64 of 256 head elements) therefore
defaulted the fused GQA to the full head_dim, reading past the partial
cos/sin cache and emitting garbage from every full-attention layer.

Read `rotary_embedding_dim` off the q RotaryEmbedding node and forward it
to the GQA attributes when nonzero (full-RoPE models omit it and keep the
correct head_dim default). Adds regression tests for the partial and full
cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby requested review from a team and a lite review from Copilot August 20, 2026 04:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes three GGUF→ONNX export correctness bugs affecting hybrid Qwen3.5/3.8 (and related) models by ensuring decoder layer counts, RoPE interleave semantics, and partial-RoPE dimensions are preserved through config mapping and GQA fusion.

Changes:

  • Adjust GGUF block_count → decoder num_hidden_layers by subtracting nextn_predict_layers (MTP/“nextn” blocks) during config mapping.
  • Stop deriving rope_interleave from rope.dimension_sections (M-RoPE sections); keep the flat interleave flag only for truly adjacent-pair (GPT-J style) layouts (currently deepseek4).
  • Propagate rotary_embedding_dim from RotaryEmbedding into fused GroupQueryAttention nodes when nonzero (partial RoPE), with regression tests covering both partial and full RoPE behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/mobius/rewrite_rules/_group_query_attention.py Preserve partial-RoPE rotary_embedding_dim when fusing RotaryEmbedding+Attention into GroupQueryAttention.
src/mobius/rewrite_rules/_group_query_attention_test.py Adds regression coverage for partial-RoPE propagation and full-RoPE omission of rotary_embedding_dim.
src/mobius/integrations/gguf/_config_mapping.py Fixes Qwen “nextn” block layer-count handling and corrects rope_interleave derivation logic.
src/mobius/integrations/gguf/_config_mapping_test.py Adds regression tests for Qwen3.5/3.8 MTP block exclusion and M-RoPE section handling not forcing interleave.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…oherent decode

The GGUF import path for Qwen3.5/3.8 hybrid models (arch `qwen35`/`qwen35moe`)
produced garbage / degenerate (repeating) decode because three llama.cpp
converter transforms of the Gated-DeltaNet weights were not undone when mapping
into mobius's `GatedDeltaNet`, whose forward expects raw HF-style parameters.

All three are applied in `_normalize_gguf_weights`, arch-scoped and derived from
config (no hardcoded head counts), so future DeltaNet variants keep working:

1. A_log double-exp: the converter stores the SSM decay pre-transformed as
   `ssm_a = -exp(A_log)`, but `GatedDeltaNet` recomputes `-exp(A_log)` at
   runtime, squashing every head's decay to ~-1. Recover the raw parameter via
   `A_log = log(-ssm_a)` (scoped to `linear_attn.A_log`).

2. Zero-centered RMSNorm +1: the converter bakes `+1` into every `*norm.weight`
   except `linear_attn.norm.weight`; mobius adds it back via `OffsetRMSNorm`,
   so subtract 1 to avoid double-counting (scoped to `_OFFSET_NORM_GGUF_ARCHS`).

3. Gated-DeltaNet V-head tiling: for grouped linear attention
   (`num_value_heads != num_key_heads`) the converter reorders every V-indexed
   `linear_attn` tensor from HF grouped order into ggml tiled order. mobius
   consumes grouped order, so `_reorder_deltanet_v_heads` undoes the tiling for
   `in_proj_qkv` (V rows), `in_proj_z`, `in_proj_a/b`, `A_log`, `dt_bias`,
   `conv1d` (V channels) and `out_proj` (quantized K-block columns). Handles
   MatMulNBits triplets (weight/scales/zero_points) losslessly.

With all three, greedy decode of unsloth/Qwen3.8-27B-Q4_0 → int4 CUDA ONNX is
coherent: "The capital of France is" → "Paris. The capital of Germany is
Berlin. ...".

Adds regression tests asserting `_reorder_deltanet_v_heads` is the exact inverse
of the converter's `_reorder_v_heads`, plus A_log / norm-offset unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Update: Gated-DeltaNet weight-transform fixes → coherent Qwen3.8-27B decode

Pushed b541508, which fixes the remaining GGUF Gated-DeltaNet incoherence for qwen35/qwen35moe (Qwen3.5 / Qwen3.8 hybrid linear-attention). Three converter transforms were not being undone when importing into mobius's GatedDeltaNet (whose forward expects raw HF-style params):

  1. A_log double-exp — GGUF stores ssm_a = -exp(A_log); GatedDeltaNet re-applies -exp(A_log), collapsing every head's decay to ≈-1. Recover via A_log = log(-ssm_a).
  2. Zero-centered RMSNorm +1 — converter bakes +1 into all *norm.weight except linear_attn.norm.weight; OffsetRMSNorm re-adds it → double count. Subtract 1 (arch-scoped).
  3. V-head grouped→tiled reorder — for grouped linear attention (num_value_heads != num_key_heads) the converter tiles every V-indexed linear_attn tensor; mobius consumes grouped order. _reorder_deltanet_v_heads undoes it for in_proj_qkv(V rows), in_proj_z, in_proj_a/b, A_log, dt_bias, conv1d(V channels), out_proj(quantized K-block columns), handling MatMulNBits triplets losslessly. Derived from config — no hardcoded 16/48/128.

Result (unsloth/Qwen3.8-27B-Q4_0 → int4 CUDA ONNX, greedy):

  • The capital of France isParis. The capital of Germany is Berlin. The capital of Italy is Rome. ...
  • Once upon a time, in a land far away, there was a group of young adventurers ...
  • The largest planet in the solar system isJupiter, which has a diameter of 142,800 km. ...

Adds regression tests asserting _reorder_deltanet_v_heads is the exact inverse of the converter's _reorder_v_heads (round-trip on float row tensors + quantized out_proj block/zero-point columns), plus A_log / norm-offset unit tests. _builder_test.py: 55 passed.

def _index_dim0(t: "torch.Tensor", idx: "torch.Tensor") -> "torch.Tensor":
return t.index_select(0, idx)

def _index_dim1(t: "torch.Tensor", idx: "torch.Tensor") -> "torch.Tensor":
@justinchuby

Copy link
Copy Markdown
Member Author

@copilot update from main.

Signed-off-by: GitHub <noreply@github.com>

# Conflicts:
#	src/mobius/integrations/gguf/_config_mapping_test.py
#	src/mobius/rewrite_rules/_group_query_attention.py
#	src/mobius/rewrite_rules/_group_query_attention_test.py

Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ justinchuby
❌ Copilot
You have signed the CLA already but the status is still pending? Let us recheck it.

Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants