Pantheon/docs/architecture/models-qwen35moe.md

282 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Qwen3.5-MoE Model Implementation
Pantheon implementation of the Nex-N2-mini architecture: a 40-layer hybrid Gated-Delta-Net SSM / sparse GQA attention stack with 256-expert MoE on every layer.
Source files:
- `src/models/qwen35moe.c` — loader, config parser, GGUF weight mapping
- `src/models/qwen35moe_internal.h` — config/block structs, dual-state types, cross-file kernel declarations
- `src/models/qwen35moe.h` — public API, `PN_QWEN35MOE_MAX_SEQ_CAP`
- `src/models/qwen35moe_forward.c` — CPU forward, dual-state lifecycle, KV ring init
- `src/models/qwen35moe_ssm.c` — Gated-Delta-Net SSM block kernel
- `src/models/qwen35moe_forward_cuda.c` — CUDA forward: 2-die split, GPU weight residency, decode slots, async step machine (7 348 lines)
---
## Architecture
### Dims (from `pn_qwen35moe_config`, `qwen35moe_internal.h:36–60`)
| Field | Value | GGUF key |
|---|---|---|
| `n_layer` | 40 | `qwen35moe.block_count` |
| `full_attention_interval` | 4 | `qwen35moe.full_attention_interval` |
| `n_embd` | 2048 | `qwen35moe.embedding_length` |
| `n_head` | 16 | `qwen35moe.attention.head_count` |
| `n_head_kv` | 2 | `qwen35moe.attention.head_count_kv` |
| `head_dim` | 256 | `qwen35moe.attention.key_length` |
| `rope_dim` | 64 | `qwen35moe.rope.dimension_count` (partial NEOX) |
| `n_expert` | 256 | `qwen35moe.expert_count` |
| `n_expert_used` | 8 | `qwen35moe.expert_used_count` |
| `expert_ff` | 512 | `qwen35moe.expert_feed_forward_length` |
| `shared_ff` | 512 | `qwen35moe.expert_shared_feed_forward_length` |
| `ssm_state` | 128 | `qwen35moe.ssm.state_size` |
| `ssm_n_group` | 16 | `qwen35moe.ssm.group_count` (K-heads, GQA key/query) |
| `ssm_dt_rank` | 32 | `qwen35moe.ssm.time_step_rank` (V-heads) |
| `ssm_d_inner` | 4096 | `qwen35moe.ssm.inner_size` |
| `ssm_conv` | 4 | `qwen35moe.ssm.conv_kernel` |
| `conv_dim` | 8192 | derived: read from `blk.0.attn_qkv.weight` outermost dim (`qwen35moe.c:163–167`) |
### Layer layout
Layer type is set by `qwen35moe_is_attn_layer()` (`qwen35moe.c:216–220`):
```c
return ((i + 1) % interval) == 0; /* interval default 4 */
```
With `full_attention_interval=4` and 40 layers, this yields:
- Layers 3, 7, 11, 15, 19, 23, 27, 31, 35, 39 → **GQA full-attention** (10 layers, `n_attn=10`)
- All other 30 layers → **Gated-Delta-Net SSM** (`n_ssm=30`)
`block_type[i]` and `block_index[i]` in `pn_qwen35moe_model` (`qwen35moe.c:59–60`) map any global layer index to its type and per-type slot. These are the only two arrays that must be consulted to dispatch a layer.
### Per-layer block structure
Every layer (SSM or attention) shares the same outer shell:
```
attn_norm → (SSM block | GQA attn block) → +residual
→ post_attention_norm → shared-MoE FFN → +residual
```
(`qwen35moe_forward.c:316–369`)
### MoE routing
Every layer carries an identical `pn_qwen35moe_moe` block (`qwen35moe_internal.h:75–84`):
1. **Router**: `ffn_gate_inp [n_expert, n_embd]` → logits → softmax → top-8 selection → `norm_topk_prob` renormalization → scaled by `expert_weights_scale` (default 1.0; key absent in current GGUFs, `qwen35moe.c:141–145`).
2. **Routed experts**: 256 experts, zero-copy sliced from merged 3-D GGUF tensors `blk.N.ffn_gate_exps.weight` / `ffn_up_exps.weight` / `ffn_down_exps.weight` (`qwen35moe.c:246–293`). Each expert is a SwiGLU: `silu(gate·x) * (up·x)` → `down`. `moe_out = Σ weight_e * SwiGLU_e(x)`.
3. **Shared expert** (always on): separate `ffn_gate_shexp` / `ffn_up_shexp` / `ffn_down_shexp` + a scalar sigmoid gate `ffn_gate_inp_shexp [n_embd]`. `shexp_out = silu(gate·x) * (up·x) → down`, then scaled by `sigmoid(gate_inp_shexp · x)`.
4. **Combine**: `out = moe_out + shexp_out`. Residual add is the caller's responsibility.
(`qwen35moe_internal.h:278–308`)
### GQA attention block (`qwen35moe_internal.h:160–196`)
Op order for attention layers:
1. `attn_q [2*head_dim*n_head, n_embd]` → split: first `head_dim` per head is Q, second is gate z.
2. QK-RMSNorm on Q (per head via `attn_q_norm [head_dim]`) and on K (via `attn_k_norm [head_dim]`), pre-RoPE.
3. `attn_k [head_dim*n_head_kv, n_embd]` and `attn_v [head_dim*n_head_kv, n_embd]`.
4. Partial NEOX RoPE on the first `rope_dim=64` dims of every Q and K head. Multi-section RoPE is parsed (`rope_sections[4]`, `qwen35moe.c:111–130`) but collapses to standard NEOX for text.
5. GQA: 16 Q heads broadcast across 2 KV heads (each KV head serves 8 Q heads), scale `1/sqrt(256)`. K/V appended to per-layer ring before attention.
6. Output gate: `attn_out *= sigmoid(z)`.
7. `attn_output [n_embd, head_dim*n_head]`.
### SSM (Gated-Delta-Net) block (`qwen35moe_ssm.c:229–351`)
Op order for SSM layers:
1. Projections from `attn_norm`-ed input: `attn_qkv → qkv [conv_dim=8192]`; `attn_gate → z [ssm_d_inner=4096]`; `ssm_beta → beta [32]` → sigmoid; `ssm_alpha → alpha [32]`.
2. Decay gate per V-head (`qwen35moe_ssm.c:86–93`): `g[h] = ssm_a[h] * softplus(alpha[h] + ssm_dt[h])`. `ssm_a` is stored as `−exp(A_log)` (negative), so `exp(g) ∈ (0,1)`.
3. Causal depthwise conv1d (kernel size 4) over `conv_dim` channels + SiLU (`qwen35moe_ssm.c:110–135`). Rolls `conv_state` in place (oldest-first, channel-major layout).
4. Split convolved output into q `[128*16]`, k `[128*16]`, v `[128*32]`; L2-normalize q/k per K-head (`qwen35moe_ssm.c:70–76`); GQA-tile from 16 K-heads to 32 V-heads using interleaved tiling (`vh % n_k_heads == kh`, `qwen35moe_ssm.c:311`).
5. Delta-rule recurrence per V-head (`qwen35moe_ssm.c:163–195`): for each of 32 `[128×128]` state matrices (stored transposed, row j = column j of S): decay → pred → delta → rank-1 write → read-after-write y. Scale `1/sqrt(128)` applied once at read.
6. Gated RMSNorm: `RMSNorm(y, ssm_norm) * silu(z)` per V-head (`qwen35moe_ssm.c:206–223`), then `ssm_out [n_embd, ssm_d_inner]`.
The recurrence is parallelized across V-heads via `fw_for` when `USE_THREADS` is set and `n_v_heads >= 4` (`qwen35moe_ssm.c:332–336`).
---
## 2-Die Split and Placement
### Constants (`qwen35moe_forward_cuda.c:89–95`)
```c
#define GPU_DIE_SPLIT_LAYER 40 /* default = disabled (all on one die) */
#define GPU_DIE_A_DEFAULT 0
#define GPU_DIE_B_DEFAULT 1
```
`GPU_DIE_SPLIT_LAYER=40` means no split by default; the single-die path is the validated baseline. A split is engaged only when `QWEN35_GPU_SPLIT_LAYER < n_layer` or explicit placement provides two dies.
### Boundary resolution (`gpu_split_layer`, `qwen35moe_forward_cuda.c:696–711`)
```
pn_load_on(2 dies) → split = n_layer/2 = 20
pn_load_on(1 die) → split = n_layer (single-die forced)
no placement → GPU_DIE_SPLIT_LAYER (40 = disabled)
QWEN35_GPU_SPLIT_LAYER env → overrides boundary (unless 1-die placement)
```
Clamped to `(0, n_layer]`.
### Die id resolution (`gpu_die_ids`, `qwen35moe_forward_cuda.c:717–736`)
Priority: `pn_load_on` explicit placement > `QWEN35_GPU_DIE_A` / `QWEN35_GPU_DIE_B` env vars > `GPU_DIE_A_DEFAULT=0` / `GPU_DIE_B_DEFAULT=1`.
Die ids are ordinals relative to `CUDA_VISIBLE_DEVICES`. On a K80 host, one K80 card exposes two GK110 dies; `CUDA_VISIBLE_DEVICES` maps them to ordinals 0 and 1.
### Weight placement
- Die A (`ctx_a`): layers `[0, split)` + token embedding (read host-side, row uploaded per token).
- Die B (`ctx_b`): layers `[split, n_layer)` + `output_norm` + LM head.
Each `gpu_ssm_layer.ctx` / `gpu_attn_layer.ctx` stores a back-pointer to its owning die context (`qwen35moe_forward_cuda.c:205`, `285`).
### 2-die multiplexing (`gpu_select_die`, `qwen35moe_forward_cuda.c:751–764`)
On legacy large-BAR K80 rigs (single MMIO slot), `barswap` time-multiplexes the two dies. The forward:
1. Acquires die A via `bs_acquire`.
2. Runs front layers on `ctx_a`.
3. Syncs, D2H copies the boundary `hidden [n_embd float32]`.
4. Acquires die B via `bs_acquire`.
5. H2D uploads `hidden` to `ctx_b`.
6. Runs back layers + LM head on `ctx_b`.
In small-BAR mode (modern driver, real dual-device), both contexts are live simultaneously and `g->bs == NULL`; no barswap or per-layer `gpu_select_die` is needed. The decode-slot bind path and the async path only run in small-BAR mode (`qwen35moe_forward_cuda.c:4655–4658`).
---
## KV Cache
### Allocation (`qwen35moe_kv_init`, `qwen35moe_forward.c:129–176`)
One `pn_qwen35moe_kv` per attention layer, 10 total. Layout `[n_head_kv][max_seq][head_dim]` (head-major).
**Q4 mode** (`QWEN35_KV_Q4`, default ON, `qwen35moe_forward.c:47–56`):
- Cache stored as ggml-Q4_0 rows: `kq [n_head_kv * max_seq * pn_kvq4_row_bytes(head_dim)]`, same for `vq`.
- fp16 sink shadow `k_sink/v_sink [n_head_kv * PN_KVQ4_SINKMAX * head_dim]` — attention sink tokens kept at fp16 to prevent argmax flips on near-ties.
- fp16 window shadow `k_win/v_win [n_head_kv * PN_KVQ4_WMAX * head_dim]` — the most recent W tokens similarly kept at fp16.
- PPL delta vs fp32 KV: 0.024% (validated, `qwen35moe_forward.c:45`).
- Disable with `QWEN35_KV_Q4=0`.
**fp32 mode**: `k/v [n_head_kv * max_seq * head_dim float32]`.
The reactive memory ladder rung 4a (`qwen35moe_forward.c:142–152`) auto-forces Q4 if the box is under memory pressure at allocation time even without the env var.
### KV window size
`PN_QWEN35MOE_MAX_SEQ` = 4096 (compile-time default, `qwen35moe_forward.c:70`).
`QWEN35_MAX_SEQ` env var overrides, clamped to:
- minimum: 16
- maximum: `PN_QWEN35MOE_MAX_SEQ_CAP` = 262 144 (`qwen35moe.h:29`)
The cap is the single source of truth shared between the CPU KV ring allocator (`qwen35moe_max_seq`, `qwen35moe_forward.c:76–91`) and the GPU admission gate (`gpu_admit_max_ctx`, `qwen35moe_forward_cuda.c:619–629`). Memory scales linearly: `n_head_kv * max_seq * head_dim * 8` bytes per attention layer on the host (same again on device in GPU mode).
### Ring behavior
KV is a fixed-size ring; `pos % max_seq` gives the slot. There is no dynamic resizing. If `pos >= max_seq` the oldest entry is overwritten. The ring is never grown after allocation.
### Deferred host mirror (B2)
After a GPU decode token, the GPU KV ring is the live source of truth. Writing back to the host mirror (`kv->k/v` or `kq/vq`) per token was too expensive. Instead, `kv->kv_mirror_stale = 1` is set (`qwen35moe_forward_cuda.c:3547`) and the D2H copy is deferred to `pn_qwen35moe_gpu_state_to_host()` (`qwen35moe_forward_cuda.c:6779`), which is called by the prefix cache on session eviction. After `pn_qwen35moe_state_reset()` the flag is cleared to 0 (`qwen35moe_forward.c:308`).
---
## Decode Slots / Multi-User Batching
### Slot capacity
`PN_QWEN35MOE_GPU_MAX_SLOTS` = 16 (`qwen35moe_forward_cuda.c:115`).
Memory cost: ~2.1 MB per SSM layer per slot for the per-slot `conv_slots / ssm_slots` images. At 16 slots across 30 SSM layers ≈ 1 GB on the die pair.
The reactive ladder rung 2 (`pn_qwen35moe_gpu_ladder_max_slots`, `qwen35moe_forward_cuda.c:4605–4613`) caps live slots: OK → 16, TIGHT → 8, CRITICAL → 1.
### Slot protocol
```
1. prefill each user's pn_state (single-token or batched)
2. immediately after each user's prefill:
pn_qwen35moe_decode_slot_bind(model, state_u, u, K)
— D2D snapshots the live GPU SSM state into slot u;
host mirror is stale after GPU prefill so bind must precede
any other user's forward
3. step all K users: pn_qwen35moe_decode_slots(model, tokens, states, K, logits)
— ONE forward pass, weights stream once for all K users
```
(`qwen35moe.h:126–138`)
Slot-major per-user SSM images live in `gpu_ssm_layer.conv_slots / ssm_slots` (`qwen35moe_forward_cuda.c:247–250`). Bind snapshots via D2D from the shared `conv_state_g / ssm_state_g` right after prefill (`qwen35moe_forward_cuda.c:4616–4638`). A slot-count change invalidates all existing bindings.
`pn_qwen35moe_decode_slot_move(src, dst, n_slots)` (`qwen35moe.h:154`) compacts the active set via D2D copy without a host round-trip.
### Async step machine (C2, `QWEN35_ASYNC_SCHED`, default ON)
Three-state enum (`qwen35moe_forward_cuda.c:7062–7065`):
- `QW_ASYNC_IDLE` — no step in flight
- `QW_ASYNC_WAIT_A` — 2-die only: front segment (die A) in flight, CUDA event pending
- `QW_ASYNC_WAIT_FINAL` — back segment + LM head in flight (or single-die full step)
`pn_qwen35moe_forward_step_begin()` launches the step and returns without blocking (returns 1 = not async-eligible and runs synchronously in that case). `pn_qwen35moe_forward_step_poll()` reaps via `fc_event_query`, advances to the next phase, and on completion sets `*logits_out` and increments `state->pos`. This lets a 2-core host host keep up to 8 cards' generations pipelined.
Requires: small-BAR mode (real dual-device, `g->bs == NULL`); LM head must have an on-device kernel (Q3_K/Q4_K/Q5_K/Q6_K/Q8_0/F32, `qwen35moe_forward_cuda.c:7083–7098`). Disable with `QWEN35_ASYNC_SCHED=0`.
---
## The MTP / nextn Trap
The GGUF metadata for Qwen3.5-MoE may advertise MTP (multi-token prediction) draft-head weights under `blk.N.nextn.*` keys. The weights **do not ship** with public GGUF conversions; `nextn_predict_layers` must always be 0.
The loader explicitly documents this and never touches any `blk.N.nextn.*` tensor:
```c
/* Honor the MTP trap: nextn_predict_layers=0 — never touch blk.N.nextn.* . */
```
(`qwen35moe.c:475`)
If a future GGUF includes these weights, loading them would require a separate code path not yet implemented. Attempting to load them without that path would silently skip them (no tensor lookup is performed) — so correctness is maintained but draft-head acceleration would simply not engage.
---
## Gotchas and Fragile Spots
### KV ring is fixed at allocation — no resize path
`qwen35moe_kv_init` (`qwen35moe_forward.c:129`) allocates the ring once at `max_seq`. There is no resize-on-demand path. If the ring reallocates (e.g., `cache_cap` changes on the GPU side under a geometry change), the segment graph must be dropped and recaptured (`qwen35moe_forward_cuda.c:4941`). Under memory pressure the reactive ladder forces Q4 at init time but cannot shrink the ring after the fact.
**Segfault risk**: if `pos >= max_seq` is not checked before the GPU KV-append kernel writes `pos % max_seq`, and the GPU ring's `cache_cap` was allocated for a different `max_seq` than the host ring, the write goes out of bounds. The GPU ring capacity is tracked separately as `d->cache_cap` (`qwen35moe_forward_cuda.c:306`); a mismatch after a partial realloc failure would be silent until the OOB write.
### kv_mirror_stale + session switch
After a GPU decode step, `kv->kv_mirror_stale = 1`. If a session is evicted without calling `pn_qwen35moe_gpu_state_to_host()` first (e.g., the prefix cache path is bypassed or fails partway), the host mirror remains stale. A subsequent resume re-syncs from the stale host mirror instead of the GPU ring, silently corrupting the KV cache with zeros or old content.
The `pn_qwen35moe_gpu_forget_state()` call (`qwen35moe.h:212`) must be paired with `pn_state_reset()` on session reuse; omitting it leaves the GPU SSM buffers binding to the old session and the next prefill reads stale GPU residue.
### GQA repeat tiling — interleaved, not blocked
The SSM's GQA repeat tiles K-heads to V-heads using `vh % n_k_heads == kh` (interleaved), **not** the blocked `vh / kv_groups` mapping (`qwen35moe_ssm.c:311–312`). This matches `ggml_repeat`'s tiling in llama.cpp. Getting this wrong produces plausible but numerically wrong y vectors — the error is hard to detect without the oracle diff.
### conv_state layout: channel-major, oldest-first
`conv_state[c * (ssm_conv-1) + t]`, t=0 oldest. The roll-forward in `qwen35moe_conv1d_silu` (`qwen35moe_ssm.c:131–133`) shifts t=0..km1-2 forward and appends the new raw input at t=km1-1. The GPU path must replicate this exactly or the conv state diverges after the first token.
### ssm_state is stored transposed
`ssm_state[h * S * S + j * S + i] == S_h[i][j]` — row j (contiguous over i) is column j of S (`qwen35moe_internal.h:214–215`). This matches llama.cpp's `gated_delta_net` layout. Any GPU kernel that reinterprets the layout as standard row-major will compute wrong dot products.
### Batched CUDA prefill and CUDA-graph capture ordering
The per-layer segment graph is only captured after the layer has run DIRECTLY (uncaptured) at least once — `body_ran` gates the attempt (`qwen35moe_forward_cuda.c:229–232`). A batched prefill that precedes the first single-token forward never sets `body_ran` for those layers' fused descriptors, so the first single-token decode after a batch-only session must run uncaptured to build the fused descs. Capturing mid-allocation (before the fused descs exist) would call `cuMemAlloc` inside the capture, invalidating the graph. The `body_ran` gate prevents this.
### Embedding: lazy vs materialized
For untied models (a separate `output.weight` exists), `token_embd` is left `NULL` and each row is dequantized on demand from the mmap (`pn_qwen35moe_model_embed_row`, `qwen35moe.c:620–629`). For tied models, the full ~2 GB F32 embedding is materialized and also serves as the LM head (`qwen35moe.c:437–453`). The fallback for untied models where `n_embd % block_size != 0` also materializes the full heap.
### Numeric status: A8 not done
`qwen35moe_forward.c` and `qwen35moe_forward_cuda.c` both carry an open `@todo A8: validate logits vs the llama.cpp oracle`. End-to-end numeric correctness has not been verified. Several operations in the CUDA path have no dedicated GPU kernel and fall back to host-side computation with a D2H/H2D round trip (marked `TODO kernel:` inline in the CUDA file): joint Q+gate split, GQA head broadcast + L2-norm, decay gate / softplus, sigmoid gates, SwiGLU for non-Q3_K experts, attention softmax.