14 KiB
qwen35moe — Forward-Pass Design (Pantheon port)
Implementation blueprint for src/models/qwen35moe.{h,c}. Hybrid Gated-Delta-Net SSM + sparse full-attention + shared-expert MoE (Qwen3-Next lineage). Synthesized from llama.cpp qwen35moe.cpp + delta-net-base.cpp (math reference) and Pantheon rwkvx.c (structural template).
Structure clones rwkvx.c; loading follows the GGUF/llama path (pn_qweight, gguf.c), NOT rwkvx's safetensors path. Hybrid of both.
Config (mini; Pro is the same arch, bigger)
| dim | value | source key |
|---|---|---|
| n_layer | 40 | block_count |
| full_attention_interval | 4 → attn when (i+1)%4==0 (layers 3,7,…,39 = 10 attn; rest = 30 SSM) |
full_attention_interval |
| n_embd | 2048 | embedding_length |
| n_head / n_head_kv | 16 / 2 (GQA) | attention.head_count[_kv] |
| head_dim | 256 | attention.key_length/value_length |
| rope_dim | 64 (partial — only first 64 of 256 rotated) | rope.dimension_count |
| n_expert / used | 256 / 8 (+1 shared) | expert_count/expert_used_count |
| expert FF / shared FF | 512 / 512 | expert_feed_forward_length/…shared… |
| SSM: state (S) | 128 | ssm.state_size |
| SSM n_group (K-heads) | 16 | ssm.group_count |
| SSM dt_rank (V-heads) | 32 | ssm.time_step_rank |
| SSM d_inner | 4096 | ssm.inner_size |
| SSM conv kernel | 4 | ssm.conv_kernel |
| conv_dim | key·2+value = 2048·2+4096 = 8192 | derived |
Block scaffold — pre-norm sandwich (every layer, both types)
x → attn_norm → [SSM | full-attn block] → +x → post_attention_norm → shared-MoE FFN → +(post-attn x)
Final output_norm once after the stack → LM head. All norms RMS. Residuals add the un-normalized input. (Identical to llama.cpp's loop.)
Full-attention block (layers (i+1)%4==0)
Input is attn_norm'd. Op order:
- Joint Q+gate proj
attn_q→[2·head_dim·n_head]; split: Q = firsthead_dimper head, gatez= second half. - QK-RMSNorm on Q (
attn_q_norm, per-head, before RoPE). attn_k,attn_vproj; reshape K to heads; K-RMSNorm (attn_k_norm, pre-RoPE).- Partial RoPE on Q,K — rotate only first
rope_dim=64of each 256-dim head, pass the rest through (multi-section / IMRoPE; sections fromrope.dimension_sections). - GQA attention (16 Q / 2 KV heads), scale
1/√head_dim. - Output gate:
cur = attn_out * sigmoid(z)← note: sigmoid gate here. - Output proj
attn_output. Residual added in the main loop.
SSM block — Gated-Delta-Net (layers (i+1)%4!=0)
Input is attn_norm'd. This is the hard new kernel.
Projections (4 separate, from normed x):
attn_qkv→ fused q|k|v stream[8192](the thing that gets convolved).attn_gate→ z[4096](output gate, used at the end).ssm_beta→beta [32], thenβ = sigmoid(beta).ssm_alpha→alpha [32].
Decay gate (scalar per V-head, "GDA"):
g = (-exp(A_log)) · softplus(alpha + dt_bias) # ssm_a stored as -exp(A_log) (negative); ssm_dt = dt_bias
(ssm_a is consumed via plain multiply — pre-baked at load.)
Causal conv1d (ssm_conv, k=4): conv over the full 8192-channel q|k|v stream prepended with conv_state (last 3 timesteps) → SiLU. Then split into q[128×16], k[128×16], v[128×32]; L2-normalize q and k over head dim (not RMS); GQA-repeat q,k 16→32 heads.
Per-token recurrence (per V-head h∈32, state S is [128,128], i=key dim, j=value dim):
S' = exp(g_t) · S_{t-1} # (a) scalar decay, whole state
pred_j = Σ_i S'[i][j] · k_t[i] # (b) read decayed state under key
delta_j = (v_t[j] − pred_j) · β_t
S_t[i][j] = S'[i][j] + k_t[i] · delta_j # (c) rank-1 delta-rule write
y_t[j] = (1/√128) · Σ_i S_t[i][j] · q_t[i] # (d) read-AFTER-write under query
Scaling caution: apply the q/output scale once — the autoregressive (single-token) path is what we want for decode; use the form above and do not also pre-scale q.
Output path:
gated = RMSNorm(y, ssm_norm) · silu(z) # gated RMSNorm — silu(z), NOT sigmoid
cur = ssm_out · gated # proj [4096→n_embd]
Residual added in the main loop.
Shared-expert MoE FFN (every layer)
On the post_attention_norm'd tensor:
# routed:
moe_out = MoE(x, ffn_gate_inp, {ffn_gate/up/down_exps}, n_expert=256, used=8,
gate=SOFTMAX, norm_topk_prob=TRUE, scale=expert_weights_scale) # SwiGLU experts
# shared (always on):
shexp = SwiGLU(x, ffn_gate_shexp, ffn_up_shexp, ffn_down_shexp)
sgate = sigmoid(ffn_gate_inp_shexp · x) # 1 scalar/token
out = moe_out + sgate · shexp
Extends Pantheon's existing Mixtral MoE (routed top-k SwiGLU) with: softmax+norm_topk_prob gating and the sigmoid-gated shared-expert add.
Dual state (per sequence, per layer)
- SSM layers (×30): conv_state
(4-1)·8192 = 24,576f32 + ssm_state128·4096 = 524,288f32 = 548,864 f32/layer. - Attention layers (×10): KV cache, sized by
n_head_kv=2 × head_dim=256per token (the only growing state). - Mirror
rwkvx.c's dual top-level struct: parallel per-type arrays indexed by per-type index, +block_order[]/block_index[].
Pantheon mapping (from rwkvx.c)
Clone wholesale: dual-state top struct; block_order/block_index interleave + forward dispatch; model = per-type block-struct arrays + emb/output_norm/head; forward skeleton (embed → ping-pong per-layer loop → final norm → head); engine hooks (add PN_ARCH_QWEN35MOE enum + branches in the ~6 engine.c sites: pn_load_ex, pn_get_forward_fn/direct-set, pn_state_new/free/reset, pn_model_free, generate/stream tokenizer routing); adapter shim mirroring rwkvx.h; threadpool TLS; own preallocated workspace (not the arena); q8 matvec infra (reuse for expert matrices).
Add (new): SSM state struct (conv + ssm); the Gated-Delta kernel (recurrence above); shared-MoE FFN; RoPE + GQA on the attention block (rwkvx's attn block has neither — borrow RoPE from the llama path).
Replace: RWKV time-mix → SSM kernel; attn internals (add RoPE/GQA, sigmoid output gate); FFN → shared-MoE; RWKV trie tokenizer → Qwen BPE (ft_tokenizer via the GGUF path); head_size=64 hardcode → config-driven.
Loader: GGUF (not safetensors). Parse all qwen35moe.* keys; honor nextn_predict_layers=0 and never load blk.N.nextn.*. Layer type from (i+1)%4==0. Per-layer tensors via the gguf/pn_qweight conventions.
Implementation order (= Bucket A / Phase 1 A2–A8)
- A2 arch+config+loader (detect, parse, GGUF, layer-type, nextn).
- A3 full-attention block (closest to known territory — GQA+QK-norm+partial RoPE+sigmoid gate).
- A4 SSM kernel (conv → recurrence → gated-norm). Validate this op in isolation first (hardest).
- A5 shared-MoE FFN (extend Mixtral path).
- A6 assemble hybrid + dual state + engine hooks.
- A8 validate vs the llama.cpp oracle (
temp 0,oracle_reference.md).
Correctness watch-list
- Decay is scalar per V-head (GDA), not per-dim.
- conv runs over the full 8192 stream, then split; q,k get L2-norm post-conv.
- Partial RoPE: only first 64 dims.
- Attention layer gate = sigmoid; SSM layer gate = silu (gated RMSNorm). Don't mix them up.
- Recurrence is read-after-write (output reads post-update state).
- Apply the q/√S scale once.
norm_topk_prob=true(renormalize the top-8 weights).- GQA head broadcast in the SSM (THE A8 gibberish bug — RESOLVED): the 16 K/Q heads repeat over 32 V-heads via
ggml_repeat, which interleaves: V-headvhpairs with K-headvh % n_k_heads, i.e.vh = r*n_k_heads + kh— NOT the blockedvh = kh*kv_groups + r. Same trap will appear in the GPU MoE/SSM port. Verified by per-layer activation bisection vsllama-eval-callback. - ChatML reasoning prefill: assistant turn opens with
<think>(add_generation_prompt); without it the first generated token diverges from the oracle. Pantheon:PN_CHAT_CHATML_THINK. QK-norm / ssm_normRESOLVED (not the bug): GGUF norm weights are ~1-centered (means 0.88–2.59, not ~0), so the converter baked(1+w)+1into them → plain RMS is correct, matches llama.cpp. A8 should look at the assembly wiring (residual placement, norm points, kernel-handoff tensor layouts, state init), not the norm convention.
A2 findings — apply in A3+ (verified against the real GGUF)
- Exact tensor names (the §blocks above used shorthand):
ssm_conv1d.weight,ssm_a(no.weight),ssm_dt.bias,ssm_alpha.weight,ssm_beta.weight,ssm_norm.weight,ssm_out.weight; attention usesattn_q/k/v.weight,attn_q_norm.weight,attn_k_norm.weight,attn_output.weight,attn_gate.weight,attn_qkv.weight; MoEffn_gate_inp,ffn_{gate,up,down}_exps,ffn_{gate,up,down}_shexp,ffn_gate_inp_shexp. A2 loads by these real names. - Q3_K added to the CPU path — the engine lacked Q3_K dequant/
pn_qmv(only Q4_K/Q5_K/Q6_K). A2 added it (validated: sane stats, no NaN). Still needs oracle-level verification at A8 — a subtle bit error would only show as output mismatch. rope.dimension_sections(the multi-section/IMRoPE array) is not parsed in A2 — onlyrope.dimension_count=64. A3 must parse the sections array for partial multi-section RoPE.token_embdkept as dequantized F32 (owned, phi3 convention);output.weight(Q6_K) zero-copypn_qweightwith tied-embedding fallback.- File-split discipline (whale prevention): A2 is one 607-LOC file. A3 →
qwen35moe_attn.c, A4 →qwen35moe_ssm.c, A5 →qwen35moe_moe.c— do NOT growqwen35moe.cinto a 4th >1000-LOC whale. Keepqwen35moe.cfor the model struct + load + forward orchestration.
K80 GPU decode — status & performance
N2-mini (Q3_K_M) runs on a K80 host via pn_qwen35moe_forward_cuda, split across the
two dies of one card (small-BAR — both dies mapped at once, no barswap): front layers
[0,split) + token_embd on die A, back layers + output_norm + LM head on die B. Select with
QWEN35_GPU_SPLIT_LAYER=20 QWEN35_GPU_DIE_A=0 QWEN35_GPU_DIE_B=1 (no barswap acquire —
QWEN35_GPU_BARSWAP=1 opts back into the legacy single-slot path).
Verified single-stream decode: ~13.7 tok/s (~0.073 s/token, 2 dies, Q3_K_M), and the GPU output
bit-matches the CPU forward — argmax 11,1496,271,142,79,88,30,81,30,271,220,381, token-0
logit 13.7252, every per-token logit ulp-0 identical. Up from a 0.2 tok/s first-light baseline (~68×).
K80 autoboost swings this ±~30% run-to-run. Actively being optimized (see headroom).
What got it there (all committed):
- GPU matvec kernels for every quant type (Q3_K/Q4_K/Q5_K/Q6_K/Q8_0/F32) — the mixed-quant
model was host-falling-back to
pn_qmv(3.7 s of 5.2 s/token, led by the 248K-vocab Q6_K LM head). - Warp-per-row matvec (one warp/row, 32 lanes coalesce each 256-elem superblock; was thread-strided → scattered 32-byte segments at 2–8 GB/s → 14–41 GB/s).
- Grouped MoE — all 8 top-k experts' gate+up+SwiGLU in one launch + fused down/accumulate (~52→10 ops/layer).
- Parallel router top-k (the serial thread-0 selection sort was ~134 ms/token over 40 layers).
- GPU-resident SSM state (kills the per-token state round-trip) + per-die scratch pool (kills ~800 alloc/free per token).
- Quant weights repacked at upload (
fc_repack_weight) into an aligned, scale-pre-unpacked, transposed device layout → coalescedu32loads + shared-memory activation staging. Matvec device-bandwidth ~2× (Q8_0 21→65, Q5_K 16→35, Q3_K 15→30 GB/s), +2 GB/die. Bit-exact (CPU-replica verifier, ulp 0).
Ceiling & headroom: post-repack the matvecs are instruction-issue-bound on Kepler — ~65–70 GB/s is near the sm_37 limit for bit-exact K-quant decode (the earlier ~170 GB/s figure was optimistic). The remaining single-stream lever is per-layer kernel fusion to cut the ~18–20 ms/token of tiny-kernel launch overhead (~100 F32 router/α/β + 94 Q8_0 launches, ~80 µs each vs ~35 µs of work) → ~15–20 tok/s. Beyond that, "hundreds" is not a single-stream number on Kepler — it comes from speculative block-verify (DeepSeek DSpark: amortize the bandwidth-bound batch-1 decode across a verified block) and continuous batching across concurrent users (serving-aggregate throughput).
Batched prefill (the serving foundation)
pn_qwen35moe_forward_batch_cuda runs n tokens of one sequence in a single pass: token-major
[n][dim] activations, every per-token matvec batched into one fc_matvec_*_nx launch (each weight
block decoded once per 4-column tile — the dequant instruction stream amortizes over the batch), SSM
conv1d + delta-rule recurrence as in-kernel scans over the n positions, causal host GQA attention
per position, per-token MoE routing in batched grouped launches. Single-token decode path untouched.
Bit-exact check (2-die split @20, measured with an out-of-tree harness): per-position logits of a batched
12-token prefill are memcmp-identical (248320 floats × 12) to the sequential single-token forward
(compare with QWEN35_FUSE=0x37 — the fused attn qknorm+rope kernel is ~1 ulp off the standalone
pair, a pre-existing fusion contraction property). Prefill → decode continuity verified: one decode
step on top of the batched state matches the fully sequential run exactly.
Measured prefill throughput (2-die split @20, boost clocks): n=32 — sequential 15.8 tok/s vs
batched 32.2 tok/s (2.04×); n=128 — sequential 17.8 tok/s vs batched 36.5 tok/s (2.05×).
Kernel-level nx speedups are 1.7–3.1× per pass (foundry/cuda/tests/test_matvec_nx). Remaining
prefill levers: wider column tiles, batching the host attention/Q-split glue onto the die, and
expert-grouped MoE batching (tokens sharing an expert stream its weights once).
Host bring-up prereqs (else CUDA falls back to CPU or the box hangs): quirk kernel (16-die
small-BAR map), nvidiafb blacklisted, NVreg_EnableMSI=0, NVreg_DynamicPowerManagement=0
(all on the GRUB cmdline), persistence mode — see foundry/barswap/k80_quirk/README.md. Profiler: QWEN35_PROF=1.
Operational: under sustained high-util load a marginal card can Xid-79 "fall off the bus"
(power/thermal — visible only now that the dies actually work); mitigate with nvidia-smi -pl 110
and/or forced-air cooling (K80s are passively cooled).