Pantheon/docs/architecture/models-qwen35moe.md

17 KiB
Raw Blame History

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):

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)

#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:

/* 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.