docs(paged): verify llama.cpp GDN decode is O(1)-in-context, not a 2.4x lever

Closes lever 5 of VLLM_DECODE_GROUNDING.md. GGUF metadata + source reading on
the paged dev tree plus nsys decode traces on Qwen3.6-27B NVFP4 (GB10 sm_121)
confirm the Gated-Delta-Net linear-attention layers decode as a fused single
CUDA kernel (gated_delta_net.cu) updating a fixed-size cached recurrent state:
no context-length parameter, no KV re-scan. Matched-batch context-scaling
control (npl4, pure decode) shows the GDN kernel flat (10.3 -> 8.0 us/launch)
across 4x context while full-attention grows 3.1x (27 -> 85 us). GDN is a small,
context-flat share (~0.4-10%% by batch); the FP4 weight GEMM dominates (~67%).
Verdict: GDN decode is efficient, not the cheap model-specific fix; the 2.4x is
the general GEMM + full-attention kernel work, as the grounding concluded.

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-24 11:21:44 +00:00
parent f7500df64e
commit e4c63179e0

View File

@@ -0,0 +1,208 @@
# GDN decode verify: is llama.cpp's Gated-Delta-Net decode O(1) or an O(ctx) re-scan?
Verdict-first, then the evidence. This closes lever 5 of `VLLM_DECODE_GROUNDING.md` ("Verify
llama's GDN/linear-attention decode path"): on the Qwen3.6 hybrid models, is llama re-scanning the
context (O(ctx)) in the linear-attention layers, or keeping vLLM's O(1)-in-context recurrent state?
Method: GGUF-metadata + source reading on the `paged` dev tree (`~/llama-paged-dev`, build-cuda
sm_121) on `dgx.casa`, plus nsys CUDA-kernel decode traces on `~/bench/q36-27b-nvfp4.gguf`
(GB10 / DGX Spark, `GGML_CUDA_DISABLE_GRAPHS=1`, paged KV, `-fa on`). Models:
`~/bench/q36-27b-nvfp4.gguf` (dense, arch `qwen35`), `~/bench/q36-35b-a3b-nvfp4.gguf`
(MoE, arch `qwen35moe`).
## TL;DR verdict
**llama.cpp's GDN decode is EFFICIENT: it is O(1)-in-context, a single fused CUDA kernel that
reads + updates a fixed-size cached recurrent state, structurally identical to vLLM's
`fused_recurrent_gated_delta_rule`. It is NOT a re-scan, NOT a context-scaling blowup, and NOT a
major contributor to the ~2.4x eager-decode gap.** There is no GDN-specific bottleneck to fix, so
the cheap model-specific lever this probe was hunting for does not exist. The 2.4x is the general
kernel work (the FP4 weight GEMM, which dominates the step, plus the O(ctx) full-attention decode
kernel in the minority of full-attention layers), exactly as `VLLM_DECODE_GROUNDING.md` concluded.
The decisive datum: at matched batch (npl4), pure decode, 4x more context, the GDN kernel time is
**flat** while the full-attention kernel grows ~3.1x:
| kernel | ctx 1024 | ctx 4096 | ratio | meaning |
|--------|---------:|---------:|------:|---------|
| `gated_delta_net_cuda` (GDN linear-attn) | 10.3 us/launch | 8.0 us/launch | **~1.0x (flat)** | **O(1) in ctx** |
| `flash_attn_tile` (full-attn layers) | 27.1 us/launch | 85.0 us/launch | **3.1x** | O(ctx), as expected |
| total ms / decode step | 84.9 | 86.0 | 1.01x | GEMM-bound, ctx-independent |
Identical decode-step counts in both windows (~190 steps, ~9134 GDN launches), so this is a
per-step like-for-like comparison: the GDN layers do **not** get more expensive as context grows.
## 1. Architecture (confirmed from GGUF metadata + tensor names)
Both Qwen3.6 models are hybrid: a `full_attention_interval` of 4 means every 4th layer is standard
full attention and the other 3/4 are Gated-Delta-Net (GDN) linear attention with a recurrent state.
**Dense Qwen3.6-27B (`general.architecture = qwen35`):**
- `block_count = 64`, `full_attention_interval = 4` -> **16 full-attention layers + 48 GDN layers**.
- Full-attn: `head_count = 24`, `head_count_kv = 4` (GQA), `key_length = value_length = 256`,
rope `freq_base = 1e7`, mrope sections `[11,11,10,0]`.
- GDN/SSM: `ssm.state_size = 128`, `ssm.conv_kernel = 4`, `ssm.group_count = 16`,
`ssm.time_step_rank = 48`, `ssm.inner_size = 6144`. So the recurrent state per GDN layer is
`[S_v=128, S_v=128, H_v=48]` per sequence (`H_v = inner_size/state_size = 6144/128 = 48` value
heads), i.e. a 128x128 state matrix per head, ~3.1 MB (F32) per sequence per layer.
**MoE Qwen3.6-35B-A3B (`general.architecture = qwen35moe`):**
- `block_count = 41`, `full_attention_interval = 4` (~10 full-attn + ~31 GDN layers).
- `head_count = 16`, `head_count_kv = 2`, `key_length = value_length = 256`,
`expert_count = 256`, `expert_used_count = 8`, `expert_feed_forward_length = 512`.
- Same SSM dims: `state_size = 128`, `conv_kernel = 4`, `group_count = 16`,
`inner_size = 4096` -> `H_v = 32` value heads.
**Tensor names confirm the op split (27B, per-layer dump):**
- GDN layers (e.g. `blk.0.*`): `ssm_alpha`, `ssm_beta`, `ssm_conv1d`, `ssm_a`, `ssm_dt.bias`,
`ssm_norm`, `ssm_out`, plus `attn_qkv` / `attn_gate` (the in/out projections of the linear-attn
block). No `attn_k/v/output`, no per-head q/k norm.
- Full-attn layers (e.g. `blk.3.*`, every 4th): `attn_q`, `attn_k`, `attn_v`, `attn_output`,
`attn_q_norm`, `attn_k_norm`. No `ssm_*`.
llama loads the GDN layers through the **recurrent memory** (`llama-memory-recurrent`), not the KV
cache: the conv state and the SSM state live in `conv_states_all` / `ssm_states_all` and are read
and written every step. Only the 16/10 full-attention layers use the (paged) KV cache. This is the
SSM-style recurrent path, not standard attention.
## 2. llama.cpp GDN decode implementation: O(1) recurrent-state update (code-proven)
Graph build (shared by both models): `src/models/delta-net-base.cpp`, dispatched from
`src/models/qwen35.cpp` and `src/models/qwen35moe.cpp` (the MoE class inherits
`llm_build_delta_net_base` and calls the same `build_recurrent_attn`, qwen35moe.cpp:472).
**Decode dispatch (`build_delta_net`, delta-net-base.cpp:425-447):** when `n_seq_tokens == 1`
(decode), it takes `build_delta_net_fused` if `cparams.fused_gdn_ar` (the default, see below), else
`build_delta_net_autoregressive`. Both are O(1):
- `build_delta_net_autoregressive` (delta-net-base.cpp:289-371) is the explicit rank-1 recurrence on
the fixed-size state `s` shaped `[S_v, S_v, H_v, n_seqs]`: `s *= exp(g)` (decay),
`sk = sum_rows(s * k)`, `d = (v - sk^T) * beta`, `s += k (x) d^T` (rank-1 update),
`o = sum_rows(s * q)`. **No loop over past tokens, no KV read** - it touches only the state and
the single new token's q/k/v/g/beta. `GGML_ASSERT(n_tokens == 1)`.
- `build_delta_net_fused` (delta-net-base.cpp:373-423) collapses the same recurrence into one op,
`ggml_gated_delta_net(q, k, v, g, b, s, K=1)`.
**State is cached across steps, not rebuilt (`build_recurrent_attn`, delta-net-base.cpp:527-606):**
the input state `s` is read from `ssm_states_all` via `build_rs`, and the new state is copied back
with `ggml_cpy(new_state, view(ssm_states_all, ... kv_head ...))` (lines 555-558). The causal-conv
state is handled the same way in `build_conv_state` (449-525): the previous `conv_kernel-1 = 3`
samples are read from `conv_states_all`, the new token is appended, and the last 3 are written back.
So both pieces of GDN state persist in the recurrent cache exactly like a KV cache persists tokens -
this is the recurrent analogue, fixed size, independent of context length.
**Defaults (`src/llama-context.cpp:200-201`):** `cparams.fused_gdn_ar = true` and
`fused_gdn_ch = true`. They are only auto-disabled if the fused op cannot be scheduled on the same
device as the layer (`device_gdn != device_kv`, lines 540-595); on a single GB10 with `-ngl 99`
that does not happen, so the **fused single-kernel path is what runs**.
**The CUDA kernel (`ggml/src/ggml-cuda/gated_delta_net.cu`) is the crux, and it is unambiguously
O(1) in context:**
- Launch grid `dim3(H, n_seqs, ceil(S_v/4))` and block `(min(warp,S_v), 4, 1)` (lines 184-185):
the grid spans heads x sequences x state-columns. **There is no context-length dimension and no
context-length argument anywhere in the kernel signature** (q/k/v/g/beta are the new token(s)
`[S_v, H, n_tokens, n_seqs]`; `curr_state` is the fixed `[S_v, S_v, H, n_seqs]`).
- Each warp loads its shard of the fixed-size state into registers **once** (lines 57-61), then
loops `for (t = 0; t < n_tokens; t++)` (line 63). At decode `n_tokens == 1`, so it is a single
iteration: read the one new token, do the rank-1 update
`s_shard[r] = g * s_shard[r] + k[i] * delta_col` and the readout `attn = S^T q` (lines 84-141),
then write the updated state back (lines 161-167). No second loop, no read of any past KV.
- Work per decode step is therefore proportional to `S_v * S_v * H * n_seqs` (the state size x
batch) and **constant in context length**. This is precisely vLLM's
`fused_recurrent_gated_delta_rule_packed_decode_kernel` (one batched launch updating a
fixed-size `[K,V]` state) cited in the grounding doc.
A chunked GPU kernel for prefill is a TODO (delta-net-base.cpp:181 `//TODO: Add chunked kernel`);
the chunked CPU/graph path (`build_delta_net_chunking`) only runs for multi-token ubatches
(prefill), never at decode.
## 3. nsys decode profiling: GDN is a small share and does not scale with context
Qwen3.6-27B NVFP4, sm_121, `GGML_CUDA_DISABLE_GRAPHS=1`, paged KV, `-fa on`, `llama-server` driven
to steady decode by a looping completion client. Kernel time bucketed by name (full classifier and
sqlites under `~/bench/gdn_study/`).
**(a) Share at the headline batch (npl128, ctx 1024), GPU 92.7% busy:**
| bucket | % of busy | us/launch |
|--------|----------:|----------:|
| GEMM_weight (`mul_mat_q`/`mul_mat_vec_q`) | 59.2 | - |
| **GDN_recurrent (`gated_delta_net_cuda`)** | **8.9** | 369 |
| GEMM_act_quant (`quantize_mmq_nvfp4`) | 8.2 | - |
| elementwise / act_glu / norm / rope | ~13.5 | - |
| embed_gather (`get_rows`) | 2.9 | - |
| **ATTENTION_full (`flash_attn`, 16 layers)** | **1.8** | 107 |
| copy_cast (`cpy`) | 1.8 | - |
| **GDN_conv (`ssm_conv`)** | **1.5** | - |
The whole GDN path (recurrent 8.9% + conv 1.5%) is ~10% of the step; full attention is ~2%; the
**weight GEMM dominates at ~67% (59.2% GEMM + 8.2% act-quant requant)**. This is the dense model,
where the grounding predicted the GEMM would be the lever.
**(b) Share at low batch (npl32, ctx 1024), weight-bandwidth (GEMV) regime, GPU ~100%:**
GEMM_weight 88.7%, GDN_recurrent 0.8%, ATTENTION_full 0.7%, GDN_conv 0.3%. At low batch the
weight-read GEMV swamps everything and GDN is negligible; the GDN share tracks the batch, not the
context.
**(c) Context-scaling control (the decisive test): matched batch npl4, pure decode, ctx 1024 vs
4096.** Small batch -> fast prefill -> a clean pure-decode capture (verified: GEMM is the M=1
`mul_mat_vec_q` decode GEMV, and the client completed decode rounds inside the window). Identical
decode-step counts (~190 steps, gated_delta_net launched 9141 vs 9134 times), so per-launch time is
a true per-step comparison:
| kernel / bucket | ctx 1024 | ctx 4096 | ratio |
|-----------------|---------:|---------:|------:|
| `gated_delta_net_cuda` us/launch | 10.3 | **8.0** | **0.78x (flat)** |
| GDN_recurrent share | 0.6% | 0.4% | flat/down |
| `ssm_conv` (GDN_conv) us/launch | 5.2 | 5.2 | 1.00x |
| `flash_attn_tile` us/launch | 27.1 | **85.0** | **3.14x** |
| ATTENTION_full share | 0.6% | 1.8% | 3.0x up |
| total ms / decode step | 84.9 | 86.0 | 1.01x |
The GDN kernel time is flat (even a hair faster) across a 4x context increase, while the
full-attention kernel grows ~3x, exactly the O(1)-vs-O(ctx) signature. The total step time barely
moves because at this batch the (context-independent) FP4 weight GEMM is 88% of the step. This is
the empirical confirmation of the code analysis: **llama's GDN decode does not re-scan the context.**
(An earlier npl32 ctx4096 attempt was discarded: with 32 parallel slots each independently
prefilling ~4100 tokens, the nsys window caught prefill, not steady decode - the `mul_mat_q(M=128)`
+ `flash_attn_ext_f16(ctx4096)` signature gave it away. The npl4 runs above avoid this by keeping
prefill short.)
## 4. Verdict and fix scope
**Efficient, not a bottleneck.** llama.cpp runs the Qwen3.6 GDN/linear-attention layers as a fused,
single-CUDA-kernel, O(1)-in-context recurrent-state update, with the conv and SSM state cached in
the recurrent memory across decode steps. It is algorithmically the same as vLLM's O(1)
`fused_recurrent` decode. The probe's worst case (llama re-scanning context => GDN layers ballooning
with context and concurrency) is **falsified**: the GDN kernel is flat across 4x context, and the
op carries no context-length parameter at all.
**So the GDN path is not the cheap model-specific lever.** It is a small-to-moderate, context-flat
share of the step (~0.4-0.8% at low batch, ~10% including conv at batch 128), and removing it would
not dent the 2.4x. The gap is the general kernel work, confirming `VLLM_DECODE_GROUNDING.md`:
1. the **FP4 weight GEMM** is the dominant bucket (~59% GEMM + ~8% `quantize_mmq_nvfp4` requant that
vLLM fuses away via native FP4-MMA / grouped Marlin); this is the biggest, hardest lever.
2. the **full-attention decode kernel** is the O(ctx) residual (the only thing that grows with
context, ~3x per-launch over 4x ctx), in the minority of full-attention layers.
If anything on the GDN side is ever worth touching, it is a bounded micro-optimization, not a
complexity fix: the kernel is memory-bound on the F32 recurrent state (state read+write is
`S_v^2 * H * batch` = ~0.79 GB/step over 273 GB/s at batch 128, hence the ~8.9% share), and this
traffic is **intrinsic to the architecture - vLLM pays the identical state I/O**, so it is not a
llama-specific inefficiency. A future win could keep the recurrent state in bf16 or fuse the
`ssm_conv` + gated-norm into the delta-net kernel to shave that ~10%, but the ceiling is small and
it does not close the 2.4x. The throughput effort stays where the grounding put it: the FP4 GEMM
(fused act-quant + native FP4-MMA) and the full-attention decode kernel, with a CUDA-graphed
steady-state step as the bounded host-side add-on.
## Reproduce
- Metadata: `python3 gguf-py/gguf/scripts/gguf_dump.py --no-tensors ~/bench/q36-27b-nvfp4.gguf`.
- Code: `src/models/delta-net-base.cpp` (build_delta_net 425, autoregressive 289, fused 373,
build_recurrent_attn 527, build_conv_state 449); `src/llama-context.cpp:200-201,540-595`
(fused_gdn defaults/guard); `ggml/src/ggml-cuda/gated_delta_net.cu` (kernel 4-168, launch grid
184-185, dispatch 226-312).
- Profiles: `~/bench/gdn_study/drv.sh <label> <P> <K> <ctx> <delay> <dur>` runs `llama-server` under
nsys and drives `clientloop.py`; `catgdn.py <sqlite>` buckets kernels. Sqlites:
`gdn_npl128_ctx1024`, `gdn_npl32_ctx1024`, `gdn_npl4_ctx1024`, `gdn_npl4_ctx4096`.