fix(gguf/gqa): correct Qwen3.5/3.8 hybrid export (MTP blocks, M-RoPE interleave, partial-RoPE dim) - #522
fix(gguf/gqa): correct Qwen3.5/3.8 hybrid export (MTP blocks, M-RoPE interleave, partial-RoPE dim)#522justinchuby wants to merge 5 commits into
Conversation
…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>
There was a problem hiding this comment.
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→ decodernum_hidden_layersby subtractingnextn_predict_layers(MTP/“nextn” blocks) during config mapping. - Stop deriving
rope_interleavefromrope.dimension_sections(M-RoPE sections); keep the flat interleave flag only for truly adjacent-pair (GPT-J style) layouts (currentlydeepseek4). - Propagate
rotary_embedding_dimfromRotaryEmbeddinginto fusedGroupQueryAttentionnodes 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>
Update: Gated-DeltaNet weight-transform fixes → coherent Qwen3.8-27B decodePushed
Result (unsloth/Qwen3.8-27B-Q4_0 → int4 CUDA ONNX, greedy):
Adds regression tests asserting |
| 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": |
|
@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>
|
|
Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com>
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 /
nextnblock-count (_config_mapping.py)GGUF
block_countincludes the trailing Multi-Token-Prediction ("nextn") block, sonum_hidden_layerswas 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_weightssave invariant fails. Fix: subtract<arch>.nextn_predict_layers.2.
rope_interleavefrom M-RoPE sections (_config_mapping.py)rope.dimension_sectionsencodes Qwen-VL M-RoPE section sizes (e.g.[11,11,10,0]); it does not select GPT-J adjacent-pair rotation. Derivingrope_interleavefrom section presence setrotary_interleaved=1on the exported GQA/RotaryEmbedding, corrupting RoPE (Qwen uses split-half / NEOXrotate_half). Onlydeepseek4genuinely needs the flat flag.3.
rotary_embedding_dimdropped in GQA fusion (_group_query_attention.py)RotaryAttentionToGQAfuses external RotaryEmbedding + Attention into aGroupQueryAttentionwithdo_rotary=1, but never copied the sourcerotary_embedding_dim. Partial-RoPE models (Qwen3.5/3.8 rotate only 64 of 256 head elements) defaulted the fused GQA to the fullhead_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
rotary_interleaved=0androtary_embedding_dim=64(were1/ absent before), and decoder layer count is correct (65→64, MTP block excluded).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.py→ 53 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_projhandling (the directattn_qkv→in_proj_qkv/ssm_alpha,ssm_beta→in_proj_a,in_proj_bmap is shape-correct but the fused-projection ordering vs the HFin_proj_qkvz/in_proj_basplit path is unverified). The only coherence-validated DeltaNet path today is the safetensors builder. Tracking separately; these three fixes are correct and independently testable.