Pantheon/docs/architecture/qwen35moe.md

14 KiB
Raw Blame History

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:

  1. Joint Q+gate proj attn_q → [2·head_dim·n_head]; split: Q = first head_dim per head, gate z = second half.
  2. QK-RMSNorm on Q (attn_q_norm, per-head, before RoPE).
  3. attn_k,attn_v proj; reshape K to heads; K-RMSNorm (attn_k_norm, pre-RoPE).
  4. Partial RoPE on Q,K — rotate only first rope_dim=64 of each 256-dim head, pass the rest through (multi-section / IMRoPE; sections from rope.dimension_sections).
  5. GQA attention (16 Q / 2 KV heads), scale 1/√head_dim.
  6. Output gate: cur = attn_out * sigmoid(z) ← note: sigmoid gate here.
  7. 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,576 f32 + ssm_state 128·4096 = 524,288 f32 = 548,864 f32/layer.
  • Attention layers (×10): KV cache, sized by n_head_kv=2 × head_dim=256 per 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)

  1. A2 arch+config+loader (detect, parse, GGUF, layer-type, nextn).
  2. A3 full-attention block (closest to known territory — GQA+QK-norm+partial RoPE+sigmoid gate).
  3. A4 SSM kernel (conv → recurrence → gated-norm). Validate this op in isolation first (hardest).
  4. A5 shared-MoE FFN (extend Mixtral path).
  5. A6 assemble hybrid + dual state + engine hooks.
  6. 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-head vh pairs with K-head vh % n_k_heads, i.e. vh = r*n_k_heads + kh — NOT the blocked vh = kh*kv_groups + r. Same trap will appear in the GPU MoE/SSM port. Verified by per-layer activation bisection vs llama-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_norm (1+w) RESOLVED (not the bug): GGUF norm weights are ~1-centered (means 0.88–2.59, not ~0), so the converter baked +1 into 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 uses attn_q/k/v.weight, attn_q_norm.weight, attn_k_norm.weight, attn_output.weight, attn_gate.weight, attn_qkv.weight; MoE ffn_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 — only rope.dimension_count=64. A3 must parse the sections array for partial multi-section RoPE.
  • token_embd kept as dequantized F32 (owned, phi3 convention); output.weight (Q6_K) zero-copy pn_qweight with 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 grow qwen35moe.c into a 4th >1000-LOC whale. Keep qwen35moe.c for 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 → coalesced u32 loads + 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).