diff --git a/.agents/claims/CLAIM-MODEL-GLM-MOE-DSA.md b/.agents/claims/CLAIM-MODEL-GLM-MOE-DSA.md new file mode 100644 index 0000000000..7033d9be7c --- /dev/null +++ b/.agents/claims/CLAIM-MODEL-GLM-MOE-DSA.md @@ -0,0 +1,5 @@ +# CLAIM-MODEL-GLM-MOE-DSA + +| Claim | Row IDs | Agent | Worktree / remote dir | Branch | Owned scope | State | Last update | +|---|---|---|---|---|---|---|---| +| `CLAIM-MODEL-GLM-MOE-DSA` | `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` (`SPIKE`) | Claude Code (opus-5), helper role — fresh implementer writing the committed spec before any code | isolated worktree `/home/mudler/_git/vllm.cpp-glmdsa-spec`, CPU only; no build, no GPU lease, no download, no pin advance. The artifact census was taken by HTTP RANGE request against the published GGUF shard headers (~9.6 MB), and `/mnt/nas_share` was read but not written | `row/MODEL-TEXT-GLM-MOE-DSA-SPEC`, base `60a6dd97ba1a6163af1f830dd1ae345929346d66`, issue [#2214](https://github.com/mudler/vllm.cpp/issues/2214) | Owns ONLY: `## 3` of [`specs/glm-dsa-latest-deepseek.md`](../specs/glm-dsa-latest-deepseek.md); the `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` row's State, Owner and evidence cells plus its checklist mark and the two rollup counts it moves; this claim file; one appended `issue-index.md` row. EXCLUDES every other row this spike covers, which stays owned by `CLAIM-GLM-DSA-LATEST-DEEPSEEK`; `MODEL-MM-GLM53-FLASH` and [`glm5-next-flash.md`](../specs/glm5-next-flash.md), a different architecture under a different claim; `QUANT-GGUF-IQ4_XS`, which owns the one missing encoding W1 consumes; `ENG-EXPERT-STREAM`, `ENG-EXPERT-STREAM-DEVICE`, `ENG-HYBRID-PLACEMENT` and `ENG-RESIDENCY-CONFIG`, whose work this row consumes and does not take; and `KV-DSV4-MULTICACHE` ([#1925](https://github.com/mudler/vllm.cpp/issues/1925)), which owns the indexer KV side cache W5 needs | `SPIKE` | 2026-08-29 — spec committed first, records only. No product code, no build, no GPU, no artifact staged. Next action: W1 (`IQ4_XS`, owned by `QUANT-GGUF-IQ4_XS`) and W2 (config + registration + `glm-dsa` GGUF arm), both CPU and both independent | diff --git a/.agents/environment.md b/.agents/environment.md index c8dec9acbc..322dceed42 100644 --- a/.agents/environment.md +++ b/.agents/environment.md @@ -484,6 +484,56 @@ container is REUSED between jobs**, so a repair inside a staging branch is skipped on the next run and reports `nvcc already in place`. Write an environment repair unconditionally, and assert its postcondition. +### A staged CUDA toolkit links only if its SONAMEs were rebuilt, measured 2026-08-28 + +The section above records that CIFS stores no symlink and that a copied toolkit +therefore loses its library links, with CMake reporting +`Could NOT find CUDA` as the symptom. **There is a second, quieter symptom of the +same cause, and it costs a whole build rather than eleven seconds.** + +A staging branch that rebuilds `libcudart.so` and `libcublasLt.so` -- the +DEVELOPMENT links -- satisfies CMake completely. `Found CUDAToolkit` succeeds, +every CUDA translation unit compiles, and the job dies ~21 minutes later linking +the first consumer: + +``` +/usr/bin/ld: libvllm.so.0.0.3: undefined reference to `cudaStreamSynchronize@libcudart.so.13' +/usr/bin/ld: libvllm.so.0.0.3: undefined reference to `cublasLtMatmul@libcublasLt.so.13' +... 38 in total, every one @libcudart.so.13 or @libcublasLt.so.13 +``` + +`libcudart.so.13` is the **SONAME**, a THIRD name distinct from both +`libcudart.so` and `libcudart.so.13.3.29`, and it is the name the linker resolves +versioned undefined symbols against. In a real install it is a symlink, so CIFS +does not carry it and a staging branch must recreate it explicitly. + +**The trap inside the trap is the parameter expansion.** `${f#*.so.}` strips the +SHORTEST prefix, so for `libcudart.so.13.3.29` it yields `13.3.29` and not `13`: + +```sh +b=${f%%.so.*}; ln -sf "$f" "$b.so.${f#*.so.}" # links the file to ITSELF +``` + +That line looks like it makes the version link and makes nothing. Take the major +with `v=${f#*.so.}; ${v%%.*}`, or better, let `ldconfig -n ` read each +object's own `DT_SONAME` so the name cannot disagree with what the linker asks +for. `ldconfig -n` does NOT create the `.so` development link, so both are needed. + +**Assert the postcondition, and assert the one the failure depends on.** The +harness that hit this checked `[ -f .../libcublasLt.so ]`, which is precisely the +link its own reconstruction created correctly -- so the precondition passed on a +toolkit that could not be linked against. Check that `.so` resolves AND +that `.so.` exists, for `libcudart` and `libcublasLt` both. That +check costs a second and it discriminates: run against the CIFS source tree it +FAILS, which is the correct answer. + +**This is latent on any box that already has a toolkit.** The staging branch is a +fallback. `dgx:gpu0` carried `/usr/local/cuda` 13.0.88 for every earlier lease and +the fallback was never taken; the box went `unhealthy … worker_lost` for 3h20m on +2026-08-28 and came back without it, which exercised the branch for the first +time. `rc` job `1ad519b1-4e75-41d7-9386-9932076390f1`, exit 34, +[#2220](https://github.com/mudler/vllm.cpp/issues/2220). + ### Two packages a DFlash2 oracle lease needs, and the lease variable that exists, measured 2026-08-22 Measured on `dgx:gpu0` across leases `11cee02a`, `52ac5673` and `a03f34e4` diff --git a/.agents/issue-index.md b/.agents/issue-index.md index 52959ff824..6d33a6479f 100644 --- a/.agents/issue-index.md +++ b/.agents/issue-index.md @@ -838,6 +838,7 @@ rather than merged. `scripts/check-agent-record.py` gates both. | [#2156](https://github.com/mudler/vllm.cpp/issues/2156) | `MODEL-MM-QWEN4-EXP` | **The PLE dilated depthwise causal conv had no `vt::` op, and neither half of what it needs exists on one.** Measured at `d598ffd77` rather than asserted: `CausalConv1dFwd` is causal and carries a persistent `conv_state`, and `CausalConv1dArgs` has NO dilation field at all (only `silu_activation`, `batch_ptr`, `token_chunk_offset_ptr`); `DepthwiseConv1d` / `Conv1d` / `ConvTranspose1d` dilate and are stateless. PLE needs both — `kernel_size = 4`, `dilation = ngram_size = 3`, so output `t` reads lags {9, 6, 3, 0} and the state is a genuine 9-deep history read at stride 3, `(K-1)*dilation` and not `K-1`. **Closed by a NEW OpId rather than by widening `CausalConv1dArgs`, on evidence:** that struct is read by five backends (`cpu_ops.cpp`, `cuda_gdn.cu`, `rocm_gdn_conv.hip`, `vulkan_ops.cpp`, `tenstorrent_ops.cpp`) across three OpIds, a new field is silently IGNORED by every kernel that does not read it, and four of those five arms cannot be gated on a CPU-only host — so the field would ship as a live wrong-answer path on the Mamba/GDN/KDA/Kimi conv instead of a refusal; and the `K-1` state width is welded into the spec-decode path's `max_query_len = state_len - (k - 1) + 1` (`src/vt/ops.cpp:1997`), which turns a nine-column dilated state at K = 4 into a meaningless bound of 7 and feeds it to the per-request checks at `:2007` and `:2009`, so widening it would weaken the guard for every existing caller. (The shared `CheckConvCommon` is NOT that weld and this row does not claim it is: its width test at `src/vt/ops.cpp:1732` is `conv_state.shape[2] >= k - 1`, a lower bound already widened for spec-decode, which a nine-column state passes at 9 >= 3.) Proven non-invasive: `CausalConv1dArgs` is untouched and `test_ops_gdn` (62), `test_gdn_prefill_conv` (10), `test_conv_update_fast` (1) and `test_ops_mamba2_state_update` (6) stay green. `vt::Qwen4ExpPleConv` batches over sequences through `query_start_loc`, takes a nullable per-sequence cache-row index, and has NO `has_initial_state`, because upstream's first call left-zero-pads (`cache_utils.py:1053-1060`) and a zeroed row is bit-identical to that. CPU kernel in `src/vt/cpu/cpu_qwen4_exp_ple.cpp`, gated against the SAME lane-pinned transformers v5.16.0 goldens the W2 host reference answers to, plus a 10240-channel model-width `memcmp` agreement check. **The dilation is gated as a VARIABLE, not assumed**: the generator runs upstream's own `_short_conv` at dilations 3, 2 and 1 over the same input and the same weight, the three answers separate by 0.443 to 0.716 against a 1e-5 tolerance, and the test re-measures that separation so a future regeneration that collapsed it could not pass in silence. 16-mutation battery, 15 RED, each proved applied by sha256 and each build rc read before any test result — five mutations first failed to BUILD under `-Werror` and would have run a stale binary as a pass. The one survivor, M10, is an EQUIVALENT MUTANT with the equivalence proved in the spec, and it is repaired by M16 rather than recorded. **UNREACHED at this merge commit and deliberately so**, exactly as W2/W3/W4/W5b-2 landed: the only production entry point is `ModelRegistry::Forward`, which is all-or-nothing; the wiring is owed by W5b under [#2031](https://github.com/mudler/vllm.cpp/issues/2031) and tracked by [#1978](https://github.com/mudler/vllm.cpp/issues/1978), and reaching this op from the runner's recurrent cache additionally waits on [#2131](https://github.com/mudler/vllm.cpp/issues/2131). Also owed and named in the spec's `## Owed`: the CUDA arm and the accumulator width it must choose, and a bf16 `conv_state` the dispatcher refuses until an arm can write one | feature | | [#2158](https://github.com/mudler/vllm.cpp/issues/2158) | `SERVE-CHAT-TEMPLATE` | The GGUF chat-template selection that #2079 wires is gated as a FUNCTION and not as the server path: deleting the `LoadChatTemplateForModel` call site in `server_main.cpp` leaves `test_chat_template` at 37 cases / 147 assertions green, so the #2077 regression could return unseen. MEASURED on the #2079 head merged onto main, not argued. Landed with the gap named because the wiring is five lines at a production entry point and was verified by hand on gfx1100, while the defect makes every GGUF chat request useless. Owed: a case entering through `VllmServerMain` in the `test_serve_residency_config.cpp` re-exec shape. Its obstacle is why this is its own unit of work — the chat-template block sits after the full engine load, so the nonexistent-model-directory trick cannot reach it and the synthetic GGUF stops one step earlier at the missing tokenizer | bug | | [#2161](https://github.com/mudler/vllm.cpp/issues/2161) | `KV-FP8` | `test_cuda_fp8_kv_cache` went red on `main` at `191f64608` (#2080, W6). Its G1b case loops `{kMETAL, kROCM}` as the backends that register `kPagedAttention` for the float path with no fp8 dequant, and asserts the named refusal in `src/vt/ops.cpp`. W6 implemented the ROCm arm and correctly widened that refusal to `kCPU \|\| kCUDA \|\| kROCM`, so the ROCm leg stopped measuring a refusal and saw `GetOp`'s "no kernel for op PagedAttention on device rocm" instead — neither string the case asserts. The corrected predicate WAS written, at `tests/vt/test_rocm_fp8_kv_cache.cpp:196`, which loops Metal alone; that file is registered under `if(VLLM_CPP_HIP)`, so it never builds on the CPU tier and the CPU-visible copy was missed. Fixed in flow by dropping `kROCM` from the loop, mutation-proven: permitting `kMETAL` in `ops.cpp` reds the case again at 2 of 8 assertions | bug | +| [#7](https://github.com/ghazni101/vllm.cpp/issues/7) | `GFX1100-TG200` | ROCm gfx1100: fp8 KV cache disables all optimized decode attention kernels — `PagedAttnOnline` fallback is 7.5x slower than `PagedAttnDecodeGqaF32Q` at 16K context. Widen the `VT_ATTN_DECODE_GQA4` dtype guard from `kBF16` to `kBF16 \|\| kI8` and add an fp8 dequant load path in the GQA kernel. Spec [`rocm-fp8-kv-decode-attn.md`](specs/rocm-fp8-kv-decode-attn.md) | perf | | [#2166](https://github.com/mudler/vllm.cpp/issues/2166) | `ENG-MM-INPUT-PIPELINE` | **Muse Glimmer's tower could not ride the #1359 bf16 storage change, because its `compute_dtype = kF32` per-stage gate computes on the stored weight values.** The spec's §4.4 ruling that widening back is bit-identical holds for the production loader reading an all-BF16 checkpoint; it does not hold for `test_muse_glimmer_vision`, whose weights are a synthetic f32 LCG that `scripts/mm/muse_glimmer_vision_ref.py:52-61` builds as `torch.float32` and never rounds. MEASURED on a scratch tree with the bf16 store applied: the five f32-arm stages move from rel_l2 1.0-3.0e-07 to 2.164e-03 / 2.193e-03 / 2.220e-03 / 2.892e-03 / 3.462e-03 against a 1e-6 bound — five assertions red, three orders out. The PRODUCTION path is unaffected and that is measured too: the bf16 arm read `rel_l2=5.951e-03 max_abs=3.675e-02` in the same tree, byte-for-byte what it reads today, so the 3.580 GiB -> 7.161 GiB widening is genuinely removable and only the gate stands in the way. Owed: round the LCG through bf16 on BOTH sides and regenerate `muse_glimmer_vision_goldens.inc` (a reference change that needs its own red/green argument and must not ride in the change it gates), then narrow the four structs, grow `Upload` into the `UploadWeight` shape Qwen3-VL now has, and restore `TOWER_RESIDENT_BYTES` for `muse-glimmer` plus the `WIDEN` mirror to 1x. Threshold unchanged from `specs/vision-tower-dtype-polarity.md` §6.1: >= 3,459,322,368 B on the default arm | bug | | [#2164](https://github.com/mudler/vllm.cpp/issues/2164) | `BACKEND-ROCM` | **gfx1100 decode is launch-bound, and kernel micro-optimization is exhausted as a lever.** The GFX1100-TG200 campaign swept 15 levers, adopted 11, and reached ~103 tok/s (~9.71 ms/tok) against a 200 tok/s target on Qwen3.5-4B-Q4_K_M / RX 7900 XTX. The discriminating result is a NEGATIVE one: T20's full-warp `KQuantGemvMmvqRow` rewrite is 2.38x-3.13x faster on large grids in microbenchmark, and a paired interleaved 5-rep engine A/B reads 92.9 vs 92.8 tok/s — a 0.1% wash — because the dominant Q4_K path runs at grid ~576 and is bound by fixed launch cost, not by the reduction barriers the rewrite removed, while the large-grid win lands on lm_head at one call per token (~0.04 ms/tok averaged). An earlier `rocprofv3` capture shows the mechanism directly: 97 standalone `QuantizeQ8KK` launches per token, EVERY one a single block (`m*nsb <= 128` at batch 1), mean duration 48.2-50.1 us FLAT with respect to K. The issue also separates two overhead terms the campaign's summary collapses: ~4.2 ms/tok is kernel time above the 4.38 ms/tok weight-read floor (occupancy and per-launch cost INSIDE kernels), and a further ~1.13 ms/tok is wall outside kernels entirely. Next levers are HIP graph capture ([#332](https://github.com/mudler/vllm.cpp/issues/332), which predicted this on gfx1200 from an explicitly unmeasured two-point fit and which this measures on gfx1100), a `SiluMulK` quant epilogue for the 40 of 97 launches the `RmsNorm` epilogue cannot absorb, then persistent kernels. Owed: the evidence is read from unmerged [#1936](https://github.com/mudler/vllm.cpp/pull/1936) at `b058bb752` and is NOT reproducible from `main`, so landing `docs/bench-evidence/gfx1100-tg200-*.md` and the campaign spec comes first, then a fresh capture with per-token dispatch counts to replace the budget-table arithmetic with a traced split | perf | | [#2167](https://github.com/mudler/vllm.cpp/issues/2167) | `MODEL-MM-QWEN4-EXP` | **Qwen Sparse Attention on the device arm, and the finding that HALF of it was already a `vt::` op.** The issue's "why nothing existing serves it" table named `IndexSelect`, `TopKValuesIndices`, `GatherMlaCache` and the fused `kDeepseekV4Dsa`/`kDeepseekV4Compressor`, and it omitted the two that DO serve: `vt::DsaIndexerLogits` computes `sum_h fold[t,h] * ReLU(dot(q[t,h,:], k[s,:]))` over a one-key-head MQA cache with a per-query `[win_start, win_end)` window, and with `weights` all ones, a null `q_scale` and `n_head_scale = 1` its fold collapses to the single constant `softmax_scale` — set that to `index_head_dim ** -0.5` and it IS `Qwen4ExpTextQSAIndexer`'s block score, QSA having neither DeepSeek-V4's learned `weights_proj` nor its `n_head ** -0.5`; and `vt::DsaTopkSelect` is the same all-select-below-k, ties-to-the-LOWER-index, ASCENDING-emission top-k over the block axis. So the indexer is COMPOSED from those two rather than re-implemented beside `cpu_dsa_indexer.cpp`, which would have been the parallel path AGENTS.md §"Shared seams" forbids. Measured, not argued: the composed selection equals the transformers v5.16.0 selected-token sets for every query token of both fixtures, ragged tail included. **Two ops are genuinely new**: `vt::Qwen4ExpQsaCompress` (mean pool over a NON-overlapping window of `compress_ratio`, `k_layernorm` on the pooled key, RoPE at the block's FIRST token) — the pool has no `vt::` counterpart at all, this tree having no mean, no pool, no axis reduction and no transpose to fake one with, and fusing the three stages mirrors upstream's own single kernel on the `kFusedNormRope` precedent; and `vt::Qwen4ExpQsaGatherAttention`, which has no counterpart anywhere, every DeepSeek-V4 sparse consumer attending the COMPRESSED MLA KV and MiniMax-M3's attending raw tokens at KV-PAGE granularity while QSA attends RAW tokens at ratio-4 granularity. CPU kernels in `src/vt/cpu/cpu_qwen4_exp_qsa.cpp`. **The gates run PAST the 2048-token indexer budget**, which the spec's `## Gates` requires because below it every candidate is selected: the released indexer config at 3002 tokens of context discards 238 of 750 complete blocks, and the 2051-token control beside it selects everything and is the measurement of why the requirement exists. **A mutation survived and was REPAIRED rather than recorded**, and it is the one this wave exists to stop: a dense masked walk over every cached row that reports the SPARSE `keys_visited` figure passed 10 cases / 4167 assertions, exactly as W4's M22c passed 12 / 7251 — a counter a kernel writes cannot convict the kernel that writes it, and no value comparison can either, because `exp(-inf - m)` is exactly +0 so a mask agrees with a gather value for value. The repair is an observable of the WALK: a cache whose UNSELECTED rows are NaN, which a gather never addresses and a mask multiplies by a zero weight into `0.0f * NaN` = NaN. **UNREACHED at this merge commit and deliberately so**, as W2/W3/W4/W5b-1/W5b-2 landed: `ModelRegistry::Forward` is the only production entry point and is all-or-nothing; the wiring is owed by W5b under [#2031](https://github.com/mudler/vllm.cpp/issues/2031), tracked by [#1978](https://github.com/mudler/vllm.cpp/issues/1978), and reaching the ops from the runner's caches additionally waits on [#2131](https://github.com/mudler/vllm.cpp/issues/2131). Also owed and named in the spec's `## Owed`: the CUDA arm of both ops and the three decisions it must make first, a single-pass online softmax, the ungated bf16 STORAGE arms, and the paged side-cache store | feature | @@ -867,5 +868,11 @@ rather than merged. `scripts/check-agent-record.py` gates both. | [#2240](https://github.com/mudler/vllm.cpp/issues/2240) | `QUANT-GGUF-IQ2_XS` | **IQ2_XS (17) and IQ4_XS (23) — the last two GGUF dequantizers the staged GLM-5.3-Flash artifact needed, and the two the loader stopped dead on.** "UD-Q2_K_XL" names a target average, not a format: of that artifact's 1412 tensors only TWO are Q2_K, while 82 are IQ2_XS (the `ffn_gate_exps`/`ffn_up_exps` routed experts) and 3 are IQ4_XS, so `LoadedEngine::FromModelDir` refused at `blk.3.ffn_gate_exps.weight has unknown ggml type id 17` before any dequant code ran — the reader had no block stride for 17, and the switch had no decoder for either. Both ported 1:1 from llama.cpp `b10451` (`ggml/src/ggml-quants.c:2516` `dequantize_row_iq2_xs`, `:2743` `dequantize_row_iq4_xs`, `ggml/src/ggml-common.h:627` `iq2xs_grid`) and gated BYTE-FOR-BYTE against the oracle's own decoders over REAL bytes read out of the two tensors that failed. IQ2_XS is the middle member of a family of three same-shaped codebooks — 256 / 512 / 1024 entries — where reaching for the wrong table still runs and still produces plausible magnitudes, so the 512-entry grid carries an FNV-1a seal as well. IQ4_XS reuses `kValuesIq4nl` unchanged; its delta is the super-block scale layout, a 6-bit `ls` spliced from a `scales_l` nibble and a `scales_h` bit pair and then biased by -32. Also carries the record correction the issue asked for: `.agents/specs/glm5-next-flash.md` O5/O8 are about the converter's WRITE side and were read as meaning the i-quant lane was absent entirely. Owning row `QUANT-GGUF-IQ2_XS` (and `QUANT-GGUF-IQ4_XS`); found by W5 of [#1998](https://github.com/mudler/vllm.cpp/issues/1998) via [#2223](https://github.com/mudler/vllm.cpp/issues/2223) | feature | | [#2243](https://github.com/mudler/vllm.cpp/issues/2243) | `MODEL-MM-glm5-next-glm5-next-for-conditional-generation` | **`glm5next.attention.head_count_kv` is a per-layer `array[i32]` in the published artifact and `Glm5NextHfConfigFromGguf` reads it as a scalar.** Found while landing [#2240](https://github.com/mudler/vllm.cpp/issues/2240): with IQ2_XS and IQ4_XS decoded, the production loader gets past the type-17 refusal, opens all four shards, sizes all 1412 tensors, and stops instead at `glm5_next gguf: key glm5next.attention.head_count_kv is not an integer`. The artifact stores the layer schedule there — length 46, `0` on the 35 KDA layers and `1` on the 11 DSA/MLA layers — and `swiglu_clamp_exp`/`swiglu_clamp_shexp` are per-layer `array[f32]` of the same length directly behind it. Filed rather than fixed in that flow because it belongs to this row's config/loader wave and not to a dequant change; listed under `## Owed` as O18 in [`specs/glm5-next-flash.md`](specs/glm5-next-flash.md) | bug | | [#2201](https://github.com/mudler/vllm.cpp/issues/2201) | `BACKEND-TENSTORRENT-QWEN35` | **W3, the GDN row's reviewer leftovers: the d2h counter misses two download paths, and `EnsureGdnCacheDevice`'s fast path accepts a conv-transposed host pointer.** `GdnStateD2hBytes()` (`tenstorrent_ops.cpp:4164`) counts `:5039`/`:5109` but not the `EnsureGdnCacheDevice` (`:4216`) slow-path download nor the `CommitConvTransposed` (`:4563`) untracked-buffer fallback, so `state_d2h_bytes` is a lower bound and counter-asserting legs cannot see those paths; and the fast path keys on the host pointer alone without the `conv_transposed` role check, so cross-role pointer reuse would serve a wrong-geometry cached tensor (not live today — `qwen3_5.cpp` uses distinct buffers). Test-first fix in `tests/vt/test_tenstorrent_backend.cpp`: red per leftover, then the two missing `fetch_add`s and a role-mismatch refusal that names it; sacred 16/16 goldens byte-identical | bug | +| [#2214](https://github.com/mudler/vllm.cpp/issues/2214) | `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` | **GLM-5.3 is 97.49% routed experts, so the hardware blocker was computed in the wrong FRAME and the row moves `BLOCKED` -> `SPIKE` on a committed port plan.** [#2194](https://github.com/mudler/vllm.cpp/issues/2194) sized RESIDENT weights — 753,329,940,480 parameters, 703.74 GiB of fp8, 1403.2 GiB bf16 against 119.631 GiB on `dgx:gpu0`, needing 1.36 bpw — which is the right frame for a dense model and the wrong one for a 256-expert MoE. Recomputed here from `zai-org/GLM-5.3`'s own `config.json` and checked against the checkpoint's `model.safetensors.index.json` (`metadata.total_size = 755,617,140,416` over 118,629 tensors): routed experts are **734,439,407,616 of 753,328,717,824 = 97.49% streamable**, resident is **18,889,310,208 = 2.51%**, and the total reproduces the measured `safetensors.total` to **-1,222,656 params (-0.00016%)** — tighter than #2214's own 745.8B model, which omitted the MTP block's 256 experts and put resident 2.1B too high. **The decisive number is measured from the artifact, not derived.** A full GGUF header census of `unsloth/GLM-5.3-GGUF` `UD-IQ1_S` at revision `346b3591c7f28d1a23716f97a065ecf12ec14771` — 6 shards, 1809 tensors matching the file's own `split.tensors.count`, read by HTTP RANGE request at a cost of ~9.6 MB with nothing downloaded — splits it into **228 `*_exps.weight` towers = 187.312 GiB streamable and 1581 resident tensors = 14.511 GiB**, and the resident class is ~14.5 GiB in every published UD arm because the recipe keeps every non-expert tensor at Q4_K or better. One decode step at `c = 1` touches `75 x 3 x 8 = 1800` distinct slices = **11.21 GiB** of uniform slots, so resident + a 4096-slot cache is **40.01 GiB**. **A `UD-*` name is a target average and not a format:** `UD-IQ1_S` is 106 IQ1_S + 71 IQ3_XXS + 44 IQ2_XXS + 4 IQ4_XS + 3 K-quant expert tensors, and `UD-Q2_K_XL` holds exactly TWO Q2_K tensors out of 1809. **The decoder/`vec_dot` verdict was re-measured against the merged tree, because `origin/main` moved under this branch and inverted it.** At the base `60a6dd97b`, neither `IQ4_XS` (23) nor `IQ2_XS` (17) had a vt block dtype or a decoder and both were a hard refusal. `94de63ff5` ([#2245](https://github.com/mudler/vllm.cpp/issues/2245)), landed 2026-08-29 for the sibling Flash row, ported BOTH DEQUANTIZERS and NEITHER keep-quant `vec_dot`. `gguf_keep_quant.cpp::KeepQuantDType` returns false without `vt::cpu::HasQuantDotKernel`, so **a type with a decoder and no `vec_dot` EXPANDS to bf16 at load**, and `gguf_device_fit.cpp:85-100` is all-or-nothing across a model's `*_exps` tensors, so four offending tensors out of 228 drop the whole arm out of the expert-streaming lane. One `*_exps` tower is 3,221,225,472 elements = 6.000 GiB at bf16, so `UD-IQ1_S`'s four IQ4_XS towers go **6.375 -> 24.000 GiB** and `UD-Q2_K_XL`'s 148 IQ2_XS towers go **128.344 -> 888.000 GiB**, while the uniform slot grows from 6.375 MiB to a 24.00 MiB bf16 slice. **So the row is blocked on exactly one kernel and it is a `vec_dot`, not a decoder: `VecDotIQ4_XSQ8_K`**, four tensors `blk.{8,75,76,77}.ffn_down_exps.weight`, which also unlocks `UD-IQ2_M`; `QUANT-GGUF-IQ4_XS` already owns it. `IQ1_M` (29) still has no reader traits, so `UD-IQ1_M` refuses at file OPEN. The general defect, named but not repaired here: landing a decoder without its `vec_dot` converts a loud refusal into a silent 3.4x memory multiplier no token gate can see. **One premise of #2194 is corrected: the pinned vLLM class CAN load this checkpoint.** It never reads `indexer_types` — zero occurrences tree-wide at `555967922` — it DERIVES the schedule at `deepseek_v2.py:1092-1103` from `index_topk_freq`/`index_skip_topk_offset`, giving 21 full trunk indexers plus the MTP layer, and drops surplus checkpoint indexer weights at `:1566-1582`. The config's `indexer_types`, that derivation, and llama.cpp `b10451`'s hardcoded `GLM_5_2_DEFAULT_INDEXER_TYPES` (`src/models/glm-dsa.cpp:6-27`) agree bit for bit over all 78 layers, and the checkpoint ships `self_attn.indexer.*` on exactly 22 of 79 blocks. `modules_to_not_convert`'s `self_attn.indexers_proj` matches no shipped tensor and no upstream module at the pin, so it is a config-level shorthand and a loader must not mirror it. **The delta is smaller than the spike's own DSA verdict implies**, because a device-native DSA lightning indexer now lives in the shared MLA block (`mla_attention.cpp:598-745`, CPU+CUDA `DsaIndexerLogits`/`DsaTopkSelect`, reached in production by `Dots3NoteForCausalLM`) and this model's MLA geometry already validates and dispatches to native kernels. Net-new: the indexer KV side cache ([#1925](https://github.com/mudler/vllm.cpp/issues/1925)), sparse prefill, the heterogeneous per-layer schedule with `skip_topk` selection reuse, the fp32 router GEMM, a `"glm-dsa"` `kGgufArchArms` row, and lifting the expert-streaming seam out of `qwen3_5.cpp`, which is the only model TU that constructs it. **The gate is the honest cost: NO end-to-end token gate against vLLM is reachable on this fleet**, because vLLM implements the architecture and cannot run 703.74 GiB on a 119.631 GiB unified device whose host RAM is the same pool; what is reachable is module parity against the pin on CPU, a headers-only structural loader gate, a streamed-vs-resident identical-logits gate needing no oracle, and an llama.cpp `b10451` floor on the identical artifact, with speed an open gap by construction. Eight waves planned, none landed. Records and spec only: no product code, no pin advance, no build, no GPU lease, no download | feature | | [#2247](https://github.com/mudler/vllm.cpp/issues/2247) | `QUANT-GGUF-IQ2_XS` | **Keep-quant `vec_dot` for IQ2_XS and IQ4_XS: 325.58 GiB, and the difference between the staged GLM-5.3-Flash artifact fitting `dgx:gpu0` and overflowing it 3.6x.** [#2245](https://github.com/mudler/vllm.cpp/pull/2245) gave both types a row DECODER, which is what moved the loader past `unknown ggml type id 17`. A decode-only type has no `vec_dot`, so `HasQuantDotKernel` is false and every GEMM weight of that type expands to bf16 at load. Measured from the artifact's own headers, all four shards and all 1412 tensors: **101.24 GiB on disk, 597.46 GiB as bf16**, an expansion of 5.9x, of which IQ2_XS alone is 53.33 -> 369.00 GiB and IQ4_XS 3.59 -> 13.50 GiB. Resident TODAY **426.72 GiB** against the ~119.63 GiB the box has, so it does not fit; with these two kernels **101.14 GiB**, which fits with 18.49 GiB of headroom. Every other encoding in the file already keeps its quantization, IQ3_XXS (`VecDotIQ3_XXSQ8_K`) included, so these two are the entire gap. Two rows in `src/vt/cpu/cpu_quant_dot.cpp` beside the fifteen already there, ported from the pinned llama.cpp `b10451` and gated BYTE-FOR-BYTE against the oracle's own kernel on real artifact bytes, because a `vec_dot` defect shows up as numeric drift and not as a crash. Owning rows `QUANT-GGUF-IQ2_XS` and `QUANT-GGUF-IQ4_XS` in [`quantization-matrix.md`](quantization-matrix.md), both carrying it as `C` = `-`; also recorded as O18 in [`specs/glm5-next-flash.md`](specs/glm5-next-flash.md) | feature | | [#2252](https://github.com/mudler/vllm.cpp/issues/2252) | `SPEC-DFLASH2` | **The draft block's paged attention set neither `query_start_loc_host` nor `max_seq_len`, so it took a `cudaStreamSynchronize` path inside the CUDA graph capture it runs in, and the committed speed gate could not produce a number at all.** Found by running `scripts/dflash2-speed-gate.sh` on `main` at `e65678109` in an `rc` lease on `dgx:gpu0`: our arm exited 1 with `[vt cuda: paged prefill qsl sync: operation not permitted when stream is capturing]`, `GATE_RC=2`, `RESULT_PRESENT=no`. `DflashBlockPagedAttention` (`qwen3_dflash_internal.h`) set `scale`, `causal`, `window_size` and `uniform_spec_query_len` but left BOTH host-metadata fields at their defaults, and `include/vt/ops.h` states the cost of each in the same words — `query_start_loc_host` nullptr "=> the launcher falls back to the D2H+sync" (`:1546`), `max_seq_len` 0 "=> that launcher falls back to the D2H+sync" (`:1555`). That fallback is `BuildPrefillTiles`' legacy arm (`cuda_paged_attn.cu:2267-2272`), a D2H copy plus `cudaStreamSynchronize`, shared by all six prefill launchers (`:2301`, `:2333`, `:2371`, `:2411`, `:2452`, `:2495`), so which one ran does not change the outcome — **and the draft block is the ONE lane this tree captures** (`P == 1`, `qwen3_dflash.cpp:1716`, replaying `st.g_graph`). REGRESSION with a witness: `9aea9efec` (W11, [#1890](https://github.com/mudler/vllm.cpp/issues/1890)) added the call and is NOT an ancestor of `04ed7b984`, where the same gate's our-arm demonstrably RAN — the O28B evidence carries `our-arm.json` and `clock-ours.json` and refused only on a clock window ("idle for 2630 of 3222 SM-clock samples, 18.37% busy, below the 50% floor"), which is reachable only by executing and being sampled. WHY NOTHING CAUGHT IT: both fields are OPTIONAL by type, so omitting them compiles and is merely SLOW wherever nothing is capturing; the CPU backend ignores them entirely, so the file's own byte-identical equivalence suite stayed green through the whole defect (**measured**: under the mutation 13 of 15 cases still passed); and the failure needs CUDA AND capture AND the prefill ladder together, which nothing in CI reaches ([#1972](https://github.com/mudler/vllm.cpp/issues/1972)). Fixed by setting both from values the function already derives — the single-request host qsl `[0, tq)` and the EXTENDED bound `ctx_len + tq`, which `DflashBlockPagedInputsOf` computes and the call already refuses on if it disagrees; an upper bound is explicitly safe because it only sizes grids (`ops.h:1551-1553`). Gated by extracting the args build into a pure `DflashBlockPagedArgsOf`, on the `DflashBlockPagedInputsOf` / `DflashBlockPagedMaskOf` precedent this file already sets, because asserting the host-meta helper ALONE would pass while production forgot to use it — which is exactly how this shipped. Red-first proven by mutation: both new cases fail on `REQUIRE(pa.query_start_loc_host != nullptr)` with the fields defaulted, 48/48 assertions green with them set, and all 29 `dflash` test binaries pass | bug | +| [#2250](https://github.com/mudler/vllm.cpp/issues/2250) | `MODEL-TEXT-laguna-laguna-for-causal-lm` | Lever #2: measure what bounds `QuantDotGemmGroupedKernel` (Q4_K/Q5_K, 62.1% of Laguna decode GPU) BEFORE tuning it. W11 labelled it "BW-tuning", but the sibling `QuantDotGemmQ8_0Kernel` was measured LATENCY- and LSU-pipe-bound with five structural levers refuted and a recorded floor, so the label is not evidence. W1 is `ncu` counters on the decode path only | perf | +| [#2177](https://github.com/mudler/vllm.cpp/issues/2177) | `MODEL-MM-glm5-next-glm5-next-for-conditional-generation` | **The GGUF config builder never read `glm5next.attention.head_count_kv`, so the published artifact's layer schedule was refused rather than read — and the `idx % 4 != 3` fallback that would have replaced it is right on this checkpoint by coincidence.** Fixed together with [#2243](https://github.com/mudler/vllm.cpp/issues/2243), because they are two faces of one defect: `Glm5NextHfConfigFromGguf` read that key as a scalar (`OptInt` -> `KvInt`, whose `default:` arm threw `key glm5next.attention.head_count_kv is not an integer`) and then REQUIRED a `glm5next.layer_types` string array only `scripts/convert-glm5-next-gguf.py` writes. The published `unsloth/GLM-5.3-Flash-GGUF` UD-Q2_K_XL artifact carries neither shape: measured 2026-08-29 out of shard 1's own 72-key KV block, `attention.head_count_kv` is `array[i32]` n=46, `swiglu_clamp_exp` and `swiglu_clamp_shexp` are `array[f32]` n=46, and there is no `layer_types` key at all. The reader now accepts llama.cpp's scalar-or-array spelling of that key (`b10451:src/llama-model.cpp:1177` reads it through `get_key_or_arr(..., n_layer, false)`) and DERIVES the schedule from the values with llama.cpp's own predicate, `is_recr_impl[i] = hparams.n_head_kv(i) == 0` (`b10451:src/models/kimi-linear.cpp:18`, "KDA layers are recurrent"). **The array is 34 zeros and 12 ones, not 35 and 11**: `block_count` is 46 because it counts the MTP block (`nextn_predict_layers = 1`) on top of 45 model layers, the ones sit at 3, 7, ..., 43 AND at 45, and `45 % 4 == 1` — so a consumer that re-derives the stride selects eleven MLA-shaped blocks where the file states twelve and reports nothing. That is why the non-stride fixture, not the published one, is the case that proves the values are read. The array is a SCHEDULE and not a KV-head count: its non-zero entries are `1`, the single latent KV head MLA has, while upstream requires `num_attention_heads == num_key_value_heads`, so the array form leaves `num_key_value_heads` at upstream's `None -> num_attention_heads` default. When both spellings are present they are cross-checked on the layer KIND and a clash refuses by name; a per-layer array whose length is not `block_count` refuses by name with the shape found; a non-uniform clamp array refuses, because upstream has ONE `swiglu_limit`; and a file stating neither schedule still refuses, naming both keys. Listed as O18 in [`specs/glm5-next-flash.md`](specs/glm5-next-flash.md) | bug | +| [#2268](https://github.com/mudler/vllm.cpp/issues/2268) | `MODEL-MM-glm5-next-glm5-next-for-conditional-generation` | **The published GGUF spells MLA geometry by llama.cpp's `attention.key_length` convention and carries no `attention.linear_head_count`, so the loader stops there next.** Measured 2026-08-29 on one tree and one binary, with the [#2243](https://github.com/mudler/vllm.cpp/issues/2243) / [#2177](https://github.com/mudler/vllm.cpp/issues/2177) array fix reverted and restored, driving `LoadedEngine::FromModelDir` at `/mnt/nas_share/rc/ckpt/GLM-5.3-Flash-UD-Q2_K_XL/` on `device = kCPU`, headers only: without the fix it stops at `key glm5next.attention.head_count_kv is not an integer`, with it at `attention.key_length_mla - attention.key_length is -256 but rope.dimension_count is 0` (`glm5_next_weights.cpp:435`). The file is not malformed. llama.cpp writes `key_length = kv_lora_rank + qk_rope_head_dim` and `key_length_mla = qk_nope_head_dim + qk_rope_head_dim` (`b10451:conversion/deepseek.py:345-348`), which for this model gives the artifact's own 512 / 512 / 256 / 256; `scripts/convert-glm5-next-gguf.py` writes `key_length = qk_nope_head_dim`, a different quantity under the same name. `glm5next.attention.linear_head_count`, a `ReqInt` in the builder, is in none of the file's 72 keys and llama.cpp spells it nowhere. Filed rather than fixed in that flow because it moves the WRITE side: putting the reader on llama.cpp's meaning without moving the converter would refuse our own output, and which spelling this project writes is a row-and-spec decision. O7 records that our converter has never been run, so no artifact of ours is invalidated by the move. Recorded under `## Owed` as O18 in [`specs/glm5-next-flash.md`](specs/glm5-next-flash.md) | bug | +| [#2220](https://github.com/mudler/vllm.cpp/issues/2220) | `LTX25-ORACLE-ABSOLUTE` | **A CUDA toolkit staged off CIFS compiles but cannot be LINKED against, and the precondition that should catch it checks the one link that works.** `/workspace` is CIFS and stores no symlink, so a staged toolkit carries only `libcudart.so.13.3.29` and `libcublasLt.so.13.6.0.2`. `ltx25-oracle-absolute-render.sh` rebuilt the links with `b=${f%%.so.*}; ln -sf "$f" "$b.so"; ln -sf "$f" "$b.so.${f#*.so.}"` -- and `${f#*.so.}` strips the SHORTEST prefix, so it expands to `13.3.29` rather than `13`. The second `ln` therefore links `libcudart.so.13.3.29` to ITSELF and **`libcudart.so.13`, the SONAME, is never created**. That is the name `ld` resolves versioned undefined symbols against, so CMake reports `Found CUDAToolkit`, every CUDA TU compiles, and the job dies 21 minutes later with 38 `undefined reference to ...@libcudart.so.13` / `@libcublasLt.so.13` and `ninja: build stopped`. `need_ok` tested `[ -f .../libcublasLt.so ]`, which is exactly the link the loop DID create, so it passed on an unlinkable toolkit -- a precondition that cannot fail. LATENT, not new: the staging branch is a FALLBACK, and every earlier lease found `/usr/local/cuda` 13.0.88 and never took it; `dgx:gpu0` went `unhealthy ... worker_lost` for 3h20m on 2026-08-28 and returned without a toolkit, which exercised it for the first time. The two runs A/B in their own configure logs: `20260827T220845Z` `/usr/local/cuda` 13.0.88 built in 1192 s, `20260828T224529Z` `/root/cudatk` 13.3.73 failed at link. Fixed in flow: take the MAJOR (`v=${f#*.so.}; ${v%%.*}`), prefer `ldconfig -n` which reads each object's own `DT_SONAME`, and assert `.so` resolves AND `.so.` exists for both libraries BEFORE the build. Red-before/green-after on a replica of the CIFS layout: old loop creates no `.so.13`, new logic creates both, and the guard FAILS on the old layout, PASSES on the new, and FAILS on the real NAS source. `rc` job `1ad519b1-4e75-41d7-9386-9932076390f1`, exit 34. Also recorded in [`environment.md`](environment.md) as a lease-environment fact, because it will bite the next row | bug | +| [#2244](https://github.com/mudler/vllm.cpp/issues/2244) | `BACKEND-TENSTORRENT-QWEN35` | **The staging upload still pays tt-metal's full creation path on every step.** W4 (#2107) landed levers 1+2 — bulk bf16 staging and single-slot resolution, 0.104 → 0.177 tok/s (+70%), staging `Numel()` share 27.09% → 1.76% — and re-attributed the residual: ~23% of the staging chain is tt-metal per-upload internal work (a fresh `MeshBuffer` allocation, cluster/chip discovery, CQ completion handling) and ~19.2% is CPU threadpool spin, with lever 3 (batch per-layer staging) explicitly not taken. Every upload still goes through `UploadRowsBf16` (`src/vt/tenstorrent/tenstorrent_ops.cpp:469`), which builds a new `ttnn::Tensor` via `from_span`, so identical geometry pays the creation path every step. The lever the W4 record named: allocate the device buffer once per staging slot — lifecycle tied to the slot structures under the #1486 never-destroy rule — and write the host bytes through the mesh command queue (`MeshCommandQueue::enqueue_write`/`enqueue_write_shard`), making the upload allocation-free. The tt-metal-internal half is a proof obligation, not an assumption: read the pinned tt-metal's mesh write path and trace the executed chain before declaring any part of the lever unreachable. `StagingStats` gains route counters for the new path; the capture-unsafe host-write refusals keep their semantics; the f32-conversion arms keep their declared dtypes. Invariant: staging stays bit-identical — the sacred golden pair 16/16 and the full TT suite green; this wave changes speed, never tokens. Evidence owed: same-method before/after profile on the P150 (identical leg, lock discipline) plus a fresh benchmark-record entry; a wall that does not move is a reported result, not a failure — the attribution shifts or the lever is named unreachable with the trace that proves it. Owned by `BACKEND-TENSTORRENT-QWEN35`, next wave after W3 (#2201, landed via #2217) | feature | diff --git a/.agents/kernel-matrix.md b/.agents/kernel-matrix.md index 751b5ec90a..c084a6d02d 100644 --- a/.agents/kernel-matrix.md +++ b/.agents/kernel-matrix.md @@ -137,7 +137,7 @@ host/sched. Detail: state `KERNEL-FA2-GQA-SWAP-FLIP`. | `KERNEL-ATTN-FA3-FA4` | FlashAttention-3/4 | FA3/FA4 build `setup.py:1113-1124`; runtime version selection `vllm/v1/attention/backends/fa_utils.py:132-250` | - | - | [inventory](specs/kernel-family-inventory.md) | `INVENTORIED` | - | | `KERNEL-ATTN-FLASHINFER-TRTLLM` | FlashInfer paged attention, XQA, and TensorRT-LLM generation kernels | availability/dispatch `vllm/utils/flashinfer.py:206-342,373-511`; SM90 decode and SM100+ prefill/decode gates | - | - | [inventory](specs/kernel-family-inventory.md) | `INVENTORIED` | - | | `KERNEL-ATTN-TRITON-FLEX-HPC` | Triton, FlexAttention, TurboQuant, and HPC attention families | registry `registry.py:34-120`; Triton contract `triton_attn.py:250-383`; HPC gate `hpc_attn.py:235-247` | - | - | [inventory](specs/kernel-family-inventory.md) | `INVENTORIED` | - | -| `KERNEL-ATTN-MLA-SPARSE` | MLA and sparse attention: CUTLASS, FlashMLA, FlashInfer, FA, Triton, MSA **W6: the MLA attention BLOCK + WEIGHT ABSORPTION — the layer that COMPOSES W3+W4+W5** — [mla_attention.h](../include/vllm/model_executor/models/mla_attention.h) + [mla_attention.cpp](../src/vllm/model_executor/layers/attention/mla_attention.cpp) <- `mla.py:119-181` (`MultiHeadLatentAttentionWrapper.forward`) over `mla_attention.py:553-620` (the cache-update-BEFORE-attention order), `:624-874` (`forward_impl`: the dispatch + the absorbed decode) and `:2344-2425` (`forward_mha`); `AbsorbKvBProjBf16` <- `:875-962 process_weights_after_loading` (split `:892-900`, permutes `:959-962`); `MakeMlaUpProjectFn` <- `:2141-2170` (the `kv_b_proj` callback W5 left open); `BuildDeepseekRopeCosSinCache` <- `deepseek_scaling_rope.py:76-118` over `rotary_embedding/common.py:34-70`; `MlaAttentionScale` <- `deepseek_v2.py:995,1067-1075` (the mscale^2 correction, kept SEPARATE from the rope's own rotation mscale). **Absorption is a LOAD-TIME weight transform plus TWO batched GEMMs, not a fused kernel**, so it needed only two new general primitives: **`vt::BatchedMatmul`** <- `torch.bmm` at `mla_attention.py:789` (q-side W_UK fold) and `:1034` (`_v_up_proj`), CUDA impl = cuBLASLt STRIDED-BATCHED [cuda_matmul.cu](../src/vt/cuda/cuda_matmul.cu) (the cuBLASLt form of the cuBLAS `gemmStridedBatchedEx` torch.bmm resolves to; the only upstream alternatives are ROCm-only aiter fp8/fp4 bmm branches) + CPU ref [cpu_ops.cpp](../src/vt/cpu/cpu_ops.cpp), stride-driven because BOTH call sites pass `.transpose(0,1)` views; and **`vt::ConcatMlaNopeRope`** <- `ConcatMLAQKernel` (`csrc/libtorch_stable/concat_mla_q.cuh`) + wrapper `cache_kernels.cu:1555-1600`, GENERALIZED to arbitrary nope/rope widths and a head-BROADCAST rope operand so one op also serves `_concat_k_nope_k_pe` (`:2063-2092`) — CUDA [cuda_mla_attn.cu](../src/vt/cuda/cuda_mla_attn.cu), CPU [cpu_ops.cpp](../src/vt/cpu/cpu_ops.cpp). Two ADDITIVE relaxations of existing ops, integer-identical for contiguous tensors: `vt::RopeFromCache` stride-driven on q/k (DeepSeek rotates the TRAILING 64-dim slice and its `k_pe` is a column block of the fused kv_a projection) and `vt::MatmulBT` accepting a row-strided ACTIVATION (`kv_b_proj` applied to a 512-column slice of the 576-wide workspace, `:2160`) | CUDA priority `vllm/platforms/cuda.py:84-176` (`_get_backend_priorities`, both branches); MLA classes `vllm/v1/attention/backends/mla/*.py`; MLA prefill selector `mla/prefill/selector.py:47-76`; capability filter `vllm/v1/attention/backend.py:307-360`; CUTLASS build `CMakeLists.txt:1037-1061` **W6** [test_mla_attention_block.cpp](../tests/vllm/model_executor/layers/attention/test_mla_attention_block.cpp) **10/10 cases / 2,372,644 assertions** and [test_ops_mla_absorb.cpp](../tests/vt/test_ops_mla_absorb.cpp) **9/9 / 1,644,807 assertions** on dgx sm_121 — ports of `tests/kernels/test_concat_mla_q.py` (BOTH arms incl. the NON-CONTIGUOUS transposed-nope case, compared bit-exactly since a concat is a pure copy), the MLA-geometry sweep of `tests/v1/attention/test_mla_backends.py`, and the two-pass-oracle discipline of `tests/kernels/attention/test_mla_decode_cpu.py`. **THE ABSORBED-vs-UNABSORBED EQUIVALENCE IS PROVEN NUMERICALLY, THREE WAYS:** an INDEPENDENT double-precision block oracle computing the attention BOTH ways agrees to **< 1e-11** (the identity itself, at both query branches); our absorbed decode reproduces the UNABSORBED oracle to **< 2e-4** (f32); and the SAME batch driven once through the ABSORBED MQA decode kernel and once through the UNABSORBED materialized-MHA prefill path agrees to **< 3e-4** (CPU f32) / **< 4e-2** (CUDA bf16) — two code paths sharing nothing but the weights. Real geometry throughout (V2-Lite 512/128/64/128/16-head, plus V3's 7168 / 128-head / `q_lora_rank=1536` for the lora branch, which has NO e2e coverage and says so). Decode-only / prefill-only-no-context / chunked-prefill-with-context / MIXED (decode packed FIRST) all gated; NaN-poisoned outputs; run-to-run BIT-exact; CUDA cases proven to EXECUTE (124,941 + 290,835 assertions when run alone). memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors** (the last requires `--num-cuda-barriers 65536`: the default table OVERFLOWS on a binary driving this many kernel families and the tool then emits a bogus `unspecified launch failure`). Clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 138/138, Qwen3-dense 664/664, OPT 36/36). No speed number — W9 owns tuning | **SELECTION (W2) + the DECODE KERNEL (W4) + the PREFILL PATH and CHUNKED-CONTEXT LOOP (W5). What is still absent is the MLA attention BLOCK and MODEL (W6/W7).** Priority TABLE [cuda_attn_priority.h:49](../include/vllm/platforms/cuda_attn_priority.h#L49) (both branches, one row per upstream arch arm) + lookup [cuda_attn_priority.h:86](../include/vllm/platforms/cuda_attn_priority.h#L86); `is_mla()`/`is_sparse()` filter [registry.cpp:63](../src/vllm/v1/attention/registry.cpp#L63); `TritonMLABackend` NAME + 3-D `get_kv_cache_shape` [backend.h:580](../include/vllm/v1/attention/backend.h#L580), [backend.cpp:83](../src/vllm/v1/attention/backend.cpp#L83), registration [backend.cpp:108](../src/vllm/v1/attention/backend.cpp#L108) — **W4: `vt::MlaDecodeAttention`** — `OpId::kMlaDecodeAttention` + args/validation [ops.h](../include/vt/ops.h), [ops.cpp](../src/vt/ops.cpp); CPU single-pass REFERENCE [cpu_mla_attn.cpp](../src/vt/cpu/cpu_mla_attn.cpp) (numerics from `csrc/cpu/mla_decode.cpp`); CUDA two-stage split-KV [cuda_mla_attn.cu](../src/vt/cuda/cuda_mla_attn.cu) — `MlaDecodeStage1` <- `_fwd_grouped_kernel_stage1` (`triton_decode_attention.py:278-458`, IS_MLA `v = tl.trans(k)` branch `:424-431`), `MlaDecodeStage2` <- `_fwd_kernel_stage2` (`:575-639`), `ComputeNumKvSplits` <- `_compute_num_kv_splits` (`triton_mla.py:40-47`), split workspace via the house grow-only per-stream scratch (upstream's `_reserve_attn_logits_workspace` `:57-78`). Deterministic by construction: fixed ASCENDING split merge, NO atomicAdd. `TritonMLABackend::get_impl_cls()` now returns a real `TritonMLAImpl` [backend.h](../include/vllm/v1/attention/backend.h), [backend.cpp](../src/vllm/v1/attention/backend.cpp); PREFILL remains W5 and `TritonMLAImpl::forward` refuses a prefill-shaped batch by name. **W5: `vt::MlaPrefillAttention` + `vt::GatherMlaCache` + `vt::MergeAttnStates` + the chunked-context driver** — `vt::MlaPrefillAttention` [cuda_mla_prefill.cu](../src/vt/cuda/cuda_mla_prefill.cu) / CPU ref [cpu_mla_prefill.cpp](../src/vt/cpu/cpu_mla_prefill.cpp) <- `mla/prefill/flash_attn.py:153-248` `FlashAttnPrefillBackend` (the ONLY MLA prefill backend reachable on sm_121 per `mla/prefill/selector.py:66-76`, and it HARD-RAISES with no fallback at `:191-194`), running over the vendored FA-2 through the NEW launcher entry `LaunchMlaPrefillFA2Bf16` [cuda_flash_attn_fa2.cu](../src/vt/cuda/cuda_flash_attn_fa2.cu) plus two new explicit instantiations of the UNCHANGED generic template (`flash_fwd_split_hdim192_bf16{,_causal}_sm80.cu`). V is ZERO-PADDED 128->192 and the output sliced back, exactly as upstream's `requires_v_padding` path does (`flash_attn.py:88-99,164-168,196-197`) — which is WHY the asymmetric QK 192 / V 128 pair needs no asymmetric kernel. `vt::GatherMlaCache` <- `csrc/libtorch_stable/cache_kernels.cu:992-1064`; `vt::MergeAttnStates` <- `csrc/libtorch_stable/attention/merge_attn_states.cu:18-192` (BOTH `-inf` edge cases ported verbatim). The workspace-bounded loop is [mla_chunked_context.h](../include/vllm/model_executor/layers/attention/mla_chunked_context.h) <- `mla_attention.py:1422-1451,1667-1745,2094-2199,2344-2425`. **The paged launcher `LaunchPrefillFA2Bf16` that every non-MLA prefill calls is textually UNTOUCHED** (211 insertions / 0 deletions in that TU; 2 new vendored files) | [test_attn_backend_registry.cpp:146](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L146) (GB10 MLA list), [:203](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L203) (`use_mla=true` -> `TRITON_MLA`, matching the W0 oracle observation), [:230](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L230) (the DSA seam, proven both directions with a stand-in sparse backend) — ports of `test_attention_backends_selection.py` (MLA cases), `test_mla_prefill_selector.py`, `test_mla_prefill_registry.py`; **W4** [test_ops_mla_attn.cpp](../tests/vt/test_ops_mla_attn.cpp) — port of `tests/kernels/attention/test_mla_decode_cpu.py` (`ref_mla` as a TWO-PASS oracle, its bs=4/mean_seq_len=256/h_q=16/d=576/dv=512/block=16 parametrization, BOTH varlen arms, and its NaN-padding out-of-bounds detector) plus the `test_mla_backends.py` shape sweep: ragged, multi-block, single-block/single-token, EVERY num_kv_splits in {1,2,3,4,5,8,16,17,64,300,512} (incl. splits > seq_len, the empty-split path both stages must skip), 128-head DeepSeek-V3 geometry, head counts 1/3/17 that do not fill a BLOCK_H tile, a 288/256 block-32 non-V2-Lite geometry, bf16 + f32, and run-to-run BIT-exactness over 5 runs. Gated on dgx/sm_121: 11/11 cases, 2,303,193 assertions; `compute-sanitizer` memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors**; clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 6/6, Qwen3-dense 16/16, OPT 6/6). NO speed number yet — decode perf is W9. **W5** [test_ops_mla_prefill.cpp](../tests/vt/test_ops_mla_prefill.cpp) **4/4 cases / 2,377,052 assertions** and [test_ops_mla_chunked_context.cpp](../tests/vt/test_ops_mla_chunked_context.cpp) **5/5 / 306,037 assertions** on dgx sm_121 — ports of `tests/v1/attention/test_mla_backends.py` and `tests/v1/attention/test_mla_prefill_quant_output.py` (its fp8 arms NOT ported: they need device-capability family 100, unreachable on sm_121 — recorded, not dropped). REAL V2-Lite prefill geometry (QK 192 / V 128 / latent 576, block 16, mscale^2 scale) against an INDEPENDENT double-precision TWO-PASS oracle, plus — for the chunked loop — a SINGLE-SHOT whole-sequence oracle that never chunks: exact / +1 / -1 chunk boundaries, a request with NO context, a chunk in which a request contributes ZERO keys, ragged multi-chunk, 128-head V3, single-token queries, ADVERSARIAL reverse-interleaved block tables, NaN-poisoned outputs, run-to-run BIT-exact over 5 runs. memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors** on both binaries; clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 138/138, Qwen3-dense 664/664, OPT 36/36). Prefill perf is W9 | [MLA campaign spike](specs/mla-deepseek-campaign.md) | `PARTIAL` | `CLAIM-MLA-DEEPSEEK` | +| `KERNEL-ATTN-MLA-SPARSE` | MLA and sparse attention: CUTLASS, FlashMLA, FlashInfer, FA, Triton, MSA **W6: the MLA attention BLOCK + WEIGHT ABSORPTION — the layer that COMPOSES W3+W4+W5** — [mla_attention.h](../include/vllm/model_executor/models/mla_attention.h) + [mla_attention.cpp](../src/vllm/model_executor/layers/attention/mla_attention.cpp) <- `mla.py:119-181` (`MultiHeadLatentAttentionWrapper.forward`) over `mla_attention.py:553-620` (the cache-update-BEFORE-attention order), `:624-874` (`forward_impl`: the dispatch + the absorbed decode) and `:2344-2425` (`forward_mha`); `AbsorbKvBProjBf16` <- `:875-962 process_weights_after_loading` (split `:892-900`, permutes `:959-962`); `MakeMlaUpProjectFn` <- `:2141-2170` (the `kv_b_proj` callback W5 left open); `BuildDeepseekRopeCosSinCache` <- `deepseek_scaling_rope.py:76-118` over `rotary_embedding/common.py:34-70`; `MlaAttentionScale` <- `deepseek_v2.py:995,1067-1075` (the mscale^2 correction, kept SEPARATE from the rope's own rotation mscale). **Absorption is a LOAD-TIME weight transform plus TWO batched GEMMs, not a fused kernel**, so it needed only two new general primitives: **`vt::BatchedMatmul`** <- `torch.bmm` at `mla_attention.py:789` (q-side W_UK fold) and `:1034` (`_v_up_proj`), CUDA impl = cuBLASLt STRIDED-BATCHED [cuda_matmul.cu](../src/vt/cuda/cuda_matmul.cu) (the cuBLASLt form of the cuBLAS `gemmStridedBatchedEx` torch.bmm resolves to; the only upstream alternatives are ROCm-only aiter fp8/fp4 bmm branches) + CPU ref [cpu_ops.cpp](../src/vt/cpu/cpu_ops.cpp), stride-driven because BOTH call sites pass `.transpose(0,1)` views; and **`vt::ConcatMlaNopeRope`** <- `ConcatMLAQKernel` (`csrc/libtorch_stable/concat_mla_q.cuh`) + wrapper `cache_kernels.cu:1555-1600`, GENERALIZED to arbitrary nope/rope widths and a head-BROADCAST rope operand so one op also serves `_concat_k_nope_k_pe` (`:2063-2092`) — CUDA [cuda_mla_attn.cu](../src/vt/cuda/cuda_mla_attn.cu), CPU [cpu_ops.cpp](../src/vt/cpu/cpu_ops.cpp). Two ADDITIVE relaxations of existing ops, integer-identical for contiguous tensors: `vt::RopeFromCache` stride-driven on q/k (DeepSeek rotates the TRAILING 64-dim slice and its `k_pe` is a column block of the fused kv_a projection) and `vt::MatmulBT` accepting a row-strided ACTIVATION (`kv_b_proj` applied to a 512-column slice of the 576-wide workspace, `:2160`) | CUDA priority `vllm/platforms/cuda.py:84-176` (`_get_backend_priorities`, both branches); MLA classes `vllm/v1/attention/backends/mla/*.py`; MLA prefill selector `mla/prefill/selector.py:47-76`; capability filter `vllm/v1/attention/backend.py:307-360`; CUTLASS build `CMakeLists.txt:1037-1061` **W6** [test_mla_attention_block.cpp](../tests/vllm/model_executor/layers/attention/test_mla_attention_block.cpp) **10/10 cases / 2,372,644 assertions** and [test_ops_mla_absorb.cpp](../tests/vt/test_ops_mla_absorb.cpp) **9/9 / 1,644,807 assertions** on dgx sm_121 — ports of `tests/kernels/test_concat_mla_q.py` (BOTH arms incl. the NON-CONTIGUOUS transposed-nope case, compared bit-exactly since a concat is a pure copy), the MLA-geometry sweep of `tests/v1/attention/test_mla_backends.py`, and the two-pass-oracle discipline of `tests/kernels/attention/test_mla_decode_cpu.py`. **THE ABSORBED-vs-UNABSORBED EQUIVALENCE IS PROVEN NUMERICALLY, THREE WAYS:** an INDEPENDENT double-precision block oracle computing the attention BOTH ways agrees to **< 1e-11** (the identity itself, at both query branches); our absorbed decode reproduces the UNABSORBED oracle to **< 2e-4** (f32); and the SAME batch driven once through the ABSORBED MQA decode kernel and once through the UNABSORBED materialized-MHA prefill path agrees to **< 3e-4** (CPU f32) / **< 4e-2** (CUDA bf16) — two code paths sharing nothing but the weights. Real geometry throughout (V2-Lite 512/128/64/128/16-head, plus V3's 7168 / 128-head / `q_lora_rank=1536` for the lora branch, which has NO e2e coverage and says so). Decode-only / prefill-only-no-context / chunked-prefill-with-context / MIXED (decode packed FIRST) all gated; NaN-poisoned outputs; run-to-run BIT-exact; CUDA cases proven to EXECUTE (124,941 + 290,835 assertions when run alone). memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors** (the last requires `--num-cuda-barriers 65536`: the default table OVERFLOWS on a binary driving this many kernel families and the tool then emits a bogus `unspecified launch failure`). Clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 138/138, Qwen3-dense 664/664, OPT 36/36). No speed number — W9 owns tuning | **SELECTION (W2) + the DECODE KERNEL (W4) + the PREFILL PATH and CHUNKED-CONTEXT LOOP (W5). What is still absent is the MLA attention BLOCK and MODEL (W6/W7).** Priority TABLE [cuda_attn_priority.h:49](../include/vllm/platforms/cuda_attn_priority.h#L49) (both branches, one row per upstream arch arm) + lookup [cuda_attn_priority.h:86](../include/vllm/platforms/cuda_attn_priority.h#L86); `is_mla()`/`is_sparse()` filter [registry.cpp:63](../src/vllm/v1/attention/registry.cpp#L63); `TritonMLABackend` NAME + 3-D `get_kv_cache_shape` [backend.h:587](../include/vllm/v1/attention/backend.h#L587), [backend.cpp:83](../src/vllm/v1/attention/backend.cpp#L83), registration [backend.cpp:108](../src/vllm/v1/attention/backend.cpp#L108) — **W4: `vt::MlaDecodeAttention`** — `OpId::kMlaDecodeAttention` + args/validation [ops.h](../include/vt/ops.h), [ops.cpp](../src/vt/ops.cpp); CPU single-pass REFERENCE [cpu_mla_attn.cpp](../src/vt/cpu/cpu_mla_attn.cpp) (numerics from `csrc/cpu/mla_decode.cpp`); CUDA two-stage split-KV [cuda_mla_attn.cu](../src/vt/cuda/cuda_mla_attn.cu) — `MlaDecodeStage1` <- `_fwd_grouped_kernel_stage1` (`triton_decode_attention.py:278-458`, IS_MLA `v = tl.trans(k)` branch `:424-431`), `MlaDecodeStage2` <- `_fwd_kernel_stage2` (`:575-639`), `ComputeNumKvSplits` <- `_compute_num_kv_splits` (`triton_mla.py:40-47`), split workspace via the house grow-only per-stream scratch (upstream's `_reserve_attn_logits_workspace` `:57-78`). Deterministic by construction: fixed ASCENDING split merge, NO atomicAdd. `TritonMLABackend::get_impl_cls()` now returns a real `TritonMLAImpl` [backend.h](../include/vllm/v1/attention/backend.h), [backend.cpp](../src/vllm/v1/attention/backend.cpp); PREFILL remains W5 and `TritonMLAImpl::forward` refuses a prefill-shaped batch by name. **W5: `vt::MlaPrefillAttention` + `vt::GatherMlaCache` + `vt::MergeAttnStates` + the chunked-context driver** — `vt::MlaPrefillAttention` [cuda_mla_prefill.cu](../src/vt/cuda/cuda_mla_prefill.cu) / CPU ref [cpu_mla_prefill.cpp](../src/vt/cpu/cpu_mla_prefill.cpp) <- `mla/prefill/flash_attn.py:153-248` `FlashAttnPrefillBackend` (the ONLY MLA prefill backend reachable on sm_121 per `mla/prefill/selector.py:66-76`, and it HARD-RAISES with no fallback at `:191-194`), running over the vendored FA-2 through the NEW launcher entry `LaunchMlaPrefillFA2Bf16` [cuda_flash_attn_fa2.cu](../src/vt/cuda/cuda_flash_attn_fa2.cu) plus two new explicit instantiations of the UNCHANGED generic template (`flash_fwd_split_hdim192_bf16{,_causal}_sm80.cu`). V is ZERO-PADDED 128->192 and the output sliced back, exactly as upstream's `requires_v_padding` path does (`flash_attn.py:88-99,164-168,196-197`) — which is WHY the asymmetric QK 192 / V 128 pair needs no asymmetric kernel. `vt::GatherMlaCache` <- `csrc/libtorch_stable/cache_kernels.cu:992-1064`; `vt::MergeAttnStates` <- `csrc/libtorch_stable/attention/merge_attn_states.cu:18-192` (BOTH `-inf` edge cases ported verbatim). The workspace-bounded loop is [mla_chunked_context.h](../include/vllm/model_executor/layers/attention/mla_chunked_context.h) <- `mla_attention.py:1422-1451,1667-1745,2094-2199,2344-2425`. **The paged launcher `LaunchPrefillFA2Bf16` that every non-MLA prefill calls is textually UNTOUCHED** (211 insertions / 0 deletions in that TU; 2 new vendored files) | [test_attn_backend_registry.cpp:146](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L146) (GB10 MLA list), [:203](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L203) (`use_mla=true` -> `TRITON_MLA`, matching the W0 oracle observation), [:230](../tests/vllm/v1/attention/test_attn_backend_registry.cpp#L230) (the DSA seam, proven both directions with a stand-in sparse backend) — ports of `test_attention_backends_selection.py` (MLA cases), `test_mla_prefill_selector.py`, `test_mla_prefill_registry.py`; **W4** [test_ops_mla_attn.cpp](../tests/vt/test_ops_mla_attn.cpp) — port of `tests/kernels/attention/test_mla_decode_cpu.py` (`ref_mla` as a TWO-PASS oracle, its bs=4/mean_seq_len=256/h_q=16/d=576/dv=512/block=16 parametrization, BOTH varlen arms, and its NaN-padding out-of-bounds detector) plus the `test_mla_backends.py` shape sweep: ragged, multi-block, single-block/single-token, EVERY num_kv_splits in {1,2,3,4,5,8,16,17,64,300,512} (incl. splits > seq_len, the empty-split path both stages must skip), 128-head DeepSeek-V3 geometry, head counts 1/3/17 that do not fill a BLOCK_H tile, a 288/256 block-32 non-V2-Lite geometry, bf16 + f32, and run-to-run BIT-exactness over 5 runs. Gated on dgx/sm_121: 11/11 cases, 2,303,193 assertions; `compute-sanitizer` memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors**; clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 6/6, Qwen3-dense 16/16, OPT 6/6). NO speed number yet — decode perf is W9. **W5** [test_ops_mla_prefill.cpp](../tests/vt/test_ops_mla_prefill.cpp) **4/4 cases / 2,377,052 assertions** and [test_ops_mla_chunked_context.cpp](../tests/vt/test_ops_mla_chunked_context.cpp) **5/5 / 306,037 assertions** on dgx sm_121 — ports of `tests/v1/attention/test_mla_backends.py` and `tests/v1/attention/test_mla_prefill_quant_output.py` (its fp8 arms NOT ported: they need device-capability family 100, unreachable on sm_121 — recorded, not dropped). REAL V2-Lite prefill geometry (QK 192 / V 128 / latent 576, block 16, mscale^2 scale) against an INDEPENDENT double-precision TWO-PASS oracle, plus — for the chunked loop — a SINGLE-SHOT whole-sequence oracle that never chunks: exact / +1 / -1 chunk boundaries, a request with NO context, a chunk in which a request contributes ZERO keys, ragged multi-chunk, 128-head V3, single-token queries, ADVERSARIAL reverse-interleaved block tables, NaN-poisoned outputs, run-to-run BIT-exact over 5 runs. memcheck **0 errors**, racecheck **0 hazards**, synccheck **0 errors** on both binaries; clean CUDA build 0 warn/0 err; regression set UNCHANGED (27B 235/235, 35B 315/315, Coder 138/138, Qwen3-dense 664/664, OPT 36/36). Prefill perf is W9 | [MLA campaign spike](specs/mla-deepseek-campaign.md) | `PARTIAL` | `CLAIM-MLA-DEEPSEEK` | | `KERNEL-ATTN-DFLASH-BLOCK` | **DFlash in-block attention — the project's FIRST non-causal / bidirectional attention primitive** (SPEC-DFLASH D2, DF-DRAFT-MODEL). Per-request uniform (1+k) query block attends within its own block: FULL-attention layers BIDIRECTIONAL (`causal=false`, no mask), SWA layers causal-within-window. f32 online softmax, GQA broadcast. A SEPARATE `vt::` op from the causal `kAttention`/`kPagedAttention` so every other model stays byte-identical | `vllm/model_executor/models/qwen3_dflash.py:86-146` (`_resolve_layer_attention`: full layers default non-causal, SWA causal) + `:149-263` (`DFlashQwen3Attention`); flashinfer non-causal path (vllm#48167 Blackwell non-causal attn, in-pin) | `OpId::kDFlashBlockAttention` + `DFlashBlockAttentionArgs` + decl [ops.h:1713](../include/vt/ops.h#L1713) + wrapper/validation [ops.cpp:2069](../src/vt/ops.cpp#L2069); CPU REFERENCE `DFlashBlockAttentionKernel` [cpu_ops.cpp:2929](../src/vt/cpu/cpu_ops.cpp#L2929) (three-pass block-local softmax, the authoritative impl); CUDA `DFlashBlockAttentionKernelCuda` [cuda_ops.cu:1300](../src/vt/cuda/cuda_ops.cu#L1300) mirroring the causal `AttentionKernel` block-reduction recurrence with per-block bounds + the bidirectional/window mask; the draft model that consumes it [qwen3_dflash.cpp:52](../src/vllm/model_executor/models/qwen3_dflash.cpp#L52) | **CPU GATE GREEN** [test_ops_dflash_block_attn.cpp:79](../tests/vt/test_ops_dflash_block_attn.cpp#L79) 5 cases / 12 assertions — hand-checked non-causal (query 0 sees the future key), the RED causal-vs-non-causal separation (the mask is load-bearing), per-request cu_seqlens block isolation, SWA window bound, GQA; model forward [test_qwen3_dflash_forward.cpp:116](../tests/vllm/models/test_qwen3_dflash_forward.cpp#L116) 5 cases / 95 assertions (RED full-layer-causal-flip); existing causal `test_ops_attention` 9/9·23 UNCHANGED. **GPU GATE GREEN on dgx (2026-07-26, GB10 sm_121a):** CUDA `-Werror=all-warnings` build clean (kernel compiles as-written, no change); CUDA==CPU parity [test_ops_dflash_block_attn CUDA case](../tests/vt/test_ops_dflash_block_attn.cpp#L153) 198412/198412 within the 1e-4 f32-softmax envelope over all 5 corners; `compute-sanitizer --tool memcheck` 0 errors; consumed by the draft-forward parity gate ([test_qwen3_dflash_draft_parity](../tests/parity/test_qwen3_dflash_draft_parity.cpp), fc rel-L2 0.46% / hidden ≤1.3% vs the real vLLM draft). **DONE 2026-07-27 with the DFlash block (`CLAIM-DFLASH-D14`):** the D2 non-causal in-block primitive is the CPU/materialized reference the D12+ paged/warp kernels are gated against; closure [ledger](parity-ledger.md#L722). | [DFlash spec](specs/dflash-spec-decode.md) §1.3/§6 D2 | `DONE` | `489a7544` | | `KERNEL-ATTN-DFLASH-PAGED-BLOCK` | **DFlash PAGED in-block attention — the CAPTURE-SAFE form of `KERNEL-ATTN-DFLASH-BLOCK`** (SPEC-DFLASH D12 Part B, the CUDA-graph draft-attention primitive). The (1+k) block queries attend over `[PAGED context ; their own (1+k) block]`: the growing context enters as DATA (paged K/V cache `[pages,block_size,Hkv,D]` + per-request `seq_lens` + `block_table`, mirroring `PagedAttentionKernel`) instead of a variable-size materialized combined buffer, so the launch grid is STATIC over the fixed `Nq=(1+k)*num_reqs` rows and EVERY metadata input is a persistent DEVICE tensor read in place — NO `cudaMallocAsync`/`cudaMemcpyAsync` of a function-local host `cu_seqlens` (the [[cudagraph-capture-bakes-stack-addresses]] UAF class the eager `LaunchDFlashBlockAttention` had). Same f32 online softmax + D2 in-block mask over the COMBINED index; bit-identical to `DFlashBlockAttention` over the materialized `[context;block]` buffer | vLLM full CG `dflash/cudagraph.py` + `speculator.py:411-458` + `precompute_and_store_context_kv` (`qwen3_dflash.py:548-619`) @ `555967922`; paged read mirrors our `PagedAttentionKernel` [cuda_paged_attn.cu:184](../src/vt/cuda/cuda_paged_attn.cu#L184) | `OpId::kDFlashPagedBlockAttention` + `DFlashPagedBlockAttentionArgs` + decl [ops.h](../include/vt/ops.h) + wrapper/validation [ops.cpp](../src/vt/ops.cpp); CPU REFERENCE `DFlashPagedBlockAttentionKernel` [cpu_ops.cpp](../src/vt/cpu/cpu_ops.cpp); CUDA `DFlashPagedBlockAttentionKernelCuda` [cuda_ops.cu](../src/vt/cuda/cuda_ops.cu#L1452) (static grid, persistent device metadata) + D14 WARP variant [DFlashPagedBlockAttentionWarpKernel](../src/vt/cuda/cuda_ops.cu#L1433) | **GPU GATE GREEN on dgx (2026-07-27, GB10 sm_121a):** CUDA `-Werror` clean (0 warnings); [test_ops_dflash_paged_block_attn.cpp](../tests/vt/test_ops_dflash_paged_block_attn.cpp#L79) cross-checks CPU-paged == materialized `DFlashBlockAttention` across 6 corners (non-causal, causal-SWA, block isolation, GQA, multi-page, zero-context) + CUDA==CPU (f32+bf16) = **795648/795648 assertions**; `compute-sanitizer --tool memcheck` **0 errors**. **D13 (2026-07-27, `CLAIM-DFLASH-D13`): WIRED INTO PRODUCTION** — the single-request DFlash draft forward (`ForwardPagedBody`, `qwen3_dflash.cpp`) now runs the (1+k) block through this kernel reading a fixed-capacity paged `DflashDeviceKVStore`, and the whole draft step is captured into a per-request CUDA graph + replayed (the growing context enters only via the in-place `seq_lens`). Capture-correctness PROVEN: `test_qwen27_dflash_spec_decode` 27/27 with the graph BIT-IDENTICAL to eager (same tokens + acceptance 19/39/29/25); c1 throughput NEAR-PARITY with vLLM-DFlash-ON (ours 0.978×, ~2% below the tight 3-rep band; gap closed 0.917×→0.978× via the paged read, the CG is perf-neutral) — the kernel is landed + wired + gated; STAYS `ACTIVE` with the engine feature (the ~2% ≥vLLM residual is per-step compute for an nsys). **D14 (2026-07-27, `CLAIM-DFLASH-D14`): the residual WAS this kernel → WARP-scoped variant added → SPEED GATE MET, `DONE`.** An nsys (`--cuda-graph-trace=node`) attributed the D13 ~2% residual to THIS kernel: `DFlashPagedBlockAttentionKernel` = 242.9 ms = 1.8% of the graphed step's GPU time, median ~460 us/call (grid `(nq=17,hq=32)` × kBlock=256 threads looping SERIALLY over C~500-640 keys with a 256-wide shared-mem tree reduction + 2 `__syncthreads` PER key — the latency/sync storm the ViT tower fixed with `AttentionDenseFast`), vs vLLM's fused flash draft-attn ~0.15%. Added `DFlashPagedBlockAttentionWarpKernel` ([cuda_ops.cu](../src/vt/cuda/cuda_ops.cu)): ONE WARP per (block-query,head), `__shfl_xor` butterfly head_dim reduction, register accumulator, NO `__syncthreads`; SAME paged/block combined-index read + causal/SWA mask + GQA (copied verbatim from the block kernel), mirroring the shipped `AttentionWarpKernel`. Default ON; `VT_DFLASH_ATTN_BLOCK=1` keeps the bit-identical D12/D13 block kernel. Draft attn **242.9 → 77.9 ms (3.1×)**; our-ON c1 **28.60 → 29.32 tok/s**; FINAL 3-rep A/B our-ON 29.32 ≥ vLLM-ON 29.240 (non-overlapping bands, 1.003×) ⇒ **≥vLLM MET**. Not bit-identical to the block kernel but same f32-online-softmax math within envelope; CUDA==CPU `test_ops_dflash_paged_block_attn` **795648/795648** (f32 1e-4/bf16 3e-2) + **compute-sanitizer 0**; e2e 27/27 graph==eager, acceptance 19/39/29/25 unchanged (1629 accepted identical warp-vs-block); SACRED 235/235 + MTP 9/9 inert; `-Werror` clean; closure [ledger](parity-ledger.md#L738) | [DFlash spec](specs/dflash-spec-decode.md) §0 D12/D13/D14 | `DONE` | `489a7544` | | `KERNEL-DFLASH2-GROUPED-CONV` | **DFlash2 grouped dynamic depthwise convolution — the project's FIRST dynamic (input-conditioned) convolution kernel** (SPEC-DFLASH2 W2, #1314). `out[i,c] = sum_t (base[side,t,c] + delta[i,side,t,g(c)]) * x[i-t,c]`, with tap `t` contributing only where `(i mod block_size) >= t`, `g(c) = c / group_size`, and `block_size` the QUERY block `1 + k`. Three things separate it from the shipped `KERNEL-DEPTHWISE-CONV1D`: the kernel is DYNAMIC (a per-position `delta` projected from the sublayer input, added to a static per-channel `base`), it is GROUPED (one delta per group of channels, one base per channel), and its taps are ZEROED ACROSS THE BLOCK BOUNDARY rather than across the sequence — which is what lets a proposal position see the ones before it without another backbone pass. `base_kernel` dim 0 is the prepare/finish SIDE and not a tap; on the published 27B draft both axes are 2, so nothing but the port note and the shape assertion separates a correct load from a transposed one. Every intermediate rounds to the tensor dtype, mirroring upstream's bf16 chain, so the op is elementwise with NO reduction-order freedom and the CUDA arm is specified BIT-IDENTICAL to CPU rather than within an envelope | **BEYOND-PIN** — `vllm/model_executor/models/qwen3_dflash2.py` (`_grouped_conv`, `DFlashGroupedConv`, `DFlash2Qwen3DecoderLayer.forward`) @ [vllm-project/vllm#52816](https://github.com/vllm-project/vllm/pull/52816) head `19c9351904df4c63042671bc67a866ca48dc7d6f`; the parity pin `555967922` does not carry the architecture and this row does NOT advance it | `OpId::kDFlashGroupedConv` + `DFlashGroupedConvArgs` + decl/wrapper `include/vt/ops.h::DFlashGroupedConv` and `src/vt/ops.cpp::DFlashGroupedConv`; CPU REFERENCE `src/vt/cpu/cpu_ops.cpp::DFlashGroupedConvKernel` (the authoritative impl); CUDA mirror `src/vt/cuda/cuda_ops.cu::DFlashGroupedConvKernelCuda` (one thread per (row, channel); `__fadd_rn`/`__fmul_rn` forbid the FMA contraction the CPU build pins off). Consumed by the draft through `src/vllm/model_executor/models/qwen3_dflash.cpp::DflashConvPrepare` and `src/vllm/model_executor/models/qwen3_dflash.cpp::DflashConvFinish`, called from all THREE layer bodies, with the uniform-block precondition in `src/vllm/model_executor/models/qwen3_dflash.cpp::CheckDflashConvBatch`; weights loaded by `src/vllm/model_executor/models/qwen3_dflash_weights.cpp::LoadQwen3DFlash` | **CPU GATE GREEN 2026-08-19** ([test_ops_dflash2_grouped_conv.cpp](../tests/vt/test_ops_dflash2_grouped_conv.cpp)) 8 cases / **9930 assertions**, `Status: SUCCESS!`, exit 0 (was 6 / 9410 on 2026-08-19, before the bf16 rounding cases below) — upstream's own sequential reference loop at block 5 (the `% block` arm), 8 and 16 (the two PUBLISHED checkpoints, `z-lab/Qwen3.8-27B-DFlash2` and `z-lab/Muse-Glimmer-30B-DFlash2`; upstream's parametrize covers 5 and 8 only), both published taps/group shapes on both sides, plus hand-computed corners for the block boundary, the group map and the side. MODEL GATE GREEN ([test_qwen3_dflash2_draft.cpp](../tests/vllm/models/test_qwen3_dflash2_draft.cpp)) 16 cases / 108 assertions, `Status: SUCCESS!`, exit 0 — weights read off a REAL on-disk safetensors shard by the production loader, an IDENTITY conv proven BIT-IDENTICAL to no conv, and each conv driven ALONE through each of the three layer bodies. MUTATION-PROVEN 2026-08-19, each restored byte-for-byte and verified by sha256: deleting the call sites in `ForwardBlockLogits` (5 cases / 9 assertions red), in `ForwardWithCtxKVDev` (1/1) and in `ForwardPagedBody` (1/1); forcing `args.side` to 0 (op 2 cases/4353 assertions red, model 1/1); dropping the block mask (3/449); the wrong group map (3/7436); and dropping the uniform-block guard (1/1). **The PER-STEP ROUNDING was added to that set on 2026-08-20**, after the wave's second fresh review proved it had no executing assertion: replacing the bf16 branch of the `round` lambda in `src/vt/cpu/cpu_ops.cpp::DFlashGroupedConvKernel` with `return v;` compiled clean and left BOTH suites fully green, because every case in the op file ran in f32 where that rounding is the IDENTITY, and the model suite asserts only RELATIONALLY between two runs of the same kernel. Two CPU-only bf16 cases now pin it — one hand-computed against literals that differ from the round-once-at-the-end answer in six of eight outputs, one bit-exact at three shapes against a reference that rounds where UPSTREAM materializes — and under the same mutation the file is 8 cases / 2 failed, 9930 assertions / 225 failed, `Status: FAILURE!`. THREE gate repairs have now come out of this row's mutation passes and are recorded rather than hidden: activating both convs at once could not see one missing call site, the first side probe could not see a forced side, and no case at all could see the rounding policy. **CUDA VERIFIED 2026-08-20** — the kernel and its registration are written and the CUDA==CPU bit-identity case exists over six shapes, and it has now COMPILED AND RUN on a device: an `rc` job on `dgx:gpu0` (GB10, sm_121a, `nvcc` 13.0) at W3 head `b29b6f886`, `BUILD_RC=0`, `COMPILE_ERRORS=0`, zero `no CUDA backend; skipping` lines, this suite among the five of six that passed ([#1489](https://github.com/mudler/vllm.cpp/issues/1489)). Spec `## Owed` O6 is DISCHARGED by that run. The AUTHORING HOST still has no `nvcc`, so the case still reports `no CUDA backend; skipping` there — a property of that box and no longer of the kernel | [DFlash2 spec](specs/dflash2-spec-decode.md) W2, [#1314](https://github.com/mudler/vllm.cpp/issues/1314) | `ACTIVE` | `CLAIM-SPEC-DFLASH2-W2` | diff --git a/.agents/model-matrix.md b/.agents/model-matrix.md index 9d03171b56..8bc43ee57d 100644 --- a/.agents/model-matrix.md +++ b/.agents/model-matrix.md @@ -78,8 +78,8 @@ Rollup by lifecycle state (must equal the detailed per-state row counts): | INVENTORIED | 324 | | PARTIAL | 22 | | ACTIVE | 12 | -| SPIKE | 9 | -| BLOCKED | 5 | +| SPIKE | 10 | +| BLOCKED | 4 | | DONE | 3 | | READY | 3 | | GATING | 1 | @@ -127,7 +127,7 @@ Engaged architectures (the 55 non-`INVENTORIED` rows): | 📋 | `LTX2VideoTransformer3DModel` | LTX-2.5 (21.00B joint video+audio flow-matching DiT, Lightricks) | **L0 spec committed 2026-08-11 (#435, [spec](specs/ltx-2-5.md)).** Geometry MEASURED from the FP8 checkpoint's own safetensors header by HTTP range request (6124 tensors, 881,048-byte header, no payload downloaded): **21.00B** params — 48 blocks @ 386.7M = 18.560B, audio embeddings connector 2.016B, global 0.427B. The filename says `22b` and the Diffusers card says ~19B; the MEASURED count is what this row uses. Video stream 4096 (32 heads x 128), audio stream 2048 (32 heads x 64), in/out channels 128 both. Per block SIX attentions — `attn1` (video self), `attn2` (video<->text, cross 4096), `audio_attn1`, `audio_attn2` (cross 2048), and the two CROSS-MODAL `audio_to_video_attn` / `video_to_audio_attn` — which is the structural break from MiniMax-H3: H3 packs every modality into ONE sequence with per-row token tags, LTX runs TWO streams coupled by explicit cross-attention. **Per-head gated attention on every attention** (`to_gate_logits` = `Linear(query_dim, heads, bias=True)`, `attention.py:513-514`, applied AFTER the attention output at `:577`) — H3 has no analogue and getting it wrong renders plausibly-wrong rather than erroring. FFN is gelu-approximate 4096->16384->4096 with **NO bias** while `audio_ff` 2048->8192->2048 **HAS** bias, which independently confirms `ff_bias=false` / `audio_ff_bias=true` from `model_configurator.py:78-80` — checkpoint and source agreeing, not either alone. **RETRACTED 2026-08-12, was billed as a FREE WIN.** The spec claimed 2.5 sets `use_prompt_adaln_single=false` so the cross-attention K/V are timestep-free and cacheable. The shipped checkpoint DISPROVES it: it carries 12 `prompt_adaln_single`/`audio_prompt_adaln_single` tensors including a `timestep_embedder.linear_1 [4096, 256]` (256 = the sinusoidal timestep width), and `model.py:223-227` builds that module ONLY when the flag is TRUE. `transformer.py:441` was quoted as proof of no timestep term, but `:442-443` add one whenever `prompt_timestep` is not None, and the comment above them says exactly that. NO SHIPPED DEFECT: `ltx2_dit.cpp:672` refuses the cache by name when the flag is on, so the feature is correct-and-inapplicable rather than silently wrong, and stays gated bit-identical and prompt-bound for any checkpoint that does set it false. **ORACLE:** vLLM-Omni does NOT support 2.5 — `resolve_ltx_pipeline_recipe` keys on 2 / 2.3 only and RAISES otherwise (`ltx2_recipes.py:162-166`), with upstream [vllm-omni#6066](https://github.com/vllm-project/vllm-omni/issues/6066) filed 2026-08-11 — but its `DiffusersAdapterPipeline` is generic (`DiffusionPipeline.from_pretrained`, `pipeline_diffusers_adapter.py:116`), so vLLM-Omni CAN execute 2.5 via `--load-format diffusers`. Binding oracle = that adapter; immediate cross-check = Lightricks `ltx-pipelines`. **HW: FITS ONE GB10** at ~29 GB (NVFP4 DiT 18.72 + NVFP4 Gemma-4 TE 7.40 + VAEs 1.83 + upscaler 1.00) vs H3's ~41 GB. **OWED UP FRONT, not to be discovered later:** the speed axis lands `PENDING` because the adapter is a black box (`supports_step_execution=False`, `supports_request_batch=False`) and therefore NOT vLLM's production configuration, which AGENTS.md requires as the denominator; DiffVAE (`NADiffusionDecoder`, neighborhood attention) is REFUSED BY NAME until its own row rather than silently downgraded to the Conv VAE; and no render-quality claim is made from structural e2e. | `MODEL-DIFFUSION-ltx-2-5-ltx2-video-transformer-3d-model` | | ✅ | `LagunaForCausalLM` | Poolside Laguna-S-2.1 (118B/8B MoE) | **LONG-CTX DECODE LEVERS LANDED + MEASURED (2026-08-03, `CLAIM-LAGUNA-LONGCTX-LEVERS`): window-bounded SWA reads (`VT_LAGUNA_SWA_WINDOW`, default-ON, BYTE-EXACT) bound the four `DecodeAttnGqa*` kernels' read to the ~512 sliding window (vLLM `laguna.py:412`) — GB10 A/B token-IDENTICAL `=1` vs `=0` at 520-token context (truncation active), MEASURED −0.30 ms/step at ~2k (~0 at ≤512, grows linearly). bf16 paged KV (`VT_LAGUNA_KV_BF16`, default-OFF opt-in) a distributional near-tie left UNRATIFIED. See BENCHMARKS `CLAIM-LAGUNA-LONGCTX-LEVERS`.** — **NVFP4 W4A4 ARM RAN on GB10 (N4, 2026-08-01, `CLAIM-LAGUNA-NVFP4-N4`): the additive safetensors NVFP4 arm (N1a/N1b/N2/N3 — `Nvfp4Weight` expert fields + `LoadLagunaForCausalLMWeights` + `LqGemmNvfp4Fp4` per-expert TRUE-W4A4 + `LagunaFfnBlock` `fp4` branch + `laguna_gen` dir-autodetect; CPU-gated `test_laguna_nvfp4_loader` 3/3·61, GGUF path byte-identical) generates COHERENTLY on the real 67 GiB `poolside/Laguna-S-2.1-NVFP4`. vs the vLLM MARLIN golden (vLLM's exact prompt ids injected): FIRST 2 TOKENS MATCH exactly, then near-tie divergence (our TRUE-W4A4 fp4-activations vs the MARLIN golden's W4A16 bf16-activations — different precision, EXPECTED; shares golden vocab). SPEED (N5, trace-driven, 2026-08-01): 0.16 → ~4.5 tok/s (~28× THIS SESSION), now ~4× from vLLM 18.8. **Lever #2** (nsys found the bf16 tower running host `MatmulNK` on the CUDA queue): route it to the GPU (`LqGemm` bf16 → `CastBf16` + `MatmulBT`, weight stays bf16) → 6.34 → 0.39 s/tok (16×). **Lever #1** (nsys found the emulation expert GEMM at 92%, GPU 87% busy): the engine's native sm120a fp4 tensor-core MMA (`MatmulNvfp4Fp4Native`) reads the SAME linear scales — it was gated OFF behind `VT_NVFP4_FP4_NATIVE`; default it ON in the driver → 0.39 → ~0.20-0.24 s/tok (~2×). Both coherent + near-tie (byte-identical ids to emulation; first token matches golden). Two GB10 memory fixes landed to run (shard-release + context-before-load). OPEN #234 (remaining ~4×): grouped W4A4 MoE (top_k×3 launches → 3), `ResidentNvfp4`, decode CUDA-graph + on-GPU sampling (the host-orchestration tail). Spec `.agents/specs/laguna-nvfp4-arm-2026-07-31.md` §N4/§N5. The GGUF-Q4_K track (below) is the separate keep-quant vehicle.** Prior **FASTER DECODE (W9, 2026-07-31, `CLAIM-LAGUNA-W9-GROUPED`): the 30 un-grouped per-expert keep-quant GEMV launches/step (top_k × {gate,up,down} `LqGemmRowSlice`) fold onto the SHARED `vt::MatmulBTQuantGrouped` op — per token, Pk experts' gate/up/down each collapse to ONE grouped launch over the already-stacked `[E*N,H]` tower (no loader change). Same-binary A/B on real UD-Q4_K_XL (GB10, `--gpu`, drop_caches cold, 24 tok): grouped (`VT_LAGUNA_GROUPED_MOE=1`, default) == per-expert (`=0`) BYTE-IDENTICAL (md5 `754728c6`, both == W6 golden) + decode 0.18 → 0.13 s/tok (1.38×). Routes through the shared vt op (fold policy). Cumulative with W8: decode 0.66 → 0.13 s/tok (5.1×; 1.5 → 7.7 tok/s; 18× → 3.6× vs llama.cpp 27.8). Next lever: device-resident decode (#1). See spec §W9.** Prior **FASTER DECODE (W8, 2026-07-31, `CLAIM-LAGUNA-W8-EMBED`): `LagunaEmbed` no longer converts the whole 1.23 GB embed table to f32 every token (it gathered T rows out of the whole [Vsz,H] table via `ReadF32` — ~311M host element-converts/token, the DOMINANT decode cost the W7 profile under-filed as "#5"); now gathers only the T needed rows directly (BIT-IDENTICAL — same per-element conversion, same rows). GATED on the real 3-shard UD-Q4_K_XL GGUF (GB10, `--gpu`, W6 cached, drop_caches cold, 24 tok): TOKEN-IDENTICAL to the W5/W6 golden (`22345 83 350 785 …`, coherent " Paris.") + decode 0.66 → 0.17 s/tok = 3.9× (1.5 → 5.9 tok/s; 18× → 4.7× vs llama.cpp 27.8). See `.agents/specs/laguna-s21-w7-speed-2026-07-31.md` §W8. Next: grouped-expert GEMM (=A3) then device-resident decode.** Prior **DECODE-SPEED ATTRIBUTED (W7 profile-only, 2026-07-31, `CLAIM-LAGUNA-W7-SPEED`): `nsys` of the W6 decode (real UD-Q4_K_XL GGUF, GB10) attributes the 0.66 s/tok (~1.5 tok/s vs llama.cpp 27.8 on identical bytes, ~15-18x) to HOST-ORCHESTRATION, not kernel compute — GPU active only 32.7% of the step, 67.3% host/idle; 22,115 `cudaStreamSynchronize` (~2,764/step, zero GPU overlap) from the ~1,795 per-GEMM `DrainQueue` in `LagunaForwardGgufCached` + scalar host glue; 39.4% of GPU time is `QuantizeQ8K` activation-quant (per-GEMM), weight GEMVs un-grouped at ~22% of the 240 GB/s peak (llama.cpp ~76%); no H2D/D2H (unified memory). Ranked levers (all in-tree from ds4): device-resident decode 1.5->~5-7 tok/s, grouped-expert GEMM (`MatmulBTQuantGrouped`) +1.5-2x + dedupes the activation-quant, decode CUDA-graph, tuned MMVQ; + free host cleanups (`LagunaEmbed` copies the whole 1.23 GB embed table/token, per-token RoPE-cache rebuild). Honest reachable ~13-20 tok/s, 27.8 a stretch. NO code changed. See `.agents/specs/laguna-s21-w7-speed-2026-07-31.md`. Prior RUNNABLE + FAST DECODE (W6, 2026-07-31): a per-layer K/V cache + single-token incremental decode replaces W5's O(n²) STATELESS recompute — TOKEN-IDENTICAL (byte-equal ids, md5 `754728c6…` match, == the W5 golden) and 5.05× faster per token: decode 3.33 → 0.66 s/tok on the real UD-Q4_K_XL GGUF (GB10, `--gpu`, keep-quant), same " Paris.…" text. `LagunaKvCache` (mirrors `DeepseekV4KvCache`, MLA-latent → GQA multi-head K/V; caches post-QK-RMSNorm/post-RoPE K + raw V at f32 — bit-exact since RoPE/QK-norm are position-only and attention is causal), MIXED attention per-layer: 12 GLOBAL layers grow unbounded + 36 SLIDING-WINDOW-512 layers EVICT rows beyond the 512 window (gemma2/3 `is_sliding`); `LagunaForwardGgufCached` + shared `LagunaAttention`/`LagunaFfnBlock` helpers used by BOTH forwards (identical float ops; recompute ids unchanged after refactor), `examples/laguna_gen --stateless` A/B flag. No cache bug (bit-exact first run). Next speed = grouped-expert GEMM + device-resident decode (both in-tree from ds4). See `.agents/specs/laguna-s21-w6-2026-07-31.md`. Prior RUNNABLE (W5, 2026-07-31): our engine greedy-generates COHERENT text on the REAL 3-shard UD-Q4_K_XL GGUF (GB10 keep-quant) — "The capital of France is" → " Paris. …", first token "Paris." matches the llama.cpp-Poolside reference. Multi-shard GGUF reader + keep-quant tower (`LoadLagunaFromGgufShards`) + `LagunaForwardGguf` (ds4 keep-quant Gemm/GemmRowSlice) + `examples/laguna_gen`; load 20.6s, peak 71 GiB, 3.27 s/tok stateless recompute (speed=W6).** Prior W3: **W3 REAL forward + 3 new ops landed** (`laguna_ops.cpp`: per-head softplus attn out-gate + ungrouped sigmoid-noaux router + dual per-layer RoPE cos/sin builders; `LagunaModel::Forward` now a REAL runnable host-reference composition — variable-Q-head GQA + dual RoPE + sliding-window mask + softplus gate + dense L0 / ungrouped-MoE L1..47 + untied lm_head — replacing the W1/W2 `VT_CHECK(false)` stub; CPU `-Werror` full-library build clean; `test_laguna_scaffold` **8/8·166** incl. softplus math, router selection+tie-break RED-first, dual-RoPE cos/sin bit-match, variable-Q-head shapes, forward composition on synthetic weights; `test_model_registry` 24/24). W1 oracle DECISION: vLLM native `laguna.py` in pin ⇒ config constructs; dual-oracle = vLLM-NVFP4/-FP8 (fits GB10, BF16 235 GiB does NOT) + llama.cpp-Q4_K token-exact. DEFERRED to W4 (needs 73 GB checkpoint): GGUF keep-quant tower materialization + device/paged production forward + strict dual-oracle greedy gate. ~85-90% reuse (ds4-MoE + gemma-sliding + olmo3-dual-rope + landed Q4_K keep-quant); NEW = the 3 landed host ops + name-map + variable-Q-head device runner. **W4 (2026-07-31, `CLAIM-LAGUNA-W4`, in progress):** the UD-Q4_K_XL GGUF (73.4 GiB, 3 shards) FETCHED to dgx + its metadata/tensor-map READ AUTHORITATIVELY (814 tensors, arch `laguna`, `expert_gating_func=2` sigmoid, `leading_dense_block_count=1`, `expert_weights_scale=2.5`). Three CPU-verified FIDELITY corrections the W1-W3 scaffold got wrong, each grounded in the real GGUF + llama.cpp: (1) **per-head QK-RMSNorm** (`attn_q_norm`/`attn_k_norm` F32[128]) added to params+forward — the scope MISSED it (surfaces only in the tensor map); (2) **dual-RoPE mscale** now uses llama.cpp's `yarn_attn_factor·(1+0.1·ln(factor))` off the GGUF-authoritative `factor=32`/`yarn_attn_factor=1.0` (256K-ctx build, NOT HF's factor-128/1.4852 1M-ctx scalar) — resolves the numerics-delicate residual; (3) **separate** `ffn_gate_exps`/`ffn_up_exps` (Q4_K) + `ffn_down_exps` (Q5_K) + Q8_0 shared/attn (the scaffold assumed merged gate_up). GGUF keep-quant tower materialization (`Mw`/`Sew` mirror of ds4) + keep-quant `ForwardGguf` (vt::MatmulBT/GemmRowSlice) + the real-model greedy run vs the llama.cpp-laguna same-quant oracle remain the W5 close (73 GB single-GB10, host-orchestrated) | `MODEL-TEXT-laguna-laguna-for-causal-lm` | | 🚫 | `DeepseekV3ForCausalLM` / `DeepseekV32ForCausalLM` | DeepSeek-V3 / V3.2 | HW-blocked (671B, ~642 GiB fp8 vs 119 GiB unified memory); V3.2 additionally DEP-blocked (DSA indexer) | `MODEL-TEXT-deepseek-v2-deepseek-v3-for-causal-lm` | -| 🚫 | `GlmMoeDsaForCausalLM` | GLM-5 / GLM-5.3 (DSA) | HW-blocked and DEP-blocked. **HW, re-measured 2026-08-28 against the published `zai-org/GLM-5.3` (`model_type: glm_moe_dsa`, revision `935644c05e76`): 753,329,940,480 parameters, 703.74 GiB of fp8 over 141 safetensors shards, 1403.2 GiB at bf16, against 119.631 GiB on `dgx:gpu0`.** Fitting the largest fleet device needs **1.36 bits per weight**, and the smallest published GGUF arm is `unsloth/GLM-5.3-GGUF` `UD-Q3_K_XL` at 319.41 GiB. **DEP: GLM-5.x is DeepSeek-V3.2 verbatim AT THE PIN and no longer on vLLM `main`**, which re-homes the alias and gives it its own `VerifyAndUpdateConfig`. Both oracles reach the architecture at the revisions this tree already pins, so neither needs a pin advance and neither needs a new oracle file; both are `gateable = no` for this model on MEMORY, not on missing support | `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` | +| 📋 | `GlmMoeDsaForCausalLM` | GLM-5 / GLM-5.3 (DSA) | **SCOPED, NOT IMPLEMENTED — the hardware blocker was computed in the wrong frame and the port plan is committed** ([#2214](https://github.com/mudler/vllm.cpp/issues/2214), [spec](specs/glm-dsa-latest-deepseek.md) §3). Recomputed from `zai-org/GLM-5.3`'s own `config.json` and checked against its `model.safetensors.index.json`, the routed experts are **97.49% of 753.33B parameters** (reproducing the API's measured total to -0.00016%), so the question is the step working set, not resident capacity. A full HTTP-range census of `unsloth/GLM-5.3-GGUF` `UD-IQ1_S` (revision `346b3591c7f2`, 6 shards, 1809 tensors) measures **14.511 GiB resident + 187.312 GiB of streamable `*_exps` towers**; one `c = 1` decode step touches 1800 slices = 11.21 GiB of uniform slots, so resident + a 4096-slot cache is **40.01 GiB** against 119.631 GiB on `dgx:gpu0`. **Blocked on exactly one kernel, and it is a `vec_dot` rather than a decoder: `VecDotIQ4_XSQ8_K`, 4 tensors**, owned by `QUANT-GGUF-IQ4_XS`. `94de63ff5` ([#2245](https://github.com/mudler/vllm.cpp/issues/2245)) landed the IQ4_XS and IQ2_XS DEQUANTIZERS on 2026-08-29 and neither keep-quant `vec_dot`, so both types now EXPAND to bf16 at load — `UD-IQ1_S`'s four towers go 6.375 -> 24.000 GiB — and `gguf_device_fit.cpp:85-100`'s all-or-nothing rule drops the whole arm out of the streaming lane. `IQ1_M` still has no reader traits, so `UD-IQ1_M` refuses at file open. The pinned vLLM class CAN load this checkpoint (it derives the indexer schedule from `index_topk_freq`/`index_skip_topk_offset` at `deepseek_v2.py:1092-1103` and never reads `indexer_types`), but it cannot RUN it on any fleet device, so **NO end-to-end token gate against vLLM is reachable** and the spec says so before any wave promises one. Eight waves; none landed. Nothing is implemented: `GlmMoeDsaForCausalLM` appears nowhere under `src/` | `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` | | 🚫 | `MiniMaxM2ForCausalLM` | MiniMax-M2 | HW-blocked (~230B / ~428 GiB bf16, ~4x over unified memory) | `MODEL-TEXT-minimax-m2-mini-max-m2-for-causal-lm` | | ✅ | `GemmaForCausalLM` | Gemma 1 (gemma-2b) | STRICT token-exact SACRED gate 48/48 greedy vs vLLM 0.25.0 (K=5 ALL-DETERMINISTIC → STRICT; BOS-verified; ungated `unsloth/gemma-2b` mirror). The original Gemma: two fused add+RMSNorm/layer, `head_dim^-0.5` scale, GeGLU + `sqrt(hidden)` embed-scale, tied lm_head, no soft-cap/QK-norm/sliding; reuses the W1 GeGLU/embed-scale primitives; speed pending | `MODEL-TEXT-gemma-gemma-for-causal-lm` | | ✅ | `Gemma2ForCausalLM` | Gemma 2 (gemma-2-2b-it) | near-tie-band SACRED gate 48/48 vs vLLM 0.25.0 (44/48 strict on vLLM's greedy + 4/48 at gap 0.0000 nats in vLLM's OWN teacher-forced logits — pure argmax-tiebreak ties, 0 forward-divergent; vLLM K=5 self-deterministic; BOS-verified; ungated `unsloth/gemma-2-2b-it`). PROVES the soft-cap primitives: `attn_logit_softcapping` 50 threaded through `PagedAttentionArgs` into paged attention (verified applied by a cap-on≠cap-off same-binary A/B + unit + CPU differs-test) + final logit soft-cap 30 (monotone); the inverse of Gemma-3 (BOTH soft-caps, NO QK-norm, single rope); speed pending | `MODEL-TEXT-gemma2-gemma2-for-causal-lm` | @@ -243,7 +243,7 @@ Transformers compatibility is capability-driven and excluded from finite counts. | `MODEL-TEXT-glm4-glm4-for-causal-lm` | `Glm4ForCausalLM` | `registry.py:113`; `vllm/model_executor/models/glm4.py::Glm4ForCausalLM` | causal generation / text | GLM-4-9B-0414 dense LANDED (G2). Impl: `include/vllm/model_executor/models/glm4.h` + `src/vllm/model_executor/models/{glm4,glm4_weights,glm4_registry}.cpp` (one `REGISTER_VLLM_MODEL`, reuses the shared dense glue). The two "new primitives" reduced to EXISTING infra: partial + INTERLEAVED rope routes `RopeFromCache` with `is_neox_style=false` (`cuda_ops.cu:697-698` / `cpu_ops.cpp:744-746`, the DeepSeek-V2 decoupled-rope path) over `rotary_dim=0.5*head_dim=64`, tail passed through; SANDWICH NORMS (`glm4.py:206,211`) are standalone `vt::RmsNorm` on the attn/mlp output. Biased qkv (`attention_bias:true`, `vt::Add` row-broadcast, 1-D `LoadMergedBf16Vector`), pre-merged `gate_up_proj`, no QK-norm, GQA 32/2, untied lm_head, MTP-tail skip. Loader 523 tensors, zero missing/unmapped. Tests: `tests/parity/test_glm4_paged_engine.cpp` (SACRED), rope unit gate at GLM dims both layouts (`tests/vt/test_ops_rope_cache.cpp`), registry resolution. Runs EAGER (bf16, no decode graph). Gate: 16/16 vs vLLM 0.25.0 (STRICT 13/16 + near-tie 3/16, max gap 0 nats) — speed pending | [glm-dsa-latest-deepseek spike](specs/glm-dsa-latest-deepseek.md) | `READY` | `test_glm4_paged_engine` 16/16 (dgx); rope unit 6692/6692; registry 22/22; registration `src/vllm/model_executor/models/glm4_registry.cpp:120`; test `tests/vllm/models/test_model_registry.cpp:97` | - | | `MODEL-TEXT-glm4-moe-glm4-moe-for-causal-lm` | `Glm4MoeForCausalLM` | `registry.py:114`; `vllm/model_executor/models/glm4_moe.py::Glm4MoeForCausalLM` | causal generation / text | model loader/forward; paged attention/KV; FusedMoE/grouped GEMM; GLM-4.5/4.6/4.7 (`glm4_moe.py:24-25`) = **Qwen3-MoE attention + DeepSeek-V2 router**: GQA with optional QK-norm (`:305-322`) and partial NeoX rope (`:289`), NOT MLA; but a router that is a near-verbatim `DeepseekV2MoE` port — fp32 `nn.Linear` gate (`:147-152`), `e_score_correction_bias` (`:153-155`), sigmoid scoring (`:204`), grouped top-k (`:200-202`), `routed_scaling_factor` (`:206-207`), `first_k_dense_replace` (`:362-379`). Needs the SHARED router extension (our `vt::MoeRouterTopKArgs` has only `top_k`+`renormalize`). **HW-BLOCKED e2e:** smallest genuine checkpoint `zai-org/GLM-4.5-Air` 110.5B / **205.8 GiB bf16** vs GB10's ~119 GiB; the 104.8 GiB FP8 variant depends on an fp8 checkpoint-loading row we do not own. Gateable subset = config/registry resolution + weight-map on a single shard + router unit parity at 128-expert/top-8 | [glm-dsa-latest-deepseek spike](specs/glm-dsa-latest-deepseek.md) | `SPIKE` | none | `CLAIM-GLM-DSA-LATEST-DEEPSEEK` | | `MODEL-TEXT-glm4-moe-lite-glm4-moe-lite-for-causal-lm` | `Glm4MoeLiteForCausalLM` | `registry.py:115`; `vllm/model_executor/models/glm4_moe_lite.py::Glm4MoeLiteForCausalLM` | causal generation / text | model loader/forward; FusedMoE/grouped GEMM; MLA/latent KV; **★ THE RECOMMENDED SECOND MLA GATE VEHICLE.** GLM-4.7-Flash = DeepSeek-V2 with GLM's MoE bolted in: `glm4_moe_lite.py:94-95` and `:98-99` are LITERAL zero-override subclasses of `DeepseekV2Attention` / `DeepseekV2MLAAttention`; decoder layer, model and `load_weights` (incl. the `fused_qkv_a_proj` merge `:330-335`, `:544-551`) are structural copies of deepseek_v2; only `Glm4MoeLite = Glm4MoE` (`:86-87`) is GLM-specific. Verified live config: `q_lora_rank: 768` (non-null), `topk_method: noaux_tc` (so `e_score_correction_bias` present), kv_lora 512, qk_nope 192 / qk_rope 64 / v_head 256, 47L, 64 experts + 1 shared, top-4, **NO `index_topk` so `is_v32 == False` — DENSE MLA, not DSA**. `zai-org/GLM-4.7-Flash` 31.2B / **58.2 GiB bf16 — FITS GB10.** EXTENDS the MLA campaign: closes BOTH coverage gaps that spike named as unit-gated-only on DeepSeek-V2-Lite (q_lora_rank=null; no e_score_correction_bias). Gated on `CLAIM-MLA-DEEPSEEK` reaching its W6 | [glm-dsa-latest-deepseek spike](specs/glm-dsa-latest-deepseek.md) | `ACTIVE` (correctness COMPLETE, speed PENDING) | **G1 LANDED 2026-07-24 — SACRED gate 8/8 vs vLLM 0.25.0.** GLM-4.7-Flash reuses the ENTIRE DeepSeek-V2 MLA stack over the SAME `DeepseekV2Weights` — the noaux_tc grouped router (already landed in the MLA campaign's W3), the MLA attention block with the q_lora branch (W6), the bf16 grouped MoE + shared expert (W7), the decode CUDA-graph (W9). Genuinely NEW work: (1) an ADDITIVE `head_dim=256` dispatch in the MLA prefill launcher (`cuda_flash_attn_fa2.cu` `LaunchMlaPrefillFA2Bf16` — GLM's qk_nope 192 + qk_rope 64 = 256; the 256 split-KV instantiation was already compiled for the 27B/35B paged prefill, so the 192 path is byte-identical); (2) a MTP-tolerant parse/loader (`allow_mtp_tail` on `ParseDeepseekV2Params`/`LoadDeepseekV2ForCausalLMWeights` — GLM ships `num_nextn_predict_layers: 1`, defaulted false so DeepSeek-V2 is byte-identical); (3) the GLM registry TU `glm4_moe_lite_registry.cpp`; (4) a scoring-func fix — GLM's config OMITS `scoring_func` and its model class hardcodes sigmoid, so `noaux_tc` + absent-key now defaults to sigmoid (DeepSeek-V2-Lite is greedy → softmax UNCHANGED). **THE GATE (measured, not assumed):** vLLM 0.25.0 is DETERMINISTIC at batch=1 (K=5, 0 multi-valued cells) → STRICT bar. Our engine: STRICT token-exact 1/8, near-tie-band 7/8, 69/128 tokens strictly exact; the teacher-forcing diagnostic shows ALL 59 divergent positions at gap EXACTLY 0.0000 nats (vLLM's OWN argmax on OUR prefix IS our token), 0 tokens outside vLLM's top-20, 0 forward-divergent — a cleaner pass than DeepSeek-V2's (0.25-nat root flip). PROOF THE PATH RAN: fa_page_size 36864 = block 32 × 576 × 2B (NO factor 2 — real MLA cache); split stats prefill_only=8/decode_only=120 (8 MLA prefills + 120 MLA decodes); coherent English ("Paris. The capital of the United Kingdom is London..."). **CLOSES the MLA campaign's C2 gaps:** the q_lora query branch (fused_qkv_a_proj/q_a_layernorm/q_b_proj) and the whole noaux_tc router (sigmoid + e_score_correction_bias + routed_scaling_factor 1.8 + renormalize) now have e2e coverage (were unit-gated-only on DeepSeek-V2-Lite). LOADER GATE 3/3 cases / 57117 assertions (9491 main-model tensors mapped, 212 MTP-tail skipped, 0 unmapped, 0 missing; q_lora branch + f32 e_score_correction_bias asserted). ROUTER unit gate at real GLM dims (64E/top-4/n_group1/routed_scale 1.8/noaux_tc/renorm) vs CPU ref, CUDA==CPU, memcheck 0 errors. compute-sanitizer memcheck on the FULL GLM gate (head_dim-256 MLA prefill + MoE glue + MLA decode): ERROR SUMMARY 0 errors, gate still 8/8. eager==graph bit-identical (`VT_DEEPSEEK_CUDAGRAPH=0` same 8/8 + anchored tokens). Clean full CUDA `-Werror` 0 warn/0 err. Regressions ALL byte-identical STANDALONE under flock: 27B 235/235, 35B 315/315, Qwen3-Coder 138/138, Qwen3-dense 184/184, OPT 63/63, DeepSeek-V2 223/223 (Release AND asserts-on exit 0 — the shared-TU canary), Llama 92/92, Mistral 92/92. **Speed PENDING** (no number; `DONE` needs vLLM every-axis parity).; registration `src/vllm/model_executor/models/glm4_moe_lite_registry.cpp:179`; test `tests/vllm/models/test_glm4_moe_lite_load.cpp:102` | `CLAIM-GLM-DSA-LATEST-DEEPSEEK` | -| `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` | `GlmMoeDsaForCausalLM` | `vllm/model_executor/models/registry.py` -> `"GlmMoeDsaForCausalLM"` (`:117` at the pin, the sole occurrence in that file; the row said `:116`, which is `"Glm4MoeLiteForCausalLM"`, a different model); `vllm/model_executor/models/deepseek_v2.py::GlmMoeDsaForCausalLM` (`:1930` at the pin) | causal generation / text | model loader/forward; paged attention/KV; FusedMoE/grouped GEMM; MLA/latent KV; DSA sparse indexer. GLM-5.x is DeepSeek-V3.2 VERBATIM **AT THE PIN, AND ONLY THERE** — at `555967922` `deepseek_v2.py::GlmMoeDsaForCausalLM` (`:1930-1931`) is `class GlmMoeDsaForCausalLM(DeepseekV2ForCausalLM): pass`, and the ONLY behavioural special case is the fp32 router dtype forced by `model_type == "glm_moe_dsa"` in `deepseek_v2.py::_get_moe_router_dtype` (`:127`, the only occurrence of that string in the file). **On vLLM `main` `d1922cb5a7` (read 2026-08-28) it is no longer verbatim**, in three named places: `registry.py:118` re-homes the alias to `vllm.models.deepseek_v32`, whose `__init__.py:17-29` binds it to `DeepseekV32ForCausalLM` under CUDA and keeps the `deepseek_v2` subclass on every other platform; the architecture gains its own `VerifyAndUpdateConfig` (`vllm/model_executor/models/config.py::GlmMoeDsaForCausalLM`, `:43`, registered `:936`) that sets the decode-context-parallel defaults `comm_backend="a2a", q_replicate=True`; and it is named in `DEFAULT_BREAKABLE_CUDAGRAPH_ARCHITECTURES` (`vllm/config/vllm.py:81`). Reaching any of that needs a pin advance, which this row does not take. Numerical deltas vs V3.2 live in the newer tree: interleaved indexer RoPE vs NeoX split-half (`vllm/models/deepseek_v32/nvidia/kernels.py:300,697`) and `index_topk_freq=4` (`nvidia/attention.py:206`); live `zai-org/GLM-5` config confirms `indexer_rope_interleave: true`, `index_topk: 2048`, `index_n_heads: 32`. **DOUBLY BLOCKED: (1) DEP-BLOCKED** — on sm_121 the sparse XOR filter (`vllm/v1/attention/backend.py:345-350`) ELIMINATES `TRITON_MLA`, leaving `FLASHINFER_MLA_SPARSE_SM120` as the SOLE candidate, and that path is non-functional on flashinfer 0.6.12 (4 concrete failures, spike §0.2); **(2) HW-BLOCKED** — 753.9B / **1404.2 GiB** vs 119 GiB memory and 184 GiB disk. Registry/config resolution only | [glm-dsa-latest-deepseek spike](specs/glm-dsa-latest-deepseek.md) | `BLOCKED` | **W10 (blocked-row honesty pass, 2026-07-22), cross-claim edit by `CLAIM-MLA-DEEPSEEK` with the disposition recorded in coordination.md — HW-BLOCKED **and** DEP-BLOCKED.** **HW:** `zai-org/GLM-5` is 1404.2 GiB bf16 against 119 GiB of unified memory (measured 2026-07-21 by the GLM spike). **DEP:** GLM-5.x is DeepSeek-V3.2 VERBATIM (`deepseek_v2.py:1917-1918` is `class GlmMoeDsaForCausalLM(DeepseekV2ForCausalLM): pass`), so it is a SPARSE/DSA model and inherits exactly the sm_121 dependency dead-end recorded on `MODEL-TEXT-deepseek-v2-deepseek-v3-for-causal-lm`: the sparse XOR filter removes `TRITON_MLA`, leaving `FLASHINFER_MLA_SPARSE_SM120` alone, and that path dispatches to flashinfer's dense-only XQA backend which discards `sparse_mla_top_k`. Upstream-watch item, not work. **What can still be gated:** config resolution and the fp32-router special case (`deepseek_v2.py:120-130`) at unit level; nothing e2e. **RECONCILED 2026-08-28 ([#2194](https://github.com/mudler/vllm.cpp/issues/2194)), records only — the row stays `BLOCKED` and no pin moved.** **(a) Both upstream anchors were stale at our own parity pin, and one of them confirmed itself.** `registry.py:116` at `555967922` is `"Glm4MoeLiteForCausalLM"`, a DIFFERENT model, so a reader who checked it casually read a plausible GLM line and moved on; the entry for this architecture is `:117`. `deepseek_v2.py:1917-1918` is `load_weights` / `loader = AutoWeightsLoader(self)`; the class is at `:1930`. Both corrected anchors are UNIQUE: `git show :` piped to `grep -n GlmMoeDsaForCausalLM` returns exactly one line per file. **Neither number was ever wrong when it was written, and that is the mechanism:** both are exact at the PRIOR pin `e24d1b24` (`registry.py:116`, `deepseek_v2.py:1917`), which is the revision the spike's `### Upstream chain` names and still names honestly. The 2026-07-26 advance to `555967922` moved `registry.py` by one line and `deepseek_v2.py` by thirteen, and this row carried the old coordinates forward with no revision label attached, so nothing could see them drift. The corrected anchors below therefore carry the pin they were measured at. **(b) The checkpoint this row predates now exists.** `zai-org/GLM-5.3` revision `935644c05e76`, read from the HuggingFace API on 2026-08-28: `model_type: glm_moe_dsa`, `architectures: ["GlmMoeDsaForCausalLM"]`, `dtype: bfloat16`, `quantization_config.quant_method: fp8` (`e4m3`, dynamic activations, `weight_block_size [128,128]`), 78 layers, hidden 6144, 256 routed experts + 1 shared, top-8, `q_lora_rank 2048`, `qk_rope_head_dim 64`, `index_topk 2048`, `index_n_heads 32`, `indexer_rope_interleave: true`, one MTP layer. It is NOT the `glm5_next` of `MODEL-MM-GLM53-FLASH`, and the two rows stay separate. One detail retires part of the fp32-router special case for THIS checkpoint: GLM-5.3 DOES expose `moe_router_dtype: float32`, so the upstream comment "older GLM-5/5.2 configs ... do not expose `moe_router_dtype` yet" no longer describes the newest artifact, although the forced branch still fires first. **(c) The blocker arithmetic, recomputed from the MEASURED parameter count so nobody redoes it.** The API reports 753,329,940,480 parameters (751,226,191,872 `F8_E4M3` + 2,103,729,152 `BF16` + 19,456 `F32`), which confirms the 753.9B this row already carried and gives 1403.2 GiB at bf16 rather than the 1404.2 GiB written above. On disk the published fp8 weights are 755,632,050,320 bytes = 703.74 GiB over 141 shards. `dgx:gpu0` holds 128,452,956,160 B = 119.631 GiB, so this model needs **1.3641 bits per weight** to fit, against 2.32 bpw for the smallest arm that does fit anything comparable. At 2.32 / 1.70 / 1.50 bpw GLM-5.3 is **203.5 / 149.1 / 131.5 GiB**, and none of the three fits. GGUF conversion has started and does not change the verdict: `unsloth/GLM-5.3-GGUF` (re-read 2026-08-28 16:14Z, revision `8cf52b13b130`) holds one complete arm, `UD-Q3_K_XL`, 9 files, **319.41 GiB** = 3.64 bpw; `AtomicChat/GLM-5.3-GGUF` and `MaliAir/GLM-5.3-MXFP4-MOE-Q8_0-GGUF` hold ZERO `.gguf` files. `rc devices` on 2026-08-28 listed `dgx:gpu0`, `orin:gpu0`, `strix:gpu0` and `thor:gpu0`, and none is larger than `dgx:gpu0`. **(d) Oracles: both already registered, neither needs a new file, and that is the finding.** vLLM is the PRIMARY and reaches this architecture AT OUR PIN (`registry.py:117`, `deepseek_v2.py:1930`), so no pin advance and no second vLLM record — a second `role = primary` is refused by `scripts/check-oracle-pins.py`, and a second transcription of one pin is the drift `.agents/oracles/README.md` exists to stop. llama.cpp reaches it at our STOCK release pin `b10451` (`10bf611e533d81f739128304991c5e133c6aebd8`, confirmed by `git ls-remote --tags`): a fresh bare clone of `ggml-org/llama.cpp` shows `LLM_ARCH_GLM_DSA` -> `"glm-dsa"` at `src/llama-arch.cpp:85` with its case at `:1051` (the only two occurrences of `GLM_DSA` in that file), the graph in `src/models/glm-dsa.cpp`, and the converter registration `conversion/glm.py:274-276` (`GlmMoeDsaModel(DeepseekV2Model)`, `model_arch = GLM_DSA`). So NO scoped PR-oracle file is needed here, UNLIKE `llama-cpp-qwen4exp` and the `llama-cpp-glm5next` proposed in [#2178](https://github.com/mudler/vllm.cpp/issues/2178), both of which exist only because no llama.cpp RELEASE carries the architecture at all. Both oracles are `gateable = no` FOR THIS MODEL, and the reason is MEMORY, not missing support — which is exactly what separates this row from `MODEL-MM-GLM53-FLASH`, blocked because no SERVING oracle registers `glm5_next` at any revision — that row is gated piecewise against `transformers` **v5.16.1**, which does implement it, as W4 ([#2098](https://github.com/mudler/vllm.cpp/issues/2098), landed on `main` 2026-08-28 as `6c715de00`) did by RUNNING the reference mHC modules. That per-model verdict lives here and not in `.agents/oracles/*.md`, whose `gateable` key is a property of the oracle and not of one checkpoint. none | `CLAIM-GLM-DSA-LATEST-DEEPSEEK` | +| `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm` | `GlmMoeDsaForCausalLM` | `vllm/model_executor/models/registry.py` -> `"GlmMoeDsaForCausalLM"` (`:117` at the pin, the sole occurrence in that file; the row said `:116`, which is `"Glm4MoeLiteForCausalLM"`, a different model); `vllm/model_executor/models/deepseek_v2.py::GlmMoeDsaForCausalLM` (`:1930` at the pin) | causal generation / text | model loader/forward; paged attention/KV; FusedMoE/grouped GEMM; MLA/latent KV; DSA sparse indexer. GLM-5.x is DeepSeek-V3.2 VERBATIM **AT THE PIN, AND ONLY THERE** — at `555967922` `deepseek_v2.py::GlmMoeDsaForCausalLM` (`:1930-1931`) is `class GlmMoeDsaForCausalLM(DeepseekV2ForCausalLM): pass`, and the ONLY behavioural special case is the fp32 router dtype forced by `model_type == "glm_moe_dsa"` in `deepseek_v2.py::_get_moe_router_dtype` (`:127`, the only occurrence of that string in the file). **On vLLM `main` `d1922cb5a7` (read 2026-08-28) it is no longer verbatim**, in three named places: `registry.py:118` re-homes the alias to `vllm.models.deepseek_v32`, whose `__init__.py:17-29` binds it to `DeepseekV32ForCausalLM` under CUDA and keeps the `deepseek_v2` subclass on every other platform; the architecture gains its own `VerifyAndUpdateConfig` (`vllm/model_executor/models/config.py::GlmMoeDsaForCausalLM`, `:43`, registered `:936`) that sets the decode-context-parallel defaults `comm_backend="a2a", q_replicate=True`; and it is named in `DEFAULT_BREAKABLE_CUDAGRAPH_ARCHITECTURES` (`vllm/config/vllm.py:81`). Reaching any of that needs a pin advance, which this row does not take. Numerical deltas vs V3.2 live in the newer tree: interleaved indexer RoPE vs NeoX split-half (`vllm/models/deepseek_v32/nvidia/kernels.py:300,697`) and `index_topk_freq=4` (`nvidia/attention.py:206`); live `zai-org/GLM-5` config confirms `indexer_rope_interleave: true`, `index_topk: 2048`, `index_n_heads: 32`. **DOUBLY BLOCKED: (1) DEP-BLOCKED** — on sm_121 the sparse XOR filter (`vllm/v1/attention/backend.py:345-350`) ELIMINATES `TRITON_MLA`, leaving `FLASHINFER_MLA_SPARSE_SM120` as the SOLE candidate, and that path is non-functional on flashinfer 0.6.12 (4 concrete failures, spike §0.2); **(2) HW-BLOCKED** — 753.9B / **1404.2 GiB** vs 119 GiB memory and 184 GiB disk. Registry/config resolution only | [glm-dsa-latest-deepseek spike](specs/glm-dsa-latest-deepseek.md) | `SPIKE` | **W10 (blocked-row honesty pass, 2026-07-22), cross-claim edit by `CLAIM-MLA-DEEPSEEK` with the disposition recorded in coordination.md — HW-BLOCKED **and** DEP-BLOCKED.** **HW:** `zai-org/GLM-5` is 1404.2 GiB bf16 against 119 GiB of unified memory (measured 2026-07-21 by the GLM spike). **DEP:** GLM-5.x is DeepSeek-V3.2 VERBATIM (`deepseek_v2.py:1917-1918` is `class GlmMoeDsaForCausalLM(DeepseekV2ForCausalLM): pass`), so it is a SPARSE/DSA model and inherits exactly the sm_121 dependency dead-end recorded on `MODEL-TEXT-deepseek-v2-deepseek-v3-for-causal-lm`: the sparse XOR filter removes `TRITON_MLA`, leaving `FLASHINFER_MLA_SPARSE_SM120` alone, and that path dispatches to flashinfer's dense-only XQA backend which discards `sparse_mla_top_k`. Upstream-watch item, not work. **What can still be gated:** config resolution and the fp32-router special case (`deepseek_v2.py:120-130`) at unit level; nothing e2e. **RECONCILED 2026-08-28 ([#2194](https://github.com/mudler/vllm.cpp/issues/2194)), records only — the row stays `BLOCKED` and no pin moved.** **(a) Both upstream anchors were stale at our own parity pin, and one of them confirmed itself.** `registry.py:116` at `555967922` is `"Glm4MoeLiteForCausalLM"`, a DIFFERENT model, so a reader who checked it casually read a plausible GLM line and moved on; the entry for this architecture is `:117`. `deepseek_v2.py:1917-1918` is `load_weights` / `loader = AutoWeightsLoader(self)`; the class is at `:1930`. Both corrected anchors are UNIQUE: `git show :` piped to `grep -n GlmMoeDsaForCausalLM` returns exactly one line per file. **Neither number was ever wrong when it was written, and that is the mechanism:** both are exact at the PRIOR pin `e24d1b24` (`registry.py:116`, `deepseek_v2.py:1917`), which is the revision the spike's `### Upstream chain` names and still names honestly. The 2026-07-26 advance to `555967922` moved `registry.py` by one line and `deepseek_v2.py` by thirteen, and this row carried the old coordinates forward with no revision label attached, so nothing could see them drift. The corrected anchors below therefore carry the pin they were measured at. **(b) The checkpoint this row predates now exists.** `zai-org/GLM-5.3` revision `935644c05e76`, read from the HuggingFace API on 2026-08-28: `model_type: glm_moe_dsa`, `architectures: ["GlmMoeDsaForCausalLM"]`, `dtype: bfloat16`, `quantization_config.quant_method: fp8` (`e4m3`, dynamic activations, `weight_block_size [128,128]`), 78 layers, hidden 6144, 256 routed experts + 1 shared, top-8, `q_lora_rank 2048`, `qk_rope_head_dim 64`, `index_topk 2048`, `index_n_heads 32`, `indexer_rope_interleave: true`, one MTP layer. It is NOT the `glm5_next` of `MODEL-MM-GLM53-FLASH`, and the two rows stay separate. One detail retires part of the fp32-router special case for THIS checkpoint: GLM-5.3 DOES expose `moe_router_dtype: float32`, so the upstream comment "older GLM-5/5.2 configs ... do not expose `moe_router_dtype` yet" no longer describes the newest artifact, although the forced branch still fires first. **(c) The blocker arithmetic, recomputed from the MEASURED parameter count so nobody redoes it.** The API reports 753,329,940,480 parameters (751,226,191,872 `F8_E4M3` + 2,103,729,152 `BF16` + 19,456 `F32`), which confirms the 753.9B this row already carried and gives 1403.2 GiB at bf16 rather than the 1404.2 GiB written above. On disk the published fp8 weights are 755,632,050,320 bytes = 703.74 GiB over 141 shards. `dgx:gpu0` holds 128,452,956,160 B = 119.631 GiB, so this model needs **1.3641 bits per weight** to fit, against 2.32 bpw for the smallest arm that does fit anything comparable. At 2.32 / 1.70 / 1.50 bpw GLM-5.3 is **203.5 / 149.1 / 131.5 GiB**, and none of the three fits. GGUF conversion has started and does not change the verdict: `unsloth/GLM-5.3-GGUF` (re-read 2026-08-28 16:14Z, revision `8cf52b13b130`) holds one complete arm, `UD-Q3_K_XL`, 9 files, **319.41 GiB** = 3.64 bpw; `AtomicChat/GLM-5.3-GGUF` and `MaliAir/GLM-5.3-MXFP4-MOE-Q8_0-GGUF` hold ZERO `.gguf` files. `rc devices` on 2026-08-28 listed `dgx:gpu0`, `orin:gpu0`, `strix:gpu0` and `thor:gpu0`, and none is larger than `dgx:gpu0`. **(d) Oracles: both already registered, neither needs a new file, and that is the finding.** vLLM is the PRIMARY and reaches this architecture AT OUR PIN (`registry.py:117`, `deepseek_v2.py:1930`), so no pin advance and no second vLLM record — a second `role = primary` is refused by `scripts/check-oracle-pins.py`, and a second transcription of one pin is the drift `.agents/oracles/README.md` exists to stop. llama.cpp reaches it at our STOCK release pin `b10451` (`10bf611e533d81f739128304991c5e133c6aebd8`, confirmed by `git ls-remote --tags`): a fresh bare clone of `ggml-org/llama.cpp` shows `LLM_ARCH_GLM_DSA` -> `"glm-dsa"` at `src/llama-arch.cpp:85` with its case at `:1051` (the only two occurrences of `GLM_DSA` in that file), the graph in `src/models/glm-dsa.cpp`, and the converter registration `conversion/glm.py:274-276` (`GlmMoeDsaModel(DeepseekV2Model)`, `model_arch = GLM_DSA`). So NO scoped PR-oracle file is needed here, UNLIKE `llama-cpp-qwen4exp` and the `llama-cpp-glm5next` proposed in [#2178](https://github.com/mudler/vllm.cpp/issues/2178), both of which exist only because no llama.cpp RELEASE carries the architecture at all. Both oracles are `gateable = no` FOR THIS MODEL, and the reason is MEMORY, not missing support — which is exactly what separates this row from `MODEL-MM-GLM53-FLASH`, blocked because no SERVING oracle registers `glm5_next` at any revision — that row is gated piecewise against `transformers` **v5.16.1**, which does implement it, as W4 ([#2098](https://github.com/mudler/vllm.cpp/issues/2098), landed on `main` 2026-08-28 as `6c715de00`) did by RUNNING the reference mHC modules. That per-model verdict lives here and not in `.agents/oracles/*.md`, whose `gateable` key is a property of the oracle and not of one checkpoint. **RECONCILED AGAIN 2026-08-29 ([#2214](https://github.com/mudler/vllm.cpp/issues/2214)), `BLOCKED` -> `SPIKE`: the hardware blocker was computed in the wrong FRAME, and the port plan is now committed as [`glm-dsa-latest-deepseek.md`](specs/glm-dsa-latest-deepseek.md) §3.** The 2026-08-28 arithmetic sized RESIDENT weights, which is right for a dense model and wrong for a 256-expert MoE. Recomputed from the published `config.json` and checked against the checkpoint's own `model.safetensors.index.json` (`metadata.total_size = 755,617,140,416` over 118,629 tensors): the routed experts are **734,439,407,616 of 753,328,717,824 parameters = 97.49% streamable**, leaving **18,889,310,208 = 2.51% resident**; the model total reproduces the API's measured `safetensors.total` of 753,329,940,480 to **-1,222,656 params (-0.00016%)**, which is a tighter reconciliation than #2214's 745.8B and moves its 21.0B resident figure DOWN to 18.89B (bf16 resident 35.18 GiB, not 39.19). **The figure that actually decides the row is measured from the artifact, not derived:** a full GGUF header census of `unsloth/GLM-5.3-GGUF` `UD-IQ1_S` at revision `346b3591c7f28d1a23716f97a065ecf12ec14771` (6 shards, 1809 tensors == `split.tensors.count`, read by HTTP RANGE request, ~9.6 MB, nothing downloaded) splits the file into **228 `*_exps.weight` towers = 187.312 GiB streamable and 1581 resident tensors = 14.511 GiB**, and the resident class is 14.5 GiB in EVERY published UD arm because the recipe keeps every non-expert tensor at Q4_K or better. One decode step at `c = 1` touches `75 x 3 x 8 = 1800` distinct slices = **11.21 GiB** of uniform slots, so resident + a 4096-slot cache is **40.01 GiB** against 119.631 GiB on `dgx:gpu0`. **A `UD-*` name is a target average and not a format, and the census is what proves it:** `UD-IQ1_S` is 106 IQ1_S + 71 IQ3_XXS + 44 IQ2_XXS + 4 IQ4_XS + 3 K-quant expert tensors, and `UD-Q2_K_XL` contains exactly TWO Q2_K tensors out of 1809. **The decoder/`vec_dot` verdict was RE-MEASURED against the merged tree, because `origin/main` moved under it and inverted the answer.** At this branch's base `60a6dd97b` neither `IQ4_XS` (23) nor `IQ2_XS` (17) had a vt block dtype or a decoder, so both were a hard refusal at load. `94de63ff5` ([#2245](https://github.com/mudler/vllm.cpp/issues/2245), landed 2026-08-29 for the sibling Flash row) ported BOTH DEQUANTIZERS and NEITHER keep-quant `vec_dot`. Since `gguf_keep_quant.cpp::KeepQuantDType` returns false without `vt::cpu::HasQuantDotKernel`, **a type with a decoder and no `vec_dot` now EXPANDS TO bf16 at load** — and `gguf_device_fit.cpp:85-100` is ALL-OR-NOTHING across a model's `*_exps` tensors, so four non-keep-quant tensors out of 228 drop the whole arm out of the expert-streaming lane. Exactly: one `*_exps` tower is `2048*6144*256 = 3,221,225,472` elements = **6.000 GiB at bf16**, so `UD-IQ1_S`'s 4 IQ4_XS towers go **6.375 -> 24.000 GiB** and `UD-Q2_K_XL`'s 148 IQ2_XS towers go **128.344 -> 888.000 GiB**; the uniform slot would also have to grow from 6.375 MiB to a 24.00 MiB bf16 slice, making a 4096-slot cache 96.00 GiB. **So the row is blocked on exactly one kernel and it is a `vec_dot`, not a decoder: `VecDotIQ4_XSQ8_K`**, four tensors `blk.{8,75,76,77}.ffn_down_exps.weight`, which also unlocks `UD-IQ2_M`. `UD-Q2_K_XL` additionally needs `VecDotIQ2_XSQ8_K`; `IQ1_M` (29) still has no reader traits at all so `UD-IQ1_M` refuses at file OPEN. The kernel already has a row, `QUANT-GGUF-IQ4_XS` (`quantization-matrix.md:78`, `INVENTORIED`). **The general defect, which is not this row's to repair: landing a decoder without its `vec_dot` turns a loud refusal into a silent 3.4x memory multiplier that no token gate can see.** **One premise of the 2026-08-28 reconcile is corrected: the pinned vLLM class CAN load this checkpoint.** It never reads `indexer_types` (0 occurrences tree-wide at the pin) — it DERIVES the schedule at `deepseek_v2.py:1092-1103` from `index_topk_freq`/`index_skip_topk_offset`, yielding 21 full trunk indexers plus the MTP layer, and drops surplus checkpoint indexer weights at `:1566-1582`. The config's `indexer_types`, that derivation, and llama.cpp `b10451`'s hardcoded `GLM_5_2_DEFAULT_INDEXER_TYPES` (`src/models/glm-dsa.cpp:6-27`) agree bit for bit over all 78 layers, and the checkpoint ships `self_attn.indexer.*` on exactly 22 of 79 blocks. `modules_to_not_convert`'s `self_attn.indexers_proj` matches NO shipped tensor and no upstream module at the pin; it is a config-level shorthand, not a naming divergence. **The delta against `DeepseekV2ForCausalLM` is smaller than §0.2's DSA verdict implies**, because a device-native DSA lightning indexer now lives in the shared MLA block (`mla_attention.cpp:598-745`, CPU+CUDA `DsaIndexerLogits`/`DsaTopkSelect`, reached in production by `Dots3NoteForCausalLM`) and this model's MLA geometry (qk 192+64, v 256, latent 576/512) already validates and already dispatches to native FA-2 and decode instantiations. Net-new: the indexer KV side cache (#1925), sparse prefill, the per-layer heterogeneous schedule with `skip_topk` selection reuse, the fp32 router GEMM (`deepseek_v2.cpp:350` is hardcoded bf16), a `"glm-dsa"` `kGgufArchArms` row, and lifting the expert-streaming seam out of `qwen3_5.cpp`, which is the ONLY model TU that constructs it. **The gate is the honest cost and the spec says so before any wave promises one: NO end-to-end token gate against vLLM is reachable on this fleet**, because vLLM implements the architecture and cannot run 703.74 GiB on a 119.631 GiB unified device where host RAM is the same pool. Reachable instead: module parity against the pin on CPU, a headers-only structural loader gate, a streamed-vs-resident identical-logits gate that needs no oracle, and an llama.cpp `b10451` floor on the identical artifact. Speed is an open gap by construction. Records only: no product code, no pin advance, no GPU lease, no download. none | `CLAIM-MODEL-GLM-MOE-DSA` | | `MODEL-TEXT-gpt-oss-gpt-oss-for-causal-lm` | `GptOssForCausalLM` | `registry.py:117`; `vllm/model_executor/models/gpt_oss.py::GptOssForCausalLM` | causal generation / text | model loader/forward; paged attention/KV; FusedMoE/grouped GEMM; sliding-window attention | ☐ required | `INVENTORIED` | none | unassigned | | `MODEL-TEXT-gpt2-gpt2-lmhead-model` | `GPT2LMHeadModel` | `registry.py:118`; `vllm/model_executor/models/gpt2.py::GPT2LMHeadModel` | causal generation / text | model loader/forward; paged attention/KV; cross-attention | ☐ required | `INVENTORIED` | none | unassigned | | `MODEL-TEXT-gpt-j-gptjfor-causal-lm` | `GPTJForCausalLM` | `registry.py:119`; `vllm/model_executor/models/gpt_j.py::GPTJForCausalLM` | causal generation / text | model loader/forward; paged attention/KV | ☐ required | `INVENTORIED` | none | unassigned | diff --git a/.agents/specs/glm-dsa-latest-deepseek.md b/.agents/specs/glm-dsa-latest-deepseek.md index 1c8848cd23..9458fa9e12 100644 --- a/.agents/specs/glm-dsa-latest-deepseek.md +++ b/.agents/specs/glm-dsa-latest-deepseek.md @@ -1024,3 +1024,1048 @@ across every other row that uses it. - It did not change the row's state. `BLOCKED` is still correct, and §2.3 is the arithmetic that keeps it correct. - It did not touch `MODEL-MM-GLM53-FLASH` or its spec. + +--- + +## 3. Port plan: `GlmMoeDsaForCausalLM` / `zai-org/GLM-5.3` under expert streaming (2026-08-29) + +**Issue:** [#2214](https://github.com/mudler/vllm.cpp/issues/2214). +**Row:** `MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm`, `BLOCKED` -> `SPIKE`. +**Claim:** `CLAIM-MODEL-GLM-MOE-DSA`. +**Scope of THIS section:** a committed port plan and nothing else. No product +code, no pin advance, no GPU lease, no download. Every number below was +recomputed here from primary sources — the published `config.json`, the +published GGUF shard headers over HTTP range requests, and the local tree — and +none of it was copied from #2214 or from §2 above. Where a recomputation +DISAGREES with a figure already on record, the disagreement is stated. + +**What changes versus §2.** §2 concluded `BLOCKED` on resident capacity, and +that conclusion was correct for the frame it used. This section changes the +frame: for a model that is 97.4% routed experts, the question is not whether the +weights fit but whether the **step working set** fits, and that is a different +and much smaller number. The row therefore moves to `SPIKE` — scoped in a +committed spec, not implemented — and stays there until W1 lands. + +### 3.1 The streaming arithmetic, recomputed + +**Method.** Fetch `https://huggingface.co/zai-org/GLM-5.3/raw/main/config.json` +(29,464 B, HTTP 200, read 2026-08-29). Sum the parameter count of every tensor +group analytically from the config's own fields, then check the total against +the checkpoint's own `model.safetensors.index.json` +(`metadata.total_size = 755,617,140,416` over 118,629 tensors, fetched through +the `resolve` endpoint because the `raw` endpoint serves the 11,359,251-byte +LFS pointer) and against the HuggingFace API's `safetensors.total`. A model that +does not reproduce the published total is a model of some other checkpoint. + +The config's own layer schedule is read, not assumed. `mlp_layer_types` is 78 +entries, 3 `dense` then 75 `sparse`, which agrees with +`first_k_dense_replace = 3`. `indexer_types` is 78 entries, **21 `full` and 57 +`shared`**, in the pattern `full,full,full` then `(shared,shared,shared,full)` +repeating. `num_nextn_predict_layers = 1` adds a 79th block. + +Per-group formulae, all from `config.json`: + +| Group | Formula | Params | +|---|---|---| +| one routed expert | `3 * hidden * moe_inter` = `3 * 6144 * 2048` | 37,748,736 | +| routed experts, one MoE layer | `256 *` the above | 9,663,676,416 | +| **routed experts, 75 MoE layers** | `75 *` the above | **724,775,731,200** | +| **routed experts, the MTP block** | `1 *` the above | **9,663,676,416** | +| MLA, one layer | `H*q_lora + q_lora*n_h*qk_head + H*(kv_lora+qk_rope) + kv_lora*n_h*(qk_nope+v_head) + n_h*v_head*H` | 165,019,648 | +| MLA, 78 layers | | 12,871,532,544 | +| indexer, one layer | `q_lora*idx_n_h*idx_head + H*idx_head + H*idx_n_h` | 9,371,648 | +| indexer, 21 `full` layers | | 196,804,608 | +| shared expert, 75 MoE layers | `75 * 1 *` one expert | 2,831,155,200 | +| dense MLP, 3 layers | `3 * 3 * 6144 * 12288` | 679,477,248 | +| router gates, 75 layers | `75 * 6144 * 256` | 117,964,800 | +| embed + lm_head | `2 * 154880 * 6144` | 1,903,165,440 | +| MTP block, non-expert | MLA + indexer + shared expert + gate + `eh_proj(2H x H)` | 289,210,368 | + +| | params | share | +|---|---|---| +| routed experts, **streamable** | **734,439,407,616** | **97.49%** | +| everything else, **must be resident** | **18,889,310,208** | **2.51%** | +| model total, this arithmetic | 753,328,717,824 | — | +| API `safetensors.total`, measured | 753,329,940,480 | — | +| **residual** | **-1,222,656** | **-0.00016%** | + +The residual is 1.2M parameters over 753.3B — the bias terms and the 79 +`k_norm.bias` / layernorm vectors this model does not enumerate. **This is a +tighter reconciliation than #2214's, and the numbers differ, which is why it was +redone.** #2214 models 724.8B streamable / 21.0B resident / 745.8B total and +calls that "within 1%". The 7.5B gap is the MTP block, whose 256 experts are +themselves streamable; folding it in moves the streamable share from 97.2% to +**97.49%** and the resident total from 21.0B **down** to 18.89B. Both figures +favour the argument, so the correction does not change the verdict — but the +resident dtype table below is materially different and the difference is 4 GiB. + +**Resident footprint by dtype.** `dgx:gpu0` reports 128,452,956,160 B = +**119.631 GiB** from `cudaMemGetInfo` (measured 2026-08-28, §2.3; not +re-measured here, because this section took no GPU lease). + +| resident dtype | bpw | resident | whole model | fits `dgx:gpu0` resident-only | +|---|---:|---:|---:|---| +| bf16 | 16.0000 | **35.18 GiB** | 1403.18 GiB | yes | +| Q8_0 | 8.5000 | 18.69 GiB | 745.44 GiB | yes | +| Q6_K | 6.5625 | 14.43 GiB | 575.52 GiB | yes | +| Q5_K | 5.5000 | 12.09 GiB | 482.34 GiB | yes | +| Q4_K | 4.5000 | 9.90 GiB | 394.65 GiB | yes | +| Q2_K | 2.6250 | 5.77 GiB | 230.21 GiB | yes | + +#2214 gives bf16 resident as 39.19 GiB; recomputed it is **35.18 GiB**, because +its resident set was 21.0B and the correct one is 18.89B. Neither number changes +the answer. **The measured resident figure that actually matters is neither of +these, and it is in §3.4: the published UD arms carry the non-expert tensors at +mixed Q4_K/Q5_K/Q6_K/Q8_0/F32 and weigh 14.51 GiB.** + +**One decode step, batch 1.** `num_experts_per_tok = 8` over 75 MoE layers +touches `8 * 75 * 37,748,736` = 22,649,241,600 parameters, i.e. 3.0% of the +routed set: + +| dtype | per decode step | +|---|---:| +| bf16 | 42.19 GiB | +| Q8_0 | 22.41 GiB | +| Q4_K | 11.87 GiB | +| Q2_K | 6.92 GiB | +| IQ1_S | 4.12 GiB | + +#2214's "~6.86 GiB at 2.6 bpw" reproduces as 6.92 GiB at Q2_K's exact 2.625 bpw. +**The step figure is per token and it scales with batch**: at concurrency `c` the +distinct set is bounded by `min(256, 8c)` experts per layer, so the touched bytes +grow until they saturate at the whole 187 GiB tower set. This is a paging +problem at `c = 1` and a capacity problem well before `c = 32`, and §3.6 keeps +that inside the gate. + +### 3.2 What `expert_streamer.cpp` provides today, and what this model needs + +Read at base `60a6dd97b`. The capability is real and it is **not turnkey for this +model**; five of the eight gaps below are load-bearing. + +**What exists.** `include/vllm/model_executor/expert_streamer.h` (154 lines) and +`src/vllm/model_executor/expert_streamer.cpp` (224 lines), plus +`expert_slot_cache.{h,cpp}` (the policy) and +`host_expert_slot_store.h` / `device_expert_slot_store.{h,cpp}` (the +destinations). + +- `ExpertSlotStore` (`expert_streamer.h:43`) is a pure-virtual destination seam: + `slot_bytes()`, `slot_count()`, `WriteSlot`, `SlotForWrite`, `CommitSlot`. + There is deliberately no virtual `SlotForRead`; the read is the concrete + `HostExpertSlotStore::Slot()`. +- `ExpertStreamer` (`expert_streamer.h:91`) offers `Ensure`, `EnsureSpan`, + `EnsureFile(key, fd, file_offset, bytes)` and `EndStep()`. +- `ExpertSlotCache` (`expert_slot_cache.h:61`) is a hotness-decayed LFU with LRU + tiebreak (`expert_slot_cache.cpp:19-44`, default decay 0.98), a dense slot + table with an `unordered_map` logical->physical remap, and a + **per-step protection rule**: every `Acquire` marks the entry protected + (`expert_slot_cache.cpp:91`) and only `EndStep()` clears it + (`:142-145`). If every slot is protected, `Acquire` returns `-1` and sets + `capacity_exhausted_` (`:105-113`). +- The backing store is the **GGUF file on disk, read by `pread(2)`** against the + model fd (`expert_streamer.cpp:85-100`), or a memcpy out of the mmap when no fd + is available. The resident store is host RAM: a plain `std::vector` + arena of `slots * slot_bytes` (`host_expert_slot_store.h:40`). +- Admissible weight formats are **GGUF keep-quant / keep-f16 stacked + `[E, out, in]` towers only** (`gguf_device_fit.cpp:95` refuses anything that is + not `kKeepQuant` or `kKeepF16`). Slices are **pure byte offsets, never a + repack**, which is a layout precondition stated at `gguf_expert_span.h:12-16`: + whole rows of the same K, no block ever cut. +- Config surface, live and reachable from production: `VT_MOE_EXPERT_STREAM`, + `_SLOTS` (default **64**, `weight_residency.cpp:1035-1039`), `_SLOT_BYTES`, + plus the JSON `{"vllm_cpp":{"expert_stream":{...}}}` schema at + `include/vllm.h:502-506`, installed at + `model_loader.cpp:2251` inside `LoadedEngine::FromModelDir`, parsed by the + OpenAI server (`server_main.cpp:654-655`, `:1088-1089`, `:1326`) and the C ABI + (`vllm_c.cpp:666-667`). Default is OFF. +- Tests: six binaries, `tests/CMakeLists.txt:1562-1633`. The end-to-end suite is + `tests/vllm/model_executor/test_expert_stream_wiring.cpp`, which proves decode + reaches the streamer, that a streamed slice and the tower view produce + identical logits, and that a file-backed tower is served by `pread` at a + deliberately unaligned offset. + +**What is missing for GLM-5.3.** Each of these is work, not configuration. + +1. **The wiring is not a seam. It lives inside `qwen3_5.cpp`.** + `Qwen35ExpertStream` (`qwen3_5.cpp:5725`), `KqExpertSlice` (`:6180`), + `KqHostSliceView` (`:6169`), `Reserve` (`:6284`) and the step guard are all in + that one translation unit, and it is the **only** model TU that constructs + `HostExpertSlotStore` / `ExpertSlotCache` / `ExpertStreamer` (`:6038-6040`). + `deepseek_v2.cpp` has zero references to any streamer symbol. + `qwen3_moe.cpp:195-197` holds only the step guard. A new architecture cannot + include a header and get streaming; the mechanism has to be lifted into a + shared seam first. **This is W2 and it is the largest single item.** +2. **The default slot budget fails closed and quietly.** The decode working set + is `75 layers * 3 towers * 8 experts = 1800` distinct slices, every one + protected until `EndStep`. The default is 64 slots. Below the working set, + `Slice` returns `nullptr`, `exhausted_` increments (`qwen3_5.cpp:5824`), and + every slice falls back to reading the mmap in place — **counted on stderr, not + an error**. On this model that fallback is a 187 GiB random read per token. +3. **No prefetch, no double buffering, no async I/O**, stated verbatim at + `expert_streamer.h:25-29`. A miss is a blocking `pread` inline in front of the + GEMM. 1800 serialized syscalls per token in the cold case. +4. **Eviction is an O(resident) linear scan per miss** + (`expert_slot_cache.cpp:26-44`). At the slot counts §3.3 needs (thousands) + and ~1800 misses per step, that is a host cost nobody has profiled. +5. **No device destination is wired.** `DeviceExpertSlotStore` exists, is filled + correctly through `EnsureFile`, is gated by + `tests/vllm/model_executor/test_device_expert_slot_store.cpp`, and **is + selected by nothing** (`expert_streamer.h:13-23`, and `qwen3_5.cpp:6067` + holds the concrete host store). The production predicate is + `qwen3_5.cpp:6199`: `cpu || host_memory_is_device_addressable()`. A discrete + CUDA GPU answers false and falls through. **`dgx:gpu0` is a GB10 with unified + memory and answers TRUE**, which is precisely why this row is viable there and + would not be on a discrete part. +6. **Streaming and the grouped keep-quant MoE path are mutually exclusive** + (`qwen3_5.cpp:6307-6312`); enabling one disables the other, with one line on + stderr. +7. **`pread` streaming has never run on a real checkpoint.** + `.agents/specs/expert-streaming.md` `## Owed`, verbatim: "**The `pread` path + has never run on the model.** ... It is still unmeasured on a real + checkpoint." No test model has more than 4 experts or 4 layers + (`tests/support/expert_stream_model.h:130-131`). +8. **Windows has no streaming at all**: `EnsureFile` throws + `"expert streamer: EnsureFile needs pread"` (`expert_streamer.cpp:31-36`). + +**Row states, read rather than assumed.** `ENG-EXPERT-STREAM` +(`engine-matrix.md:117`) is `READY`, owner `-`, and its "Our code" and "Our +tests/evidence" columns are both a bare `-` despite ~700 shipped lines and six +test binaries; its row text describes "fixed contiguous Marlin slots" and **no +Marlin code is on this path**. `ENG-HYBRID-PLACEMENT` (`:119`) is `ACTIVE` and +is the *inverse* mechanism — it moves expert COMPUTE to the CPU — not a +substitute. `ENG-RESIDENCY-CONFIG` (`:120`) is `ACTIVE`, is the only one of the +three with populated code/evidence columns, and owns the config surface this row +uses unchanged. `ENG-EXPERT-STREAM-DEVICE` (`:122`, `ACTIVE`, #1124) is the row +that owns gap 5; its `## Now` says W1 "lands UNREACHED" and W2 owns the wiring. +**This row does not take any of those four rows' work.** It consumes them, and +where it needs more than they provide it says so under `## Owed`. + +### 3.3 The residency plan + +Grounded in what §3.2 measured, not in what the streaming row claims. + +**Two tensor classes, and the split is the GGUF tensor name.** The streamer's +own admission rule is the `_exps.weight` suffix (`model_loader.cpp:2472`, +`kStreamedExpertSuffix`; `gguf_device_fit.h:98-99`), and GLM-5.3's GGUF +conveniently draws the same line: `blk.N.ffn_{gate,up,down}_exps.weight` are the +228 stacked `[256, out, in]` towers and every other tensor is per-layer. + +| class | tensors | UD-IQ1_S size | placement | +|---|---:|---:|---| +| **resident** | 1581 | **14.511 GiB** | device pool, whole run | +| **streamed** | 228 | **187.312 GiB** | slot cache, paged from the file | + +The resident class is: `token_embd`, `output`, `output_norm`, and per block +`attn_norm`, `attn_q_a`, `attn_q_a_norm`, `attn_q_b`, `attn_kv_a_mqa`, +`attn_kv_a_norm`, `attn_k_b`, `attn_v_b`, `attn_output`, `ffn_norm`, +`ffn_gate_inp`, `exp_probs_b`, the three shared-expert projections, the five +`indexer.*` tensors, the three dense-MLP projections on blocks 0-2, and the four +`nextn.*` tensors on block 78. Full census in §3.4. + +**The resident expert cache budget.** Slots are uniform and sized to the +LARGEST slice (`host_expert_slot_store.h:30-33`; a bigger slice is refused by +name, `expert_streamer.cpp:181-186`), so on UD-IQ1_S `slot_bytes` is set by the +IQ4_XS `ffn_down_exps` slice: + +| slice encoding | bytes | MiB | +|---|---:|---:| +| IQ1_S gate/up | 2,457,600 | 2.344 | +| IQ2_XXS gate/up | 3,244,032 | 3.094 | +| IQ3_XXS down | 4,816,896 | 4.594 | +| **IQ4_XS down (the max)** | **6,684,672** | **6.375** | + +| slots | arena | note | +|---:|---:|---| +| 1800 | 11.21 GiB | the bare decode working set at `c = 1`; **the floor, not a budget** | +| 4096 | 25.50 GiB | ~2.3 steps of history | +| 8000 | 49.80 GiB | the shape `benchmarks/expert_stream_device_w0e.cpp` already uses | + +**Proposed default for the first run: 4096 slots = 25.50 GiB.** Resident 14.51 + +slots 25.50 = **40.01 GiB**, against 119.631 GiB on `dgx:gpu0`, leaving ~79 GiB +for the KV cache, activations, scratch pools and the CUDA context. The KV +arithmetic, from the config: the MLA latent row is `kv_lora + qk_rope = 576` +elements per token per layer, so 78 layers at bf16 is 89,856 B/token = 87.75 +KiB/token, and the DSA indexer cache adds 132 B/token/indexer-layer over 22 +layers = 2,904 B/token. At 8192 context that is **0.71 GiB**; at 131,072 context, +11.32 GiB. Even the long-context case fits inside the headroom, and the +`max_position_embeddings` of 1,048,576 does not, which is a configuration limit +to refuse rather than a surprise. + +**Uniform slots waste 46% of the arena on this artifact.** 1800 slices at their +real sizes are 6.03 GiB; at the uniform 6,684,672 B they are 11.21 GiB. That is +the price of the pure-byte-offset design, it is a known cost rather than a +defect, and W6 records it as a measured lever rather than fixing it +speculatively. + +**On a cache miss mid-step: the step stalls, synchronously, per slice.** There is +no other behaviour available (§3.2 gap 3). The chain is +`ExpertMlpKq -> MatmulBf16Slice -> KqExpertSlice -> Qwen35ExpertStream::Slice -> +EnsureFile -> ::pread`, blocking, immediately before `vt::MatmulBT` runs on that +weight. On a throw the acquisition is undone (`expert_streamer.cpp:108-111`, +`:163-166`, `:214-217`) so nothing half-filled becomes resident. + +**On cache EXHAUSTION — every slot protected this step — the model does not +fail. It silently degrades**, and on this artifact that degradation is fatal to +any measurement: `Slice` returns `nullptr` and the caller reads the tower in +place out of a 201.83 GiB mmap. **W1 therefore owes a refusal, not a fallback,** +when the configured slot count is below the model's computed decode working set. +A model that quietly reads 187 GiB per token through the page cache is the exact +shape of measurement this repository has been burned by, and a `capacity < +75*3*num_experts_per_tok` check at load costs one comparison. + +### 3.4 The artifact, and its encodings + +**Re-measured 2026-08-29, and the repository has changed completely since +2026-08-28.** §2.3 recorded `unsloth/GLM-5.3-GGUF` at revision `8cf52b13b130` +holding ONE arm, `UD-Q3_K_XL` at 319.41 GiB. At revision +`346b3591c7f28d1a23716f97a065ecf12ec14771` (`lastModified` +`2026-08-29T02:35:58Z`) it holds **twelve arms, 140 `.gguf` files, 5542.40 GiB**: + +| arm | files | size | +|---|---:|---:| +| **UD-IQ1_S** | 6 | **201.83 GiB** | +| UD-IQ1_M | 6 | 212.80 GiB | +| UD-IQ2_M | 6 | 222.19 GiB | +| UD-Q2_K_XL | 7 | 236.44 GiB | +| UD-IQ3_XXS | 7 | 262.34 GiB | +| UD-Q3_K_XL | 9 | 319.41 GiB | +| UD-IQ4_XS | 9 | 340.22 GiB | +| UD-Q4_K_XL | 11 | 435.20 GiB | +| UD-Q5_K_XL | 13 | 523.84 GiB | +| UD-Q6_K_XL | 16 | 637.37 GiB | +| Q8_0 | 17 | 746.32 GiB | +| BF16 | 33 | 1404.42 GiB | + +Re-read this table rather than quoting it. The repository was being populated +live on both days this row looked at it. + +**The census, and why a name is not a format.** Method: HTTP range requests +against the six `UD-IQ1_S` shards, parsing only the GGUF header — magic, +version, `tensor_count`, the KV block, then each `tensor_info`'s name, dims, +`ggml_type` and offset. Header sizes are 9,428,677 B for shard 1 (metadata only, +0 tensors, carrying the 20 MB tokenizer) and 25-30 kB for shards 2-6. **Nothing +was downloaded**; the four arms below cost ~9.6 MB of range reads in total. +`split.tensors.count` is 1809 and the shards sum to 455+419+412+397+126 = 1809, +so the census is complete rather than sampled. + +`UD-IQ1_S`, 1809 tensors: + +| ggml type | n | GiB | of which experts | expert GiB | resident | resident GiB | +|---|---:|---:|---:|---:|---:|---:| +| IQ3_XXS | 71 | 81.539 | 71 | 81.539 | 0 | 0.000 | +| IQ1_S | 106 | 62.109 | 106 | 62.109 | 0 | 0.000 | +| IQ2_XXS | 44 | 34.031 | 44 | 34.031 | 0 | 0.000 | +| Q5_K | 312 | 7.154 | 0 | 0.000 | 312 | 7.154 | +| **IQ4_XS** | **4** | **6.375** | **4** | **6.375** | 0 | 0.000 | +| Q8_0 | 476 | 4.852 | 0 | 0.000 | 476 | 4.852 | +| Q2_K | 2 | 1.969 | 2 | 1.969 | 0 | 0.000 | +| Q3_K | 1 | 1.289 | 1 | 1.289 | 0 | 0.000 | +| Q6_K | 82 | 1.000 | 0 | 0.000 | 82 | 1.000 | +| Q4_K | 2 | 0.997 | 0 | 0.000 | 2 | 0.997 | +| F32 | 709 | 0.508 | 0 | 0.000 | 709 | 0.508 | +| **TOTAL** | **1809** | **201.823** | **228** | **187.312** | **1581** | **14.511** | + +**`UD-IQ1_S` contains 106 IQ1_S tensors out of 1809.** The name is a target +average, exactly as #2214 warned from the Flash row's `UD-Q2_K_XL`. The same +census over three neighbours: + +| arm | expert encodings | resident encodings | resident GiB | +|---|---|---|---:| +| UD-IQ1_S | 106 IQ1_S, 71 IQ3_XXS, 44 IQ2_XXS, 4 **IQ4_XS**, 2 Q2_K, 1 Q3_K | Q8_0/Q5_K/Q6_K/Q4_K/F32 | 14.511 | +| UD-IQ1_M | 76 **IQ1_M**, 74 IQ2_XXS, 71 IQ3_XXS, 4 **IQ4_XS**, 2 Q2_K, 1 Q3_K | same | 14.511 | +| UD-IQ2_M | 148 IQ2_XXS, 71 IQ3_XXS, 4 **IQ4_XS**, 2 IQ2_S, 2 Q2_K, 1 Q3_K | same | 14.621 | +| UD-Q2_K_XL | 148 **IQ2_XS**, 73 IQ3_XXS, 4 **IQ4_XS**, 2 Q2_K, 1 Q3_K | same | 14.621 | + +Two facts fall straight out. **The resident class is ~14.5 GiB in every arm** — +the UD recipe keeps every non-expert tensor at Q4_K or better regardless of the +name on the tin — so the residency plan in §3.3 is arm-independent. And +`UD-Q2_K_XL` contains **two** Q2_K tensors out of 1809, both on the MTP block. + +**The verdict against our decoders and `vec_dot` lists. This section was +rewritten after `origin/main` moved under it, and the correction inverts the +answer.** At this branch's base `60a6dd97b`, `IQ4_XS` and `IQ2_XS` had neither a +`vt` block dtype nor a decoder, so both were a hard refusal. On 2026-08-29 at +`94de63ff5` ([#2245](https://github.com/mudler/vllm.cpp/issues/2245)) main landed +**the dequantizers for both**, for the sibling `MODEL-MM-GLM53-FLASH` row's own +staged artifact. `kIQ2_XS` and `kIQ4_XS` now exist in `include/vt/dtype.h::DType`, +`gguf_reader.cpp` sizes id 17 at `{256, 74}`, and `gguf_dequant.cpp` cases 17 and +23 decode. **Neither gained a keep-quant `vec_dot`, and that is the half that +decides this row.** + +Three lists decide it, and they are not the same list: + +1. `gguf_reader.cpp::FindGgmlTraits` — the ggml ids we can SIZE. An id outside it + throws `"gguf: unknown ggml type id N"` at file OPEN. **17 and 23 are now in.** +2. `vt::BlockDTypeFromGgmlTypeId` + `gguf_dequant.cpp` — the ids we can DECODE. + **17 and 23 are now in.** +3. `src/vt/cpu/cpu_quant_dot.cpp::BlockVecDot`, read through + `vt::cpu::HasQuantDotKernel` — the ids that stay COMPRESSED. + `Q4_0, Q5_0, Q8_0, Q2_K, Q3_K, Q4_K, Q5_K, Q6_K, IQ2_XXS, IQ3_XXS, IQ2_S, + IQ1_S, IQ1_XXXS, IQ4_NL, MXFP4`. **17 and 23 are NOT in, and nothing else in + the four censused arms is missing.** + +`gguf_keep_quant.cpp::KeepQuantDType` is the gate: it resolves the block dtype +and then `if (!vt::cpu::HasQuantDotKernel(dt)) return false;`. **A type with a +decoder and no `vec_dot` therefore EXPANDS TO bf16 at load** — exactly the +failure mode #2214 named, arriving here through the door that had just been +opened. + +| type | traits | decoder | `vec_dot` | what happens | +|---|---|---|---|---| +| Q4_K, Q5_K, Q6_K, Q8_0, F32 | yes | yes | yes | resident class stays compressed | +| IQ1_S, IQ2_XXS, IQ3_XXS, IQ2_S, Q2_K, Q3_K | yes | yes | yes | expert towers stay compressed | +| **IQ4_XS (23)** | **yes** | **yes, since `94de63ff5`** | **NO** | **expands to bf16** | +| **IQ2_XS (17)** | **yes** | **yes, since `94de63ff5`** | **NO** | **expands to bf16** | +| IQ1_M (29) | NO | NO | NO | `gguf: unknown ggml type id 29` at file OPEN | + +**And an expanded tower does not merely cost bytes — it leaves the streaming lane +entirely.** `gguf_device_fit.cpp:85-100` walks every `*_exps.weight` tensor, +asks `PeekRoute` for its residency, and returns **false for the whole arm** the +moment one of them is not `kKeepQuant` or `kKeepF16`. The eligibility is +per-MODEL, not per-tensor. So four IQ4_XS tensors out of 228 disqualify all 228. + +The cost, computed exactly. One `*_exps` tower is +`2048 * 6144 * 256 = 3,221,225,472` elements, **6.000 GiB at bf16**: + +| arm | offending type | compressed | expanded to bf16 | delta | +|---|---|---:|---:|---:| +| UD-IQ1_S | 4 x IQ4_XS | 6.375 GiB | **24.000 GiB** | +17.6 GiB | +| UD-IQ2_M | 4 x IQ4_XS | 6.375 GiB | **24.000 GiB** | +17.6 GiB | +| UD-Q2_K_XL | 148 x IQ2_XS | 128.344 GiB | **888.000 GiB** | +759.7 GiB | + +And the slot arithmetic collapses with it: a bf16 expert slice is +`6144 * 2048 * 2 = 25,165,824 B = 24.00 MiB` against the IQ4_XS slice's 6.375 +MiB, and slots are uniform at the largest, so §3.3's 4096-slot cache would be +**96.00 GiB** instead of 25.50 GiB — more than three quarters of the device on +its own. + +**So the verdict changes shape but not sign, and it is sharper than it was.** + +- **The row is blocked on ONE kernel and it is a `vec_dot`, not a decoder: + `VecDotIQ4_XSQ8_K` against the Q8_K activation encoding.** Four tensors, + `blk.{8,75,76,77}.ffn_down_exps.weight`. With it, UD-IQ1_S loads entirely + compressed at 201.823 GiB and every tower is streamable. Without it, the arm + loads at 219.4 GiB, cannot stream at all, and is dead on this fleet. +- The port is small and well-precedented, and it is smaller today than it was at + this branch's base: `94de63ff5` already ported the 136-byte `block_iq4_xs` + layout and its decoder from llama.cpp `b10451`, so what remains is the dot + product itself over a codebook this tree already carries for `IQ4_NL` + (`kValuesIq4nl`, `cpu_quant_dot.cpp::VecDotIQ4_NLQ8_0`, anchored `quants.c:1254`). + Upstream's is `ggml_vec_dot_iq4_xs_q8_K`. +- `UD-IQ2_M` needs the same one and nothing else. `UD-Q2_K_XL` needs + `VecDotIQ2_XSQ8_K` as well, and `UD-IQ1_M` is still rejected outright on + `IQ1_M`, which has no traits at all. +- The row already exists: `QUANT-GGUF-IQ4_XS` + (`.agents/quantization-matrix.md:78`, `INVENTORIED`). + +**The general lesson this section paid for, and the reason it is written out +rather than quietly corrected: a decoder and a `vec_dot` are two different +obligations, and landing only the first turns a loud refusal into a silent 3.4x +memory multiplier.** At `60a6dd97b` this arm refused at load with a message +naming the type. At `94de63ff5` it loads, and the only symptom is that a +119.631 GiB device runs out of memory for reasons the log does not name. +`gguf_device_fit`'s all-or-nothing rule is what converts the same defect from +"+17.6 GiB" into "no streaming at all", and neither is visible to a token gate. + +**The GGUF's own metadata, and one thing it does NOT carry.** Shard 1's KV block +declares `general.architecture = glm-dsa`, `glm-dsa.block_count = 79`, +`context_length = 1048576`, `embedding_length = 6144`, `expert_count = 256`, +`expert_used_count = 8`, `expert_feed_forward_length = 2048`, +`expert_shared_count = 1`, `expert_gating_func = 2` (sigmoid), +`expert_weights_scale = 2.5`, `expert_weights_norm = true`, +`leading_dense_block_count = 3`, `attention.q_lora_rank = 2048`, +`attention.kv_lora_rank = 512`, `attention.key_length = 576`, +`attention.value_length = 512`, `attention.key_length_mla = 256`, +`attention.value_length_mla = 256`, `rope.dimension_count = 64`, +`rope.freq_base = 8e6`, `nextn_predict_layers = 1`, +`attention.indexer.head_count = 32`, `attention.indexer.key_length = 128`, +`attention.indexer.top_k = 2048`, `tokenizer.ggml.pre = glm4`, +`general.file_type = 24`, and an imatrix provenance block +(`quantize.imatrix.entries_count = 1065`, `chunks_count = 209`). + +**It does NOT carry `glm-dsa.attention.indexer.types`, and that is a trap with a +known workaround** — see §3.5, D3. + +**Fleet and staging.** `rc devices` on 2026-08-29 lists `dgx:gpu0` (busy), +`orin:gpu0`, `strix:gpu0`, `thor:gpu0`; none is larger than `dgx:gpu0`. +`/mnt/nas_share` has **2.2 TiB free** of 7.3 TiB, so the 201.83 GiB arm stages +there. **`dgx.casa`'s local disk had 184 GiB free when last measured (§0.1 C3, +2026-07-21), which is LESS than the arm**, so W7 must either free local disk or +`pread` across CIFS — and a CIFS-backed `pread` of 1800 slices per token is a +different measurement from a local-NVMe one. §3.9 O7 owes that number. + +### 3.5 The delta against `DeepseekV2ForCausalLM` + +#### 3.5.1 Upstream, at the pin — and §2.2's premise needed one correction + +`registry.py:117` and `deepseek_v2.py:1930-1931` are as §2.1 records, both unique +at `555967922`. `_get_moe_router_dtype` (`deepseek_v2.py:123-133`) forces +`torch.float32` on `model_type == "glm_moe_dsa"` at `:127` before the generic +`moe_router_dtype == "float32"` branch at `:131`, so the special case is +redundant on THIS checkpoint and still fires first. That much §2.2 had right. + +**What §2.2 left open, and what is now measured: the pinned class CAN load this +checkpoint, and it does not read `indexer_types` to do it.** At the pin, +`grep -c indexer_types` over `deepseek_v2.py` is 0, and over every `*.py` in the +tree it is 0. `mlp_layer_types` is likewise unread by this model (it exists only +in `cohere2_moe.py` and `mellum.py`). The schedule is DERIVED, at +`deepseek_v2.py:1092-1103`: + +```python +_index_topk_freq = getattr(config, "index_topk_freq", 1) +_index_topk_pattern = getattr(config, "index_topk_pattern", None) +_index_skip_topk_offset = getattr(config, "index_skip_topk_offset", 2) +if _index_topk_pattern is None: + _skip_topk = max(layer_id - _index_skip_topk_offset + 1, 0) % _index_topk_freq != 0 +``` + +with the indexer built at `:1115` when `self.is_v32 and (not _skip_topk or +is_mtp_layer)`. Evaluated on GLM-5.3 (`freq = 4`, `offset = 3`, 78 layers) that +yields full layers `{0,1,2} ∪ {6,10,…,74}` = **21**, plus the MTP layer forced +full at `:1110-1115`, = **22 indexers**. + +**Three independent derivations agree, and they agree bit for bit.** + +| source | schedule | +|---|---| +| the checkpoint's `config.json` `indexer_types` | `111000100010001000…` (78 entries, 21 ones) | +| vLLM at the pin, `deepseek_v2.py:1097-1101`, evaluated | identical | +| llama.cpp `b10451`, `src/models/glm-dsa.cpp:6-27` `GLM_5_2_DEFAULT_INDEXER_TYPES` | identical over all 78 | +| the checkpoint's own tensor index | `self_attn.indexer.*` present on **22** of 79 blocks | + +Those 22 are the 21 trunk full layers plus block 78, the MTP block — exactly what +the pin builds. The pin also anticipates a checkpoint that ships MORE indexer +weight than it builds, dropping the surplus at `deepseek_v2.py:1566-1582` +("With index_topk_freq>1 only some layers build an indexer, yet the checkpoint +ships indexer weights for all of them"). GLM-5.3 does not need that path, but the +PUBLISHED GGUF does — see D3. + +**`n_shared_experts = 1`** is an ordinary read (`deepseek_v2.py:299`, `:349`, +`:352`, `:385`) and needs nothing special. + +**The `indexers_proj` question from §2 is now answered, and the answer is that it +names no tensor.** `grep -n indexers_proj` over every `*.py` at the pin returns +zero. The checkpoint's own `model.safetensors.index.json` (118,629 tensors, +`metadata.total_size = 755,617,140,416`, fetched 2026-08-29) ships the upstream +spellings and only those: `self_attn.indexer.{wq_b,wk,weights_proj,k_norm}`, 22 +of each, with `wq_b` and `wk` carrying `weight_scale_inv` sidecars and +`weights_proj` and `k_norm` carrying none. `modules_to_not_convert`'s 22 +`self_attn.indexers_proj` entries are a quantization-skip shorthand that matches +no shipped tensor name; the tensor it means (`indexer.weights_proj.weight`) is +unquantized anyway. **It is a config-level string, not a naming divergence, and +a loader must not mirror it.** vLLM at the pin fuses `wk` + `weights_proj` into +one `MergedColumnParallelLinear` named `wk_weights_proj` through the stacked +mapping at `deepseek_v2.py:1536-1540`, with an fp8 dequant-into-the-fused-param +helper `_try_load_fp8_indexer_wk` at `:820-860`. + +`vllm/models/deepseek_v32/nvidia/attention.py` exists at the pin and implements +the same skip schedule at `:211-219`, but `registry.py:117` routes this +architecture to `deepseek_v2`, so that tree is **not** reached at the pin. The +re-homing §2.2 describes is a `main`-only change and stays out of scope. + +#### 3.5.2 Our side — what is free, what is adjacent, what is new + +**Free from the existing DeepSeek-V2 + shared MLA stack.** Verified at +`60a6dd97b`: + +- **The MLA geometry is already supported and already exercised.** + `mla::MlaBlockDims::Validate` (`mla_attention.cpp:89-192`) requires + `v_head_dim <= qk_head_dim()`; GLM-5.3 is `256 <= 192+64 = 256`, which passes, + and there is no rule forcing `qk_nope_head_dim == v_head_dim`. The prefill + head-dim switch (`src/vt/cuda/cuda_mla_prefill.cu:194-209`) hits the native + FA-2 256 instantiation with no padding — the same instantiation GLM-4.7-Flash + already uses. Decode runs in latent space at `head_size = 576` / + `v_head_dim = 512` and takes the `<= 512` arm, byte-identical to DeepSeek-V3 + (`cuda_mla_attn.cu:671-682`). **No MLA refusal fires for this model.** +- Load-time `kv_b_proj` absorption at the asymmetric 192/256 split + (`AbsorbKvBProjBf16`, `mla_attention.cpp:205-229`, splitting at `row = p + v`). +- **Interleaved (GPT-J) RoPE**, which is DeepSeek's default here: + `MlaBlockDims::is_neox_style` defaults `false` (`mla_attention.h:136`). Upstream + passes `is_neox_style=False` unconditionally (`deepseek_v2.py:1073`) and reads + no top-level `rope_interleave`, so our default is parity-correct — **but it is + correct by default rather than by a read, and W2 writes that down**. +- The **noaux_tc grouped router** at `n_group = 1` / `topk_group = 1`, sigmoid + scoring, `norm_topk_prob`, `routed_scaling_factor 2.5` and + `e_score_correction_bias` (`deepseek_v2_weights.cpp:286-341`, + `deepseek_v2.cpp:355-366`). This is exactly the configuration GLM-4.7-Flash + already gates end-to-end (§0.1 C2). +- The MoE expert layout has **no hardcoded expert-count limit** + (`vt::MoeGroupedGemmBf16` validation, `ops.cpp:904-928`, requires only + `weight_ptrs.Numel() == e`), so 256 x 75 is representable. +- `first_k_dense_replace`-driven dense/MoE layout + (`DeepseekV2Params::is_moe_layer`, `deepseek_v2.h:126-129`) reproduces + upstream's rule and is arithmetically identical to the checkpoint's + `mlp_layer_types` for this config. The batch split, decode CUDA graph and + paged engine come along unchanged. + +**Adjacent and already landed, but not wired to DeepSeek-V2.** This is the +finding that most changes the size of the port: + +- **A device-native DSA lightning indexer already lives inside the SHARED MLA + block**, `mla_attention.cpp:598-745`, landed for `dots3-note`. It is a port of + upstream's non-fused `Indexer.forward` (`deepseek_v2.py:803-842`): `wq_b` GEMM + (`:646`), split `wk` / `weights_proj` GEMMs (`:655`, `:658`), `k_norm` as a + real **LayerNorm with bias at eps 1e-6** (`:663-664`), leading-slice rope under + an independent `dims.indexer_rope_is_neox_style` (`:667-673`, upstream's + `not indexer_rope_interleave` at `deepseek_v2.py:1120`), chunked logits under a + 16 Mi-element budget (`:698-712`), then `vt::DsaIndexerLogits` + + `vt::DsaTopkSelect` per request (`:741-742`), handed to decode at `:880-883`. + Both ops are implemented and registered on **CPU** (`cpu_dsa_indexer.cpp:184,186`) + and **CUDA** (`cuda_dsa_indexer.cu:320,322`). Geometry fields + `index_n_heads` / `index_head_dim` / `index_topk` / + `indexer_rope_is_neox_style` already exist on `MlaBlockDims` + (`mla_attention.h:210-232`), as do the five indexer tensors + (`mla_attention.h:426-430`). **This is a much stronger starting point than + §0.2's "GB10 cannot run DSA" verdict suggests** — that verdict was about + vLLM's flashinfer path, not about ours, and ours has since been built. +- The freq/offset + pattern + explicit-list indexer schedule parser is already + written and gated, in the WRONG model's translation unit: + `glm5_next.cpp:287-338`, whose fallback at `:322-330` is line-for-line + upstream's `:1097-1101`. The `mlp_layer_types` reader is at + `glm5_next.cpp:262-284`. Both are liftable. +- The block-fp8 config reader exists (`fp8_block_quant.{h,cpp}`, reading + `weight_block_size`, `activation_scheme`, `modules_to_not_convert`), with + exactly one consumer, the Qwen3.5 **dense** loader. + +**Genuinely net-new, in order of size.** + +1. **The indexer KV side cache.** Sparse decode today refuses any step in which + any request RESUMES (`dots3_note_device.cpp:1147-1180`), because the indexer's + `k` comes from the step's own hidden states and a resumed request needs the + indexer's own 128-wide cache. Upstream's is `DeepseekV32IndexerCache` + (`deepseek_v2.py:696-701`), a 132 B/token row in its OWN kv-cache group. + Tracked as `KV-DSV4-MULTICACHE` ([#1925](https://github.com/mudler/vllm.cpp/issues/1925)). + **Without it there is no multi-step decode, so there is no gate.** Largest item. +2. **The expert-streaming seam.** §3.2 gap 1: the mechanism is welded into + `qwen3_5.cpp` and has to be lifted before a second model can reach it. +3. **Sparse prefill.** `MlaPrefillAttentionArgs` has no topk member at all + (`ops.h` through `:1737`); `MlaPrefillAttention` (`ops.cpp:4159-4230`) has no + selection arm. Upstream forces ALL tokens through `forward_mqa` for a sparse + impl (`mla_attention.py:697-702`), so this is not optional at long context. +4. **Per-layer heterogeneous `MlaBlockDims`** — 22 indexer-bearing blocks out of + 79 — plus the **`shared` / `skip_topk` selection-reuse** semantics + (`vllm/model_executor/layers/mla.py:180`: a skip layer runs no indexer but + stays `is_sparse` and attends through the preceding full layer's + `topk_indices_buffer`). Nothing in this tree reuses a prior layer's top-k. +5. **The `IQ4_XS` encoding** (§3.4), owned by `QUANT-GGUF-IQ4_XS`. +6. **A `"glm-dsa"` GGUF arm.** `kGgufArchArms` (`model_loader.cpp:1029-1037`) + knows `deepseek4`, `muse-glimmer`, `qwen35`, `qwen35moe`, `qwen3next`, + `qwen4exp`, `glm5next` — and no `deepseek2` and no `glm-dsa`. + `deepseek_v2_registry.cpp:68-71` throws + `"Model architecture DeepseekV2ForCausalLM does not support GGUF weights"`. + The whole GGUF path for this family is net-new. +7. **The fp32 router GEMM.** `deepseek_v2.cpp:350` hardcodes + `DBuf dlog(d, DType::kBF16, {T, E});`. The softmax/top-k stage is already f32; + only the gate GEMM is bf16. Small and real. +8. **Registration and the lifting of the tripwire.** `ParseDeepseekV2Params` + refuses any checkpoint carrying `index_topk` + (`deepseek_v2_weights.cpp:358-364`) and any `quantization_config` + (`:365-369`), and refuses `num_nextn_predict_layers > 0` unless + `allow_mtp_tail` (`:353-357`, which only `Glm4MoeLiteForCausalLM` passes). + `GlmMoeDsaForCausalLM` appears nowhere under `src/` or `include/`. + +**Deliberately NOT in scope.** The safetensors arms. The published bf16/fp8 +checkpoint is 703.74 GiB across 141 shards and the DeepSeek-V2 loader holds +`OwnedTensor` host bytes with no streaming path — 57,600 host tensors for the +routed experts alone. There is no MoE-expert block-fp8 rung anywhere in the tree. +**This row ships a GGUF arm and refuses safetensors by name**, which inverts the +usual polarity and is the correct inversion here: the quantized arm is the only +one that can be fed. Recorded as D1. MTP is skipped through `allow_mtp_tail`, +following `glm4_moe_lite_registry.cpp:161,169`; there is no MTP drafter in the +tree at all (`src/vllm/v1/spec_decode/` holds three files, none of them an MTP +proposer). Recorded as O5. + +### 3.6 The gate + +**The honest headline: no end-to-end token gate against vLLM is reachable on this +fleet, and this section says so before any wave promises one.** + +vLLM at the pin implements this architecture and, per §3.5.1, would load this +checkpoint. It cannot RUN it here. The published weights are 703.74 GiB at fp8; +`dgx:gpu0` is 119.631 GiB of unified memory, which is also its host RAM, so +`--cpu-offload-gb` offloads into the same pool it is offloading out of. No fleet +device is larger (`rc devices`, 2026-08-29). vLLM has no GGUF path for this +architecture either. **The denominator does not exist, and that is a measured +absence rather than a missing effort.** AGENTS.md's rule applies directly: say so +plainly, gate against what can actually be run, and do not call the result +token-exact against the runtime. + +Four gates ARE reachable, and together they are the row's spine. + +**G1 — module parity against the pinned vLLM, on CPU, at small shapes.** vLLM's +`Indexer`, `_get_moe_router_dtype`, the skip-topk schedule formula and the +noaux_tc router are all importable and runnable without the checkpoint. Capture +goldens out of `555967922` on synthetic inputs and compare numerically, not by +token. This gates the primitives in items 4 and 7 of §3.5.2 and it is the ONLY +place vLLM is the reference. Precedent: `MODEL-MM-GLM53-FLASH` W3 +([#2213](https://github.com/mudler/vllm.cpp/issues/2213)) gated its indexer this +way against transformers, asserting **SET equality of the selected indices** with +the margin printed — the right shape for a discrete selection, where the error is +bimodal and a tolerance bounds nothing. + +**G2 — the structural loader gate, headers only, env-gated.** Every tensor in the +real `UD-IQ1_S` shards is enumerated and accounted: 1809 == 1809, zero +unaccounted, and every `ggml_type` in the file is one this tree can decode. This +is the gate that would have caught `IQ4_XS` before a wave was planned, and it +costs ~9.6 MB of range reads, so CI can run it against the published repository +without the asset. + +**G3 — the streaming self-consistency gate, and it needs no oracle at all.** The +row's novelty is the streaming mechanism, and its correctness question is +internal: **a streamed slice and the resident tower must produce identical +logits.** `tests/vllm/model_executor/test_expert_stream_wiring.cpp:215` already +asserts exactly this for Qwen3.5 through `SetForceFallback`, inside one process. +Extended to a GLM-5.3-shaped synthetic model it gates the seam lift, the capacity +refusal and the slot arithmetic, on CPU, with no checkpoint. **This is the gate +that decides whether W3 landed correctly**, and it is available from W3 onward. + +**G4 — an end-to-end floor against llama.cpp `b10451`, on the IDENTICAL +artifact, labeled as a secondary floor and never as the bar.** llama.cpp reaches +this architecture at our stock pin (§2.4) and, unlike vLLM, can run it: it mmaps +the GGUF and pages from disk. Run `llama-cli` on the same `UD-IQ1_S` shards, same +prompts, greedy, and compare. **Expect a near-tie band and not token-exactness**, +because two independent i-quant implementations agree on the dequantized values +but not on reduction order, and `bf16` stores absorb the difference unevenly. +Ratify the band before running, or the run becomes an argument. Two preconditions +this section does NOT wave away: the artifact must be staged (O7), and the +llama.cpp side must itself be shown to load and generate before a single number +from it is quoted — `gateable = yes` is a property of the oracle, and running +THIS model on it is a separate measurement. + +**What no gate here does.** None of the four is token-exact against vLLM, and no +wave may report one as if it were. No speed axis has a denominator: vLLM cannot +run the model, so the only comparable is llama.cpp on the same artifact, and that +is a labeled secondary floor. Per AGENTS.md the speed axis is therefore an **open +gap by construction**, not a waiver and not silence. + +### 3.7 Work breakdown + +Eight waves. Each is a separate `row/MODEL-TEXT-GLM-MOE-DSA-W` branch, a +separate pull request, a fresh implementer and a fresh reviewer. **W1-W4 and W6 +are CPU-gateable and need no GPU. W5, W7 and W8 need a GPU.** Sizes are the +author's estimate of reviewable diff, not a budget. W1 and W2 are independent of +each other; everything else is ordered. + +#### W1 — the `IQ4_XS` encoding (CPU, medium) + +**Scope:** the keep-quant `VecDotIQ4_XSQ8_K` and its `QuantTypeTraits` row, so +`vt::cpu::HasQuantDotKernel(kIQ4_XS)` becomes true and +`gguf_keep_quant.cpp::KeepQuantDType` stops expanding the type to bf16. The +dtype, the 136-byte block layout and the decoder already landed at `94de63ff5` +([#2245](https://github.com/mudler/vllm.cpp/issues/2245)); this wave is the half +that was not in it. **Owned by `QUANT-GGUF-IQ4_XS`** +(`.agents/quantization-matrix.md:78`, `INVENTORIED`), consumed here; this row +does not steal that row's state. +**Exclusions:** no model code. `VecDotIQ2_XSQ8_K` is the same shape and is NOT +in scope, because no arm this row targets needs it; `IQ1_M` stays unimplemented +and `UD-IQ1_M` stays refused. +**Anchors:** llama.cpp `b10451` `ggml/src/ggml-common.h::block_iq4_xs` (256 +elements, 136 bytes) and `ggml/src/.../quants.c::ggml_vec_dot_iq4_xs_q8_K`; the +shared 16-entry `kValuesIq4nl` codebook this tree already carries for `IQ4_NL` +(`cpu_quant_dot.cpp::VecDotIQ4_NLQ8_0`, anchored `quants.c:1254`); the reader +already sizes it at `gguf_reader.cpp` case 23, `{256, 136}`. +**Tests:** RED first — `HasQuantDotKernel(kIQ4_XS)` is false today and +`KeepQuantDType(23, ...)` returns false, so a test asserting a real IQ4_XS tensor +loads COMPRESSED fails before the change and passes after. Then the `vec_dot` +against the existing dequant-composite fallback on the same blocks, and the +LOWER bound a quantized arm needs: the kept-quant result must not merely +correlate with the expanded one, it must agree to the encoding's own error. +`tests/vt/iq2xs_iq4xs_golden_vectors.h` already carries `94de63ff5`'s reference +vectors. +**Gate:** focused ctest, full preflight. **Reachability:** the type must arrive +through `GgufFile::OpenOne` on a real header, not through a hand-built block. +**Stop:** if the 136-byte layout does not reproduce llama.cpp byte for byte, +return `NEEDS_DECISION` rather than widening a tolerance. + +#### W2 — config, registration, GGUF arch arm, refuse-by-name (CPU, medium) + +**Scope:** a `glm_moe_dsa` config parser that resolves the indexer schedule by +upstream's DERIVED rule (`index_topk_freq` / `index_skip_topk_offset` / +`index_topk_pattern`, `deepseek_v2.py:1092-1103`) with the explicit +`indexer_types` list as an override, lifting the parser at +`glm5_next.cpp:287-338` rather than writing a second one; the `mlp_layer_types` +reader (`glm5_next.cpp:262-284`) with its `first_k_dense_replace` fallback and a +refusal when the two disagree; `GlmMoeDsaForCausalLM` registered from its own +translation unit; a `"glm-dsa"` row in `kGgufArchArms` +(`model_loader.cpp:1029-1037`); and a `Forward` that refuses by name, naming +every unimplemented primitive and this section. +**Exclusions:** no forward math, no loader materialization, no change to +`DeepseekV2Params` or to the DeepSeek-V2 refusals — GLM-5.3 gets its own params +struct, because sharing one would make the `index_topk` tripwire +(`deepseek_v2_weights.cpp:358-364`) a choice rather than a wall for DeepSeek-V2. +**Anchors:** `deepseek_v2.py:1092-1103`, `:1110-1115`, `:127`; +`glm5_next.cpp:262-338`; registration pattern +`glm4_moe_lite_registry.cpp:18-38`; refusal pattern `kimi_k3.cpp:44-51`. +**Tests:** the derived schedule equals the checkpoint's `indexer_types` for all +78 entries, as a committed fixture from the real `config.json` (this is the test +that makes §3.5.1's three-way agreement executable); the `mlp_layer_types` +disagreement refusal; `is_neox_style == false` asserted rather than defaulted; +a `glm-dsa` GGUF header reaches the config builder through +`LoadedEngine::FromModelDir`; the refusal message names each missing primitive. +**Gate:** CPU build, focused ctest, full preflight. **Evidence:** the registry +contract test's architecture count moves by exactly one. +**Reachability:** deleting the `REGISTER_VLLM_MODEL` line, or the `kGgufArchArms` +row, must red the focused gate. + +#### W3 — lift the expert-streaming seam out of `qwen3_5.cpp` (CPU, large) + +**Scope:** move `Qwen35ExpertStream`, `KqExpertSlice`, `KqHostSliceView`, +`Reserve` and the step guard (`qwen3_5.cpp:5725`, `:6180`, `:6169`, `:6284`) into +a shared header + translation unit that a second model TU can include, with +Qwen3.5 rewritten as its first client and byte-identical behaviour. **Plus the +capacity refusal §3.3 argues for**: a configured slot count below +`n_moe_layers * 3 * num_experts_per_tok` refuses at load, by name, instead of +degrading to the mmap fallback. +**Exclusions:** no policy change (the LFU stays), no prefetch, no async I/O, no +device store — those are `ENG-EXPERT-STREAM` W6 and +`ENG-EXPERT-STREAM-DEVICE` W2 and this row does not take them. +**Anchors:** `expert_streamer.{h,cpp}`, `expert_slot_cache.{h,cpp}`, +`host_expert_slot_store.h`, `gguf_expert_span.h:12-16`, +`gguf_device_fit.cpp:95`, `model_loader.cpp:2472`. +**Tests:** **G3** — a streamed slice and the resident tower produce identical +logits, extended from `test_expert_stream_wiring.cpp:215` to a model with more +than 4 experts and more than 4 layers; the capacity refusal RED first; Qwen3.5's +six existing streaming binaries stay green and its goldens byte-identical. +**Gate:** focused ctest, full preflight, Qwen3.5 SACRED inertness. +**Reachability:** deleting the seam's call site in `qwen3_5.cpp` must red the +Qwen3.5 streaming suite. +**Stop:** if the lift cannot preserve Qwen3.5 byte-identity, return +`NEEDS_DECISION`; a behaviour change to a gated model is not this wave's to make. + +#### W4 — the heterogeneous indexer schedule and selection reuse (CPU, medium) + +**Scope:** per-layer `MlaBlockDims` so 22 of 79 blocks carry an indexer and 57 do +not; the `skip_topk` semantics — a shared layer runs no indexer, stays +`is_sparse`, and attends through the preceding full layer's selection +(`vllm/model_executor/layers/mla.py:180`); the fp32 router gate GEMM +(`deepseek_v2.cpp:350`). +**Exclusions:** no KV cache work, no prefill work. +**Anchors:** `deepseek_v2.py:1115`, `:1134-1135`, `:1175`; +`vllm/model_executor/layers/mla.py:180`; ours +`mla_attention.cpp:414`, `:598-745`, `:880-883`; +`_get_moe_router_dtype` `deepseek_v2.py:123-133`. +**Tests:** **G1** — the selection a shared layer uses is byte-identical to the +one its owning full layer produced, mutation-proven by re-pointing it at a +different layer; the router GEMM's output dtype asserted as f32 against a vLLM +golden; a full layer and a shared layer produce DIFFERENT attention outputs (the +tautology guard). +**Gate:** focused ctest, full preflight. **Reachability:** the schedule must +arrive from the config parsed in W2, not be constructed in the test. + +#### W5 — the indexer KV side cache (GPU, large) — [#1925](https://github.com/mudler/vllm.cpp/issues/1925) + +**Scope:** the indexer's own 132 B/token cache in its own kv-cache group, so a +resumed request no longer refuses. This is `KV-DSV4-MULTICACHE`'s work and this +row consumes it; if that row does not schedule it, this row's W5 is where it +lands and the ownership is recorded in both places before a line is written. +**Exclusions:** sparse prefill, which is W6. +**Anchors:** `DeepseekV32IndexerCache` `deepseek_v2.py:696-701`; the +`MLAAttentionSpec` merge rule `vllm/v1/kv_cache_interface.py:399-429` that forces +it into a separate group; our refusal `dots3_note_device.cpp:1147-1180`. +**Tests:** a two-step decode with a resumed request produces the same tokens as +the same prompt decoded in one step; the refusal at +`dots3_note_device.cpp:1147-1180` is deleted and its replacement is gated, not +merely absent. +**Gate:** focused ctest on GPU, full preflight, dots3-note inertness. +**Needs a GPU.** + +#### W6 — sparse prefill (GPU, large) + +**Scope:** a topk/selection arm on `MlaPrefillAttentionArgs` and +`MlaPrefillAttention`, mirroring upstream's rule that a sparse impl forces ALL +tokens through the MQA path with no prefill/decode split +(`vllm/model_executor/layers/attention/mla_attention.py:697-702`). +**Exclusions:** no change to the dense prefill path any other model takes. +**Anchors:** `mla_attention.py:697-702`; ours `ops.h` `MlaPrefillAttentionArgs`, +`ops.cpp:4159-4230`. +**Tests:** prefill selection SET-equal to decode selection on the same context; +DeepSeek-V2 and GLM-4.7-Flash prefill byte-identical. +**Needs a GPU.** + +#### W7 — the loader, the streamed towers, and the first load (GPU + large asset) + +**Scope:** the `glm-dsa` GGUF weight loader; `_exps.weight` towers routed to the +W3 seam; the resident class staged to device; safetensors refused by name (D1); +`allow_mtp_tail` skipping block 78. Stage `UD-IQ1_S` (201.83 GiB, 6 shards) to +`/mnt/nas_share` — 2.2 TiB free — and record the sha256 of each shard. +**Exclusions:** no speed number. +**Tests:** **G2** structurally over the real shard headers, env-gated; the model +loads and produces a first token; the resident footprint measured against the +14.511 GiB this section predicts, and the difference explained if it is not +within a few percent. +**Gate:** the load itself, under an `rc` lease on `dgx:gpu0`. +**Needs a GPU and the asset.** **Stop:** if `dgx.casa`'s local disk cannot hold +201.83 GiB, do NOT quietly `pread` across CIFS and report the result as a +streaming measurement — record it as a CIFS number and open O7's measurement. + +#### W8 — the gates, once and only once a load exists (GPU + asset) + +**Scope:** G4 against llama.cpp `b10451` on the identical artifact, with the band +ratified in advance and the oracle's own ability to run this model demonstrated +first. Then, and only then, the speed axis — recorded as an open gap with a +labeled secondary floor and no vLLM denominator (§3.6). +**Exclusions:** no correctness claim that names vLLM as the runtime denominator. +**Needs a GPU and the asset.** + +### 3.8 Risks and decisions taken in this section + +**D1 — the GGUF arm ships and the safetensors arms are refused by name.** This +inverts `porting-a-model.md`'s usual polarity, which treats bf16 as the base arm +and the quantized arms as the obligation. Here the bf16/fp8 checkpoint is 703.74 +GiB with no streaming loader and no MoE block-fp8 rung, and the GGUF arm is the +only one that can be fed on this fleet. The refusal names the missing pieces so a +reader meets it at load rather than discovering it. + +**D2 — `UD-IQ1_S` is the target arm.** Smallest at 201.83 GiB, needs exactly one +keep-quant `vec_dot` (`IQ4_XS`), and its resident class is the same 14.5 GiB as +every larger arm. `UD-IQ2_M` (222.19 GiB) is the fallback and needs the same +single kernel, so W1 unlocks both. `UD-Q2_K_XL` additionally needs +`VecDotIQ2_XSQ8_K`, and `UD-IQ1_M` is rejected outright because `IQ1_M` has no +reader traits. None of the four is rejected on size. + +**D3 — the published GGUF's indexer schedule cannot be read out of the file, and +the port must not try.** The file declares indexer weights on **all 79 blocks** +while the checkpoint ships them on 22, so the conversion broadcast the shared +layers' weights — ~770 MB of duplicated Q8_0 — and it does **not** write +`glm-dsa.attention.indexer.types`, which `b10451`'s converter would have written +(`conversion/glm.py:337-340`). llama.cpp survives this by falling back to a +HARDCODED table: `is_pre_5_2 = n_ctx_train < 1048576` is false for this model +(`max_position_embeddings` is exactly 1048576), so it uses +`GLM_5_2_DEFAULT_INDEXER_TYPES` (`src/models/glm-dsa.cpp:6-27`), which §3.5.1 +verified is bit-identical to GLM-5.3's list. **We do not copy that table.** W2 +derives the schedule from `index_topk_freq` / `index_skip_topk_offset` the way +vLLM does, reads `indexer.types` when present, and refuses when a file declares +neither and the derivation is unavailable. A hardcoded 78-entry constant that +happens to be right is the shape that silently becomes wrong on GLM-5.4. + +**D4 — the row moves to `SPIKE`, not to `READY` or `ACTIVE`.** Scoped in a +committed spec, not implemented. It leaves `SPIKE` when W2 lands. + +**R1 — the slot cache has never run at this scale.** 1800 protected slices per +step against a policy whose eviction is an O(resident) linear scan +(`expert_slot_cache.cpp:26-44`) and whose fills are 1800 serialized blocking +`pread`s. Nothing in the tree has run the `pread` path on a real checkpoint at +all. W7 is where this becomes a number, and it may be the number that reopens the +blocked verdict on throughput grounds rather than capacity grounds. + +**R2 — batch is the capacity axis, not context.** At concurrency `c` the distinct +expert set per layer is bounded by `min(256, 8c)`, so the working set grows to the +whole 187 GiB tower set well before `c = 32`. The row's viability claim is a +`c = 1` claim and W8 must say so beside every number. + +**R3 — `dgx:gpu0`'s viability depends on it being a GB10.** The production +predicate is `cpu || host_memory_is_device_addressable()` +(`qwen3_5.cpp:6199`); a discrete CUDA part answers false and falls through to +`KqResidentSlice`. Unified memory is what makes the host slot arena readable by +the device without a device store, and `ENG-EXPERT-STREAM-DEVICE` W2 — the +virtual `SlotForRead` — is what a discrete part would need. This row does not +take that work; it records that the port is GB10-shaped until that lands. + +**R4 — the streamed and grouped MoE paths are mutually exclusive** +(`qwen3_5.cpp:6307-6312`). Every speed number on this row is a +grouped-MoE-disabled number, and that has to be said each time rather than once. + +### 3.9 Owed + +- **O1 — no end-to-end token gate against vLLM exists or can exist on this + fleet** (§3.6). Owed against a device that can hold 703.74 GiB, or against a + multi-device execution path this project does not have. Tracked by + [#2214](https://github.com/mudler/vllm.cpp/issues/2214). Discharged by either + of those two things and by nothing else. +- **O2 — `IQ4_XS` has a decoder and no keep-quant `vec_dot`, so the target arm + loads by EXPANDING four expert towers from 6.375 GiB to 24.000 GiB and, worse, + drops out of the streaming lane entirely** (`gguf_device_fit.cpp:85-100` is + all-or-nothing across a model's `*_exps` tensors). Discharged by W1 landing + `VecDotIQ4_XSQ8_K`. Owned by `QUANT-GGUF-IQ4_XS`. +- **O3 — `IQ2_XS` (id 17) is in the same state and `IQ1_M` (id 29) has no reader + traits at all.** `UD-Q2_K_XL` would expand 148 towers from 128.344 GiB to + 888.000 GiB; `UD-IQ1_M` refuses at file open. Discharged by a + `VecDotIQ2_XSQ8_K` and an `IQ1_M` port, or by this row permanently recording + those two arms as unreachable. Nothing here needs either; they are named so a + later reader does not rediscover them as defects. +- **O3b — `94de63ff5` left `IQ2_XS` and `IQ4_XS` decodable but not keep-quant, + for every row, not only this one.** That is a silent 3.4x memory multiplier on + any artifact carrying them, invisible to a token gate, and it is not this row's + record to repair. Named here because a reader who checks `gguf_dequant.cpp` and + stops will conclude both types are supported. +- **O4 — the indexer KV side cache does not exist**, so sparse decode refuses any + resumed request (`dots3_note_device.cpp:1147-1180`). Discharged by W5, whose + work is `KV-DSV4-MULTICACHE`'s + ([#1925](https://github.com/mudler/vllm.cpp/issues/1925)). +- **O5 — MTP is skipped, not implemented.** `num_nextn_predict_layers: 1` and + `index_share_for_mtp_iteration: true` are dropped through `allow_mtp_tail`. + There is no MTP drafter in the tree (`src/vllm/v1/spec_decode/` holds three + files, none of them one). Discharged by a drafter row that does not exist yet. +- **O6 — sparse prefill does not exist** (§3.5.2 item 3). Discharged by W6. +- **O7 — no artifact is staged and no `pread` streaming number exists on any real + checkpoint** (`expert-streaming.md` `## Owed`, verbatim: "The `pread` path has + never run on the model"). `dgx.casa`'s local disk was 184 GiB free at its last + measurement (§0.1 C3, 2026-07-21) against a 201.83 GiB arm, so W7 may be forced + onto CIFS. Discharged by W7 staging the shards, recording their sha256, and + reporting which filesystem served the `pread`s. +- **O8 — the expert-streaming mechanism has no shared seam**, so it is reachable + from exactly one model TU (§3.2 gap 1). Discharged by W3. +- **O9 — the resident 14.511 GiB is arithmetic from the shard headers, not a + measurement.** It excludes KV cache, activations, scratch pools and the CUDA + context, which is the same omission `expert-streaming.md` `## Owed` already + records for its own fit bound. Discharged by W7 reporting the measured resident + footprint beside this prediction. +- **O10 — no speed axis has a denominator** (§3.6). vLLM cannot run the model, so + the only comparable is llama.cpp on the same artifact, a labeled secondary + floor. Open gap by construction, not a waiver. +- **O11 — `docs/USAGE.md` carries no weights row for this model**, because + nothing is reachable yet. Owed in the same change that makes the capability + reachable, i.e. W7: file names, sizes, `unsloth/GLM-5.3-GGUF` at its exact + revision, per-shard sha256, and the refused arms named beside them. +- **O12 — the `ENG-EXPERT-STREAM` row (`engine-matrix.md:117`) carries `-` in + both its "Our code" and "Our tests/evidence" columns**, and its row text + describes "fixed contiguous Marlin slots" when no Marlin code is on that path. + Not this row's record to fix, and named here because a reader who checks that + row before this section will conclude the capability does not exist. + +### 3.10 Now + +`SPIKE`, 2026-08-29. The row moves off `🚫 BLOCKED` because the blocker was +computed in the wrong frame, and the correct frame is measured here: **97.49% of +this model's parameters are routed experts, the resident class is 14.511 GiB in +every published UD arm, and one decode step at `c = 1` touches 1800 expert slices += 11.21 GiB of uniform slots.** Resident plus a 4096-slot cache is 40.01 GiB +against 119.631 GiB on `dgx:gpu0`. Nothing is implemented; `GlmMoeDsaForCausalLM` +appears nowhere under `src/` or `include/`, and `ParseDeepseekV2Params` refuses +this checkpoint at the `index_topk` tripwire before anything else runs. + +Three findings shape what happens next, and each corrects something this +repository previously believed. + +**The port is smaller than §0.2 implies.** That section's verdict — "GB10 cannot +run DSA end-to-end" — was a statement about vLLM's flashinfer sm120 path, and it +is still true of that path. It is no longer a statement about ours: a +device-native DSA lightning indexer now lives in the shared MLA block +(`mla_attention.cpp:598-745`) with CPU and CUDA `DsaIndexerLogits` / +`DsaTopkSelect`, reached in production by `Dots3NoteForCausalLM`. The MLA +geometry this model needs already validates and already dispatches to native +kernel instantiations. What is left is the indexer KV side cache (O4), sparse +prefill (O6), and the schedule/reuse semantics. + +**The blocker that remains is one quantization kernel, it is named, and +`origin/main` changed which half of it is missing while this spec was being +written.** The census of `UD-IQ1_S` over its own shard headers says the arm is +106 IQ1_S + 71 IQ3_XXS + 44 IQ2_XXS + 4 IQ4_XS + 3 K-quant expert tensors. At +this branch's base `60a6dd97b`, `IQ4_XS` had no decoder and the arm refused +loudly at load. At `94de63ff5`, landed 2026-08-29 for the sibling Flash row +([#2245](https://github.com/mudler/vllm.cpp/issues/2245)), it has a decoder and +still no keep-quant `vec_dot` — so the arm now LOADS, expands those four towers +from 6.375 GiB to 24.000 GiB, and drops out of the expert-streaming lane +altogether, because `gguf_device_fit.cpp:85-100` is all-or-nothing across a +model's `*_exps` tensors. **The missing piece is `VecDotIQ4_XSQ8_K`, and the +failure it prevents is now silent rather than loud.** That is the sharper form of +this finding and it is why the census had to be redone against the merged tree +rather than trusted from an hour earlier. + +**The gate is the honest cost.** vLLM at the pin implements this architecture and +cannot run it on any device this project can reach, so no wave may promise a +token-exact number against it. What W3 onward can prove is that a streamed slice +and a resident tower produce identical logits, which is the row's actual novelty +and needs no oracle at all. + +**Next action:** W1 and W2, both CPU, both independent. W1 belongs to +`QUANT-GGUF-IQ4_XS` and unlocks two arms at once. diff --git a/.agents/specs/glm5-next-flash.md b/.agents/specs/glm5-next-flash.md index 80e151fb86..9bc619c3c7 100644 --- a/.agents/specs/glm5-next-flash.md +++ b/.agents/specs/glm5-next-flash.md @@ -1679,23 +1679,93 @@ Debts this row carries, each visible rather than waived: `dgx:gpu0` lease on this row; [#2213](https://github.com/mudler/vllm.cpp/issues/2213) records it. -- **O18 — the loader now stops on a per-layer CONFIG KEY instead of on a tensor - type, and the artifact still does not FIT.** With +- **O18 — the three per-layer CONFIG ARRAYS are DISCHARGED, and the loader now + stops one geometry key further on. The artifact still does not FIT.** With [#2240](https://github.com/mudler/vllm.cpp/issues/2240)'s IQ2_XS and IQ4_XS decoders in, `LoadedEngine::FromModelDir` opens all four shards of the staged `/mnt/nas_share/rc/ckpt/GLM-5.3-Flash-UD-Q2_K_XL/` artifact, sizes all 1412 - tensors, and runs on into config resolution, where it stops with + tensors, and ran on into config resolution, where it stopped with `glm5_next gguf: key glm5next.attention.head_count_kv is not an integer`. The published artifact stores that key as a per-layer `array[i32]` of length 46, - and `Glm5NextHfConfigFromGguf` reads it as a scalar. + and `Glm5NextHfConfigFromGguf` read it as a scalar. `glm5next.swiglu_clamp_exp` and `glm5next.swiglu_clamp_shexp` are per-layer - `array[f32]` of the same length, so the same shape is waiting twice more - directly behind it. Measured 2026-08-29 by driving the production loader - read-only, with the reader's `case 17:` deleted and restored to prove the - before/after on ONE binary: without it the same probe stops at - `tensor "blk.3.ffn_gate_exps.weight" has unknown ggml type id 17 in - ...-00002-of-00004.gguf`. Owned by the config/loader wave on this row; - [#2243](https://github.com/mudler/vllm.cpp/issues/2243) records it. + `array[f32]` of the same length, and there is no `glm5next.layer_types` key at + all, so the same shape was waiting twice more and a `ReqStrArray` behind that. + + **Discharged by [#2243](https://github.com/mudler/vllm.cpp/issues/2243) and + [#2177](https://github.com/mudler/vllm.cpp/issues/2177) together**, which are + one defect seen from two sides: the builder now accepts llama.cpp's + scalar-or-array spelling of `attention.head_count_kv` + (`b10451:src/llama-model.cpp:1177` reads it through + `get_key_or_arr(..., n_layer, false)`) and DERIVES the schedule from the values + with llama.cpp's own predicate, `is_recr_impl[i] = hparams.n_head_kv(i) == 0` + (`b10451:src/models/kimi-linear.cpp:18`, "KDA layers are recurrent"). When both + spellings are present they are cross-checked on the layer KIND and a clash + refuses by name; a per-layer array whose length is not `block_count` refuses by + name with the shape found; a non-uniform clamp array refuses, because upstream + has ONE `swiglu_limit`; and a file that states the schedule in neither spelling + still refuses, naming both keys. The `idx % 4 != 3` fallback survives ONLY on + the `config.json` path, where it is upstream's own default. + + **THE NEW STOPPING POINT, measured 2026-08-29 on one tree and one binary**, + with the array fix reverted and restored so the before/after is not a + cross-build comparison. Driven through `LoadedEngine::FromModelDir` on + `device = kCPU`, headers only, no tensor materialised: + + ```text + without the fix : glm5_next gguf: key glm5next.attention.head_count_kv is not an integer + with the fix : vt: glm5_next gguf: attention.key_length_mla - attention.key_length + is -256 but rope.dimension_count is 0; the file states this model's + rotary width twice and the two disagree + ``` + + The refusal is the rotary-width cross-check in `Glm5NextHfConfigFromGguf`, + named here rather than by `file:line` because the anchor moved once inside the + pull request that measured it. The append-only index row for + [#2268](https://github.com/mudler/vllm.cpp/issues/2268) quotes the line number + it had when the row was appended and cannot be edited; this entry is the + corrected surface. + + **BLOCKS ARE NOT LAYERS, and the GGUF path used to conflate them.** + `c.num_hidden_layers` was set straight from `block_count`, so the SAME model + resolved to a 45-layer backbone from its `config.json` and a 46-layer one from + its GGUF, and the extra entry was the MTP block. Nothing downstream would have + refused it: `ParseGlm5NextParams` sizes all three schedules from + `num_hidden_layers`, so W5b and W5c would have built a decoder layer out of + the MTP block, and it would have run and produced plausible tokens. The + contract is BACKBONE depth — `glm5_next.h:193` already annotates the field as + `// 45` — and llama.cpp states the relationship in its own converters: + `self.block_count = self.hparams["num_hidden_layers"] + + self.hparams.get("num_nextn_predict_layers", 0)` + (`b10451:conversion/exaone.py:134`, and the same `+=` at + `b10451:conversion/deepseek.py:470` and `:545`). The builder therefore + resolves `num_hidden_layers = block_count - nextn_predict_layers`, validates + every per-block array against `block_count`, and truncates the three + schedules to the backbone. A file claiming more MTP blocks than blocks is + refused by name. Our own converter writes `block_count = n_layers` with + `nextn_predict_layers = 0` (`scripts/convert-glm5-next-gguf.py:997`, `:1024`), + so the formula leaves its output unchanged. + + **What W5b inherits from this.** The MTP block is read, counted and DROPPED: + its entry in `attention.head_count_kv` — index 45, value `1`, MLA-shaped — is + not carried into `layer_types`, and no field on `HfConfig` or + `Glm5NextParams` holds `nextn_predict_layers` yet. So W5b + ([#2241](https://github.com/mudler/vllm.cpp/issues/2241)) gets a stack sized + to 45 and must not build a layer for block 45; if the MTP head needs that + block's kind, W5b adds the field, because this change deliberately did not. + O2 still owns the head itself. + + That is NOT a malformed file. `attention.key_length` is 512 and + `attention.key_length_mla` is 256 in the published artifact because llama.cpp + writes `key_length = kv_lora_rank + qk_rope_head_dim` and + `key_length_mla = qk_nope_head_dim + qk_rope_head_dim` + (`b10451:conversion/deepseek.py:345-348`), while + `scripts/convert-glm5-next-gguf.py` writes `key_length = qk_nope_head_dim` — a + different quantity under the same name. `glm5next.attention.linear_head_count`, + a `ReqInt` here, appears in none of the file's 72 keys, and llama.cpp spells it + nowhere. Both are one defect and both change the WRITE side, so they are filed + as [#2268](https://github.com/mudler/vllm.cpp/issues/2268) rather than folded + into a config-array fix, and this row owns them. **The array is 34 zeros and 12 ones.** Parsed 2026-08-29 from shard 1's KV block. Key index 21, `glm5next.attention.head_count_kv: array[i32] len=46`: diff --git a/.agents/specs/laguna-grouped-gemv.md b/.agents/specs/laguna-grouped-gemv.md new file mode 100644 index 0000000000..cc1e42bbd0 --- /dev/null +++ b/.agents/specs/laguna-grouped-gemv.md @@ -0,0 +1,103 @@ +# `PERF-LAGUNA-GROUPED-GEMV` — what bounds the grouped Q4_K/Q5_K GEMV + +Issue [#2250](https://github.com/mudler/vllm.cpp/issues/2250). Owning row +`MODEL-TEXT-laguna-laguna-for-causal-lm`. Lever #2 of +[`laguna-s21-w7-speed-2026-07-31.md`](laguna-s21-w7-speed-2026-07-31.md) §W11, +opened after lever #1 closed ([#2061](https://github.com/mudler/vllm.cpp/issues/2061)). + +## The row starts from prior measurement, not from the lever's name + +W11 measured the keep-quant GEMVs at ~87% of Laguna decode GPU and labelled the +lever "BW-tuning". **That label is not load-bearing and this row does not inherit +it**, because the sibling kernel carrying the other 24.7% was measured and turned +out to be something else entirely. + +**The Q8_0 half is CLOSED and must not be re-opened on a bandwidth premise.** +`ds4-q8-ncu-2026-07-30.md` plus `74a70427a` and `8779eccb7` establish, at the +counter rather than by inference: + +| counter | value | reading | +|---|---|---| +| `long_scoreboard` | 54-57 | memory-LATENCY exposure | +| achieved occupancy | 72-75% | NOT occupancy-starved | +| `l1tex__t_sector_hit_rate` | 96.6% | the 16x sector over-fetch never reaches DRAM | +| `lg_throttle` | 74.2 | LSU global-load pipe saturated on the weight unpack | +| `local_ld` / `local_st` | 0 / 0 | no register spill — the SINK4 hypothesis REFUTED | + +Five structural levers came back flat or refuted there: aligned repack (Brick 4), +sub-warp occupancy (Brick 11), launch consolidation (Brick 12), the register-spill +hypothesis, and multi-row/prefetch ILP ("RE-CONFIRMED WASH"). `74a70427a` records +a MEASURED FLOOR. + +## Scope + +| Field | Content | +|---|---| +| In | `QuantDotGemmGroupedKernel` (`src/vt/cuda/cuda_quant_dot.cu:815`), the Q4_K/Q5_K grouped routed-expert GEMV, 62.1% of Laguna decode GPU by W11's `cuda_gpu_kern_sum`. W1 measures what bounds it; any lever is chosen from those counters and is a LATER wave | +| Out | `QuantDotGemmQ8_0Kernel` — closed above, and re-opening it needs new evidence rather than a new attempt; the fused gate/up arm (#2061, done); the fp4/NVFP4 Laguna path, a different branch with a different bottleneck; device-residency, DEMOTED by W11 | +| Gate model | `unsloth/Laguna-S-2.1-GGUF UD-Q4_K_XL` @ `750f92f9`, staged at `/workspace/ckpt/laguna-s21-ud-q4kxl/` | + +## Why the kernel is a plausible candidate ANYWAY + +Not because of W11's label, but because its own spec says so. +[`cuda-keepquant-gemm.md`](cuda-keepquant-gemm.md) records the current structure as +correctness-first and names the deferral in its scope: *"MMVQ warp-per-output is +the correctness-first structure; tensor-core tiling is a later speed brick."* + +The kernel is one warp per output element, lanes striding the K super-blocks with +a warp reduce, mirroring llama.cpp's `mmvq.cu` structure but with our Q8_K +numerics. It has never been bandwidth- or ILP-tuned on CUDA. So there is a +documented, deliberately-deferred axis here — which is a different thing from +assuming the kernel is bandwidth-bound. + +## W1 — the measurement, and what would make it wrong + +Profile `QuantDotGemmGroupedKernel` with `sudo ncu` inside a real Laguna decode on +`dgx:gpu0`, capturing the SAME counter set the Q8_0 work used so the two kernels +are directly comparable: `long_scoreboard`, achieved occupancy, `l1tex` and `lts` +sector hit rates, bytes per sector, `lg_throttle`, `local_ld`/`local_st`, and +compute/memory SOL. + +**It must profile DECODE, not the whole run.** `nsys`/`ncu` over a whole +invocation aggregates prefill with decode and folds in one-time load-path work; +that trap has already produced one wrong attribution in this tree, where a +whole-run `kern_sum` contaminated by load-time Marlin repack yielded a +"kernels already at parity" claim that a clean graph A/B later reversed. Target +the kernel by name and skip past prefill. + +**The reading decides the next wave, and the four readings are not the same +lever:** + +- **Bandwidth-bound** (high memory SOL, low L1 hit): vectorised loads, a wider + per-thread footprint. This is what W11's label assumed. +- **Latency-bound** (high `long_scoreboard`, healthy occupancy): the Q8_0 story + repeating, in which case its five refuted levers are the prior and the expected + value of trying them again is low. +- **LSU-pipe-bound** (high `lg_throttle`): the unpack instruction count is the + cost, and the lever is fewer, wider loads per block rather than more of them. +- **Occupancy-bound**: register or shared-memory pressure — and note the Q8_0 + kernel was NOT occupancy-starved at 72-75%, so this would be a genuine + difference between the two rather than a shared cause. + +**A null result is a result.** If the counters say the grouped kernel is bound the +same way Q8_0 is, this row records that and stops, because that is the finding — +the two kernels sharing a floor is more useful than a sixth refuted brick. + +## Gates + +- W1 is measurement-only. NO product code, no default flipped, no speed claimed. +- Counters recorded with the run's own artefact paths, kernel name, launch + configuration and the checkpoint revision, so the numbers can be re-derived + rather than re-quoted. +- Any later lever gates on bit-exactness against the current kernel first, then + on a warm order-balanced A/B — the shape #2061's W3b arrived at, after its + single-run predecessor produced a 2.15x figure that was a cold-cache artefact. +- No llama.cpp denominator is quoted. `27.8 tok/s` and every ratio derived from it + stay superseded under [#1003](https://github.com/mudler/vllm.cpp/issues/1003); + W11's own "~22% of peak vs llama.cpp ~76%" inherits that supersession and is + therefore ALSO not quotable as a target. + +## Now + +`READY`. Spec committed, no implementation. Next action is W1, which needs a GPU +lease and the staged checkpoint, and produces counters rather than code. diff --git a/.agents/specs/ltx25-oracle-absolute.md b/.agents/specs/ltx25-oracle-absolute.md index 542622da3d..4b62ffb2bc 100644 --- a/.agents/specs/ltx25-oracle-absolute.md +++ b/.agents/specs/ltx25-oracle-absolute.md @@ -408,27 +408,34 @@ Stop and report, do not work around: ## Owed -- **[#2140](https://github.com/mudler/vllm.cpp/issues/2140): the BF16 caption - projections do not load, so gate 5's READING is PENDING.** The gate is - landed, exercised and mutation-tested; what is missing is our render, and it is - missing for a located reason rather than for want of a lease. `LoadProjection` - (`src/vllm/model_executor/models/ltx2_loader.cpp:928-960`) hard-assumes - torchao-NVFP4 for the two caption projections, so the BF16 text tower loads and - the render then refuses. Substituting the NVFP4 tower would measure the - text-encoder arm rather than the render, so the reading stays PENDING and is - not manufactured. Owner: this row. §Outcome records the run that established - it. -- **`--steps` is WIRED AND UNPROVEN END TO END, and that is the one thing this - change lands without an executed path through it** ([#2130](https://github.com/mudler/vllm.cpp/issues/2130) - closes the absence of the flag, not the absence of its proof). Every link is - verified by inspection and none by execution: `main.cpp` assigns `vp.steps`, - `vllm_c.cpp:1664` forwards it, `ltx2_video.cpp:4027` reads it. The lease DID - pass `--steps 8`, and the render refused at the checkpoint load 76 s in, before - the sampler ever resolved a sigma schedule, so no run in this tree has yet - observed the value arrive. Nothing gates it: no test builds `ltx2-gen`. - Unblocking it needs the same render that - [#2140](https://github.com/mudler/vllm.cpp/issues/2140) blocks, so it is owed - together with gate 5's reading and not separately. Owner: this row. +- **Gate 5's READING IS TAKEN. It is no longer owed.** `rc` job + `4b0666ee-248c-45fc-9de6-372b6d0c1fab` on `dgx:gpu0` rendered the manifest's + request and the comparison returned `PASS` / `NO_WORSE_THAN_ORACLE_ON_BLOCKINESS` + against both reference forms. The panel is in `## Outcome`. What remains owed + from #1854 is prompt adherence only, which is a separate bullet below and was + never in this row's scope. +- **`--steps` IS NOW PROVEN END TO END, by execution rather than by + inspection.** This was the row's one wired-but-unexecuted path + ([#2130](https://github.com/mudler/vllm.cpp/issues/2130) closed the absence of + the flag, not the absence of its proof). The render observed it arrive: + `steps_requested=8 steps_observed={8} dit_forwards=32` in `PROVENANCE`, where + the observed set is the distinct denominators of `PhaseLog::Tick`'s + `step k/M` lines and `M` is `sigmas.size() - 1`, the RESOLVED count. Not the + flag echoed back: a number the sampler computed. **32 forwards over 8 steps is + 4 per step**, which is the guided denoiser's cond / uncond / perturbed / + modality quartet, so the count corroborates the schedule rather than merely + agreeing with it. + + The SILENT failure mode was ruled out before the run and is worth keeping, + because it is the one a reader would not think to check. A step override + reaches two branches (`ltx2_video.cpp:4025-4073`): the schedule is computed + from `steps` only when `phase.sigmas` is EMPTY, and a phase carrying its own + sigmas either REFUSES the override or, when `allow_request_sigmas` is true, + keeps its schedule and IGNORES it. A silent 30-step render against an 8-step + reference would have carried a 3.75x denoise-budget confound in the direction + that flatters us, and passed. `OneStagePhase` (`ltx2_pipeline.cpp:1124-1147`) + sets no sigmas and `OneStageRecipe` (`:1149-1163`) never assigns + `allow_request_sigmas`, so `one_stage` takes the branch that reads `steps`. - **Five line anchors into `examples/ltx2_gen/main.cpp` are now STALE and cannot be repaired, because they live in the append-only issue index.** Adding `--steps` moved that file's later lines by +12, and @@ -448,10 +455,20 @@ Stop and report, do not work around: ## Now -`ACTIVE`. W1 and W2 are in this change and complete. W3 ran, refused at the -checkpoint load, and its refusal is the row's finding rather than its absence: -[#2140](https://github.com/mudler/vllm.cpp/issues/2140). The gate's reading is -`PENDING` on that issue and is listed under `## Owed`. +`DONE`. W1, W2 and W3 are complete and gate 5 has its reading: +`PASS` / `NO_WORSE_THAN_ORACLE_ON_BLOCKINESS`, against both reference forms, +from `rc` job `4b0666ee-248c-45fc-9de6-372b6d0c1fab` on `dgx:gpu0`. + +W3 took four attempts and each failed at a different and further stage, every +one located rather than guessed: the checkpoint load +([#2140](https://github.com/mudler/vllm.cpp/issues/2140), CLOSED), then the +fleet (`dgx:gpu0` `unhealthy ... worker_lost` for 3h20m), then the BUILD +([#2220](https://github.com/mudler/vllm.cpp/issues/2220), a defect in this row's +own harness), then the render itself, which succeeded. + +#1854 is NOT closed by this row and should not be: its prompt-adherence +sub-question is untouched and needs a vision-language oracle this tree does not +have. See `## Owed`. ## Outcome @@ -531,3 +548,204 @@ a later reader would propose. **What this row does not claim.** One request, one geometry, one seed, bf16 only, 25 frames. Two of four panel statistics. Prompt adherence is untouched and open. A pure-noise render passes, and a test says so. + +### W3, third attempt: the build died at the link, and the cause was our own harness + +`rc` job `1ad519b1-4e75-41d7-9386-9932076390f1` on `dgx:gpu0` reached the device, +cleared the memory floor at 115.0 GiB against 78.0 GiB, passed all three source +guards, and **failed at [D] build after 21 minutes** with 38 +`undefined reference to ...@libcudart.so.13`. It never reached staging or the +render. + +**The cause is [#2220](https://github.com/mudler/vllm.cpp/issues/2220), a defect +in THIS row's own harness.** `/workspace` is CIFS and stores no symlink, so the +staged toolkit carries only `libcudart.so.13.3.29`. The reconstruction used +`${f#*.so.}`, which strips the SHORTEST prefix and yields `13.3.29` rather than +`13` — so it linked the file to ITSELF and never created `libcudart.so.13`, the +SONAME the linker resolves versioned undefined symbols against. `need_ok` then +tested `libcublasLt.so`, the one link the loop DID create correctly, so the +precondition passed on a toolkit that could not be linked against. + +**It was latent, and the A/B is in the two runs' own configure logs.** The staging +branch is a FALLBACK; every earlier lease found `/usr/local/cuda` and never took +it. + +| run | toolkit | version | build | +|---|---|---|---| +| `20260827T220845Z` | `/usr/local/cuda` | 13.0.88 | succeeded, 1192 s | +| `20260828T224529Z` | `/root/cudatk`, staged | 13.3.73 | **failed at link** | + +`dgx:gpu0` went out of the pool for 3h20m the same day and returned without a +toolkit, which exercised the branch for the first time. + +**Fixed, with red-before/green-after on a replica of the CIFS layout.** Take the +MAJOR; prefer `ldconfig -n`, which reads each object's own `DT_SONAME` so the name +cannot disagree with what the linker will ask for; and assert the postcondition in +seconds instead of after a 21-minute build. Measured: the old loop creates no +`.so.13` at all, the new logic creates both. The guard FAILS on the old layout, +PASSES on the new, and FAILS on the real NAS source tree — so it discriminates +rather than passing by construction, which is precisely the defect it replaces. +The resolved SONAMEs are printed and written to `PROVENANCE`, so a later reader +can see which toolkit the artefacts were linked against. + +Recorded in [`environment.md`](../environment.md) as well as here: a staged CUDA +runtime whose SONAME links did not survive CIFS is a lease-environment fact that +will bite the next row, not a property of this one. + +### W3, second attempt: no lease, and the port is no longer what blocks it + +The GPU was never reached. `dgx:gpu0` — the GB10, the box #1864 rendered on — +read `unhealthy (no contact 3h20m)` with `out of the pool worker_lost`, and it +stayed there for the whole session. No job was queued against it: a queued job +against a dead worker is a lease held on a hope. It was not cleared, because +clearing needs an admin token and is the developer's call, and no `ssh` was +attempted, because a device that is unschedulable through `rc` is never a reason +to reach it another way. + +**So the deliverable of this attempt is the elimination of every REMAINING +non-GPU unknown, on the real bytes, at `fe21faf63`.** The point is that the next +lease spends its wall on the render rather than on discovering a refusal, which +is exactly what the first attempt spent 44m45s doing. + +**The four checkpoints are digest-verified, from the NAS, against the manifest.** +Not sizes: sha256, all four, all matching, 15m51s of CIFS reads. + +| checkpoint | sha256 | verdict | +|---|---|---| +| `ltx-2.5-22b-dev-transformer-bf16.safetensors` | `792a2bad…c8e7584` | matches | +| `gemma4-12b-with-proj-ltx-2.5-bf16.safetensors` | `ef724361…d16561d1` | matches | +| `ltx-2.5-video-vae-conv-bf16.safetensors` | `685b06ee…97dfce8d` | matches | +| `ltx-2.5-audio-vae-bf16.safetensors` | `c52733d3…0d54837a5` | matches | + +**#2140's refusal is GONE, re-run on the bytes that produced it.** +`scripts/probe_ltx2_text_encoder_load.cpp` against the bf16 tower resolves +`video out=4096 in=188160` and `audio out=2048 in=188160` — the logical width, +not the doubled 376320 the old `LoadProjection` computed — with +`quantized_modules = 0`, in 32.9 s at 8.68 GiB peak, exit 0. The first attempt's +verbatim message was `'text_embedding_projection.video_aggregate_embed.weight' +unpacks to in_features 376320 but the Gemma geometry gives 188160`. It does not +occur. + +**THE 42 GB BF16 DiT WAS THE OPEN QUESTION AND IT RESOLVES.** Every LTX-2.5 +render this project has taken loaded the NVFP4 or the FP8 transformer; `8bfd3a542` +fixed the TEXT ENCODER, and nothing had established that the dev bf16 transformer +is not refused in turn. `scripts/probe_ltx2_dit_load.cpp` is new and answers it in +1.8 s off the header: + + resolved_arm kNone + contract 4091 tensors + contract_bytes 37985180160 (35.38 GiB, what a load materializes) + bound 4091 of 4349 file tensors + unbound 258 tensors: audio_embeddings_connector video_embeddings_connector + unported none: the load does NOT need allow_unported_modules + +Every one of the 4091 contract tensors is present under the ComfyUI prefix, at +the contract's shape, in a dtype this loader materializes, holding exactly the +bytes that shape requires — which is `MaterializeDitTensor`'s own BF16 check +(`ltx2_loader.cpp:499-506`). The 258 unbound tensors are the two +`*_embeddings_connector` families, which `UnportedFamilies` skips via +`LoadedElsewhere` (`:618-631`) and which `RefuseUnported`'s own message says +"are not in this list either and never will be" (`:654-656`), so the load needs +no `allow_unported_modules` and `ltx2-gen` does not pass one. + +**WHY A PROBE AND NOT A LOAD, stated rather than left to be inferred.** +`Ltx2LoadDitFromSafetensors` and `Ltx2StreamDitToDevice` share their whole +prologue and differ only in what the per-tensor loop does with the bytes +(`:703-806`). The prologue is header-only and is where every DiT refusal in this +tree has happened, including #1148's. The loop is 35.38 GiB, and the CPU box this +ran on had 23-26 GiB available, so materializing would have OOM-ed a shared box +to re-measure a memcpy. **The probe therefore does NOT establish that the render +runs, and it prints that sentence itself before its `OK`.** + +**The probe can say no, and that is measured rather than assumed.** Pointed at +the text encoder — a real safetensors file that is not a DiT — it exits 1 with +`REFUSED: ltx2 loader: 'hf_asset__chat_template.jinja' is U8 (NVFP4-packed) but +rank 1`. Pointed at the DiT it exits 0. An instrument that only ever passes is +not evidence. + +**Independently confirmed by the tree's own case.** `ltx2 video: the SHIPPED +Lightricks checkpoints parse and load` / `the FULL bf16 dev DiT resolves onto the +L2 contract`, run against the same file with `LTX2_CHECKPOINT_ROOT` set: 1 case, +**18 assertions**, 0 failed, `quant=kNone, 4349 tensors, 4059 BF16 / 290 F32`. +That case and the probe are separate readers of the same header, and they agree. + +**The three non-GPU gates are green at this head**: `test_ltx25_absolute_reference.py` +21 tests OK, `test_ltx25_render_compare.py` 65 tests OK, +`test_ltx2_oracle_goldens.py` PASSED. + +**What is still owed is the render, and only the render.** The harness is +committed and unchanged in its request; the reference frames are on the NAS (25 +PPM plus `audio.wav`, 26 files); the previous lease's binary cache is at +`$W/absref-bin` and will rebuild once, because its `SRC_SHA` predates `8bfd3a542`. + +### W3, fourth attempt: THE READING, and it is a PASS + +`rc` job `4b0666ee-248c-45fc-9de6-372b6d0c1fab` on `dgx:gpu0`, source +`0002ddfba26b59279732aeb4e3c99e092b436f28`, built in-lease, 53 minutes wall. +The harness exited on the comparison's own verdict rather than on "the script +finished". + +**Provenance, so the reading is attributable.** Binary +`7b1f4367...6817c05d`, library `9e3dc6f4...41287329` (the library is the one that +matters, #1881), harness `5649b4e8...2b01f6f2`, tarball `1cd4dcc1...57c2ad87`. +Geometry `320x192/25f steps=8 seed=42`, 240 video tokens, prompt sha256 +`a65a14fe...39f4cb93`. All four checkpoint sha256 recomputed INSIDE the lease on +the locally staged copies and all four match the manifest — a second independent +reading of the same digests. The toolkit's rebuilt SONAMEs are recorded too +(`libcudart.so.13 -> libcudart.so.13.3.29`), so a reader can see what the +artefacts were linked against. + +**The render ran, and `--steps 8` arrived.** `render_rc=0`, 503 s, 25 of 25 +frames, 193,964 bytes of audio. `steps_requested=8 steps_observed={8} +dit_forwards=32`. The observed set is the distinct denominators of the sampler's +own `step k/M` lines, so it is a number the sampler computed and not the flag +echoed back. 32 forwards over 8 steps is 4 per step, the guided denoiser's +quartet, which corroborates the schedule instead of merely agreeing with it. + +**The C0 checks, on our render.** 25 distinct frame hashes of 25, zero +near-uniform frames, minimum per-frame variance 2186.296, zero zero-motion pairs, +mean adjacent MAD 5.4060. So the clip has content and it moves. + +**The panel, ours beside the reference's own 25 frames** (form `frames`, 25 +digests verified against `SHA256SUMS`): + +| statistic | ours | reference mean | reference per-frame range | bound | verdict | +|---|---|---|---|---|---| +| `blockiness_grid8` | **1.022135** | 1.042812 | [0.947454, 1.143393] | <= 1.143393 | **PASS**, margin +0.121257 | +| `blockiness_grid32` | **1.025445** | 1.037230 | [0.920299, 1.148672] | <= 1.148672 | **PASS**, margin +0.123227 | +| `blockiness_grid8_defined` | 0 of 1600 collapsed | — | — | 0 | **PASS** | +| `blockiness_grid32_defined` | 0 of 1600 collapsed | — | — | 0 | **PASS** | +| `sharpness_mean` | 10.517609 | 11.274039 | [10.839144, 11.760068] | REPORTED | — | +| `clipped_fraction` | 0.00075825 | 0.00165039 | [0.00122613, 0.00210503] | REPORTED | — | +| `audio_rms_mean` | 133.303581 | not committed | — | REPORTED | — | + +`READING NO_WORSE_THAN_ORACLE_ON_BLOCKINESS`, `VERDICT PASS (exit 0)`. **Both +reference forms agree**: the 25 NAS PPM frames and the committed `upstream-render.mp4` +each returned exit 0, which re-runs section 2's claim that the two agree on the +gated bound rather than leaving it as a number somebody wrote down. + +**The bound was recomputed, not transcribed.** The JSON records +`reference/bounds/blockiness_grid8/frame_max = 1.1433929206406797` and +`digests_verified = 25`, i.e. the gate read its ceiling off the reference in hand +on this run. T9 exists because a transcribed literal left the whole suite green, +and this is the field that shows it did not happen here. + +**WHAT THE GREEN DOES NOT SAY, and this is the honest half of the reading.** Our +render is LESS blocky than the reference's own mean on both grids, not merely +under its maximum. But on the two REPORTED statistics we sit OUTSIDE the +reference's per-frame range in the same direction: sharpness 10.5176 against a +reference minimum of 10.8391, and clipped fraction 0.000758 against a reference +minimum of 0.001226. Less blocky, less sharp and less clipped is one coherent +picture — **our render is somewhat SMOOTHER than upstream's** — and a smoothness +difference is exactly what a one-sided blockiness ceiling is blind to by +construction. Neither statistic is gated, and section 5 gives the measured reason +a bound cannot be derived for either (sharpness has no structural null and is +content-driven; the clipped fraction does not survive the mp4's `yuv420p` round +trip). It is recorded here rather than left in a JSON because a reader who takes +`PASS` as "matches upstream" would be wrong. The gate's claim is its name: no +worse on blockiness. + +**And prompt adherence is still not measured**, here or anywhere in this tree. +Nothing above says the 25 frames depict a red fox in a snowy pine forest. That is +#1854's first sub-question, it needs a vision-language model pinned as an oracle, +and it stays open. diff --git a/.agents/specs/qwen4-exp-flash-next.md b/.agents/specs/qwen4-exp-flash-next.md index 43c7308906..55cffbd519 100644 --- a/.agents/specs/qwen4-exp-flash-next.md +++ b/.agents/specs/qwen4-exp-flash-next.md @@ -1807,6 +1807,131 @@ result: the four-state group is never allocated on a DEVICE (the CPU host takes and no mutation here can see the zero-seeded n-gram history, because no test in this tree reads that row's CONTENTS. All three are under `## Owed`. +## Mutation record — W5b-6 (#2218) + +The gamma-polarity wave. Every mutation was sha256-proven applied, **its BUILD +rc was read before any test result**, the tree was restored byte-for-byte with +the hash re-checked, and both were RE-ARMED on the final head after the registry +comment landed. `cpu_qwen4_exp.cpp` was measured at `4accd54e82be…` and +`qwen4_exp_weights.cpp` at `81328de99cc1…`; both are the head's. + +### The RED, before the change + +`test_qwen4_exp_forward`, the new composition case, driven through +`ModelRegistry::Load` on the synthetic `qwen4exp` file: + +``` +tests/vllm/models/test_qwen4_exp_forward.cpp:222: ERROR: + CHECK( MaxAbsDiff(mixed, want_mixed) < 1e-5f ) is NOT correct! + values: CHECK( 1.50578 < 1e-05 ) + logged: site layer0.attn_hc + ... identically at site layer0.mlp_hc and site model.mixer +[doctest] test cases: 1 | 0 passed | 1 failed | 0 skipped +[doctest] assertions: 409 | 406 passed | 3 failed | +``` + +1.50578 against a 1e-5 bound is not a tolerance question. `w_hf` is in [0, 1) on +this fixture and `1 + w_hf` in [1, 2), so the two parameterizations are a whole +multiplicative unit apart; on the RELEASED checkpoint `w_hf` sits within an ulp +or two of zero and the wrong one produces a stream scaled by ~0. + +### Counts, before and after, on the same tree + +The base was measured by checking `HEAD~1`'s copies of the four changed files +into this worktree, rebuilding (rc 0) and running, then restoring — not by +quoting the numbers a previous wave recorded. + +| Suite | Before | After | +|---|---|---| +| `test_qwen4_exp_forward` | did not exist | 1 / 421 / rc 0 | +| `test_qwen4_exp_hc_device` | 9 / 87 / rc 0 | 9 / 87 / rc 0 | +| `test_qwen4_exp_hc` | 15 / 246 / rc 0 | 15 / 246 / rc 0 | +| `test_qwen4_exp_gguf_weights` | 11 / 2975 / rc 0 | 11 / 2975 / rc 0 | +| `test_qwen4_exp_ple_device` | 10 / 538 / rc 0 | 10 / 538 / rc 0 | +| `test_qwen4_exp_qsa_device` | 12 / 4697 / rc 0 | 12 / 4697 / rc 0 | + +The op's numeric contract changed and **every existing count is identical**, +which is the check that the change is a re-parameterization and not a new +answer: the goldens store `w_hf` either way, the fold simply moved from the test +harness into the kernel. The fixture extraction is likewise count-neutral on the +loader suite, 11 / 2975 before and after. + +### The battery + +| # | Mutation | Build | Result | +|---|---|---|---| +| M-P1 | the kernel drops the `1 +`, i.e. the pre-#2218 contract restored | rc 0 | `test_qwen4_exp_forward` RED 1/1, at all three hyper-connection sites; `test_qwen4_exp_hc_device` RED 4/9, 12 of 87 assertions. The op half is gated | +| M-P2 | `LoadGatedResidual` stops unshifting, `unshift=false` | rc 0 | `test_qwen4_exp_forward` RED 1/1 **at its precondition**, after 6 assertions — the `model_gamma + 1 == file_gamma` `REQUIRE` fires before any arithmetic runs; `test_qwen4_exp_gguf_weights` RED 1/11, 25 assertions. The LOADER half is gated, so a future edit that moves the fold back into `load_weights` cannot land silently | + +M-P2 is the half that matters. A case that only reddened on M-P1 would gate the +op against a number the test chose; reddening on both is what makes it a gate on +the SEAM. + +### What the battery did NOT reach + +Stated because a battery's silence is not a result. + +- **The injection arm does not discriminate polarity at this fixture and the + case says so out loud.** `2 * sigmoid(inject . normed / hc)` runs the + fixture's `inject` ramp against a 128-wide normed row and reaches ~10^4 under + BOTH gammas, so the sigmoid saturates at 2.0 either way. The case asserts the + saturation explicitly, so the day it stops being saturated is loud rather than + silent, and `mixed` carries the whole discriminating claim. +- **Nothing here decodes.** The composition gated is loader -> one op. The layer + loop that would put 97 of these calls in sequence does not exist, so no token, + no `hyper` stream and no `lm_head` is involved. +- **No CUDA arm was measured** because none exists; the op is CPU-only and the + spec carries that under `## Owed`. + +### The fresh review's findings, and what each one cost + +The review returned `PASS` on the change: the mutations reproduce and all eight +pre-existing suites are count-identical. Six of its eight findings were prose, +records, a dead build define and a merge. The other two changed something +measured — one a published claim, one a dtype — and both are recorded here, +because a reader of this section would otherwise take the earlier text at face +value. + +**The "first suite in this tree" claim was FALSE unscoped.** `## Now` said this +was the first suite here to load a gamma through `ModelRegistry::Load` and run it +through a device op in one case. `tests/vllm/models/test_nemotron_h_paged_forward.cpp` +and `tests/vllm/models/test_kimi_linear_paged.cpp` already do both inside a +`TEST_CASE`. Scoped to `qwen4_exp` the claim holds, and the argument it supports +— eleven single-sided waves of THIS row could not see the contradiction — +survives unchanged. Corrected in `## Now`, in `## Owed` and on #2218 itself. + +**The four-gamma attribution was wrong about one CONSUMER.** The `## Owed` entry +said `RunQwen4ExpQsaBlock` normalizes all four QSA gammas through +`vt::RmsNorm(gemma = true)` and then cited three line pairs. The count exposed +it: `idx_k_norm` never reaches `vt::RmsNorm`. It goes to `Qwen4ExpQsaIndex` +(`qwen4_exp_qsa_block.cpp:401-403`) and is consumed by `vt::Qwen4ExpQsaCompress` +(`:181`), which adds the 1 itself. Same polarity, different op, so "three of the +four consumers already add the 1" stands with the consumer named correctly. + +**THE FOLD'S DTYPE HAD DRIFTED, AND THE BAND WAS ABSORBING IT.** Before this +wave, the wide-accumulator case handed one identical `float` multiplier to both +arms. After it, the kernel folded `1.0f + w` in f32 while the double reference +folded `1.0 + (double)w`, so the two arms no longer described the same multiplier +and the case's own comment — "the only thing this widens is the reduction" — +stopped being true. Nothing failed, which is the point. Measured on exactly the +data in the case, by forcing the bound to `1e-30` and reading the logged `worst`: + +| Reference's fold | Worst absolute deviation, `mixed` vs reference | +|---|---| +| `1.0f + w_hf`, widened AFTER (f32, as landed here) | 1.17323e-06 | +| `1.0 + (double)w_hf` (the drifted form) | 9.8457e-07 | + +Both sit far inside the band — the bound is `1e-5` and the `float ss` mutant +reads 6.702e-4 — so no tolerance was ever at risk. What was at risk is the +meaning of the number: **1.173e-06 is the figure this file and the W5b-2 table +record as "ours, double accumulator", and the drifted form no longer reproduced +it.** The f32 fold is also what upstream does — +`output * (1.0 + self.weight.float())` (`modeling_qwen4_exp.py:177`) folds a weak +Python `1.0` into an fp32 tensor and the promotion stays fp32 — so mirroring +upstream and restoring the recorded measurement are the same edit. AGENTS.md +"Inherit vLLM defaults" decides it either way: f32 is the default and the wider +value would have been the annotated exception, unannotated. + ## Stop conditions - vLLM registers `qwen4_exp`: **stop and reconcile onto vLLM** before continuing. @@ -2845,6 +2970,105 @@ is listed under `## Owed`. reorder when num_v != num_k" reaches R = 2, and that is the self-inverse K == R. Both roads end at the same place, but a reader chasing "K == R" through the default fixture would not find it. +- **W5b-6 (#2218) RESOLVES THE GAMMA POLARITY, AND IT RESOLVES IT THE OTHER WAY + ROUND FROM WHAT THAT ISSUE PROPOSED.** #2218 asked the layer loop to fold + `hc_norm`, `norm_key`, `norm_query` and `norm_conv` through + `vllm::qwen4_exp::HcNormWeightFromHf` before use. Folding the last three would + have been the same defect moved one tensor to the left: their consumers + already add the 1. Measured in this tree rather than argued — + `RunQwen4ExpQsaBlock` normalizes THREE of its four QSA gammas — + `idx_q_norm`, `q_norm` and `k_norm` — through `vt::RmsNorm(..., gemma = true)`, + which is `out * (1 + w)` (`qwen4_exp_qsa_block.cpp:383-384`, `:425-426`, + `:441-442`, three line pairs for three gammas). **The fourth, `idx_k_norm`, + never reaches `vt::RmsNorm` at all**: it is handed to `Qwen4ExpQsaIndex` + (`:401-403`) and consumed inside it by `vt::Qwen4ExpQsaCompress` (`:181`), + which documents `k_norm_weight` as "the HuggingFace gamma, applied as + `(1.0 + weight)` ... NOT vLLM's `out * weight`". The polarity is the same + either way, which is why the conclusion below is unaffected, but the CONSUMER + is a different op and this entry said `vt::RmsNorm` for all four until the + W5b-6 review counted the citations against the claim. The PLE host reference + spells `(1.0 + static_cast(weight[base + i]))` inline at + `qwen4_exp_ple.cpp:72`. **Three of the four consumers were already on the + loader's convention and only `vt::Qwen4ExpGatedResidual` was not**, so the op + moved rather than the loader. The rule is now one line: every gamma in + `Qwen4ExpWeights` is the RAW HuggingFace parameter and every consumer adds the + 1, `linear_attn.norm.weight` excepted because the converter never folds it and + `vt::RmsNormGated` wants a plain multiplier. That is also upstream verbatim, + `Qwen4ExpTextRMSNorm.forward` = `output * (1.0 + self.weight.float())` over a + zero-initialised parameter (`modeling_qwen4_exp.py:173-178`). + `HcNormWeightFromHf` survives as the bridge to the W3 HOST reference, whose + `GroupedRmsNorm` keeps vLLM's `out * w` form, and it is now called from the + two suites that drive that reference and from no production path. +- **W5b-6 (#2218) LANDS UNREACHED, by AGENTS.md "Nothing lands dead".** + `vt::Qwen4ExpGatedResidual` and `vt::Qwen4ExpGatedResidualWriteBack` + (`include/vt/ops.h`, dispatchers `src/vt/ops.cpp`, CPU kernels + `src/vt/cpu/cpu_qwen4_exp.cpp`) are the ops whose gamma contract this wave + changed, and at its merge commit nothing calls either from a production entry + point. Their only call sites are `tests/vllm/models/test_qwen4_exp_hc_device.cpp` + and the new `tests/vllm/models/test_qwen4_exp_forward.cpp`. That second suite + reaches the PRODUCTION LOADER — `ModelRegistry::Load` over a `qwen4exp` file — + and it is what makes the fix gateable at all, but a test driving a production + loader is still a test: it is not a production entry point, and reaching the + loader does not reach the op. `Qwen4ExpTextModel::Forward` does not exist and + `ForwardQwen4ExpForConditionalGeneration` + (`src/vllm/model_executor/models/qwen4_exp_registry.cpp`) still refuses by name + before any downcast, so the op stays unreached for exactly the reason W5b-2 + (#2123) recorded when it landed the op in the first place. The wiring is owed by + **W5b, the layer loop**, under + [#2031](https://github.com/mudler/vllm.cpp/issues/2031), owned by row + `MODEL-MM-QWEN4-EXP` and tracked by campaign + [#1978](https://github.com/mudler/vllm.cpp/issues/1978); the five measured + prerequisites that wave must clear first are the entry below this one. +- **THE LAYER LOOP'S PREMISE — "every component it composes is already on + `main`" — IS FALSE, AND HERE ARE THE FIVE THINGS THAT ARE NOT.** Surveyed + against this tree while W5b-6 was in flight, each independently sufficient to + stop a token, and each now named in the `ForwardQwen4ExpForConditionalGeneration` + refusal so the next reader does not re-derive it: + 1. **There is no standalone grouped RMS norm.** `Qwen4ExpTextPLELayer` holds + three `Qwen4ExpTextRMSNorm(hc_hidden_size, group_size=hidden_size)` — + `norm_key`, `norm_query`, `norm_conv` — reducing over `hc` independent + `hidden_size` slices of the 10240-wide stream. `include/vt/ops.h:556` + states the gap in its own words: "`kRmsNormGated` has no group_size; + `kRmsNormGatedGroup` requires a SILU gate". `vt::RmsNorm` reduces the + whole last dim and takes a `[D]` gamma, and the PLE gamma is `[hc*H]`, so + it cannot express this even per-branch. The only grouped reduction in the + tree is FUSED inside `vt::Qwen4ExpGatedResidual` and is not exposed. + **This is new op work, and it is the same "why a fused family op" argument + W5b-2 made, arriving at the opposite answer because PLE needs the norm + without the mix.** + 2. **The QSA consumer is CONTIGUOUS and the published cache is PAGED.** + `Qwen4ExpQsaCaches` is `key`/`value` `[max_kv, num_kv_heads, head_dim]` + and `index_key` `[max_kv, indexer_head_dim]` + (`qwen4_exp_qsa_block.h`), while `MakeQwen4ExpKVCache` publishes a + `FullAttentionSpec` and an `MLAAttentionSpec` the runner allocates as + paged `CacheBuffer`s. Bridging them is a paged arm of + `RunQwen4ExpQsaBlock`, not a cast. + 3. **Group 2 is allocated and unread**, already carried above as W5c-2: + `gather_block_table` has three call sites and reaches exactly + `full_attn_group_id_` and `gdn_group_id_`. + 4. **The MoE weights need an adapter.** `Qwen4ExpMoeWeights` holds stacked + `gate_exps`/`up_exps` `[E, moe_I, H]` and `down_exps` `[E, H, moe_I]`; + `RunMoeBlock` reads `MoeBlockWeights`, whose arms are per-expert + `[H, I]` vectors, an `Nvfp4Weight` set, or the stacked keep-quant + `expert_gate_kq [E*I, H]` / `expert_down_kq [E*H, I]`. The third arm's + shapes are exactly the qwen4_exp ones and `KqExpertSlice` is dtype-generic + (`RowSizeBytes(w.dtype, K)`), so the adapter looks like a + reinterpretation plus a router-gate orientation and a shared-expert + mapping rather than a copy — but it is unwritten and unmeasured, and + nothing yet proves a bf16 tower routes through `ExpertMlpKq`. + 5. **The mRoPE table builder has internal linkage.** + `BuildMropeCosSinHost` is `static` at `qwen3_5.cpp:9472`, and + `RunQwen4ExpQsaBlock` needs BOTH layouts derived from it: the packed + bf16 `[P, rot]` `cos|sin` cache `vt::RopeFromCache` reads and the two + separate f32 `[P, rot]` tables `vt::Qwen4ExpQsaCompress` reads, with + `CheckRopeLayoutsAgree` verifying they describe the same angles. + **And one more that is not this row's:** `ModelRegistry::Forward` refuses ANY + non-null `multi_kv` by name (`model_registry.cpp:428-440`), and this model's + three published groups make the runner set it + (`runner.cpp:787-804`, `:2283`). A forward reached through + `ModelRegistry::Forward` with a hand-built positional cache set is gateable + today; a forward reached through `GPUModelRunner` is not, and lifting that + refusal is an engine seam change DeepSeek-V4 waits on too. ## Now @@ -2896,21 +3120,60 @@ is still no token number, no speed number, no `examples/server` e2e and no arm SERVE, which is W5b, not W5a. W2, W3 and W4 remain host reference math with no production call site. -**What is owed, in order. THE OP AND SEAM WORK IS FINISHED; WHAT IS LEFT IS THE -LAYER LOOP.** W5b needed five slices and four of them are ops or seams: +**W5b-6 ([#2218](https://github.com/mudler/vllm.cpp/issues/2218)) closes the +gamma polarity and it does NOT decode.** `vt::Qwen4ExpGatedResidual` now takes +the RAW HuggingFace gamma and adds the 1 itself, which is the convention the +other three consumers of this architecture's gammas already had, so the layer +loop can hand it `Qwen4ExpWeights` directly instead of scaling the +hyper-connection stream by ~0. The gate is +`tests/vllm/models/test_qwen4_exp_forward.cpp`, the first **`qwen4_exp`** suite +that LOADS a gamma through `ModelRegistry::Load` and runs it through a device op +in one case — which is why eleven single-sided waves of THIS row could not see +it. **THE UNSCOPED FORM OF THAT SENTENCE WAS FALSE AND IS CORRECTED HERE.** It +claimed the first such suite in the tree; it is not. +`tests/vllm/models/test_nemotron_h_paged_forward.cpp` and +`tests/vllm/models/test_kimi_linear_paged.cpp` each call `ModelRegistry::Load` +inside a `TEST_CASE` and drive the loaded weights, gammas included, through the +device ops of a forward. The claim that survives is the narrow one, and it is +the one the argument needed: no `qwen4_exp` suite had ever composed the loader +with an op, so the contradiction between them was unreachable here. The synthetic +`qwen4exp` file moved to `tests/support/qwen4_exp_gguf_fixture.h` so the loader +suite and the forward suite share ONE builder. + +**AND IT CORRECTS THIS SECTION'S OWN CLAIM.** The paragraph below used to say +"THE OP AND SEAM WORK IS FINISHED; WHAT IS LEFT IS THE LAYER LOOP." That is not +true. Five things the loop composes are absent from `main` — a standalone +grouped RMS norm for PLE's three norms, a PAGED QSA consumer, the group-2 block +table (W5c-2), a MoE weight adapter, and an externally linked mRoPE builder — +and `ModelRegistry::Forward` additionally refuses every multi-cache topology by +name, which is what this model publishes. Each is measured and cited under +`## Owed`, and the production refusal in `qwen4_exp_registry.cpp` now names them +instead of naming W2, W3 and W4, which landed. **A wave dispatched to "write the +layer loop" will not decode a token; it has five prerequisites, at least two of +which (the grouped norm, the paged QSA arm) are op-sized waves of their own.** + +**What is owed, in order. THIS PARAGRAPH'S OPENING CLAIM WAS WRONG AND IS +CORRECTED ABOVE: the op and seam work is NOT finished.** What follows is still +the right list of what W5b-1..5 landed; what it got wrong is the inference that +nothing else was missing. W5b needed five slices and four of them are ops or +seams: `RunGdnBlockPaged` for the 36 linear layers (W5b-1), the two gated-residual ops for the 10240-wide stream (W5b-2), `vt::Qwen4ExpPleConv` (W5b-3) and the two QSA ops (W5b-4). W5b-5 turned the last of those into a decoder-layer BLOCK — `RunQwen4ExpQsaBlock`, the first production composition of the QSA indexer — so -**nothing this architecture needs is missing from the `vt::` surface any more.** +nothing the QSA indexer needs is missing from the `vt::` surface any more — +though the PLE block's grouped RMS norm still is, which the sentence this +replaces overstated into a claim about the whole architecture. What has no production shape yet is the PLE block, the GDN and MoE weight adapters onto `GdnLayerWeights` / `MoeBlockWeights`, the hyper-connection stream through the per-layer loop, the mRoPE cos/sin table build, and the loop itself. -One trap is recorded under `## Owed` for whoever writes it and is worth repeating -here because it is silent: the loader stores every gamma in the RAW HuggingFace -parameterization, `vt::RmsNorm` wants that under `gemma = true`, and -`vt::Qwen4ExpGatedResidual` wants the OPPOSITE — a layer loop that hands it the -raw gamma applies a near-zero scale and reads as a checkpoint bug. The +The trap this paragraph used to warn about is FIXED, not pending: the loader +stores every gamma in the RAW HuggingFace parameterization and +`vt::Qwen4ExpGatedResidual` used to want the opposite, so a layer loop handing it +the loaded tensor applied a near-zero scale that reads as a checkpoint bug. W5b-6 +([#2218](https://github.com/mudler/vllm.cpp/issues/2218)) moved the op onto the +loader's convention and gated the composition; a layer loop may now hand it +`Qwen4ExpWeights` directly. The mixer/lm_head tail is not owed: the terminal `use_combine=false` mixer IS `vt::Qwen4ExpGatedResidual` with a null `block_inject`, gated as its own case in `test_qwen4_exp_hc_device.cpp`, and diff --git a/.agents/specs/rocm-fp8-kv-decode-attn.md b/.agents/specs/rocm-fp8-kv-decode-attn.md new file mode 100644 index 0000000000..5bb3e9795e --- /dev/null +++ b/.agents/specs/rocm-fp8-kv-decode-attn.md @@ -0,0 +1,172 @@ +# ROCm fp8 KV cache decode attention (`GFX1100-TG200`, fork issue #7) + +Rows: `GFX1100-TG200` (campaign, fork issue #5) and `KV-FP8` (engine-matrix, +the W6 ROCm arm). Issue: fork +[#7](https://github.com/ghazni101/vllm.cpp/issues/7). The fp8 KV cache store +and correctness-grade read landed in W6 +([`fp8-kv-cache.md`](fp8-kv-cache.md) `## W6`); this spec covers the +performance gap the W6 spec named as owed: the fp8 read through the fast +decode kernel. + +## Scope + +- **In:** widen the `PagedAttnDecodeGqaF32Q` dispatch guard in + `src/vt/rocm/rocm_paged_attn.hip` to accept `DType::kI8` KV cache when + `args.kv_cache_dtype != kAuto`; add an fp8 dequant load path inside the + kernel; pass `k_scale`/`v_scale` to the kernel; add a `LoadRowEplFp8` + device helper that does vectorized uint8_t loads + `F8E4M3ToF32Dev` dequant + with scale. +- **Out:** the bf16 decode-opt kernels (`PagedAttnDecodeGqaBf16`, + `PagedAttnDecodeOptBf16T`) — those stage `__hip_bfloat16` fragments and a + tensor-core fp8 read is a separate performance brick, same scope line as + the CUDA W2 arm. The prefill path stays on `PagedAttnOnline` for fp8. The + `bf16_decode_opt` guard at line 1925 is not touched. fp8_e5m2 compute. + Per-head scales. Non-gfx1100 architectures. + +## Upstream chain + +vLLM's fp8 KV cache read dequantizes inside the attention kernel: +`scaled_vec_conversion` (`quant_utils.cuh:419-429`) = +`half_to_float(fp8_to_half(byte)) * scale`. The ROCm `LoadKv(uint8_t*, ...)` +helper at `rocm_paged_attn.hip:176` already mirrors this arithmetic exactly: +`F8E4M3ToF32Dev(p[i]) * scale`. The CUDA arm's `LoadKv` at +`cuda_paged_attn.cu:175-185` is the same. The dequant is not new code; it is +existing code that the fast kernel does not call. + +## Our baseline + +The `PagedAttnDecodeGqaF32Q` kernel (`rocm_paged_attn.hip:674`) is the +f32-query + bf16-KV decode kernel activated by `VT_ATTN_DECODE_GQA4=1`. It +fuses QG=4 query heads per KV group, walks the KV sequence warp-strided with +online softmax, and uses vectorized 128-bit `uint4` bf16 loads +(`LoadRowEplBf16`, line 342). The dispatch guard at line 2186-2189 requires +`k_cache.dtype == DType::kBF16 && v_cache.dtype == DType::kBF16`. + +With `--kv-cache-dtype fp8`, the KV cache is `DType::kI8`. The guard fails, +and the dispatch falls through to `PagedAttnOnline` (line 2223) — the +reference kernel that processes one key at a time with a full-block +`__syncthreads()` reduction per key (line 290-294). The code acknowledges +this at line 2231-2235. + +## Measured gap + +A/B benchmark on `kind_tharp` (Qwen3.5-4B Q4_K_M, RX 7900 XTX, ROCm 7.14.0, +128-token greedy decode, single request, 4 reps, 2026-08-27): + +| Context | fp8 KV tok/s | bf16 KV tok/s | Speedup | +|--------:|-------------:|--------------:|--------:| +| 256 | 99.94 | 143.15 | 1.43x | +| 1024 | 56.28 | 129.02 | 2.29x | +| 4096 | 20.53 | 92.08 | 4.49x | +| 8192 | 11.08 | 66.85 | 6.03x | +| 16384 | 5.78 | 43.16 | 7.47x | + +The gap widens with context because `PagedAttnOnline` is O(n) per key with +full-block sync, while `PagedAttnDecodeGqaF32Q` is warp-strided with online +softmax and no per-key sync. Qwen3.5-4B has 8 full-attention layers +(`full_attention_interval=4`, 32 total); the O(n) cost is paid on those 8 +layers x 4 KV heads x 256 head_dim. + +## Design + +### 1. `LoadRowEplFp8` device helper + +Add a new `LoadRowEplFp8` function alongside `LoadRowEplBf16` (after +line 367). For fp8, each element is 1 byte. The vectorized load width +matches the bf16 path's register pressure: + +- EPL=4: 4 bytes per lane = one `uint32_t` load +- EPL=8: 8 bytes per lane = one `uint2` load (64 bits) +- EPL=16: 16 bytes per lane = one `uint4` load (128 bits) + +After the vectorized load, dequantize each byte with +`F8E4M3ToF32Dev(byte) * scale` into the float register array. The scale is +passed as a parameter. + +### 2. Template `PagedAttnDecodeGqaF32Q` on `TKV` + +Change the kernel signature from hardcoded `const __hip_bfloat16* k_cache` +to `template ` with `const TKV* k_cache, const TKV* v_cache`. +Add `float k_scale, float v_scale` parameters. Inside the kernel, replace +the two `LoadRowEplBf16(k_cache, ...)` / `LoadRowEplBf16(v_cache, ...)` +calls with a `LoadRowEplKv(k_cache, ..., k_scale)` dispatch that +selects `LoadRowEplBf16` for `__hip_bfloat16` and `LoadRowEplFp8` for +`uint8_t` via `if constexpr`. + +### 3. Widen the dispatch guard + +At line 2186-2189, widen the condition from: +``` +k_cache.dtype == DType::kBF16 && v_cache.dtype == DType::kBF16 +``` +to: +``` +(k_cache.dtype == DType::kBF16 && v_cache.dtype == DType::kBF16) || +(k_cache.dtype == DType::kI8 && v_cache.dtype == DType::kI8 && + args.kv_cache_dtype != Fp8KVCacheDataType::kAuto) +``` + +When the KV is fp8, launch with `k_cache.Ptr()`, +`v_cache.Ptr()`, and pass `args.k_scale`/`args.v_scale`. The +`PagedAttnDecodeGqaF32Q` template instantiation `PagedAttnDecodeGqaF32Q` is the new instantiation; the existing +`PagedAttnDecodeGqaF32Q` is the unchanged +bf16 path. + +### 4. No new test file + +The correctness gate is the existing `test_ops_fp8_kv_cache` suite (W1, +CPU oracle) plus the served-model token-exact gate on the `kind_tharp` +container. The fp8 dequant arithmetic is already gated bit-identical against +the CPU codec; the new code path only changes which kernel reads the same +dequantized values. A red-first mutation: revert the guard widening and +confirm the dispatch falls back to `PagedAttnOnline`. + +## Risks + +- **Reduction order difference:** `PagedAttnDecodeGqaF32Q` uses warp-strided + online softmax, which reduces the KV sequence in a different order than + `PagedAttnOnline`'s per-key loop. Greedy decode tokens can move at exact + ties, same as the d128 decode-opt flip (line 1912-1921). The + `VT_ATTN_DECODE_GQA4=1` flag is already opt-in and already carries this + risk for bf16 KV; the fp8 arm inherits it. +- **Vectorized fp8 load alignment:** the uint8_t KV cache pages must be + 4-byte aligned for `uint32_t` loads and 8-byte aligned for `uint2` loads. + The KV cache block allocation uses `hipMalloc` with block_size * + num_kv_heads * head_dim bytes per block; for head_dim=256 and block_size=16, + that is 16*4*256 = 16384 bytes per block, which is naturally aligned. The + bf16 path already assumes `kc_hd % 8 == 0` (line 1925); the fp8 path needs + `kc_hd % 4 == 0` for the uint32_t load, which holds for head_dim=128 and + 256 (both are multiples of 4). +- **Register pressure:** the fp8 load path uses the same `float k_reg[kEpl]` + registers as the bf16 path. The dequant happens in registers; no shared + memory change. The smem allocation is unchanged. + +## Gates + +- **Correctness (CPU oracle):** `test_ops_fp8_kv_cache` GREEN — the W1 + suite already gates the fp8 dequant arithmetic; this change does not touch + the CPU path. +- **Correctness (served model, token-exact):** run `kind_tharp` with fp8 KV + + `VT_ATTN_DECODE_GQA4=1` and compare greedy decode output against the + bf16 KV baseline at short context (256 tokens). Tokens must match; at + longer context, the reduction-order risk applies and is recorded. +- **Performance (A/B):** re-run `/tmp/bench_context_scale.py` with the + optimized fp8 path and compare against the bf16 baseline. The target is + fp8 KV decode throughput within 2x of bf16 KV at 16K context (vs the + current 7.47x gap). fp8 should be faster than bf16 at long context due to + halved KV bandwidth. +- **Red-first:** revert the guard widening, confirm the dispatch falls back + to `PagedAttnOnline`, confirm the benchmark shows the original regression. + +## Git integration + +- Separate spec and implementation PRs (developer preference, recorded + 2026-08-27). +- Branch: `row/GFX1100-TG200` (existing campaign branch). +- Push to `origin` (fork `ghazni101/vllm.cpp`) only. +- Spec commit first, then implementation commits. + +## Now + +Spec committed, implementation pending. diff --git a/.agents/specs/tenstorrent-qwen35.md b/.agents/specs/tenstorrent-qwen35.md index b226334027..c539a13d56 100644 --- a/.agents/specs/tenstorrent-qwen35.md +++ b/.agents/specs/tenstorrent-qwen35.md @@ -21,13 +21,23 @@ tok/s (+70%), `Numel()` 27.09% → 1.76%, review PASS; lever 3 (batch per-layer staging) NOT taken, the residual attributed to per-upload tt-metal-internal work (`#2107`) — are all landed; see `## Evidence`. The #1486 teardown fix and the #2115 opt-out-arm golden pair (each arm -gates its own captured pair; both legs doctest 146/146) landed after it. -The `docs/USAGE.md` weights entry is complete (file, bytes, repo @ -revision, sha256, refused arms). Owed next: **W3 leftovers** (d2h -counter completeness, `conv_transposed` fast-path check, tests for -both), then the W4 record's named next lever: a per-slot persistent -device buffer written through the mesh command queue, which needs the -tt-metal-internal half of W4's lever 2. +gates its own captured pair; both legs doctest 146/146) landed after it, +as did the **W3 leftovers** (the two missing d2h `fetch_add`s and the +scoped `conv_transposed` refusal; #2201 via #2217, `a456e6eaf`), with +the suite at 44 cases / 4340 assertions. The `docs/USAGE.md` weights +entry is complete (file, bytes, repo @ revision, sha256, refused arms). +**W5** (the per-slot persistent buffer written through the mesh command +queue, [#2244](https://github.com/mudler/vllm.cpp/issues/2244)) landed +2026-08-29: allocation-free uploads proven (residual allocation 0.02% of +the profile; suite 45 cases / 5062 assertions; sacred pair byte-identical) +and the wall HONESTLY UNMOVED — the A/B trace split the W4 hypothesis: +per-upload allocation was never the wall; the wall is the per-CQ-operation +tt-metal stack (context queries, `Cluster::get_chip`, `read_cq_host_ptr` +polling) plus threadpool spin. Owed next: **W6 — lever 3, batch per-layer +staging** (one CQ write per step divides the per-op tax by the fan-in; +inside our file set), with the tt-metal-side residual (cached context +handles, amortized CQ polling) recorded as the upstream-shaped +alternative. ## Scope @@ -239,6 +249,30 @@ column above is the entry point, not the whole chain. benchmark-record entry. A wall that does not move is a reported result, not a failure — the attribution either shifts or the lever is named unreachable with the trace that proves it. +- **W5 — allocation-free staging: the per-slot persistent buffer written + through the mesh command queue (#2244).** W4's re-attribution left ~23% + of the staging chain inside tt-metal per-upload internal work (a fresh + `MeshBuffer` allocation, cluster/chip discovery, CQ completion handling) + and ~19.2% in the CPU threadpool. `UploadRowsBf16` + (`tenstorrent_ops.cpp:469`) builds a new `ttnn::Tensor` via `from_span` + on every upload, so identical geometry pays the creation path every + step. Allocate the device buffer once per staging slot — lifecycle tied + to the slot structures, under the #1486 never-destroy rule for static + caches — and write the host bytes through + `MeshCommandQueue::enqueue_write`/`enqueue_write_shard`. The + tt-metal-internal half is a proof obligation: against the pinned + tt-metal's mesh write path (`mesh_command_queue.cpp`: + enqueue_write_shard → per-device buffer write + completion), read the + source and trace the executed path, and dump it before declaring any + part of the lever unreachable. `StagingStats` gains route counters for + the new path. The capture-unsafe host-write refusals keep their + semantics; the f32-conversion arms keep their declared dtypes. + Invariant: staging is bit-identical — the sacred golden pair stays 16/16 + and the full TT suite stays green; this wave changes SPEED, never + tokens. Evidence owed: same-method before/after profile on the P150 + (identical leg, lock discipline) plus a fresh benchmark-record entry; a + wall that does not move is a reported result — the attribution shifts + or the lever is named unreachable with the trace that proves it. Each wave lands focused-green before the next; the full gate + fresh review close the row. @@ -282,9 +316,9 @@ the row. ## Git integration One pull request for spec and implementation (row claim answer 2026-08-23, recorded -in `.agents/developer-preferences.md`). Base `origin/main` @ `3fe34e2c6` (bumped -2026-08-28; W4 #2118 and the #2115 opt-out-arm pair landed since the previous -`8f5d4e4ed`). Branch `row/BACKEND-TENSTORRENT-QWEN35`, worktree +in `.agents/developer-preferences.md`). Base `origin/main` @ `a456e6eaf` (bumped +2026-08-29; W3 #2201 via #2217 landed since the previous `3fe34e2c6`). Branch +`row/BACKEND-TENSTORRENT-QWEN35`, worktree `/home/lu_zero/Sources/vllmcpp-tt-qwen35`. ## Evidence @@ -296,6 +330,24 @@ summary was the known #1486 teardown; fixed 2026-08-27 by never destroying the static tensor caches — expect exit 0. Evidence entries below that predate the fix quote 139 as green. +### W5 — allocation-free staging: the wall did not move, and the trace says why + +`dc473a94c` (#2244): per-slot persistent device buffer, in-place CQ writes +via `ttnn::copy_to_device`; red-first implementer, fresh reviewer PASS (8 +mutations incl. reachability; full gate rerun on the immutable head). +Same-method interleaved A/B on one lock hold (identical 3-token leg, +JIT-discard per arm, `perf record -F 199 -g` per measured leg): +19.154 s vs 19.181 s mean — −0.14%, noise. The trace splits the W4 +hypothesis: `allocate_mesh_tensor_on_device_with_topology` is 0.02% of the +AFTER profile and the write stacks are identical in both arms, so the +per-upload allocation was never the wall; the wall is the per-CQ-operation +tt-metal stack (`MetalContext::instance` 11.14%, `Cluster::get_chip` 5.90%, +`read_cq_host_ptr` 5.27%+ sub-slices) plus `Threadpool::PollForWork` +14.29%. Reported result, not a failure — the next lever is W6 (batch +per-layer staging, our file set); the tt-metal-side residual is recorded +beside it. Full log: +[tt-qwen35-eager-profile-w5-20260829.log](../../docs/bench-evidence/tt-qwen35-eager-profile-w5-20260829.log). + ### W0 — refusal sweep (runs 1-8, `/tmp/w0_sweep_run{1..8}.log`) Scratch-wired the arch allow-list (reverted before commit; `src/vllm/platforms/ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 34f4897050..62cd5ec11a 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -338,6 +338,7 @@ on CUDA/CPU builds beyond the documented behavior. | `VT_GEMMA4_LAYER_TRACE` | off | `=1` layer GPU-synced phase timers; `=2` per-layer heartbeats | | `VLLM_CPP_HTTP_FIXED_POOL` | `1` (fixed) | `=0` reverts the HTTP worker pool to the legacy dynamic mode. Production uses the capacity-derived fixed pool; the opt-out exists for same-binary A/B attribution | | `VT_ROCM_ATTN_CPU_REF` | unset | `=1` routes ROCm paged attention through the CPU reference kernel instead of the HIP kernel — a correctness A/B for the ROCm attention bring-up | +| `VT_ATTN_DECODE_GQA4` | off | `=1` routes f32-query decode through `PagedAttnDecodeGqaF32Q` (QG=4 fused q-heads per KV group, warp-strided walk) for bf16 or fp8-e4m3 KV at d=128/256, hq=16, kv=4; the default falls to the `PagedAttnOnline` reference. The arm's reduction order differs from the reference's, so greedy anchors can move at exact ties — opt-in until near-tie adjudication lands | | `VT_DEBUG_SAMPLED` | unset | `=1` prints the per-step sampled token id(s) to stderr (sampling-loop debug). Read-only; does not change output. Read once per token, so it does not stall the hot loop | ## Kernel-internal knobs (deferred) diff --git a/docs/USAGE.md b/docs/USAGE.md index 20a1059cb8..f0fb662f01 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -592,13 +592,13 @@ repository in this project's history. | MiniMax-H3 tokenizer | `FL2VA/tokenizer/tokenizer.json` | 7,032,403 bytes | `MiniMaxAI/MiniMax-H3` @ `42ed227ee7df40d41602854ae760620d6eb651fe` | n/a (non-quantized) | Official tokenizer for the five-file recipe | No separate arm is recorded | | MiniMax-Music3 | Diffusers checkpoint tree | about 28.5 GB resident | `MiniMaxAI/MiniMax-Music3` @ `fbdf52fbaaca799592917417eb05f1899f1255ec` | n/a (non-quantized) | bf16 language model, depth decoder, condition encoder; fp32 transformer and vocoder | Native `.pth` layout | | MiniMax-Music3 depth decoder | `rvq_depth_decoder_q4_k.gguf` | 405,752,480 bytes | `audio-cpp/MiniMax-Music3-GGUF` @ `c36aaeed683f33b05796788e4204f4eeba8fa547` | `4c5d41b27418d9c1046345f649cb61d7cde0e3bbda4af7f7cb142df2c70cbdd0` | GGUF Q4_K depth decoder | Other GGUF components and third-party lineages | -| LTX-2.5 full DiT | `diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors` | 42,018,190,584 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` | `792a2bad501ca03262c0bc2ce7a2949e85b142ce18e30894aad5bc849c8e7584` (non-quantized; hashed anyway, see the note above this table — derived 2026-08-27 from the bytes the upstream oracle render loaded) | Full bf16 DiT; declare `--checkpoint-class full` | A mismatched or missing required class is refused | +| LTX-2.5 full DiT | `diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors` | 42,018,190,584 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` | `792a2bad501ca03262c0bc2ce7a2949e85b142ce18e30894aad5bc849c8e7584` (non-quantized; hashed anyway, see the note above this table — derived 2026-08-27 from the bytes the upstream oracle render loaded) | Full bf16 DiT; declare `--checkpoint-class full`. GATED: this is the DiT #1854's absolute comparison rendered on (`rc` job `4b0666ee`, `PASS`), and the sha256 beside it was recomputed inside that lease from the locally staged copy as well as from the oracle's own bytes | A mismatched or missing required class is refused | | LTX-2.5 distilled DiT | `diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors` | 42,018,190,584 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` | n/a (non-quantized) | Distilled bf16 DiT; declare `--checkpoint-class distilled` | A mismatched or missing required class is refused | | LTX-2.5 distilled NVFP4 DiT | `diffusion_models/ltx-2.5-22b-distilled-transformer-nvfp4.safetensors` | 18,721,432,024 bytes | `Lightricks/LTX-2.5` @ `8a4ff96f581e72bedc1b44367581c49d544a05f1` | `f9c4c2ae9a6aa8f732eb02a1c4c3b34888caad3dd35bb65deaf3b5043cda78fa` | Distilled NVFP4 DiT, 7876 tensors | The same path at `6c7e5e57...` is a different artefact, and the next section gives both value sets | | LTX-2.5 distilled LoRA | `loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors` | 8,899,889,568 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` | n/a (non-quantized) | REQUIRED by every non-distilled two-stage recipe — `ti2vid_two_stage`, `keyframe_interpolation`, `a2vid_two_stage`, `res2s_two_stage` and `dfr` — and applied to both stages on the last two; rank and alpha 450; version 2.5.0 | A load that omits it on those five arms is refused by name; distinct from the 327,322,640-byte IC-LoRA | | LTX-2.5 video VAE | `vae/ltx-2.5-video-vae-conv-bf16.safetensors` | 1,452,269,922 bytes | `Lightricks/LTX-2.5` @ `8a4ff96f581e72bedc1b44367581c49d544a05f1` | `685b06ee3d9b2039647698fc4ea33175112462fc374e2777312c907897dfce8d` (non-quantized; hashed anyway, see the note above this table) | The `--video-vae` argument of every render; the CONV VAE, which is what the shipped recipes pass | The DiffVAE sibling `ltx-2.5-video-vae-bf16.safetensors` is refused by name rather than silently downgraded | | LTX-2.5 audio VAE | `vae/ltx-2.5-audio-vae-bf16.safetensors` | 364,866,540 bytes | `Lightricks/LTX-2.5` @ `8a4ff96f581e72bedc1b44367581c49d544a05f1` | `c52733d37f6a7fb7949c3dc0fb468c6cb2169e4d836983a73babb9f0d54837a5` (non-quantized; hashed anyway, see the note above this table) | The `--audio-vae` argument of every render | No quantized arm is recorded | -| LTX-2.5 Gemma-4 12B text encoder, bf16 | `text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors` | 26,263,858,182 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` (gated) | `ef7243612fdae7a75cb4d5cee9433e81380675fb6c213bd98ae74a9cd16561d1` (non-quantized; hashed anyway, and derived three independent times — the download's `x-linked-etag`, a CIFS read, and the worker's local disk during the render) | The **upstream oracle's** text tower, and the only one it accepts: `tools/oracle/ltx2_oracle.py` and #1864's reference render. This project's loader now reads it too: its two caption projections are stored BF16 [4096, 188160] and [2048, 188160] with no scale tensor in the file, and until [#2140](https://github.com/mudler/vllm.cpp/issues/2140) the loader doubled that already logical width to 376320 and refused. That refusal was MEASURED and LOCALISED on 2026-08-27 (`rc` job `001c36e9`): the 12 B tower itself loaded in bf16 in 34.815 s, and only the two caption projections refused. Measured on these bytes, not inferred | Unlike the torchao row below, this file DOES carry a `__metadata__` block, so `--encoder-config` is not required beside it. Our renders still take the NVFP4 torchao tower in the row below: no render has yet been gated on this one, and #1854's arm-matched comparison is what will do it. Upstream reads no torchao tensor at pin `fd4ded7f`, so the two are not interchangeable in either direction | +| LTX-2.5 Gemma-4 12B text encoder, bf16 | `text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors` | 26,263,858,182 bytes | `Lightricks/LTX-2.5` @ `6c7e5e573ac1667efc83407806fe9b0b93730e60` (gated) | `ef7243612fdae7a75cb4d5cee9433e81380675fb6c213bd98ae74a9cd16561d1` (non-quantized; hashed anyway, and derived three independent times — the download's `x-linked-etag`, a CIFS read, and the worker's local disk during the render) | The **upstream oracle's** text tower, and the only one it accepts: `tools/oracle/ltx2_oracle.py` and #1864's reference render. This project's loader now reads it too: its two caption projections are stored BF16 [4096, 188160] and [2048, 188160] with no scale tensor in the file, and until [#2140](https://github.com/mudler/vllm.cpp/issues/2140) the loader doubled that already logical width to 376320 and refused. That refusal was MEASURED and LOCALISED on 2026-08-27 (`rc` job `001c36e9`): the 12 B tower itself loaded in bf16 in 34.815 s, and only the two caption projections refused. Measured on these bytes, not inferred | Unlike the torchao row below, this file DOES carry a `__metadata__` block, so `--encoder-config` is not required beside it. **This tower is now the GATED one for #1854.** `rc` job `4b0666ee-248c-45fc-9de6-372b6d0c1fab` on `dgx:gpu0` rendered the oracle's exact request (320x192, 25 frames, 8 steps, seed 42) against it and the absolute comparison returned `PASS` / `NO_WORSE_THAN_ORACLE_ON_BLOCKINESS`, so the arm-matched comparison that row promised has been taken. Other LTX-2.5 renders in this tree still take the NVFP4 torchao tower in the row below, which is a DIFFERENT arm and not interchangeable with this one. Upstream reads no torchao tensor at pin `fd4ded7f`, so the two are not interchangeable in either direction | | LTX-2.5 Gemma-4 12B text encoder | `text_encoders/gemma4-12b-with-proj-nvfp4-torchao.safetensors` | 7,423,624,178 bytes | `vonkaiser/LTX-2.5-FP8-NVFP4` @ `5a40ba9ab209a90ddb7943d1e3d374c51cfd3256` | `12132b7157925332d2b21de9fc6f507c14f4f0cbc7081484d1968ebf8a19b4bf` | The `--encoder` argument of every render, NVFP4 torchao | This file carries NO `__metadata__` block, so `--encoder-config` is REQUIRED beside it and the loader refuses by name without it (`ltx2_text_encoder.cpp`) | | Qwen3.8-27B GGUF language model | `Qwen3.8-27B-Q4_K_M.gguf` | 17,106,775,008 bytes | `unsloth/Qwen3.8-27B-GGUF` @ `fe1e2a23d973adb629709749dc4f6756df66ef10` | `7e78da5d7e3ae28d178121f58646953305f3e5bd3cb46f4a75584e8b6c6fe169` | Q4_K_M text model loads through `--model` and decodes on CPU | **The token gate against llama.cpp `b10451` FAILED** on 2026-08-23: tokenizer exact 6/6, generation divergent 5/6 ([evidence](bench-evidence/qwen38-27b-q4km-token-gate-20260823.md), #821). GGUF multimodal forward is missing | | Qwen3.8-27B GGUF projector | `mmproj-BF16.gguf` | 931,146,432 bytes | `unsloth/Qwen3.8-27B-GGUF` @ `fe1e2a23d973adb629709749dc4f6756df66ef10` | `83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53` | BF16 `clip` projector loads and validates through `--mmproj` | No request path runs the loaded projector | diff --git a/docs/bench-evidence/gfx1100-tg200-t3a-20260823.md b/docs/bench-evidence/gfx1100-tg200-t3a-20260823.md new file mode 100644 index 0000000000..3cf1c4f286 --- /dev/null +++ b/docs/bench-evidence/gfx1100-tg200-t3a-20260823.md @@ -0,0 +1,95 @@ +# GFX1100-TG200 — T3a: idle-host graph A/B, environment drift, and the GQA4 f32-Q attention arm + +Date: 2026-08-23 (second session). Follows `gfx1100-tg200-t2b-20260823.md`. + +## 1. Definitive idle-host T2b graph A/B — the projected +5.9 did NOT materialize + +Same-window, host quiet, acceptance workload (110-tok prompt, 256 gen, +greedy, batch 1, `examples/vllm-cli`, 5 reps each): + +| Arm | median | +|---|---| +| graph replay ON | **36.39** tok/s | +| `VLLM_CPP_CUDAGRAPH=0` | **35.91** tok/s | + +Replay verified engaged (1274 replays / 1 captured size). The win is +**+0.48 tok/s (~0.38 ms/tok)**, not the ~2.08 ms/tok dispatch gap priced in +T1b. Reading: under hipGraph replay most of the "gap" was already hidden by +async launch pipelining; the true serial-launch cost per token is ~0.4 ms. +T2's residual headroom on this axis is small. + +## 2. Environment drift: yesterday's 40.65 baseline is not reproducible today + +The pre-lever build (`995dd625c`, T1-era) re-measured today reads **33.36** +median, not 40.65. Cross-checks run: + +- Interleaved HEAD vs base (`69c514a1d`): HEAD wins all rounds (+2.7 median) + — no intra-branch regression from the merged gate_up or graph flip. +- DPM pinning experiments (`high`, `manual`+mclk=3): both SLOWER (~31.9); + forcing a performance level freezes sclk in its "S" state on this amdgpu. + Reverted to `auto`. Under `auto` a clock sampler caught mclk at **96 MHz + for 12/18 samples during an active bench** — the memory system spends most + of its time downclocked between launch bursts and ramps too slowly for + bursty single-stream decode. +- Host load correlation: co-tenant compile storms (rustc/cargo, load 6-9) + depress every arm; one 11 tok/s outlier run coincided with load spikes. + +Campaign consequence: absolute cross-session comparisons need a same-session +anchor arm. All TG200 A/Bs are interleaved same-window pairs from here on. + +## 3. T3a lever: PagedAttnDecodeGqaF32Q ported from the TG150 ladder + +T2c rocprofv3 capture at HEAD (512-tok profiled run, shares used because the +tracer inflates absolutes): PagedAttnOnline = 8 calls/token at +~593 us = the top GPU item (~4.75 ms/tok of busy). The model's full-attn +layers run f32 query × bf16 cache × f32 out ("Phase 1"), which excludes every +bf16 decode kernel; FA2 is CUDA-only (`supports_fa2_attention()` stays false +on ROCm), so the generic grid=1-block serial-walk kernel serves all 8 calls. + +Ported `PagedAttnDecodeGqaF32Q<4,8,8>` (f32 Q/out, bf16 K/V, QG=4 fused +q-heads, warp-strided walk, register online softmax) from commit `c112d8800` +on `row/ROCM-QUANT-GEMM-BW`, plus its `VT_ATTN_DECODE_GQA4=1` dispatch arm. + +### The smem defect found by engine-level verification (the important record) + +The ported dispatch arm allocated `nwarps*2*d` dynamic LDS but the kernel's +layout is `o_sh[NWARPS*QG*d] + m/l[NWARPS*QG]` — with QG=4 that is HALF the +required shared memory. Result: op-level test GREEN (14/14 cases, 1646 +assertions) while the ENGINE produced garbage after ~20 tokens (number-loop +degeneration) at an apparent 39.5 tok/s median — a garbage-fast result that +the throughput number alone would have celebrated. + +Why the op test could not see it: the GPU-parity cases in +`tests/vt/test_ops_paged_attn.cpp` are `HasCuda()`-guarded and SKIP on this +ROCm-only box; the cases that ran never hit the new arm's geometry with +out-of-bounds-sensitive shapes. Caught only by the token-coherence check on +the real workload (working rule 3). + +Fix: smem formula keys on QG (`nwarps*QG*(d+2)` floats). After the fix: +output coherent, full 256-token streams differ only in mid-stream near-tie +moves (documented reduction-order change; near-tie adjudication before any +default flip remains OWED, same policy class as VT_ATTN_DECODE_D128). + +### Measured (fixed kernel, interleaved same-window pairs) + +Host load swung 5→9 through this window (co-tenant compile storm), so runs +are paired: + +| Pair | OFF | ON | +|---|---|---| +| r1 | 34.40 / 36.37 | 34.20 / 38.88 | +| r2 | 32.22 / 36.26 | 27.43 / 26.33 (load spike) | +| r3 | 33.89 / 36.26 | 36.69 / 39.07 | + +Clean-window set (ON x4 then OFF x5): ON 35.50–36.89 (median ~36.83), +OFF 34.13–36.31 (median ~36.22). The kernel-level win (~0.6-0.9 ms/tok off +the attention item) lands as roughly +0.6-1.5 tok/s end-to-end under load +noise; a definitive idle-host median needs the co-tenant quiet. + +## Owed + +- Near-tie adjudication (teacher-forced logprob gaps vs oracle) BEFORE any + default-ON flip of `VT_ATTN_DECODE_GQA4`; until then it stays opt-in. +- ROCm-side op test coverage for the f32-Q arm (the CUDA guard skips the + parity cases that would have caught the smem bug). +- Idle-host definitive medians once the co-tenant compile storm clears. diff --git a/docs/bench-evidence/tt-qwen35-eager-profile-w5-20260829.log b/docs/bench-evidence/tt-qwen35-eager-profile-w5-20260829.log new file mode 100644 index 0000000000..8989866bbc --- /dev/null +++ b/docs/bench-evidence/tt-qwen35-eager-profile-w5-20260829.log @@ -0,0 +1,58 @@ +# Qwen3.5-0.8B on TT P150 AFTER W5 (#2244): eager decode A/B, same method as the W4 log +# tree: row/BACKEND-TENSTORRENT-QWEN35 dc473a94c (W5 persistent-buffer staging) vs a456e6eaf (W4 state, main). +# Both arms built VLLM_CPP_TENSTORRENT=ON, vllm-cli --device auto (default); BEFORE arm from a scratch +# worktree at a456e6eaf, AFTER arm from the row build dir — same session, same box, interleaved arms. +# single $HOME/gpu.lock hold, JIT-discard run per arm (discarded), greedy 3-token leg, prompt 11 tok, +# perf record -F 199 -g on every measured leg. 2026-08-29, thalia (local non-fleet P150), box otherwise idle. +# +# A/B (3 tokens, greedy; discard runs 18.843s / 18.787s): +# AFTER dc473a94c: 19.232 s, 19.076 s -> mean 19.154 s = 0.157 tok/s +# BEFORE a456e6eaf: 19.193 s, 19.168 s -> mean 19.181 s = 0.156 tok/s +# delta: -0.14% — NOISE. THE WALL DID NOT MOVE. +# +# VERDICT: the W4 record's named hypothesis — "per-upload tt-metal-internal work inside +# from_span->to_device (buffer alloc + cluster queries + read_cq_host_ptr polling)" — is SPLIT by +# this trace. The buffer-alloc half is now PROVEN GONE and PROVEN IMMATERIAL: +# allocate_mesh_tensor_on_device_with_topology 0.02% of the AFTER flat profile +# MeshBuffer::create absent from the AFTER top; also absent from the +# BEFORE top (the alloc was never a meaningful share — +# the W4 hypothesis overstated it) +# and the route is provably ACTIVE in the production leg: both arms show the identical +# MeshCommandQueueBase::enqueue_write_shards_nolock / enqueue_write_with_core_filter / +# enqueue_write_tensor stack (AFTER reaches it via ttnn::copy_to_device on the persistent +# buffer; BEFORE via to_device on a fresh one — tt-metal routes both through the same CQ write +# machinery, which is why the win is zero: the machinery is the wall, not the allocation). +# +# WHERE THE WALL ACTUALLY IS (AFTER flat profile, --no-children): +# vt::cpu::Threadpool::PollForWork 14.29% (W4 log: 14.32% — unchanged) +# tt::tt_metal::MetalContext::instance 11.14% (W4 log: 9.11%) — context query per CQ op +# memcpy 6.23% (W4 log: 6.79%) +# tt::umd::Cluster::get_chip 5.90% (W4 log: 5.24%) — cluster query per CQ op +# tt::tt_metal::read_cq_host_ptr 5.27% flat + 4.08/2.33/2.24/1.35 in callgraph sub-slices +# — CQ completion polling per CQ op +# Every one of these is paid PER CQ OPERATION, and the staging path issues one CQ write per +# staged tensor per step regardless of whether the destination buffer is fresh or persistent. +# Eliminating the allocation removed a cost that sampling could barely see. +# +# CORRECTNESS (same head, this session): e2e leg A eager 16/16 STRICT token-exact, leg B ambient +# 16/16 STRICT token-exact, suite 45/45 cases / 5062 assertions exit 0; sacred pair byte-identical. +# Route counters asserted by the suite: uploads_persistent_allocs pins one allocation per +# (slot, geometry); uploads_persistent_bf16 + staged_persistent_bf16_bytes pin the in-place arms. +# +# NEXT TRACEABLE HYPOTHESIS (replaces the W4 one): the wall is per-CQ-operation, not per-upload- +# allocation. Two shapes attack it: +# (a) OUR side — lever 3 (batch per-layer staging), explicitly not taken in W4: one CQ write per +# step instead of one per staged tensor divides the per-op context/query/poll tax by the +# fan-in. This is now the cheapest next lever and it is inside our file set. +# (b) tt-metal side — cached context handles and amortized CQ completion polling inside +# MeshCommandQueueBase::enqueue_write_shards_nolock; upstream change, not our file set. +# +# WAVE RECORD NOTES: fresh reviewer PASS on dc473a94c (8 mutations incl. reachability; full gate +# 45/45 · 5062 rerun on the immutable head). One LOW finding, comment-only, rides the next +# change to tenstorrent_device.h: the uploads_persistent_bf16 comment says "written IN PLACE +# via the mesh command queue" but the cold/allocating arm also increments it (code and test +# agree; only the comment is narrower). Awareness for W6+: staged device tensors alias the +# slot's persistent buffer, so a same-geometry restage mutates bytes under previously returned +# handles — unreachable through today's staging path (full overwrite before every return; +# weights stage once), but a multi-consumer stager must not assume W4's fresh-snapshot +# semantics. diff --git a/include/vllm/v1/attention/backend.h b/include/vllm/v1/attention/backend.h index cbeb99300a..f645893ddf 100644 --- a/include/vllm/v1/attention/backend.h +++ b/include/vllm/v1/attention/backend.h @@ -544,6 +544,13 @@ class RocmAttentionBackend final : public AttentionBackend { // MultipleOf(1) in place, so this backend advertised every block size and // then refused most of them (#1608). std::vector get_supported_kernel_block_sizes() const override { return {16}; } + // KV-FP8 W6: the ROCm paged-attn kernel reads fp8-e4m3 cache pages with + // per-tensor k_scale/v_scale dequant. The GQA decode arm on this branch + // (PagedAttnDecodeGqaF32Q) also accepts kI8 KV. e5m2 is refused at the + // ops layer (ops.cpp) with a named message. + std::vector supported_kv_cache_dtypes() const override { + return {"auto", "float16", "bfloat16", "fp8", "fp8_e4m3"}; + } std::vector get_kv_cache_shape( int64_t num_blocks, int64_t block_size, int64_t num_kv_heads, diff --git a/include/vt/ops.h b/include/vt/ops.h index aeb06759fc..c99ef488ef 100644 --- a/include/vt/ops.h +++ b/include/vt/ops.h @@ -3474,7 +3474,7 @@ void RmsNormGatedGroup(Queue& q, Tensor& out, const Tensor& x, const Tensor& gat // front. Per token: // // normed[j*H+h] = hyper[j*H+h] * rsqrt(mean_h(hyper[j*H+.]^2) + eps) -// * hc_norm_w[j*H+h] (group_size == H) +// * (1 + hc_norm_w[j*H+h]) (group_size == H) // low[r] = silu( (mix_down[r] . normed) / hc ) -- DIVIDE INSIDE // gate[p] = sigmoid( mix_up[p] . low ) -- NO divide here // mixed[h] = mean_j( gate[j*H+h] * normed[j*H+h] ) -- MEAN, not sum @@ -3486,10 +3486,26 @@ void RmsNormGatedGroup(Queue& q, Tensor& out, const Tensor& x, const Tensor& gat // on the up projection, and the collapse is a MEAN over the branches while the // product it collapses is against the NORMED stream and not the raw one. // -// `hc_norm_w` is vLLM's parameterization, i.e. ALREADY `1 + w_hf`. Upstream -// applies `output * (1.0 + weight)` on a zero-init gamma; folding it once at -// load is `vllm::qwen4_exp::HcNormWeightFromHf`, and a `qwen4exp` GGUF written by -// ggml-org/llama.cpp#27742 carries the fold already. This op never adds 1. +// `hc_norm_w` IS THE RAW HUGGINGFACE GAMMA, `w_hf`, and THIS OP ADDS THE 1. +// That is upstream's parameterization verbatim — `Qwen4ExpTextRMSNorm.forward` +// is `output * (1.0 + self.weight.float())` over a ZERO-initialised weight +// (:173-178) — and it is the SAME polarity `vt::RmsNorm(gemma=true)` and +// `vt::Qwen4ExpQsaCompress` apply to this architecture's other gammas, which is +// the point: `Qwen4ExpWeights` holds every gamma raw (the `qwen4exp` loader +// inverts the `+1` ggml-org/llama.cpp#27742 bakes in at convert time, with +// `linear_attn.norm.weight` the one tensor the converter never folds), so ONE +// rule covers the whole model and no call site has to remember which of two +// forms this particular op wanted. +// +// IT READ THE OTHER WAY UNTIL #2218, and the correction is recorded here rather +// than only in the spec because the failure is silent. Under the old contract a +// forward that handed this op the loaded `hc_norm` multiplied every +// hyper-connection norm by a gamma centred on ZERO — a plausible tensor, never +// a crash, and unreachable by any single-sided gate, because the loader was +// right about its own output and the op was right about its own input. +// `vllm::qwen4_exp::HcNormWeightFromHf` remains the `w_hf -> 1 + w_hf` bridge +// the W3 HOST reference needs (`qwen4_exp_hc.h` `GroupedRmsNorm` is vLLM's +// `out * w` form and keeps it); it is NOT a step any caller of this op takes. // // SHAPES. hyper [T, hc*H]; hc_norm_w [hc*H]; mix_down [R, hc*H]; mix_up [hc*H, R] // (both in PyTorch `nn.Linear(bias=False)` `(out_features, in_features)` order); diff --git a/scripts/check-gate-commands.py b/scripts/check-gate-commands.py index c8a472e5fe..6746880ac4 100755 --- a/scripts/check-gate-commands.py +++ b/scripts/check-gate-commands.py @@ -448,6 +448,20 @@ def audit() -> list[dict]: # A/B cannot be satisfied by a tree in which the guard does nothing, which is # exactly what "genuinely fails on a broken guard" asks for, and it was proved by # three mutations in the same change rather than asserted. +# 2026-08-29: -MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm leaves the +# runnable population because the ROW leaves the gated population, not because it +# lost a command ([#2214](https://github.com/mudler/vllm.cpp/issues/2214)). It moves +# `BLOCKED` -> `SPIKE` on a committed port plan, and `SPIKE` is not a gate-obliged +# state, so `check-agent-record.py` stops reading the row's spec for a `## Gates` +# section and this audit stops seeing the row at all. SHRINKAGE, so the set is +# re-pinned in the same change, per the note above. Nothing was weakened: the +# `### Gates` section that earned the credit is untouched in +# .agents/specs/glm-dsa-latest-deepseek.md, and §3.6 of that spec ADDS four named +# gates (module parity against the pin on CPU, a headers-only structural loader +# gate, a streamed-vs-resident identical-logits gate, and an llama.cpp b10451 +# floor) together with the statement that no end-to-end token gate against vLLM is +# reachable on this fleet. The credit returns when the row reaches a gate-obliged +# state, which its W2 does. RUNNABLE_BASELINE = frozenset({ "ENG-POOL-BEST-FIT", "ENG-UPSTREAM-LTX2-PIN", @@ -492,7 +506,6 @@ def audit() -> list[dict]: "KV-SLIDING-WINDOW-SPEC", "LOAD-SAFETENSORS-DIRECT-DENSE", "MODEL-FACTORY-registry", - "MODEL-TEXT-deepseek-v2-glm-moe-dsa-for-causal-lm", "MODEL-TEXT-gemma4-gemma4-for-causal-lm", "MODEL-TEXT-glm4-glm4-for-causal-lm", "MODEL-TEXT-glm4-moe-lite-glm4-moe-lite-for-causal-lm", diff --git a/scripts/ltx25-oracle-absolute-render.sh b/scripts/ltx25-oracle-absolute-render.sh index fdf6a17fd1..14908b3186 100755 --- a/scripts/ltx25-oracle-absolute-render.sh +++ b/scripts/ltx25-oracle-absolute-render.sh @@ -143,7 +143,30 @@ for t in ffmpeg python3 cmake ninja; do command -v "$t" >/dev/null || { echo "FA python3 -c 'import numpy' || { echo "FATAL: no numpy, and the comparison tool needs it"; exit 38; } say "=== [A] CUDA toolkit ===" -need_ok() { [ -x "$1/bin/nvcc" ] && [ -f "$1/targets/sbsa-linux/lib/libcublasLt.so" ]; } +# THE SONAME IS WHAT MUST EXIST, AND IT IS WHAT CIFS DESTROYS (#2220). +# `libcudart.so.13` is a SYMLINK in a real install; `/workspace` stores no +# symlink, so a staged copy carries only the versioned regular file +# `libcudart.so.13.3.29`. `nvcc` compiles happily against headers, and the +# failure lands 21 minutes later at the CONSUMER link as 38 lines of +# `undefined reference to ...@libcudart.so.13`. +# +# `soname_ok` therefore checks what the LINKER needs rather than what is easy to +# check. The previous `need_ok` tested `libcublasLt.so`, which is the ONE link +# the reconstruction below did create correctly, so it passed on a toolkit that +# could not be linked against. A precondition that cannot fail is not one. +soname_ok() { # $1 = lib dir, $2 = stem; true when .so AND .so. resolve + local target major + target=$(readlink -f "$1/$2.so" 2>/dev/null) || return 1 + [ -e "$target" ] || return 1 + major=$(basename "$target"); major=${major#*.so.}; major=${major%%.*} + [ -n "$major" ] || return 1 + [ -e "$1/$2.so.$major" ] +} +need_ok() { + [ -x "$1/bin/nvcc" ] || return 1 + soname_ok "$1/targets/sbsa-linux/lib" libcudart && + soname_ok "$1/targets/sbsa-linux/lib" libcublasLt +} TKLIB="" for c in /usr/local/cuda /usr/local/cuda-13.0 /root/cudatk; do if need_ok "$c"; then TKLIB=$c; break; fi @@ -152,13 +175,42 @@ if [ -z "$TKLIB" ] && [ -d /workspace/a3/cuda-staged ]; then say " staging the toolkit from /workspace/a3/cuda-staged (CIFS holds no symlink and serves 0664)" cp -a /workspace/a3/cuda-staged /root/cudatk || { echo "FATAL: cannot stage the toolkit"; exit 38; } chmod -R 0755 /root/cudatk/bin /root/cudatk/nvvm/bin 2>/dev/null - ( cd /root/cudatk/targets/sbsa-linux/lib 2>/dev/null && for f in *.so.*.*; do - b=${f%%.so.*}; ln -sf "$f" "$b.so"; ln -sf "$f" "$b.so.${f#*.so.}"; done ) 2>/dev/null + L=/root/cudatk/targets/sbsa-linux/lib + # PRIMARY: `ldconfig -n` reads each object's own `DT_SONAME` and creates exactly + # that name, so it cannot disagree with the name the linker will ask for. It + # does NOT create the `.so` development link, which is why the loop still runs. + ldconfig -n "$L" 2>/dev/null + ( cd "$L" 2>/dev/null && for f in *.so.*; do + # Only fully-versioned regular files. `libcudart.so.13` must not re-enter. + case "$f" in *.so.*.*) ;; *) continue;; esac + b=${f%%.so.*}; v=${f#*.so.} + # THE MAJOR, NOT THE FULL VERSION. `${f#*.so.}` is `13.3.29`, and the old + # line used it verbatim -- so it linked `libcudart.so.13.3.29` to ITSELF and + # never created `libcudart.so.13`. That one expansion is #2220. + ln -sf "$f" "$b.so" + [ -e "$b.so.${v%%.*}" ] || ln -sf "$f" "$b.so.${v%%.*}" + done ) 2>/dev/null need_ok /root/cudatk && TKLIB=/root/cudatk fi -[ -n "$TKLIB" ] || { echo "FATAL: no complete CUDA toolkit (nvcc + libcublasLt)"; exit 38; } +[ -n "$TKLIB" ] || { + echo "FATAL: no CUDA toolkit whose libcudart/libcublasLt SONAME links resolve (#2220)" + for d in /usr/local/cuda /usr/local/cuda-13.0 /root/cudatk; do + [ -d "$d" ] || continue + echo " $d/targets/sbsa-linux/lib:" + ls -la "$d/targets/sbsa-linux/lib" 2>/dev/null | grep -E "libcudart|libcublasLt" | head -8 + done + exit 38; } export PATH="$TKLIB/bin:$PATH" CUDAToolkit_ROOT="$TKLIB" say " toolkit $TKLIB, $(nvcc --version | tail -1)" +# ASSERTED IN SECONDS, BEFORE A 21-MINUTE BUILD. #2220 cost exactly that build: +# it ran to completion and died linking the first consumer. Printed AND recorded, +# so a later reader can see which toolkit the artefacts were linked against. +for s in libcudart libcublasLt; do + t=$(readlink -f "$TKLIB/targets/sbsa-linux/lib/$s.so") + m=$(basename "$t"); m=${m#*.so.}; m=${m%%.*} + say " $s.so -> $(basename "$t"), SONAME link $s.so.$m present" + echo "toolkit_soname $s.so.$m -> $(basename "$t")" >> "$OUT/PROVENANCE" +done say "=== [B] source ===" [ -s "$W/src.tar.gz" ] || { echo "FATAL: no $W/src.tar.gz"; exit 31; } @@ -343,6 +395,33 @@ echo "render_rc=$RENDER_RC render_seconds=$RENDER_S" >> "$OUT/PROVENANCE" # COMPLETENESS IS DEFINED, not eyeballed. Exactly the expected frame count and a # non-empty wav. A partial render that reached the comparison would produce a # blockiness number over whatever frames survived. +# --steps 8 ARRIVED, OBSERVED RATHER THAN INFERRED FROM THE FLAG BEING PASSED. +# The row's `## Owed` records that every link in `main.cpp` -> `vllm_c.cpp` -> +# `ltx2_video.cpp` is verified by INSPECTION and none by execution, because the +# lease that would have executed it refused at the checkpoint load 76 s in. +# `VLLM_RENDER_PROGRESS` is ON by default and writes one +# `[render] dit forward N phase P step k/N t=.. last=..` per DiT forward +# (docs/ENVIRONMENT.md), so the denominator in `step k/N` IS the resolved step +# count. Extracted here into its own file so the proof is an artefact of the run +# rather than something a later reader has to find in a log. +# +# WHY THE DENOMINATOR AND NOT THE LINE COUNT. `one_stage` is GUIDED and runs +# three DiT forwards per step, so counting lines measures the guider. The +# distinct set of denominators is the schedule, and a set with anything but a +# single 8 in it is the finding, not a formatting detail. +grep -oE 'step [0-9]+/[0-9]+' "$LOG" | awk -F/ '{print $2}' | sort -u > "$OUT/steps-observed.txt" +STEPS_SEEN=$(tr '\n' ',' < "$OUT/steps-observed.txt" | sed 's/,$//') +FORWARDS=$(grep -cE 'step [0-9]+/[0-9]+' "$LOG") +say " --steps: requested $STEPS, denominators observed at runtime {${STEPS_SEEN:-none}}, $FORWARDS DiT forwards" +echo "steps_requested=$STEPS steps_observed={${STEPS_SEEN:-none}} dit_forwards=$FORWARDS" >> "$OUT/PROVENANCE" +if [ "$STEPS_SEEN" != "$STEPS" ]; then + # NOT FATAL, and deliberately so: the comparison's verdict is the row's + # deliverable and a step count that did not arrive is a SECOND finding rather + # than a reason to discard the first. It is said loudly and it is recorded. + say " WARNING: the sampler did not run $STEPS steps. #2130's flag is wired and this run" + say " did NOT observe it arrive; the comparison below carries a denoise-budget confound." +fi + NF=$(ls "$D"/frame_*.ppm 2>/dev/null | wc -l) say " frames=$NF expected=$FRAMES audio=$(stat -c %s "$D/audio.wav" 2>/dev/null || echo 0) bytes" if [ "$NF" != "$FRAMES" ] || [ ! -s "$D/audio.wav" ]; then diff --git a/scripts/probe_ltx2_dit_load.cpp b/scripts/probe_ltx2_dit_load.cpp new file mode 100644 index 0000000000..1c0ac0e133 --- /dev/null +++ b/scripts/probe_ltx2_dit_load.cpp @@ -0,0 +1,252 @@ +// Does a REAL LTX-2.5 DiT resolve onto the L2 contract? — the bf16 arm, measured. +// +// A sibling of `scripts/probe_ltx2_text_encoder_load.cpp`, for the other half of +// the same question. That probe answered "does the bf16 TEXT ENCODER load", which +// is what #2140 fixed. This one answers "does the 42 GB bf16 DiT resolve", which +// nothing in this tree had asked: every LTX-2.5 render taken here loaded the +// NVFP4 or the FP8 transformer, so the dev bf16 file's own arm is unexercised +// past its header. +// +// ─── WHAT IT ESTABLISHES, AND WHAT IT CANNOT ──────────────────────────────── +// +// `Ltx2LoadDitFromSafetensors` and `Ltx2StreamDitToDevice` share their whole +// prologue and differ only in what the per-tensor loop does with the bytes +// (`ltx2_loader.cpp:703-806`): plan the file, parse the geometry, adopt the +// declared config, build the contract, and refuse an unported family. THAT +// PROLOGUE IS WHERE EVERY DiT REFUSAL IN THIS TREE HAS HAPPENED — issue #1148's +// "the checkpoint carries modules this port does NOT carry" and `PlanDit`'s own +// arm resolution are both in it — and it touches only the 677,616-byte header. +// +// This probe runs that prologue's public equivalents and then walks the contract +// against the file's own header, checking, per tensor: +// +// - the name the contract requires is IN the file, under the ComfyUI prefix; +// - its stored shape is the logical one the contract asks for; +// - its stored dtype is one this loader materializes; +// - its byte count is exactly what that shape and dtype require, which is +// `MaterializeDitTensor`'s own BF16 check (`ltx2_loader.cpp:499-506`). +// +// IT DOES NOT MATERIALIZE. A host load of this file is ~42 GB of bf16 and the +// device load is the same bytes uploaded one tensor at a time; neither fits on a +// CPU gate box and the second needs a GPU at all. So this probe cannot say the +// render works, and it does not claim to. What it CAN say is whether the arm is +// refused before a byte is read — which is the thing a GPU lease should not be +// spent discovering, and the thing the #2140 refusal turned out to be. +// +// ─── BUILD AND RUN (there is no CMake target; this is the recorded recipe) ─── +// Deliberately not a target, for the same reason its three sibling probes are +// not: a probe should not charge every configure. Written down rather than +// implied, because a reviewer cannot re-run a probe whose compile line was never +// recorded. +// +// cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +// ninja -C build vllm +// g++ -O2 -std=c++20 -Iinclude -Ithird_party +// scripts/probe_ltx2_dit_load.cpp build/libvllm.a -o /tmp/ltx2_dit -pthread +// (one line; it is split here only because a trailing backslash inside a `//` +// comment is -Wcomment, and this file is compiled warning-clean on purpose) +// /tmp/ltx2_dit +// +// Exit 0 and a trailing `OK` is a clean resolution; exit 1 and `REFUSED: ` +// is the loader's own refusal, printed rather than swallowed; exit 2 is a +// contract tensor this file cannot satisfy, listed by name. +#include +#include +#include +#include +#include +#include + +#include "vllm/model_executor/model_loader/safetensors_reader.h" +#include "vllm/model_executor/models/ltx2.h" +#include "vllm/model_executor/models/ltx2_loader.h" + +namespace { + +std::string ShapeText(const std::vector& s) { + std::string out = "["; + for (size_t i = 0; i < s.size(); ++i) { + out += (i != 0 ? ", " : "") + std::to_string(s[i]); + } + return out + "]"; +} + +// The width one stored element occupies, for the encodings this loader +// materializes. Anything else returns 0 and is reported rather than assumed. +size_t ElemBytes(const std::string& dtype) { + if (dtype == "BF16" || dtype == "F16") return 2; + if (dtype == "F32") return 4; + if (dtype == "F8_E4M3" || dtype == "U8") return 1; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 2) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 64; + } + const std::string path = argv[1]; + + try { + const vllm::SafetensorsFile file = vllm::SafetensorsFile::Open(path); + + // Census first, from the file's own header, so the arm is a count and not a + // claim. `PlanDit` decides `quant` off exactly these dtypes. + std::map dtypes; + int64_t sidecars = 0; + for (const std::string& name : file.Names()) { + ++dtypes[file.Get(name).dtype]; + if (name.size() >= 6 && name.compare(name.size() - 6, 6, "_scale") == 0) ++sidecars; + } + std::printf("file %s\n", path.c_str()); + std::printf("tensors %zu\n", file.Names().size()); + std::printf("dtypes "); + for (const auto& kv : dtypes) std::printf(" %s=%ld", kv.first.c_str(), kv.second); + std::printf("\nscale_sidecars %ld\n", sidecars); + + // The shared prologue, in the order both loaders run it. + vllm::Ltx2DitQuant quant = vllm::Ltx2DitQuant::kFp8; // never the expected value + const vllm::Ltx2DitParams from_shapes = + vllm::Ltx2ParseDitParamsFromCheckpoint(file, &quant); + const char* arm = quant == vllm::Ltx2DitQuant::kNvfp4 ? "kNvfp4" + : quant == vllm::Ltx2DitQuant::kFp8 ? "kFp8" + : "kNone"; + std::printf("resolved_arm %s\n", arm); + std::printf("model_version %s\n", vllm::Ltx2ReadCheckpointModelVersion(file).c_str()); + std::printf("geometry layers=%ld inner=%ld audio_inner=%ld in_ch=%ld audio_in_ch=%ld\n", + static_cast(from_shapes.num_layers), + static_cast(from_shapes.inner_dim()), + static_cast(from_shapes.audio_inner_dim()), + static_cast(from_shapes.in_channels), + static_cast(from_shapes.audio_in_channels)); + + // The declared config, which is what the engine adopts and what the SHAPES + // cannot see. `Ltx2AdoptDeclaredDitParams` refuses a config describing + // another checkpoint, so reaching past this line is itself a result. + const nlohmann::json config = vllm::Ltx2ReadCheckpointConfig(file); + const vllm::Ltx2DitParams declared = vllm::Ltx2AdoptDeclaredDitParams( + config, from_shapes, "the probed DiT's own __metadata__[\"config\"]"); + std::printf("adopted_config rope_f64=%d av_ca_mult=%ld prompt_adaln=%d keyframes=%d\n", + declared.double_precision_rope ? 1 : 0, + static_cast(declared.av_ca_timestep_scale_multiplier), + declared.use_prompt_adaln_single ? 1 : 0, + declared.use_keyframes_abs_pos_embedding ? 1 : 0); + + // The contract the per-tensor loop will walk. + const std::vector contract = vllm::EnumerateLtx2DitTensors(declared); + std::printf("contract %zu tensors\n", contract.size()); + + // The file's names, with the ComfyUI prefix stripped the way `PlanDit` + // strips it, so the contract's bare names can be looked up directly. + const std::string kPrefix = "model.diffusion_model."; + std::map bare_to_file; + for (const std::string& name : file.Names()) { + const std::string bare = + name.rfind(kPrefix, 0) == 0 ? name.substr(kPrefix.size()) : name; + bare_to_file[bare] = name; + } + + // The walk. Every failure is COLLECTED rather than thrown on, so one run + // reports the whole gap instead of the first name in header order. + std::vector missing; + std::vector mismatched; + std::vector unreadable; + int64_t contract_bytes = 0; + std::set bound; + for (const vllm::Ltx2TensorSpec& spec : contract) { + auto it = bare_to_file.find(spec.name); + if (it == bare_to_file.end()) { + missing.push_back(spec.name + " " + ShapeText(spec.shape)); + continue; + } + bound.insert(it->second); + const vllm::StTensor& t = file.Get(it->second); + std::vector logical = t.shape; + if (t.dtype == "U8" && logical.size() == 2) logical[1] *= 2; // two values per byte + if (logical != spec.shape) { + mismatched.push_back(spec.name + " file " + ShapeText(logical) + " contract " + + ShapeText(spec.shape)); + continue; + } + const size_t elem = ElemBytes(t.dtype); + if (elem == 0) { + unreadable.push_back(spec.name + " dtype " + t.dtype); + continue; + } + int64_t want = static_cast(elem); + for (int64_t d : t.shape) want *= d; + if (static_cast(t.nbytes) != want) { + mismatched.push_back(spec.name + " holds " + std::to_string(t.nbytes) + + " bytes, its shape and " + t.dtype + " need " + + std::to_string(want)); + continue; + } + contract_bytes += static_cast(t.nbytes); + } + + // Names the FILE carries that the contract does not bind. This is the other + // direction, and it is the one `UnportedFamilies` reads: a family here is + // what #1148 refused on. Reported as a count plus its distinct prefixes, + // because the list itself can be thousands of names. + std::set unbound_prefixes; + int64_t unbound = 0; + for (const auto& kv : bare_to_file) { + if (bound.count(kv.second) != 0) continue; + ++unbound; + const size_t dot = kv.first.find('.'); + unbound_prefixes.insert(dot == std::string::npos ? kv.first : kv.first.substr(0, dot)); + } + + std::printf("contract_bytes %ld (%.2f GiB, what a load materializes)\n", + static_cast(contract_bytes), + static_cast(contract_bytes) / 1073741824.0); + std::printf("bound %zu of %zu file tensors\n", bound.size(), file.Names().size()); + std::printf("unbound %ld tensors, top-level names:", static_cast(unbound)); + for (const std::string& p : unbound_prefixes) std::printf(" %s", p.c_str()); + std::printf("\n"); + + // THE VERDICT `RefuseUnported` WOULD REACH, stated rather than left for the + // reader to infer from a count. `UnportedFamilies` (`ltx2_loader.cpp:618-631`) + // skips a family for which `LoadedElsewhere` is true, and the two + // `*_embeddings_connector` families are exactly those: they are outside the + // DiT contract by design and `Ltx2LoadConnectorWeights` loads them, which + // `RefuseUnported`'s own message says in as many words (`:654-656`). Every + // OTHER unbound family is one this port does not carry, and without + // `allow_unported_modules` the load refuses on it — which is what a render + // would hit, since `ltx2-gen` passes the option only under `--allow-unported`. + std::vector would_refuse; + for (const std::string& p : unbound_prefixes) { + if (p == "video_embeddings_connector" || p == "audio_embeddings_connector") continue; + would_refuse.push_back(p); + } + if (would_refuse.empty()) { + std::printf("unported none: the load does NOT need allow_unported_modules\n"); + } else { + std::printf("unported the load REFUSES without allow_unported_modules on:"); + for (const std::string& p : would_refuse) std::printf(" %s", p.c_str()); + std::printf("\n"); + } + + for (const std::string& m : missing) std::printf("MISSING %s\n", m.c_str()); + for (const std::string& m : mismatched) std::printf("MISMATCH %s\n", m.c_str()); + for (const std::string& m : unreadable) std::printf("UNREADABLE %s\n", m.c_str()); + + if (!missing.empty() || !mismatched.empty() || !unreadable.empty()) { + std::printf("NOT RESOLVED: %zu missing, %zu mismatched, %zu unreadable\n", missing.size(), + mismatched.size(), unreadable.size()); + return 2; + } + // Stated rather than implied, because the difference between this and a load + // is the whole reason the probe is cheap. + std::printf( + "NOT ESTABLISHED HERE: no byte was materialized and no device was touched. " + "This says the arm is not refused before the copy, not that the render runs.\n"); + std::printf("OK\n"); + return 0; + } catch (const std::exception& e) { + std::printf("REFUSED: %s\n", e.what()); + return 1; + } +} diff --git a/src/vllm/model_executor/models/glm5_next_weights.cpp b/src/vllm/model_executor/models/glm5_next_weights.cpp index 0e67bce77f..9f83d9cb4b 100644 --- a/src/vllm/model_executor/models/glm5_next_weights.cpp +++ b/src/vllm/model_executor/models/glm5_next_weights.cpp @@ -64,35 +64,86 @@ bool OptBool(const GgufFile& g, const std::string& key, bool dflt) { return v != nullptr ? KvInt(*v, key) != 0 : dflt; } -// A required STRING array. The per-layer schedules this architecture is built -// out of travel as string arrays, and reading one wrong splits the model into -// layers of the wrong kind — so a missing or wrong-typed array fails here -// rather than defaulting to a plausible pattern. -std::vector ReqStrArray(const GgufFile& g, +// An OPTIONAL string array. `layer_types` used to be read through a `Req` +// sibling of this, because the per-layer schedule is what the whole model is +// and a synthesized one splits the stack into layers of the wrong kind. That +// obligation has not been weakened, it has MOVED: the schedule is now required +// in either of its two on-disk spellings, and the refusal that stands in for +// the removed `ReqStrArray` is the one at the bottom of the schedule block +// below, which names both keys. +std::vector OptStrArray(const GgufFile& g, const std::string& key) { const GgufValue* v = g.FindKv(key); - VT_CHECK(v != nullptr, "glm5_next gguf: missing metadata key " + key); - VT_CHECK(v->TypeId() == kGgufArray, - "glm5_next gguf: key " + key + " must be an array"); + if (v == nullptr || v->TypeId() != kGgufArray) return {}; std::vector out; for (const GgufValue& e : std::get(v->v).elems) { - VT_CHECK(e.TypeId() == kGgufString, - "glm5_next gguf: key " + key + " must contain only strings"); + if (e.TypeId() != kGgufString) return {}; out.push_back(std::get(e.v)); } return out; } -std::vector OptStrArray(const GgufFile& g, - const std::string& key) { +// A GGUF key that llama.cpp writes as EITHER one scalar or one value per +// block. `llama-model.cpp:1177` at the pinned `llama-cpp` release `b10451` +// reads `%s.attention.head_count_kv` through `get_key_or_arr(..., n_layer, +// false)`, so a per-layer array is as legal a spelling of that key as a scalar +// is, and a reader that accepts only the scalar refuses the published file +// (#2243). Returns false when the key is absent or is not an array; the caller +// then reads it as the scalar it is. +bool OptIntArray(const GgufFile& g, const std::string& key, + std::vector* out) { const GgufValue* v = g.FindKv(key); - if (v == nullptr || v->TypeId() != kGgufArray) return {}; - std::vector out; + if (v == nullptr || v->TypeId() != kGgufArray) return false; + out->clear(); for (const GgufValue& e : std::get(v->v).elems) { - if (e.TypeId() != kGgufString) return {}; - out.push_back(std::get(e.v)); + out->push_back(KvInt(e, key)); } - return out; + return true; +} + +// A per-block array has ONE entry per block or the file is describing a stack +// this reader cannot place. Refuses by name, with the key and the shape found, +// rather than indexing a schedule of the wrong length. +void CheckPerLayerLength(const std::string& key, size_t found, + int64_t n_layers) { + VT_CHECK(static_cast(found) == n_layers, + "glm5_next gguf: key " + key + " is a per-layer array of " + + std::to_string(found) + " entries but block_count is " + + std::to_string(n_layers) + + "; a per-layer array states exactly one value per block"); +} + +// A float key in the same scalar-or-array shape. `swiglu_clamp_exp` and +// `swiglu_clamp_shexp` arrive as `array[f32]` of `block_count` entries in the +// published artifact and as scalars from our own converter. +// +// UPSTREAM HAS ONE `swiglu_limit`, not one per layer. So a NON-UNIFORM array +// is a file whose clamp this config cannot represent, and taking element 0 of +// it would build a fluent model that clamps every layer with a number the file +// states for one. That is refused by name instead. Returns false when the key +// is absent. +bool OptPerLayerFloat(const GgufFile& g, const std::string& key, + int64_t n_layers, double* out) { + const GgufValue* v = g.FindKv(key); + if (v == nullptr) return false; + if (v->TypeId() != kGgufArray) { + *out = KvFloat(*v, key); + return true; + } + const std::vector& elems = std::get(v->v).elems; + CheckPerLayerLength(key, elems.size(), n_layers); + const double first = KvFloat(elems[0], key); + for (size_t i = 1; i < elems.size(); ++i) { + const double here = KvFloat(elems[i], key); + VT_CHECK(here == first, + "glm5_next gguf: key " + key + " states " + std::to_string(first) + + " for block 0 and " + std::to_string(here) + " for block " + + std::to_string(i) + + ", but this architecture has ONE `swiglu_limit` and no " + "per-layer clamp to put the second value in"); + } + *out = first; + return true; } // Does this file carry `blk..`? @@ -322,14 +373,88 @@ HfConfig Glm5NextHfConfigFromGguf(const GgufFile& gguf) { c.model_type = "glm5_next"; c.architectures = {"Glm5NextForConditionalGeneration"}; - const int64_t n_layers = ReqInt(gguf, p + "block_count"); - VT_CHECK(n_layers > 0, "glm5_next gguf: block_count must be > 0"); + // BLOCKS ARE NOT LAYERS, and the difference is one whole decoder layer. + // + // llama.cpp's `block_count` counts the multi-token-prediction blocks on top + // of the backbone, and it states the relationship in its own converters: + // `self.block_count = self.hparams["num_hidden_layers"] + + // self.hparams.get("num_nextn_predict_layers", 0)` + // (`b10451:conversion/exaone.py:134`, and the same `+=` at + // `b10451:conversion/deepseek.py:470` and `:545`). The published artifact is + // that formula exactly: `block_count = 46`, `nextn_predict_layers = 1`, and + // the released `config.json` declares `num_hidden_layers = 45`. + // + // So `num_hidden_layers` here is the BACKBONE depth, which is what the field + // means on the `config.json` path and what `glm5_next.h` annotates it as. It + // is resolved by subtraction rather than transcribed, because reading + // `block_count` into it makes ONE model resolve to a 45-layer stack from its + // config.json and a 46-layer stack from its GGUF, and the extra entry is the + // MTP block. Nothing downstream would refuse that: `ParseGlm5NextParams` + // sizes every schedule from `num_hidden_layers`, and a decoder stack built + // from 46 would carry an extra layer made out of the MTP block, run, and + // produce plausible tokens. A token gate cannot see that, which is the whole + // reason this is subtracted here and asserted as a relationship rather than + // as a number. + // + // The MTP block itself is READ, COUNTED and then DROPPED. W5b + // (https://github.com/mudler/vllm.cpp/issues/2241) owns the head that would + // consume it; until then the per-block schedules below are truncated to the + // backbone and no layer is built for it, which is what the reference does + // too. + const int64_t n_blocks = ReqInt(gguf, p + "block_count"); + VT_CHECK(n_blocks > 0, "glm5_next gguf: block_count must be > 0"); + const int64_t n_mtp = OptInt(gguf, p + "nextn_predict_layers", 0); + VT_CHECK(n_mtp >= 0, + "glm5_next gguf: nextn_predict_layers is " + std::to_string(n_mtp) + + " and a count of multi-token-prediction blocks cannot be " + "negative"); + const int64_t n_layers = n_blocks - n_mtp; + VT_CHECK(n_layers > 0, + "glm5_next gguf: block_count is " + std::to_string(n_blocks) + + " and nextn_predict_layers is " + std::to_string(n_mtp) + + ", so the backbone would be " + std::to_string(n_layers) + + " layers deep; llama.cpp writes block_count as " + "num_hidden_layers + nextn_predict_layers, so this file states " + "more MTP blocks than it has blocks"); c.hidden_size = ReqInt(gguf, p + "embedding_length"); c.num_hidden_layers = n_layers; c.num_attention_heads = ReqInt(gguf, p + "attention.head_count"); - c.num_key_value_heads = - OptInt(gguf, p + "attention.head_count_kv", c.num_attention_heads); + + // `attention.head_count_kv`, in llama.cpp's SCALAR-OR-ARRAY form, and the + // array form is not a mis-typed scalar: it is THE per-layer schedule, and on + // the only published artifact of this model it is the only schedule the file + // carries (#2243, #2177). + // + // llama.cpp reads the key with `get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT_KV, + // hparams.n_head_kv_arr, hparams.n_layer(), false)` + // (`b10451:src/llama-model.cpp:1177`) and then every hybrid family decides + // which layers are linear from the SAME predicate — `is_recr_impl[i] = + // hparams.n_head_kv(i) == 0`, spelled for this model's own KDA parent at + // `b10451:src/models/kimi-linear.cpp:18` with the comment "KDA layers are + // recurrent". So `0` means a KDA layer and any non-zero means an attention + // layer, which for this architecture is DSA/MLA. + // + // THE ARRAY IS NOT A KV-HEAD COUNT AND MUST NOT BE READ AS ONE. Its non-zero + // entries are `1`, the single latent KV head MLA has, while upstream's + // `Glm5NextTextConfig` requires `num_attention_heads == num_key_value_heads` + // and the released `config.json` states 64 for both. Assigning `1` here would + // refuse the published file with a message about GQA, which is a true + // statement about a number this file never made. The array form therefore + // leaves `num_key_value_heads` at upstream's own `None -> num_attention_heads` + // default and spends the values on the schedule below, which is the only + // thing they mean. + std::vector head_count_kv_arr; + const bool kv_is_per_layer = + OptIntArray(gguf, p + "attention.head_count_kv", &head_count_kv_arr); + if (kv_is_per_layer) { + CheckPerLayerLength(p + "attention.head_count_kv", + head_count_kv_arr.size(), n_blocks); + c.num_key_value_heads = c.num_attention_heads; + } else { + c.num_key_value_heads = + OptInt(gguf, p + "attention.head_count_kv", c.num_attention_heads); + } c.max_position_embeddings = ReqInt(gguf, p + "context_length"); c.rms_norm_eps = ReqFloat(gguf, p + "attention.layer_norm_rms_epsilon"); c.vocab_size = OptInt(gguf, p + "vocab_size", 0); @@ -363,21 +488,96 @@ HfConfig Glm5NextHfConfigFromGguf(const GgufFile& gguf) { // The per-layer schedules. Required, not defaulted: the whole model is the // interleave, and a file that does not state it is a file we cannot place a // single layer of. - const std::vector layer_types = - ReqStrArray(gguf, p + "layer_types"); - VT_CHECK(static_cast(layer_types.size()) == n_layers, - "glm5_next gguf: layer_types has " + - std::to_string(layer_types.size()) + - " entries but block_count is " + std::to_string(n_layers)); + // TWO SPELLINGS, and the schedule is READ from whichever the file carries + // rather than synthesized from a stride. + // + // `glm5next.layer_types` is a string array that only + // `scripts/convert-glm5-next-gguf.py` writes, so our own output round-trips + // through it. No other tool emits it, and the published + // `unsloth/GLM-5.3-Flash-GGUF` artifacts carry the schedule ONLY as the + // per-layer `attention.head_count_kv` array read above. Requiring + // `layer_types` refused every one of those files, and falling back to + // upstream's `idx % 4 != 3` pattern instead would be worse than refusing: + // over the published checkpoint's 45 model layers that stride happens to be + // right, so the wrong reader and the right reader agree on THIS file and + // disagree silently on a fine-tune that moves one layer (#2177). The values + // are on disk; they get read. + // + // The published 46-entry array is NOT `idx % 4 == 3` over its whole length. + // `block_count` is 46 because it counts the multi-token-prediction block, + // `nextn_predict_layers = 1`; entries 0..44 are the model's layers and entry + // 45 is the MTP block, which is MLA-shaped and sits at `45 % 4 == 1`. A + // consumer that re-derives the stride selects eleven MLA blocks where the + // file states twelve, and reports nothing. + const std::vector declared = + OptStrArray(gguf, p + "layer_types"); + if (!declared.empty()) { + VT_CHECK(static_cast(declared.size()) == n_blocks, + "glm5_next gguf: layer_types has " + + std::to_string(declared.size()) + + " entries but block_count is " + std::to_string(n_blocks)); + } + + // CROSS-CHECK, not a preference. A file that states the schedule twice and + // disagrees with itself is a file one of whose two descriptions is wrong, and + // silently taking either one loads a model whose attention kind is wrong on + // the layers where they differ. Compared on the KIND and not on the string, + // because `full_attention` is a legal `layer_types` spelling that + // `LayerKindFromString` rewrites to `deepseek_sparse_attention`, and both are + // attention layers with a non-zero KV head count. + if (!declared.empty() && kv_is_per_layer) { + for (int64_t il = 0; il < n_blocks; ++il) { + const size_t i = static_cast(il); + const bool declared_kda = declared[i] == "linear_attention"; + const bool derived_kda = head_count_kv_arr[i] == 0; + VT_CHECK(declared_kda == derived_kda, + "glm5_next gguf: the file states its layer schedule twice and " + "the two disagree at block " + + std::to_string(il) + ": layer_types says `" + declared[i] + + "` while attention.head_count_kv says " + + std::to_string(head_count_kv_arr[i]) + + " KV heads (0 means a `linear_attention` layer, non-zero an " + "attention layer)"); + } + } + + std::vector layer_types; + if (!declared.empty()) { + layer_types = declared; + } else if (kv_is_per_layer) { + for (int64_t v : head_count_kv_arr) { + layer_types.push_back(v == 0 ? "linear_attention" + : "deepseek_sparse_attention"); + } + } else { + VT_CHECK(false, + "glm5_next gguf: this file states no per-layer attention " + "schedule. The whole model is the interleave, so it cannot be " + "defaulted: write either the string array " + + p + "layer_types or a per-layer " + p + + "attention.head_count_kv array of " + + std::to_string(n_blocks) + + " entries, 0 on each `linear_attention` block"); + } + // TRUNCATE the per-block schedules to the backbone. Every array above is + // `block_count` long because it describes BLOCKS; `layer_types` and the two + // below describe LAYERS, and the trailing `n_mtp` entries are the MTP blocks + // this port does not build (O2, and W5b owns the head). Dropping them here, + // once, is what keeps `ParseGlm5NextParams` — which sizes all three schedules + // from `num_hidden_layers` — from meeting a length it would have to refuse, + // and what makes a GGUF and a config.json of the SAME model resolve to the + // same stack. + layer_types.resize(static_cast(n_layers)); c.layer_types = layer_types; std::vector mlp_layer_types = OptStrArray(gguf, p + "mlp_layer_types"); if (!mlp_layer_types.empty()) { - VT_CHECK(static_cast(mlp_layer_types.size()) == n_layers, + VT_CHECK(static_cast(mlp_layer_types.size()) == n_blocks, "glm5_next gguf: mlp_layer_types has " + std::to_string(mlp_layer_types.size()) + - " entries but block_count is " + std::to_string(n_layers)); + " entries but block_count is " + std::to_string(n_blocks)); + mlp_layer_types.resize(static_cast(n_layers)); } // THE INVENTORY CONTRADICTION CHECK, and it is a contradiction check rather @@ -446,9 +646,16 @@ HfConfig Glm5NextHfConfigFromGguf(const GgufFile& gguf) { text["intermediate_size"] = c.intermediate_size; text["layer_types"] = layer_types; if (!mlp_layer_types.empty()) text["mlp_layer_types"] = mlp_layer_types; - const std::vector indexer_types = + std::vector indexer_types = OptStrArray(gguf, p + "attention.indexer.types"); - if (!indexer_types.empty()) text["indexer_types"] = indexer_types; + if (!indexer_types.empty()) { + VT_CHECK(static_cast(indexer_types.size()) == n_blocks, + "glm5_next gguf: attention.indexer.types has " + + std::to_string(indexer_types.size()) + + " entries but block_count is " + std::to_string(n_blocks)); + indexer_types.resize(static_cast(n_layers)); + text["indexer_types"] = indexer_types; + } // MLA. text["q_lora_rank"] = ReqInt(gguf, p + "attention.q_lora_rank"); @@ -505,7 +712,37 @@ HfConfig Glm5NextHfConfigFromGguf(const GgufFile& gguf) { text["topk_group"] = OptInt(gguf, p + "expert_group_used_count", 1); text["routed_scaling_factor"] = OptFloat(gguf, p + "expert_weights_scale", 2.5); text["norm_topk_prob"] = OptBool(gguf, p + "expert_weights_norm", true); - text["swiglu_limit"] = OptFloat(gguf, p + "swiglu_clamp_exp", 10.0); + // The clamped-SwiGLU limit, from EITHER of the two keys the writers use and + // in either shape. Our converter writes both `swiglu_clamp_exp` and + // `swiglu_clamp_shexp` as scalars from the one `text["swiglu_limit"]` + // (`scripts/convert-glm5-next-gguf.py:1022-1023`); the published artifact + // writes both as per-layer `array[f32]` of `block_count` entries. Reading + // only the scalar form refused the published file one key after + // `head_count_kv` did (#2243). + // + // BOTH are read, and a disagreement between them is refused. Upstream has ONE + // `swiglu_limit` covering the routed and the shared expert alike, so a file + // that states two different clamps is describing a model this config cannot + // hold, and first-wins would pick one of them by the order of these lines. + double swiglu_limit = 10.0; + double clamp_exp = 0.0; + double clamp_shexp = 0.0; + const bool has_exp = + OptPerLayerFloat(gguf, p + "swiglu_clamp_exp", n_blocks, &clamp_exp); + const bool has_shexp = + OptPerLayerFloat(gguf, p + "swiglu_clamp_shexp", n_blocks, &clamp_shexp); + VT_CHECK(!(has_exp && has_shexp) || clamp_exp == clamp_shexp, + "glm5_next gguf: " + p + "swiglu_clamp_exp is " + + std::to_string(clamp_exp) + " and " + p + + "swiglu_clamp_shexp is " + std::to_string(clamp_shexp) + + ", but this architecture has ONE `swiglu_limit` for the routed " + "and the shared expert alike"); + if (has_exp) { + swiglu_limit = clamp_exp; + } else if (has_shexp) { + swiglu_limit = clamp_shexp; + } + text["swiglu_limit"] = swiglu_limit; raw["text_config"] = text; // The six placeholder ids, on the wrapper. Image and video share one id in diff --git a/src/vllm/model_executor/models/qwen3_dflash_internal.h b/src/vllm/model_executor/models/qwen3_dflash_internal.h index 54f9367a2b..7f517d30b0 100644 --- a/src/vllm/model_executor/models/qwen3_dflash_internal.h +++ b/src/vllm/model_executor/models/qwen3_dflash_internal.h @@ -216,19 +216,39 @@ inline DflashBlockPagedInputs DflashBlockPagedInputsOf(int64_t ctx_len, int64_t // so a non-captured run cannot see it; under capture the engine dies with // "operation not permitted when stream is capturing" (#2252). // -// One request, so the query_start_loc is exactly [0, tq). `max_seq_len` is the -// EXTENDED bound `ctx_len + tq`, the same value `DflashBlockPagedInputsOf` -// derives and this call already refuses on if it disagrees; an upper bound is -// explicitly safe because it only sizes grids (`ops.h:1551-1553`). +// One request, so the query_start_loc is exactly [0, tq); `tq` is `1 + k`, a +// constant for the life of the graph. +// +// `max_seq_len` MUST BE THE POOL CAPACITY, NOT `ctx_len + tq`. This call is +// captured into a CUDA graph ONCE and replayed on every later draft step, and +// `MakeDeviceKVStore` states the invariant that makes that legal: the +// persistent buffers never move, so "a captured graph reads the growing context +// purely through the in-place `seq_lens` value". A HOST value derived from the +// current `ctx_len` is baked into the graph at capture and is then STALE on +// every replay, because the context has grown -- which is an illegal memory +// access inside `cudaGraphLaunch`, not a wrong number. +// +// MEASURED, 2026-08-29, three arms on one boot at the smallest workload +// (`max_num_seqs=1`, c=1, 64 tokens): with `ctx_len + tq` the engine aborted +// 134 at `cudaMemcpyAsync`, and under `CUDA_LAUNCH_BLOCKING=1` at +// `cudaGraphLaunch`, naming the replay; with `VT_DFLASH_PAGED=0`, which +// bypasses this route entirely, the same binary exited 0. +// +// The capacity is replay-stable and is a valid bound: the store refuses a +// request whose `ctx_len + append + (1+k)` would exceed it +// (`runner.cpp`, the ctx-capacity fallback), so the read never addresses past +// it. An upper bound is explicitly safe here because it only sizes grids and +// rounded dims, while per-request geometry stays on the DEVICE values +// (`ops.h:1551-1553`). struct DflashBlockPagedHostMeta { std::array qsl{}; // [0, tq) for the block's single request int32_t max_seq_len = 0; // ctx_len + tq }; -inline DflashBlockPagedHostMeta DflashBlockPagedHostMetaOf(int64_t ctx_len, int64_t tq) { +inline DflashBlockPagedHostMeta DflashBlockPagedHostMetaOf(int64_t pool_capacity, int64_t tq) { DflashBlockPagedHostMeta m; m.qsl = {0, static_cast(tq)}; - m.max_seq_len = static_cast(ctx_len + tq); + m.max_seq_len = static_cast(pool_capacity); return m; } @@ -341,8 +361,12 @@ inline void DflashBlockPagedAttention(vt::Queue& q, vt::Tensor& out, const vt::T vt::ReshapeAndCache(q, block_k, block_v, pool_k, pool_v, slot_map); // #2252: `host_meta` outlives the call below, which is all it must do -- the // launcher reads the qsl on the host to size its grid before it launches. + // The POOL's capacity (pages x page rows), not this step's context length -- + // see the note on `DflashBlockPagedHostMetaOf`: a per-step value baked into a + // replayed graph reads out of bounds. + const int64_t pool_capacity = pool_k.shape[0] * pool_k.shape[1]; const DflashBlockPagedHostMeta host_meta = - DflashBlockPagedHostMetaOf(ctx_len, query.shape[0]); + DflashBlockPagedHostMetaOf(pool_capacity, query.shape[0]); const vt::PagedAttentionArgs pa = DflashBlockPagedArgsOf(scale, causal, sliding_window, query.shape[0], host_meta); vt::PagedAttention(q, out, query, pool_k, pool_v, block_table, seq_ext, cu, pa); diff --git a/src/vllm/model_executor/models/qwen4_exp_hc.cpp b/src/vllm/model_executor/models/qwen4_exp_hc.cpp index 56a8bff904..0745baeb8b 100644 --- a/src/vllm/model_executor/models/qwen4_exp_hc.cpp +++ b/src/vllm/model_executor/models/qwen4_exp_hc.cpp @@ -49,6 +49,17 @@ std::vector HcNormWeightFromHf(const std::vector& w_hf) { return w; } +// THERE IS A SECOND `GroupedRmsNorm` IN THIS NAMESPACE AND IT HAS THE OPPOSITE +// POLARITY. `qwen4_exp_ple.cpp:55` declares one at internal linkage that applies +// `out * (1.0 + weight)`, i.e. it takes the RAW HuggingFace gamma; this one +// applies `out * weight` and takes the FOLDED value, which callers reach through +// `HcNormWeightFromHf`. Nothing collides today -- the two signatures differ and +// `qwen4_exp_ple.cpp` does not include `qwen4_exp_hc.h` -- but a name shared +// across two conventions is exactly the arrangement that produced #2218, where +// the loader and `vt::Qwen4ExpGatedResidual` were each right about their own end +// and no suite composed them. The duplicate disappears when PLE's three norms +// move onto a standalone grouped-norm `vt::` op, which `## Owed` item 1 of +// `.agents/specs/qwen4-exp-flash-next.md` already carries. std::vector GroupedRmsNorm(const std::vector& x, const std::vector& weight, int64_t group_size, float eps) { if (group_size <= 0) { diff --git a/src/vllm/model_executor/models/qwen4_exp_ple.cpp b/src/vllm/model_executor/models/qwen4_exp_ple.cpp index c3ba1d383b..281e734c4e 100644 --- a/src/vllm/model_executor/models/qwen4_exp_ple.cpp +++ b/src/vllm/model_executor/models/qwen4_exp_ple.cpp @@ -52,6 +52,16 @@ void RefuseBadEos(const PleGeometry& geom) { // identity. Upstream promotes to float32 first; we accumulate in double, which // is a reference choice and not a divergence — the tolerance in the gate covers // the reduction-order difference against torch. +// +// THE OTHER `GroupedRmsNorm` IN THIS NAMESPACE HAS THE OPPOSITE POLARITY. +// `vllm::qwen4_exp::GroupedRmsNorm` (`qwen4_exp_hc.cpp:52`, declared in +// `qwen4_exp_hc.h`) applies `out * weight` on vLLM's FOLDED gamma, which callers +// build with `HcNormWeightFromHf`; this one adds the 1 itself and takes the raw +// HuggingFace parameter. This function keeps internal linkage and this file does +// not include `qwen4_exp_hc.h`, so the two cannot be confused by the compiler -- +// only by a reader, and a reader confusing two gamma conventions is #2218 +// exactly. Whichever of these survives PLE's move onto a standalone grouped-norm +// `vt::` op (`## Owed` item 1) should be the only one. void GroupedRmsNorm(const float* x, int64_t rows, int64_t width, int64_t group_size, double eps, const float* weight, float* out) { diff --git a/src/vllm/model_executor/models/qwen4_exp_registry.cpp b/src/vllm/model_executor/models/qwen4_exp_registry.cpp index b346a52e66..18b7aa4093 100644 --- a/src/vllm/model_executor/models/qwen4_exp_registry.cpp +++ b/src/vllm/model_executor/models/qwen4_exp_registry.cpp @@ -19,17 +19,26 @@ // entry, so there is no second architecture string to register. That is why // this row moves the MODEL row ratchet by ONE and not by two. // -// SCOPE HONESTY, RESTATED AT W5a (#2031). Registering this arch makes it -// RESOLVE, parse and validate its config, and — since W5a — LOAD a `qwen4exp` -// GGUF on a CPU device. It does NOT make it forward, and it does not make it -// serve: `ModelRegistry::Forward` and `make_kv_cache` both still refuse BY -// NAME, naming the wave that owes the work, so no token has been decoded by -// this architecture. The paragraph this replaces said the load refused too, -// which was true at W5a's parent and is not true here. That polarity matters -// more here than usual, because no oracle for this model runs on any hardware -// this project owns yet (`gateable = no`, blocked on memory rather than -// software), so there is no downstream token gate that would catch a forward -// returning plausible garbage. Refusing is the only safe default. +// SCOPE HONESTY, RESTATED AT W5c-1 (#2031). Registering this arch makes it +// RESOLVE, parse and validate its config, LOAD a `qwen4exp` GGUF on a CPU +// device (W5a) and PUBLISH its three KV-cache groups (W5c-1). `make_kv_cache` +// no longer refuses; the sentence that said it did was true at W5a's parent and +// stopped being true one wave later, which is the drift this paragraph keeps +// being rewritten to remove. +// +// THREE THINGS STILL REFUSE, and the one that matters is the FORWARD: no token +// has been decoded by this architecture. The other two are load-time and are +// listed here so that the count is checkable rather than rhetorical -- the +// SAFETENSORS arm refuses unconditionally at the end of +// `LoadQwen4ExpForConditionalGeneration` (every published artifact is larger +// than every device this project owns), and the GGUF arm refuses a source that +// names the kind without carrying a file. Both are stated at their own sites. +// +// The forward's polarity matters more here than usual, because no oracle for +// this model runs on any hardware this project owns yet (`gateable = no`, +// blocked on memory rather than software), so there is no downstream token gate +// that would catch a forward returning plausible garbage. Refusing is the only +// safe default. #include "vllm/model_executor/models/model_registry.h" #include "vllm/platforms/interface.h" // CurrentPlatform — the load-time device gate @@ -170,13 +179,29 @@ ForwardLogits ForwardQwen4ExpForConditionalGeneration( // And `[[noreturn]]` on a non-void return type is MSVC C4646, promoted to // C2220 under /W4 /WX; `check-windows-portability.py` caught that on the // first draft of this function. + // THE REFUSAL NAMES WHAT IS ACTUALLY MISSING, AND IT IS NOT WHAT IT SAID. + // Until #2031's W5b survey this message still owed the n-gram embedding to + // W2, the gated residual to W3 and Qwen Sparse Attention to W4 — all three + // landed waves. A refusal that names finished work sends the next reader to + // rebuild it. The five below are measured against this tree, each one + // independently sufficient to stop a token, and each is carried under + // `## Owed` in the row spec. VT_CHECK(false, - "Qwen4ExpForConditionalGeneration: the forward is not ported yet. W2 " - "owes the hashed n-gram embedding and the PLE dilated depthwise conv, " - "W3 the gated-residual hyper-connection stream, W4 Qwen Sparse " - "Attention and its indexer side cache, and W5 the assembled forward, " - "vision path and MTP. See .agents/specs/qwen4-exp-flash-next.md and " - "issue #1978."); + "Qwen4ExpForConditionalGeneration: the forward is not ported yet. " + "The ops and block seams ARE on main (W2/W3/W4/W6a/W5a/W5b-1..5, " + "W5c-1); what the layer loop still lacks is (1) a standalone grouped " + "RMSNorm op — the PLE block needs three and the only grouped " + "reduction in this tree is fused inside vt::Qwen4ExpGatedResidual; " + "(2) a PAGED Qwen Sparse Attention consumer — RunQwen4ExpQsaBlock " + "takes contiguous [max_kv, ...] caches while make_kv_cache publishes " + "paged ones; (3) reach for the indexer side cache, whose group-2 " + "block table GPUModelRunner::gather_block_table never gathers " + "(W5c-2); (4) an adapter from the stacked [E, I, H] qwen4_exp MoE " + "tensors onto MoeBlockWeights; and (5) a mRoPE cos/sin builder with " + "external linkage — qwen3_5.cpp's BuildMropeCosSinHost is static. " + "ModelRegistry::Forward additionally refuses any multi-cache " + "topology by name, and this model publishes one. See " + ".agents/specs/qwen4-exp-flash-next.md and issues #2031 and #1978."); return ForwardLogits{}; // unreachable; VT_CHECK always throws here } diff --git a/src/vt/cpu/cpu_qwen4_exp.cpp b/src/vt/cpu/cpu_qwen4_exp.cpp index 51845702f2..4e6ec22a4d 100644 --- a/src/vt/cpu/cpu_qwen4_exp.cpp +++ b/src/vt/cpu/cpu_qwen4_exp.cpp @@ -151,9 +151,30 @@ void Qwen4ExpGatedResidualKernel(Queue&, Tensor& mixed, Tensor* injection, // eps is INSIDE the rsqrt, added to the MEAN SQUARE, never to the norm. const float r = 1.0f / std::sqrt(static_cast(ss / static_cast(H)) + eps); + // THE `1 +` IS THE OP'S, AND IT IS NOT AN ALTERNATIVE PARAMETERIZATION. + // `Qwen4ExpTextRMSNorm.forward` is `output * (1.0 + self.weight.float())` + // over a ZERO-initialised gamma (modeling_qwen4_exp.py:173-178), and + // `hc_norm_w` is that gamma — the raw HuggingFace parameter, exactly as + // `Qwen4ExpGdnWeights`/`Qwen4ExpQsaWeights`/`Qwen4ExpPleWeights` carry + // every other gamma of this architecture and exactly as + // `vt::RmsNorm(gemma=true)` and `vt::Qwen4ExpQsaCompress` already read + // them. This op used to demand the FOLDED form instead, which made it the + // one consumer in the model disagreeing with the loader, and handing it + // the loaded weight scaled every hyper-connection norm by a gamma centred + // on zero. See #2218 and the composition case in + // tests/vllm/models/test_qwen4_exp_forward.cpp, which is the only gate + // that can see the disagreement: both halves are individually correct. + // + // THE FOLD IS f32, and that is upstream's width and not a convenience: + // `output * (1.0 + self.weight.float())` (:177) folds a Python weak `1.0` + // into an fp32 tensor, so the promotion stays fp32. Every host reference + // that widens its reduction to double folds in `float` first for the same + // reason (`test_qwen4_exp_hc_device.cpp`'s wide-accumulator case), so the + // widening isolates the reduction rather than also moving the multiplier. for (int64_t h = 0; h < H; ++h) { normed[static_cast(g0 + h)] = - LoadF32At(hyper, base + g0 + h) * r * LoadF32At(hc_norm_w, g0 + h); + LoadF32At(hyper, base + g0 + h) * r * + (1.0f + LoadF32At(hc_norm_w, g0 + h)); } } diff --git a/src/vt/rocm/rocm_paged_attn.hip b/src/vt/rocm/rocm_paged_attn.hip index 6cd77bd100..44bfe88a39 100644 --- a/src/vt/rocm/rocm_paged_attn.hip +++ b/src/vt/rocm/rocm_paged_attn.hip @@ -180,6 +180,19 @@ __device__ inline float LoadKv(const uint8_t* p, int64_t i, float scale) { __device__ inline float Softcap(float s, float cap) { return cap > 0.f ? cap * tanhf(s / cap) : s; } +template +__device__ inline void LoadRowEplF32(const float* p, int64_t base, int lane, float r[EPL]) { + static_assert(EPL == 4 || EPL == 8 || EPL == 16, "EPL"); +#pragma unroll + for (int i = 0; i < EPL; ++i) r[i] = p[base + lane * EPL + i]; +} + +template +__device__ inline void StoreRowEplF32(float* p, int64_t base, int lane, const float r[EPL]) { + static_assert(EPL == 4 || EPL == 8 || EPL == 16, "EPL"); +#pragma unroll + for (int i = 0; i < EPL; ++i) p[base + lane * EPL + i] = r[i]; +} // gfx1201: exp2 is the native path; expf often lowers slower. Used in online softmax. __device__ inline float FastExp(float x) { @@ -354,6 +367,72 @@ __device__ inline void LoadRowEplBf16(const __hip_bfloat16* p, int64_t base, int } } +// fp8-e4m3 vectorized row load: EPL bytes per lane, dequant + scale. +// Mirrors LoadRowEplBf16's vectorized load pattern, adapted for 1-byte elements. +template +__device__ inline void LoadRowEplFp8(const uint8_t* p, int64_t base, int lane, + float scale, float r[EPL]) { + static_assert(EPL == 4 || EPL == 8 || EPL == 16, "EPL"); + if constexpr (EPL == 4) { + const uint32_t* u = reinterpret_cast(p + base); + const uint32_t w = u[lane]; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i] = F8E4M3ToF32Dev(static_cast((w >> (i * 8)) & 0xFF)) * scale; + } else if constexpr (EPL == 8) { + const uint2* u = reinterpret_cast(p + base); + const uint2 w = u[lane]; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i] = F8E4M3ToF32Dev(static_cast((w.x >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 4] = F8E4M3ToF32Dev(static_cast((w.y >> (i * 8)) & 0xFF)) * scale; + } else { + const uint4* u = reinterpret_cast(p + base); + const uint4 w0 = u[lane]; + const uint4 w1 = u[lane + 32]; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i] = F8E4M3ToF32Dev(static_cast((w0.x >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 4] = F8E4M3ToF32Dev(static_cast((w0.y >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 8] = F8E4M3ToF32Dev(static_cast((w0.z >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 12] = F8E4M3ToF32Dev(static_cast((w0.w >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 16] = F8E4M3ToF32Dev(static_cast((w1.x >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 20] = F8E4M3ToF32Dev(static_cast((w1.y >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 24] = F8E4M3ToF32Dev(static_cast((w1.z >> (i * 8)) & 0xFF)) * scale; +#pragma unroll + for (int i = 0; i < 4; ++i) + r[i + 28] = F8E4M3ToF32Dev(static_cast((w1.w >> (i * 8)) & 0xFF)) * scale; + } +} + +// Generic KV row load: dispatches to LoadRowEplBf16 or LoadRowEplFp8 based on TKV. +// For bf16, scale is unused (inert). For fp8, dequant + scale. +template +__device__ inline void LoadRowEplKv(const TKV* p, int64_t base, int lane, + float scale, float r[EPL]) { + if constexpr (std::is_same_v) { + (void)scale; + LoadRowEplBf16(p, base, lane, r); + } else { + LoadRowEplFp8(p, base, lane, scale, r); + } +} + + template __device__ inline void StoreRowEplBf16(__hip_bfloat16* p, int64_t base, int lane, const float r[EPL]) { static_assert(EPL == 4 || EPL == 8 || EPL == 16, "EPL"); @@ -657,6 +736,152 @@ __global__ void PagedAttnDecodeGqaBf16(__hip_bfloat16* out, const __hip_bfloat16 } +template +__global__ void PagedAttnDecodeGqaF32Q(float* out, const float* query_f32, + const TKV* k_cache, const TKV* v_cache, + const int32_t* block_table, const int32_t* seq_lens, + const int32_t* query_start_loc, int64_t num_reqs, int64_t hq, + int64_t num_kv_heads, int64_t d, int64_t block_size, + int64_t bt_row, int64_t bt_col, int64_t kc_blk, int64_t kc_pg, + int64_t kc_hd, int64_t vc_blk, int64_t vc_pg, int64_t vc_hd, + float scale, float softcap, bool causal, int window_left, + int window_right, float k_scale, + float v_scale) { + constexpr int kEpl = EPL; + constexpr int d_expect = kEpl * 32; + const int64_t t = blockIdx.x; + const int64_t g = blockIdx.y; + const int warp = static_cast(threadIdx.x) >> 5; + const int lane = static_cast(threadIdx.x) & 31; + if (g >= num_kv_heads || d != d_expect) return; + + int64_t r = -1, q0 = 0, q1 = 0; + if (num_reqs == 1) { + r = 0; + q0 = query_start_loc[0]; + q1 = query_start_loc[1]; + if (t < q0 || t >= q1) return; + } else { + for (int64_t rr = 0; rr < num_reqs; ++rr) { + const int64_t a = query_start_loc[rr], b = query_start_loc[rr + 1]; + if (t >= a && t < b) { + r = rr; + q0 = a; + q1 = b; + break; + } + } + if (r < 0) return; + } + + const int64_t query_len = q1 - q0; + const int64_t seqlen = seq_lens[r]; + const int64_t context = seqlen - query_len; + const int64_t p = context + (t - q0); + int64_t jmin = 0; + if (window_left >= 0) { + jmin = p - window_left; + if (jmin < 0) jmin = 0; + } + int64_t jmax = causal ? p : (seqlen - 1); + if (window_right >= 0) { + const int64_t jr = p + window_right; + if (jr < jmax) jmax = jr; + } + if (jmax > seqlen - 1) jmax = seqlen - 1; + + const int64_t qg_total = hq / num_kv_heads; + // blockIdx.z splits a large GQA group into QG-sized tiles (e.g. global QG=8 → two×4). + const int64_t h0 = g * qg_total + static_cast(blockIdx.z) * QG; + if (h0 + QG > (g + 1) * qg_total) return; + float q_reg[QG][kEpl]; +#pragma unroll + for (int hh = 0; hh < QG; ++hh) + LoadRowEplF32(query_f32, (t * hq + (h0 + hh)) * d, lane, q_reg[hh]); + + float m[QG], lsum[QG]; + float o_reg[QG][kEpl]; +#pragma unroll + for (int hh = 0; hh < QG; ++hh) { + m[hh] = -INFINITY; + lsum[hh] = 0.f; +#pragma unroll + for (int i = 0; i < kEpl; ++i) o_reg[hh][i] = 0.f; + } + + for (int64_t j = jmin + warp; j <= jmax; j += NWARPS) { + const int64_t blk = block_table[r * bt_row + (j / block_size) * bt_col]; + const int64_t off = j % block_size; + float k_reg[kEpl]; + LoadRowEplKv(k_cache, blk * kc_blk + off * kc_pg + g * kc_hd, lane, k_scale, k_reg); + + float s_h[QG]; +#pragma unroll + for (int hh = 0; hh < QG; ++hh) { + float dot = 0.f; +#pragma unroll + for (int i = 0; i < kEpl; ++i) dot += q_reg[hh][i] * k_reg[i]; +#pragma unroll + for (int o = 16; o > 0; o >>= 1) dot += __shfl_down(dot, o); + s_h[hh] = Softcap(__shfl(dot, 0) * scale, softcap); + } + + float v_reg[kEpl]; + LoadRowEplKv(v_cache, blk * vc_blk + off * vc_pg + g * vc_hd, lane, v_scale, v_reg); +#pragma unroll + for (int hh = 0; hh < QG; ++hh) { + const float m_new = fmaxf(m[hh], s_h[hh]); + const float corr = FastExp(m[hh] - m_new); + const float pw = FastExp(s_h[hh] - m_new); +#pragma unroll + for (int i = 0; i < kEpl; ++i) o_reg[hh][i] = o_reg[hh][i] * corr + pw * v_reg[i]; + lsum[hh] = lsum[hh] * corr + pw; + m[hh] = m_new; + } + } + + extern __shared__ float smem_gqa[]; + float* o_sh = smem_gqa; + float* m_sh = o_sh + static_cast(NWARPS) * QG * d; + float* l_sh = m_sh + static_cast(NWARPS) * QG; +#pragma unroll + for (int hh = 0; hh < QG; ++hh) { + float* dst = o_sh + (static_cast(warp) * QG + hh) * d + lane * kEpl; +#pragma unroll + for (int i = 0; i < kEpl; ++i) dst[i] = o_reg[hh][i]; + if (lane == 0) { + m_sh[warp * QG + hh] = m[hh]; + l_sh[warp * QG + hh] = lsum[hh]; + } + } + __syncthreads(); + + for (int hh = warp; hh < QG; hh += NWARPS) { + float gm = -INFINITY; +#pragma unroll + for (int w = 0; w < NWARPS; ++w) gm = fmaxf(gm, m_sh[w * QG + hh]); + float gl = 0.f; + float acc[kEpl]; +#pragma unroll + for (int i = 0; i < kEpl; ++i) acc[i] = 0.f; +#pragma unroll + for (int w = 0; w < NWARPS; ++w) { + const float sc = FastExp(m_sh[w * QG + hh] - gm); + gl += l_sh[w * QG + hh] * sc; + const float* src = o_sh + (static_cast(w) * QG + hh) * d + lane * kEpl; +#pragma unroll + for (int i = 0; i < kEpl; ++i) acc[i] += sc * src[i]; + } + const float inv = (gl > 0.f) ? (1.f / gl) : 0.f; +#pragma unroll + for (int i = 0; i < kEpl; ++i) acc[i] *= inv; + StoreRowEplF32(out, (t * hq + (h0 + hh)) * d, lane, acc); + } +} + + + + // SGLang-style flash prefill GQA (steal base 2026-08-10): // BLOCK_M queries × BLOCK_N keys, Q+K tiles in smem, online softmax. // HIP default tiles from extend_attention: BLOCK_M=64, BLOCK_N=64. @@ -2011,6 +2236,79 @@ void PagedAttentionKernelRocm(Queue& q, Tensor& out, const Tensor& query, const const size_t smem = sizeof(float) * (static_cast(d) + threads); dim3 grid(static_cast(total_q), static_cast(hq)); + // F32-query decode GQA arm (GFX1100-TG150): the GGUF dense path runs + // attention with an f32 query and f32 output over a bf16 KV cache + // ("Phase 1" numerics), which excludes every bf16-decode kernel above and + // falls to the generic PagedAttnOnline — a per-context-token + // __syncthreads() walk measuring ~803us/call on the RX 7900 XTX (6.1 + // ms/token across the model's full-attention layers). This arm routes that + // exact dtype combination through the DecodeGqa geometry (QG=4 fused + // q-heads per KV group, warp-strided sequence walk). DEFAULT OFF via + // VT_ATTN_DECODE_GQA4=1: correctness-complete but the reduction order + // differs from PagedAttnOnline's, so greedy anchors can move at exact ties. + static const bool decode_gqa4_f32q = [] { + const char* e = std::getenv("VT_ATTN_DECODE_GQA4"); + return e != nullptr && e[0] == '1'; + }(); + if (decode_opt && decode_gqa4_f32q && total_q <= hq && + query.dtype == DType::kF32 && out.dtype == DType::kF32 && + (k_cache.dtype == DType::kBF16 && v_cache.dtype == DType::kBF16 || + k_cache.dtype == DType::kI8 && v_cache.dtype == DType::kI8 && + args.kv_cache_dtype != Fp8KVCacheDataType::kAuto) && + (d == 128 || d == 256) && hq == 16 && num_kv_heads == 4) { + constexpr int kDecWarpsG = 8; + constexpr int kQgG = 4; + constexpr int kNwarpsG = kDecWarpsG; + // EPL = d / 32 lanes: 8 at head_dim 256 (the original TG150 arm), 4 at + // head_dim 128 (Qwen3-dense class, TG200). One kernel body; it + // self-guards d != EPL*32, so a wrong pairing exits without reading. + const size_t smem = sizeof(float) * + (static_cast(kNwarpsG) * kQgG * static_cast(d) + + 2 * static_cast(kNwarpsG) * kQgG); + dim3 grid3(static_cast(total_q), static_cast(num_kv_heads), 1); + dim3 block3(static_cast(kNwarpsG * 32)); + const bool gqa4_fp8 = k_cache.dtype == DType::kI8; + if (d == 128) { + if (gqa4_fp8) { + PagedAttnDecodeGqaF32Q<<>>( + out.Ptr(), query.Ptr(), k_cache.Ptr(), + v_cache.Ptr(), block_table.Ptr(), seq_lens.Ptr(), + query_start_loc.Ptr(), num_reqs, hq, num_kv_heads, d, block_size, + bt_row, bt_col, kc_blk, kc_pg, kc_hd, vc_blk, vc_pg, vc_hd, args.scale, + args.logits_soft_cap, args.causal, window_left, window_right, + args.k_scale, args.v_scale); + } else { + PagedAttnDecodeGqaF32Q<<>>( + out.Ptr(), query.Ptr(), k_cache.Ptr<__hip_bfloat16>(), + v_cache.Ptr<__hip_bfloat16>(), block_table.Ptr(), seq_lens.Ptr(), + query_start_loc.Ptr(), num_reqs, hq, num_kv_heads, d, block_size, + bt_row, bt_col, kc_blk, kc_pg, kc_hd, vc_blk, vc_pg, vc_hd, args.scale, + args.logits_soft_cap, args.causal, window_left, window_right, + 1.0f, 1.0f); + } + } else { + if (gqa4_fp8) { + PagedAttnDecodeGqaF32Q<<>>( + out.Ptr(), query.Ptr(), k_cache.Ptr(), + v_cache.Ptr(), block_table.Ptr(), seq_lens.Ptr(), + query_start_loc.Ptr(), num_reqs, hq, num_kv_heads, d, block_size, + bt_row, bt_col, kc_blk, kc_pg, kc_hd, vc_blk, vc_pg, vc_hd, args.scale, + args.logits_soft_cap, args.causal, window_left, window_right, + args.k_scale, args.v_scale); + } else { + PagedAttnDecodeGqaF32Q<<>>( + out.Ptr(), query.Ptr(), k_cache.Ptr<__hip_bfloat16>(), + v_cache.Ptr<__hip_bfloat16>(), block_table.Ptr(), seq_lens.Ptr(), + query_start_loc.Ptr(), num_reqs, hq, num_kv_heads, d, block_size, + bt_row, bt_col, kc_blk, kc_pg, kc_hd, vc_blk, vc_pg, vc_hd, args.scale, + args.logits_soft_cap, args.causal, window_left, window_right, + 1.0f, 1.0f); + } + } + Check(hipGetLastError(), "paged_attention decode_gqa_f32q launch"); + + return; + } auto launch = [&](auto q_tag, auto kv_tag, auto o_tag) { using TQ = decltype(q_tag); using TKV = decltype(kv_tag); diff --git a/src/vt/tenstorrent/tenstorrent_device.h b/src/vt/tenstorrent/tenstorrent_device.h index 6ca40481d7..31f43e4363 100644 --- a/src/vt/tenstorrent/tenstorrent_device.h +++ b/src/vt/tenstorrent/tenstorrent_device.h @@ -265,6 +265,17 @@ struct StagingStats { uint64_t uploads_bulk_bf16 = 0; // bulk bf16 uploads from EnsureDevice2D uint64_t staged_bulk_bf16_bytes = 0; uint64_t staged_f32_elems = 0; // elements staged via the f32 path + // ---- BACKEND-TENSTORRENT-QWEN35 W5 (#2244): allocation-free staging ---- + // Subdivision of uploads_bulk_bf16 (which keeps counting every bulk bf16 + // staging, W4 route pin unchanged). uploads_persistent_bf16 counts the + // uploads written IN PLACE into the per-slot persistent device buffer via + // the mesh command queue; uploads_persistent_allocs counts the (re) + // allocations of that buffer — cold slot or staging-geometry change. A + // steady-state decode step must show allocs == 0 and every bulk upload on + // the persistent route; churn in allocs is visible debt for the profile. + uint64_t uploads_persistent_bf16 = 0; + uint64_t uploads_persistent_allocs = 0; + uint64_t staged_persistent_bf16_bytes = 0; }; #ifdef VLLM_CPP_TENSTORRENT StagingStats GetStagingStats(); diff --git a/src/vt/tenstorrent/tenstorrent_ops.cpp b/src/vt/tenstorrent/tenstorrent_ops.cpp index f5647fe1b9..7fedf0c889 100644 --- a/src/vt/tenstorrent/tenstorrent_ops.cpp +++ b/src/vt/tenstorrent/tenstorrent_ops.cpp @@ -57,6 +57,11 @@ // type and the span view it takes. #include #include +// W5 (#2244): the persistent staging route re-uploads through tt-metal's +// in-place H2D (ttnn::copy_to_device) instead of a fresh from_span creation. +#include +#include +#include #include #include #include @@ -284,6 +289,19 @@ struct BufferSlot { // write that replaces the shadow with a different layout. bool conv_transposed = false; uint32_t conv_slots = 0, conv_c = 0, conv_sl = 0; + // BACKEND-TENSTORRENT-QWEN35 W5 (#2244): the slot's PERSISTENT staged-device + // buffer. Allocated once per (slot, staging geometry) by the bulk bf16 arm + // and rewritten IN PLACE through the mesh command queue on every later + // staging, so an identical-geometry upload no longer pays from_span's fresh + // MeshBuffer allocation / tensor creation path. `device` above remains the + // consumer-visible shadow (dropped by every host write, replaced by commits + // and reshapes); `persistent` survives those drops and holds the resident + // device allocation. Its content is only ever observed through a shadow + // that a full staging write has just refreshed, so a stale resident buffer + // is unreachable. The slot lives in the never-destroyed Slots() map + // (#1486), so the tensor is never destroyed after tt-metal teardown. + std::optional persistent; + uint32_t persist_rows = 0, persist_cols = 0; }; std::mutex& SlotMutex() { @@ -459,6 +477,21 @@ std::atomic& StagingF32Elems() { static std::atomic v{0}; return v; } +// W5 (#2244): the persistent-route counters — in-place mesh CQ writes into +// the per-slot buffer, the (re)allocations of that buffer, and the bytes +// pushed through it. Read through GetStagingStats below. +std::atomic& StagingPersistentWrites() { + static std::atomic v{0}; + return v; +} +std::atomic& StagingPersistentAllocs() { + static std::atomic v{0}; + return v; +} +std::atomic& StagingPersistentBytes() { + static std::atomic v{0}; + return v; +} // W4 lever 1 (#2107): bulk upload of a contiguous bf16 master. The host // bytes ARE the payload: one from_span over the tensor's own memory — no f32 @@ -466,6 +499,21 @@ std::atomic& StagingF32Elems() { // bfloat16::from_float round-trip on the ttnn side. Bit-identical to the f32 // path: bf16→f32 widening is exact, and packing a value whose low 16 mantissa // bits are zero back to bf16 returns the same bits under any rounding rule. +// +// W5 (#2244): the upload no longer pays tt-metal's per-upload creation path +// on every step. A tracked base slot stages through its PERSISTENT device +// buffer: the first staging for a geometry runs the full from_span creation +// (and the buffer stays resident in the slot); every later staging packs the +// host bytes with the SAME function from_span calls (tt-metal +// host_tensor_from_span_with_pad_value, ttnn/core/tensor/tensor.cpp:170) and +// writes them through tt-metal's in-place H2D — ttnn::copy_to_device into the +// resident MeshTensor, which reaches MeshCommandQueue::enqueue_write / +// enqueue_write_shards against the existing buffer. No fresh MeshBuffer, no +// cluster/chip rediscovery, no new tensor attributes. The bytes on the device +// are the same packed bytes from_span writes, into a buffer of the same +// geometry, fully overwritten each time: bit-identical. Untracked pointers +// and interior views keep the anonymous from_span arm (W2c: a view must +// never store against the base slot). ttnn::Tensor UploadRowsBf16(const Tensor& t, uint32_t rows, uint32_t cols, MeshDevice& device) { if (std::getenv("VT_TT_TRACE_DEBUG") != nullptr && tt_capture_active()) @@ -474,8 +522,67 @@ ttnn::Tensor UploadRowsBf16(const Tensor& t, uint32_t rows, uint32_t cols, // The bytes at t.Ptr are the window's own bf16 bits (bfloat16 is a 2-byte // class wrapping the same uint16 pattern). const bfloat16* src = reinterpret_cast(t.Ptr()); - return ttnn::Tensor::from_span(ttsl::Span(src, n), - TileSpecOf(rows, cols), &device); + // Short locked probe: resolve the persistent buffer once. The CQ write runs + // OUTSIDE the lock — the same probe/upload/re-lock discipline EnsureDevice2D + // uses — and the copied handle (shared TensorAttributes) keeps the resident + // MeshBuffer alive even if the slot is unregistered mid-upload. + std::optional persistent; + bool tracked_base = false; + { + std::lock_guard g(SlotMutex()); + BufferSlot* s = FindSlot(t.data); + tracked_base = (s != nullptr && t.data == s->host); + if (tracked_base && s->persistent.has_value() && + s->persist_rows == rows && s->persist_cols == cols) { + persistent = s->persistent; + } + } + if (!tracked_base) { + // Untracked pointer or interior view (W2c): anonymous staging, no + // persistent buffer, no W5 counters — the caller's bulk counters still see it. + return ttnn::Tensor::from_span(ttsl::Span(src, n), + TileSpecOf(rows, cols), &device); + } + if (persistent.has_value()) { + // In-place arm. Host half: exactly the packing from_span performs + // (tt-metal ttnn/core/tensor/tensor.cpp:170 — same function, same spec, + // same pad), with no device argument so no device work happens. Device + // half: tt-metal's own in-place H2D (ttnn/core/tensor/tensor_ops.cpp:161 + // copy_to_device → enqueue_write_tensor into the EXISTING MeshTensor, + // tt_metal/impl/tensor/tensor_apis.cpp:149) — the same write path + // from_span's to_device takes, minus the fresh MeshBuffer allocation and + // tensor creation. Bytes on the device are the same packed bytes, into a + // buffer of the same geometry, fully overwritten: bit-identical. + if (std::getenv("VT_TT_TRACE_DEBUG") != nullptr && tt_capture_active()) + std::fprintf(stderr, "[TT-UP] UploadRowsBf16 persistent enqueue_write during capture\n"); + ttnn::Tensor host = ttnn::Tensor::from_span(ttsl::Span(src, n), + TileSpecOf(rows, cols), + /*device=*/nullptr); + ttnn::copy_to_device(host, *persistent); + StagingPersistentWrites().fetch_add(1, std::memory_order_relaxed); + StagingPersistentBytes().fetch_add(static_cast(n) * 2, + std::memory_order_relaxed); + return *persistent; + } + // Allocating arm (cold slot or staging-geometry change): the full W4 + // creation path, and the returned tensor becomes the slot's persistent + // buffer. The stale resident buffer, if any, is released here. + ttnn::Tensor dev = ttnn::Tensor::from_span(ttsl::Span(src, n), + TileSpecOf(rows, cols), &device); + { + std::lock_guard g(SlotMutex()); + BufferSlot* s = FindSlot(t.data); + if (s != nullptr && t.data == s->host) { + s->persistent = dev; + s->persist_rows = rows; + s->persist_cols = cols; + } + } + StagingPersistentWrites().fetch_add(1, std::memory_order_relaxed); + StagingPersistentAllocs().fetch_add(1, std::memory_order_relaxed); + StagingPersistentBytes().fetch_add(static_cast(n) * 2, + std::memory_order_relaxed); + return dev; } // Return a TILE BFLOAT16 device tensor for rank-2 `t`, uploading only when the @@ -6280,6 +6387,12 @@ StagingStats GetStagingStats() { s.uploads_bulk_bf16 = StagingBulkUploads().load(std::memory_order_relaxed); s.staged_bulk_bf16_bytes = StagingBulkBytes().load(std::memory_order_relaxed); s.staged_f32_elems = StagingF32Elems().load(std::memory_order_relaxed); + s.uploads_persistent_bf16 = + StagingPersistentWrites().load(std::memory_order_relaxed); + s.uploads_persistent_allocs = + StagingPersistentAllocs().load(std::memory_order_relaxed); + s.staged_persistent_bf16_bytes = + StagingPersistentBytes().load(std::memory_order_relaxed); return s; } @@ -6287,6 +6400,9 @@ void ResetStagingStats() { StagingBulkUploads().store(0, std::memory_order_relaxed); StagingBulkBytes().store(0, std::memory_order_relaxed); StagingF32Elems().store(0, std::memory_order_relaxed); + StagingPersistentWrites().store(0, std::memory_order_relaxed); + StagingPersistentAllocs().store(0, std::memory_order_relaxed); + StagingPersistentBytes().store(0, std::memory_order_relaxed); } } // namespace vt::tenstorrent diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5904c7644b..e5247567fd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3316,6 +3316,15 @@ target_include_directories(test_qwen4_exp_qsa_block PRIVATE ${CMAKE_SOURCE_DIR}/ vllm_cpp_add_test(test_qwen4_exp_hc_device vllm/models/test_qwen4_exp_hc_device.cpp) target_include_directories(test_qwen4_exp_hc_device PRIVATE ${CMAKE_SOURCE_DIR}/src) +# MODEL-MM-QWEN4-EXP W5b (#2031) -- the assembled layer loop, and the +# loader/op compositions no single-sided suite can see. Needs `src/` for the +# model-private qwen4_exp headers. It takes NO checkpoint fixture dir: the suite +# builds its `qwen4exp` file in memory through +# `tests/support/qwen4_exp_gguf_fixture.h` and parses no released config, so a +# `QWEN4_EXP_CKPT_FIXTURE_DIR` grant here would compile a path nothing reads. +vllm_cpp_add_test(test_qwen4_exp_forward vllm/models/test_qwen4_exp_forward.cpp) +target_include_directories(test_qwen4_exp_forward PRIVATE ${CMAKE_SOURCE_DIR}/src) + # The five suites that reach src/vllm/support/platform_compat.h, granted per # target rather than globally: the file already carries 123 explicit # ${CMAKE_SOURCE_DIR}/src grants, and a blanket one in vllm_cpp_add_test would diff --git a/tests/support/qwen4_exp_gguf_fixture.h b/tests/support/qwen4_exp_gguf_fixture.h new file mode 100644 index 0000000000..f06eefaed5 --- /dev/null +++ b/tests/support/qwen4_exp_gguf_fixture.h @@ -0,0 +1,452 @@ +// The synthetic tiny `qwen4exp` GGUF, shared by every suite that needs a REAL +// `Qwen4ExpLoadedModel` rather than a hand-built `Qwen4ExpWeights`. +// +// EXTRACTED VERBATIM from `tests/vllm/models/test_qwen4_exp_gguf_weights.cpp` +// (W5a, #2031) when the W5b layer loop needed the same file. The extraction is +// not tidiness: the loader suite chose every dimension below to make a specific +// defect expressible — `kNumKHeads` 2 against `kNumVHeads` 6 so the V-head +// permutation is not its own inverse, `kPleRow` 96 so `ple_embed_dim` is +// distinct from both `hidden_size` and `hidden_size * ngram_heads`, gammas on a +// bf16-exact `1 + k/128` grid so the convert-time `+1` fold cannot round away — +// and a SECOND builder would be free to disagree with all of it. The pair that +// must agree here is exactly the pair a copy would let diverge: the suite that +// gates what the loader PRODUCED and the suite that gates what the forward +// CONSUMES. +// +// Everything is `inline` in one namespace, so both suites link one definition. +// No case lives here — a fixture that asserts is a suite. +// +// THE GAMMA POLARITY THIS FIXTURE CARRIES IS THE FILE'S, NOT THE LOADER'S. +// `NormValue` returns `1 + k/128`, centred on 1.0, because that is what +// ggml-org/llama.cpp#27742 writes: every tensor whose name ends in +// `norm.weight` is stored with the `+1` fold applied, `linear_attn.norm.weight` +// excepted. `LoadNormBf16(..., unshift=true)` inverts it, so what a loaded +// `Qwen4ExpWeights` holds is the RAW HuggingFace gamma centred on 0. A test +// that wants the value the FILE carried must call `NormValue` and a test that +// wants the value the MODEL holds must subtract one. Composing the two is what +// #2218 was about. +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "vllm/gguf_builder.h" +#include "vllm/model_executor/model_loader/gguf_reader.h" +#include "vllm/model_executor/models/model_registry.h" +#include "vllm/model_executor/models/qwen3_5.h" // ForwardLogits complete type +#include "vllm/model_executor/models/qwen4_exp.h" +#include "vllm/model_executor/models/qwen4_exp_gguf_weights.h" +#include "vllm/model_executor/models/qwen4_exp_weights.h" +#include "vllm/transformers_utils/hf_config.h" +#include "vt/dtype.h" +#include "vt/quant.h" + +namespace qwen4_exp_fixture { + +using gguf_test::F32Kv; +using gguf_test::GgufModelBuilder; +using gguf_test::I32ArrayKv; +using gguf_test::StrKv; +using gguf_test::TempFile; +using gguf_test::U32Kv; + +// ── the tiny fixture geometry ──────────────────────────────────────────────── +// +// Every dimension is the smallest one that keeps a STRUCTURE the released +// config has and that a smaller value would erase: +// +// * `kNumKHeads` is 2, not 1. The V-head reorder maps grouped head `k*R + r` +// to tiled head `r*K + k`; at K = 1 that is the identity, so a fixture with +// one key head cannot tell a correct un-reorder from no un-reorder at all. +// * `kLayers` is 4 with `full_attention_interval` 4, which is the released +// 3-linear-then-1-sparse pattern at its shortest: layers 0..2 are Gated +// DeltaNet and layer 3 is QSA, so both arms of the per-layer branch run. +// * `kHeadsPerNgram` is 1, and `kPleRow` (below) is a multiple of 32, so +// `head_dim_per_ngram` is 96 — three whole Q8_0 blocks. The n-gram table is +// the ONE gather this model keeps quantized (W6a, #1989), and a table whose +// row is not a whole number of blocks could not exercise that at all. +constexpr int64_t kH = 64; // hidden_size +constexpr int64_t kLayers = 4; // 0,1,2 linear_attention; 3 qwen_sparse_attention +constexpr int64_t kVocab = 16; +constexpr int64_t kHcCount = 2; +constexpr int64_t kHcLowrank = 8; +constexpr int64_t kStream = kHcCount * kH; // the residual stream width, 128 +constexpr int64_t kExperts = 2; +constexpr int64_t kExpertsPerTok = 1; +constexpr int64_t kMoeI = 8; +constexpr int64_t kSharedI = 8; +constexpr int64_t kQHeads = 2; +constexpr int64_t kKvHeads = 1; +constexpr int64_t kHeadDim = 8; +constexpr int64_t kRotaryDim = 4; +constexpr int64_t kIdxHeads = 2; +constexpr int64_t kIdxKvHeads = 1; +constexpr int64_t kIdxHeadDim = 8; +constexpr int64_t kIdxBudget = 8; +constexpr int64_t kCompressRatio = 4; +constexpr int64_t kNumKHeads = 2; // linear_num_key_heads +// SIX, not four, and the reason is a mutation this gate failed before it was +// six. The V-head reorder maps grouped head `k*R + r` to tiled head `r*K + k`. +// At K == R that permutation is its OWN INVERSE, so a loader that applied the +// map in the wrong direction produced byte-identical output and the whole +// reorder suite stayed green (mutation M5). K = 2 with R = 3 is the smallest +// pair where the map and its inverse differ, and it is also the released +// model's own ratio: 16 key heads to 48 value heads is R = 3. +constexpr int64_t kNumVHeads = 6; // linear_num_value_heads +constexpr int64_t kLinHeadDim = 8; // linear_{key,value}_head_dim +constexpr int64_t kConvKernel = 4; +constexpr int64_t kNgramSize = 3; +constexpr int64_t kHeadsPerNgram = 1; +constexpr int64_t kPleLayer = 1; // 0-based, and a linear_attention layer +constexpr int64_t kEosTokenId = 3; + +constexpr int64_t kKeyDim = kNumKHeads * kLinHeadDim; // 16 +constexpr int64_t kValueDim = kNumVHeads * kLinHeadDim; // 48 +constexpr int64_t kConvDim = 2 * kKeyDim + kValueDim; // 80 +constexpr int64_t kNgramHeads = (kNgramSize - 1) * kHeadsPerNgram; // 2 +// 96, and it is DELIBERATELY NEITHER `kH / kNgramHeads` NOR `kH`. This is the +// fixture shape that gates `ple_embed_dim`, and neither value it replaced could. +// +// The GGUF states the PER-HEAD row width and HF states the TOTAL; the builder +// reconstructs the total as `ple_row * ngram_heads`, and `ParseQwen4ExpParams` +// falls back to `hidden_size` when the total is absent. On the RELEASED config +// those two happen to coincide (160 * 16 == 2560 == hidden_size), which is the +// coincidence #2064 was filed about. A fixture that DEFINES `kPleRow` as +// `kH / kNgramHeads` reproduces that coincidence by construction, so deleting +// the builder's `text["ple_embed_dim"]` line left the whole suite green +// (mutation MUT-C). +// +// 64 broke MUT-C but left a SECOND coincidence standing, because `kH` is also +// 64: a builder that wrote `hidden_size * ngram_heads` instead of +// `ple_row * ngram_heads` still produced 128, the correct total, and that +// mutation survived the whole suite (MUT-D). At 96 the correct total is 192, +// the `hidden_size` product is 128 and the bare `hidden_size` fallback is 64, +// so all three are distinct and each wrong one refuses the file by shape — +// which is what makes the builder's line observable at all. +// +// 96 rather than any other triply-distinct value because +// `head_dim_per_ngram() == kPleEmbedDim / kNgramHeads` must stay a whole number +// of Q8_0 blocks: the n-gram table is the one gather this model keeps +// quantized, and a ragged row cannot be kept at all. 96 is three blocks, and it +// is the smallest multiple of 32 that is neither `kH` nor `kH / kNgramHeads`. +constexpr int64_t kPleRow = 96; +// The TOTAL width, HF's own `ple_embed_dim`. 192 != kH, which is the point. +constexpr int64_t kPleEmbedDim = kPleRow * kNgramHeads; // 192 +static_assert(kPleEmbedDim != kH, + "the fixture must not reproduce the released checkpoint's " + "ple_embed_dim == hidden_size coincidence (#2064)"); +static_assert(kPleEmbedDim != kH * kNgramHeads, + "the fixture must not let `hidden_size * ngram_heads` stand in " + "for `ple_row * ngram_heads` (#2064)"); +static_assert(kPleRow % 32 == 0, "an n-gram row must be whole Q8_0 blocks"); +// The two head vocabularies the fixture STATES, the way a real `qwen4exp` file +// does (`qwen4exp.ple.head_vocab_sizes`). Their sum is 52 and +// `make_ngram_vocab_size_divisible_by` defaults to 128, so the padded table is +// 128 rows. 23 and 29 are the successive primes after 19, which is what the HF +// derivation would produce from `ngram_vocab_size_base = 20` — stated here so +// the two routes into `NgramTableRows` are the same arithmetic on a small +// config, and the released-config case gates them at 320001536. +constexpr int64_t kNgramHead0Vocab = 23; +constexpr int64_t kNgramHead1Vocab = 29; +constexpr int64_t kNgramRows = 128; + +// One `tag` per NORM tensor, so a cross-wired pair reads a different sequence. +// The per-layer ones are offset by layer as well, so a loader that read layer 0's +// gamma into layer 3 would be visible too. +constexpr int64_t kMixerNormTag = 1; +constexpr int64_t kQNormTag = 2; +constexpr int64_t kKNormTag = 3; +constexpr int64_t kIdxQNormTag = 4; +constexpr int64_t kIdxKNormTag = 5; +constexpr int64_t kPleNormKeyTag = 6; +constexpr int64_t kPleNormQueryTag = 7; +constexpr int64_t kPleNormConvTag = 8; +inline int64_t HcNormTag(int64_t layer, const char* side) { + return 10 + 2 * layer + (side[0] == 'a' ? 0 : 1); +} +inline int64_t SsmNormTag(int64_t layer) { return 30 + layer; } + +inline std::string Blk(int64_t l, const char* suffix) { + return "blk." + std::to_string(l) + "." + suffix; +} + +inline bool IsLinear(int64_t l) { return ((l + 1) % 4) != 0; } + +// ── deterministic payloads ─────────────────────────────────────────────────── + +inline std::string F32Bytes(const std::vector& v) { + std::string s(v.size() * 4, '\0'); + std::memcpy(s.data(), v.data(), v.size() * 4); + return s; +} + +// A distinguishable value per element: no two positions of any tensor share a +// value, so a permutation defect cannot hide behind a repeated number. +inline std::vector Ramp(int64_t n, float base) { + std::vector v(static_cast(n)); + for (int64_t i = 0; i < n; ++i) + v[static_cast(i)] = base + static_cast(i); + return v; +} + +inline std::string RampF32(int64_t n, float base) { return F32Bytes(Ramp(n, base)); } + +// NORM gammas get their own generator, and the reason is a measurement rather +// than tidiness. The `+1` fold this loader inverts is a subtraction of ONE, and +// bf16's step is 16 by the time a plain ramp reaches 3001 — so on a gamma +// written as `3001 + i` the fold and its absence round to the SAME bf16 value +// and the check passes either way. Every value here is `1 + k/128` with +// `k` in [0, 127], which bf16 represents exactly, and so is `k/128` after the +// fold is removed. `tag` gives each tensor its own sequence so a cross-wired +// pair (norm_key read into norm_query) is visible. +inline float NormValue(int64_t i, int64_t tag) { + return 1.0F + static_cast((i + 13 * tag) % 128) / 128.0F; +} + +inline std::string NormF32(int64_t n, int64_t tag) { + std::vector v(static_cast(n)); + for (int64_t i = 0; i < n; ++i) v[static_cast(i)] = NormValue(i, tag); + return F32Bytes(v); +} + +// Q8_0 payload for `rows x 32` — one block per row, encoded the way +// `DequantGgufRowToF32` reads it back: an f16 scale then 32 int8 codes. +inline std::string Q8_0Bytes(int64_t rows, int64_t cols) { + REQUIRE(cols % 32 == 0); + const int64_t blocks = rows * (cols / 32); + std::string s(static_cast(blocks) * 34, '\0'); + auto* p = reinterpret_cast(s.data()); + for (int64_t b = 0; b < blocks; ++b) { + const uint16_t half = vt::F32ToF16(0.5F); + std::memcpy(p + b * 34, &half, 2); + for (int64_t i = 0; i < 32; ++i) + p[b * 34 + 2 + i] = static_cast(static_cast((b + i) % 100 - 50)); + } + return s; +} + +// ── the synthetic file ─────────────────────────────────────────────────────── + +// `drop` names a tensor to OMIT and `bad_shape` one to write at a wrong shape, +// so the refusal cases enter through the same builder the happy path does. A +// second builder would be free to disagree with this one, and then the refusal +// cases would be testing the second builder. +struct FixtureOpts { + std::string drop; + std::string bad_shape; + // W5c (#2031): make `attention.compress_ratios` DISAGREE between two sparse + // layers. The file states the ratio per LAYER while HF states one value, so + // the config builder takes the first non-zero and requires the rest to + // match; a mixed schedule that silently first-wins would size the QSA + // indexer side cache for one ratio while another layer compressed at a + // different one. + // + // It DOUBLES `block_count`, and that is what makes the defect expressible at + // all. The miniature is four layers at `full_attention_interval` 4, so it has + // exactly ONE sparse layer and one non-zero ratio, which cannot disagree with + // itself; and a stray non-zero on a LINEAR layer is caught one check earlier + // by "compress_ratios disagrees with the full_attention_interval schedule". + // Eight layers give two sparse ones, 3 and 7, so the array can be + // schedule-consistent AND non-uniform. Only `Qwen4ExpHfConfigFromGguf` is + // driven with this option — it reads metadata and never walks the per-layer + // tensors, which stay at four layers. + bool mixed_compress_ratios = false; +}; + +inline void Add(GgufModelBuilder& b, const FixtureOpts& o, const std::string& name, + std::vector ne, uint32_t ggml_type, const std::string& data) { + if (name == o.drop) return; + if (name == o.bad_shape) { + // One extra row: a shape a reader that only checks rank would accept. + ne.back() += 1; + const int64_t elems_per_row = + static_cast(ne.front()); + return b.AddTensor(name, ne, ggml_type, + data + std::string(static_cast(elems_per_row) * 4, '\0')); + } + b.AddTensor(name, ne, ggml_type, data); +} + +inline std::string BuildFixture(const FixtureOpts& o = {}) { + GgufModelBuilder b; + b.AddKv(StrKv("general.architecture", "qwen4exp")); + b.AddKv(U32Kv("qwen4exp.embedding_length", kH)); + const int64_t layers_kv = o.mixed_compress_ratios ? kLayers * 2 : kLayers; + b.AddKv(U32Kv("qwen4exp.block_count", layers_kv)); + b.AddKv(U32Kv("qwen4exp.attention.head_count", kQHeads)); + b.AddKv(U32Kv("qwen4exp.attention.head_count_kv", kKvHeads)); + b.AddKv(U32Kv("qwen4exp.attention.key_length", kHeadDim)); + b.AddKv(U32Kv("qwen4exp.attention.value_length", kHeadDim)); + b.AddKv(U32Kv("qwen4exp.context_length", 256)); + b.AddKv(F32Kv("qwen4exp.attention.layer_norm_rms_epsilon", 1e-6F)); + b.AddKv(F32Kv("qwen4exp.rope.freq_base", 10000.0F)); + b.AddKv(U32Kv("qwen4exp.rope.dimension_count", kRotaryDim)); + b.AddKv(U32Kv("qwen4exp.expert_count", kExperts)); + b.AddKv(U32Kv("qwen4exp.expert_used_count", kExpertsPerTok)); + b.AddKv(U32Kv("qwen4exp.expert_feed_forward_length", kMoeI)); + b.AddKv(U32Kv("qwen4exp.expert_shared_feed_forward_length", kSharedI)); + b.AddKv(U32Kv("qwen4exp.ssm.group_count", kNumKHeads)); + b.AddKv(U32Kv("qwen4exp.ssm.time_step_rank", kNumVHeads)); + b.AddKv(U32Kv("qwen4exp.ssm.state_size", kLinHeadDim)); + b.AddKv(U32Kv("qwen4exp.ssm.conv_kernel", kConvKernel)); + b.AddKv(U32Kv("qwen4exp.ssm.inner_size", kValueDim)); + b.AddKv(U32Kv("qwen4exp.full_attention_interval", 4)); + b.AddKv(U32Kv("qwen4exp.hyper_connection.count", kHcCount)); + b.AddKv(U32Kv("qwen4exp.hyper_connection.low_rank", kHcLowrank)); + b.AddKv(U32Kv("qwen4exp.attention.indexer.head_count", kIdxHeads)); + b.AddKv(U32Kv("qwen4exp.attention.indexer.key_length", kIdxHeadDim)); + b.AddKv(U32Kv("qwen4exp.attention.indexer.top_k", kIdxBudget)); + b.AddKv(U32Kv("qwen4exp.embedding_length_per_layer_input", kPleRow)); + b.AddKv(U32Kv("qwen4exp.ple.ngram_size", kNgramSize)); + b.AddKv(U32Kv("qwen4exp.ple.heads_per_ngram", kHeadsPerNgram)); + b.AddKv(U32Kv("qwen4exp.ple.conv_kernel", kConvKernel)); + b.AddKv(U32Kv("qwen4exp.ple.eos_token_id", kEosTokenId)); + b.AddKv(I32ArrayKv("qwen4exp.ple.head_vocab_sizes", + {static_cast(kNgramHead0Vocab), + static_cast(kNgramHead1Vocab)})); + b.AddKv(I32ArrayKv("qwen4exp.ple.head_offsets", + {0, static_cast(kNgramHead0Vocab)})); + b.AddKv(I32ArrayKv("qwen4exp.ple.layers", {static_cast(kPleLayer)})); + std::vector ratios; + for (int64_t i = 0; i < layers_kv; ++i) + ratios.push_back(IsLinear(i) ? 0 : static_cast(kCompressRatio)); + if (o.mixed_compress_ratios) { + // The LAST sparse layer compresses at a different ratio from the first, so + // the array still agrees with the schedule and no longer agrees with + // itself. + ratios.back() = static_cast(kCompressRatio) * 2; + } + b.AddKv(I32ArrayKv("qwen4exp.attention.compress_ratios", ratios)); + + // Tensor dims are in GGUF `ne` order (inner/fastest dim first), which is the + // REVERSE of the torch [out, in] order the reader hands back. + Add(b, o, "token_embd.weight", {kH, kVocab}, 0, RampF32(kH * kVocab, 1.0F)); + Add(b, o, "output.weight", {kH, kVocab}, 0, RampF32(kH * kVocab, 2.0F)); + Add(b, o, "per_layer_token_embd.weight", {kPleRow, kNgramRows}, 8, + Q8_0Bytes(kNgramRows, kPleRow)); + Add(b, o, "output_hc_norm.weight", {kStream}, 0, + NormF32(kStream, kMixerNormTag)); + Add(b, o, "output_hc_down.weight", {kStream, kHcLowrank}, 0, + RampF32(kStream * kHcLowrank, 3.0F)); + Add(b, o, "output_hc_up.weight", {kHcLowrank, kStream}, 0, + RampF32(kStream * kHcLowrank, 4.0F)); + + for (int64_t l = 0; l < kLayers; ++l) { + const float base = static_cast(l * 1000 + 1); + for (const char* side : {"attn", "ffn"}) { + const std::string p = std::string("hc_") + side + "_"; + Add(b, o, Blk(l, (p + "norm.weight").c_str()), {kStream}, 0, + NormF32(kStream, HcNormTag(l, side))); + Add(b, o, Blk(l, (p + "down.weight").c_str()), {kStream, kHcLowrank}, 0, + RampF32(kStream * kHcLowrank, base)); + Add(b, o, Blk(l, (p + "up.weight").c_str()), {kHcLowrank, kStream}, 0, + RampF32(kStream * kHcLowrank, base)); + Add(b, o, Blk(l, (p + "inject.weight").c_str()), {kStream, kHcCount}, 0, + RampF32(kStream * kHcCount, base)); + } + Add(b, o, Blk(l, "ffn_gate_inp.weight"), {kH, kExperts}, 0, + RampF32(kH * kExperts, base)); + Add(b, o, Blk(l, "ffn_gate_inp_shexp.weight"), {kH}, 0, RampF32(kH, base)); + Add(b, o, Blk(l, "ffn_gate_exps.weight"), {kH, kMoeI, kExperts}, 0, + RampF32(kH * kMoeI * kExperts, base)); + Add(b, o, Blk(l, "ffn_up_exps.weight"), {kH, kMoeI, kExperts}, 0, + RampF32(kH * kMoeI * kExperts, base)); + Add(b, o, Blk(l, "ffn_down_exps.weight"), {kMoeI, kH, kExperts}, 0, + RampF32(kH * kMoeI * kExperts, base)); + Add(b, o, Blk(l, "ffn_gate_shexp.weight"), {kH, kSharedI}, 0, + RampF32(kH * kSharedI, base)); + Add(b, o, Blk(l, "ffn_up_shexp.weight"), {kH, kSharedI}, 0, + RampF32(kH * kSharedI, base)); + Add(b, o, Blk(l, "ffn_down_shexp.weight"), {kSharedI, kH}, 0, + RampF32(kH * kSharedI, base)); + + if (IsLinear(l)) { + Add(b, o, Blk(l, "attn_qkv.weight"), {kH, kConvDim}, 0, + RampF32(kH * kConvDim, base)); + Add(b, o, Blk(l, "attn_gate.weight"), {kH, kValueDim}, 0, + RampF32(kH * kValueDim, base)); + Add(b, o, Blk(l, "ssm_alpha.weight"), {kH, kNumVHeads}, 0, + RampF32(kH * kNumVHeads, base)); + Add(b, o, Blk(l, "ssm_beta.weight"), {kH, kNumVHeads}, 0, + RampF32(kH * kNumVHeads, base)); + Add(b, o, Blk(l, "ssm_conv1d.weight"), {kConvKernel, kConvDim}, 0, + RampF32(kConvDim * kConvKernel, base)); + Add(b, o, Blk(l, "ssm_norm.weight"), {kLinHeadDim}, 0, + NormF32(kLinHeadDim, SsmNormTag(l))); + Add(b, o, Blk(l, "ssm_out.weight"), {kValueDim, kH}, 0, + RampF32(kH * kValueDim, base)); + // `ssm_a` is stored as -exp(A_log); the loader recovers log(-x). Negative + // by construction, and distinct per head. + std::vector a(static_cast(kNumVHeads)); + for (int64_t i = 0; i < kNumVHeads; ++i) + a[static_cast(i)] = -static_cast(i + 1); + Add(b, o, Blk(l, "ssm_a"), {kNumVHeads}, 0, F32Bytes(a)); + Add(b, o, Blk(l, "ssm_dt.bias"), {kNumVHeads}, 0, + RampF32(kNumVHeads, base)); + } else { + Add(b, o, Blk(l, "attn_q.weight"), {kH, kQHeads * kHeadDim * 2}, 0, + RampF32(kH * kQHeads * kHeadDim * 2, base)); + Add(b, o, Blk(l, "attn_k.weight"), {kH, kKvHeads * kHeadDim}, 0, + RampF32(kH * kKvHeads * kHeadDim, base)); + Add(b, o, Blk(l, "attn_v.weight"), {kH, kKvHeads * kHeadDim}, 0, + RampF32(kH * kKvHeads * kHeadDim, base)); + Add(b, o, Blk(l, "attn_output.weight"), {kQHeads * kHeadDim, kH}, 0, + RampF32(kH * kQHeads * kHeadDim, base)); + Add(b, o, Blk(l, "attn_q_norm.weight"), {kHeadDim}, 0, + NormF32(kHeadDim, kQNormTag)); + Add(b, o, Blk(l, "attn_k_norm.weight"), {kHeadDim}, 0, + NormF32(kHeadDim, kKNormTag)); + Add(b, o, Blk(l, "indexer.q_proj.weight"), {kH, kIdxHeads * kIdxHeadDim}, + 0, RampF32(kH * kIdxHeads * kIdxHeadDim, base)); + Add(b, o, Blk(l, "indexer.k_proj.weight"), + {kH, kIdxKvHeads * kIdxHeadDim}, 0, + RampF32(kH * kIdxKvHeads * kIdxHeadDim, base)); + Add(b, o, Blk(l, "indexer.q_norm.weight"), {kIdxHeadDim}, 0, + NormF32(kIdxHeadDim, kIdxQNormTag)); + Add(b, o, Blk(l, "indexer.k_norm.weight"), {kIdxHeadDim}, 0, + NormF32(kIdxHeadDim, kIdxKNormTag)); + } + + if (l == kPleLayer) { + // [stream, ple_embed_dim] and [hidden_size, ple_embed_dim] in TORCH + // order, so the GGUF `ne` is reversed. Both are ple_embed_dim wide and + // NOT hidden_size wide, which is what MUT-C now runs into. + Add(b, o, Blk(l, "ple_key.weight"), {kPleEmbedDim, kStream}, 0, + RampF32(kPleEmbedDim * kStream, base)); + Add(b, o, Blk(l, "ple_value.weight"), {kPleEmbedDim, kH}, 0, + RampF32(kPleEmbedDim * kH, base)); + Add(b, o, Blk(l, "ple_norm_key.weight"), {kStream}, 0, + NormF32(kStream, kPleNormKeyTag)); + Add(b, o, Blk(l, "ple_norm_query.weight"), {kStream}, 0, + NormF32(kStream, kPleNormQueryTag)); + Add(b, o, Blk(l, "ple_norm_conv.weight"), {kStream}, 0, + NormF32(kStream, kPleNormConvTag)); + Add(b, o, Blk(l, "ple_conv1d.weight"), {kConvKernel, kStream}, 0, + RampF32(kStream * kConvKernel, base)); + } + } + return b.Build(); +} + +// `ModelRegistry::Load`, not `reg.factory->load_weights`, and the difference is +// load-bearing rather than stylistic. `Load` resolves the architecture, refuses +// an unsupported FP8-block quantization, runs `parse_config` and THEN the weight +// loader — which is the sequence `LoadedEngine::FromModelDir` runs at +// `entrypoints/model_loader.cpp` (`ModelSource::FromGguf(gguf)` -> +// `ModelRegistry::Load(config, gguf_source)`). Calling the hook directly skips +// `parse_config`, and that skip is exactly what hid #2064: the config builder +// and the config VALIDATOR had never been composed, so a file that built a +// config fine was refused the moment anything parsed it. +inline std::unique_ptr LoadThroughRegistry( + const vllm::GgufFile& g) { + const vllm::HfConfig config = vllm::Qwen4ExpHfConfigFromGguf(g); + const vllm::ModelSource source = vllm::ModelSource::FromGguf(g); + return vllm::ModelRegistry::Load(config, source); +} + +} // namespace qwen4_exp_fixture diff --git a/tests/vllm/models/test_glm5_next_scaffold.cpp b/tests/vllm/models/test_glm5_next_scaffold.cpp index b131f604be..7388b7b583 100644 --- a/tests/vllm/models/test_glm5_next_scaffold.cpp +++ b/tests/vllm/models/test_glm5_next_scaffold.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -735,10 +736,32 @@ namespace { // dies at the tokenizer and never reaches the refusal those cases are about. // The config-layer cases below must not also be asserting a vocabulary, which // is why this is a switch rather than an unconditional block. +// +// THE PUBLISHED-SHAPE KNOBS are what the published +// `unsloth/GLM-5.3-Flash-GGUF` artifact needs and our own converter's output +// does not. An EMPTY `layer_types` omits that key entirely, which is the shape +// of every published file; a non-empty `head_count_kv_arr` replaces the scalar +// `attention.head_count_kv` with the per-layer `array[i32]` those files carry; +// non-empty clamp vectors replace the scalar `swiglu_clamp_exp` with the +// `array[f32]` pair; and a non-zero `nextn_predict_layers` writes that key, so +// a case can state how many of the `block_count` blocks are MTP rather than +// backbone. Defaults keep every existing case byte-identical. +// +// The FIRST argument of `PublishedShapeGguf` is the BLOCK count and is written +// to `block_count`, so every per-block array this builder generates is block +// length -- which is what llama.cpp writes and what the reader checks against. +struct Glm5NextGgufArrays { + std::vector head_count_kv; + std::vector swiglu_clamp_exp; + std::vector swiglu_clamp_shexp; + int64_t nextn_predict_layers = 0; +}; + std::string PublishedShapeGguf(int64_t n_layers, const std::vector& layer_types, uint32_t head_count_kv = 64, - bool with_tokenizer = false) { + bool with_tokenizer = false, + const Glm5NextGgufArrays& arrays = {}) { gguf_test::GgufModelBuilder b; const std::string k = "glm5next."; b.AddKv(gguf_test::StrKv("general.architecture", "glm5next")); @@ -758,14 +781,33 @@ std::string PublishedShapeGguf(int64_t n_layers, b.AddKv(gguf_test::F32Kv(k + "expert_weights_scale", 2.5f)); b.AddKv(gguf_test::BoolKv(k + "expert_weights_norm", true)); b.AddKv(gguf_test::U32Kv(k + "attention.head_count", 64)); - b.AddKv(gguf_test::U32Kv(k + "attention.head_count_kv", head_count_kv)); + if (arrays.head_count_kv.empty()) { + b.AddKv(gguf_test::U32Kv(k + "attention.head_count_kv", head_count_kv)); + } else { + b.AddKv(gguf_test::I32ArrayKv(k + "attention.head_count_kv", + arrays.head_count_kv)); + } b.AddKv(gguf_test::F32Kv(k + "attention.layer_norm_rms_epsilon", 1e-5f)); b.AddKv(gguf_test::U32Kv(k + "attention.q_lora_rank", 1536)); b.AddKv(gguf_test::U32Kv(k + "attention.kv_lora_rank", 512)); b.AddKv(gguf_test::U32Kv(k + "attention.key_length_mla", 256)); b.AddKv(gguf_test::U32Kv(k + "attention.value_length_mla", 256)); b.AddKv(gguf_test::U32Kv(k + "attention.key_length", 256)); - b.AddKv(gguf_test::F32Kv(k + "swiglu_clamp_exp", 10.0f)); + if (arrays.swiglu_clamp_exp.empty()) { + b.AddKv(gguf_test::F32Kv(k + "swiglu_clamp_exp", 10.0f)); + } else { + b.AddKv(gguf_test::F32ArrayKv(k + "swiglu_clamp_exp", + arrays.swiglu_clamp_exp)); + } + if (!arrays.swiglu_clamp_shexp.empty()) { + b.AddKv(gguf_test::F32ArrayKv(k + "swiglu_clamp_shexp", + arrays.swiglu_clamp_shexp)); + } + if (arrays.nextn_predict_layers != 0) { + b.AddKv(gguf_test::U32Kv( + k + "nextn_predict_layers", + static_cast(arrays.nextn_predict_layers))); + } b.AddKv(gguf_test::U32Kv(k + "rope.dimension_count", 0)); b.AddKv(gguf_test::U32Kv(k + "attention.indexer.head_count", 32)); b.AddKv(gguf_test::U32Kv(k + "attention.indexer.key_length", 128)); @@ -780,7 +822,9 @@ std::string PublishedShapeGguf(int64_t n_layers, b.AddKv(gguf_test::U32Kv(k + "hyper_connection.count", 4)); b.AddKv(gguf_test::U32Kv(k + "hyper_connection.sinkhorn_iterations", 20)); b.AddKv(gguf_test::F32Kv(k + "hyper_connection.epsilon", 1e-6f)); - b.AddKv(gguf_test::StrArrayKv(k + "layer_types", layer_types)); + if (!layer_types.empty()) { + b.AddKv(gguf_test::StrArrayKv(k + "layer_types", layer_types)); + } std::vector mlp; std::vector indexer; for (int64_t i = 0; i < n_layers; ++i) { @@ -952,6 +996,302 @@ TEST_CASE("glm5_next: an absent gate_lower_bound is NOT promoted to -5.0") { CHECK_FALSE(ParseGlm5NextParams(ConfigFrom(doc)).kda.takes_sigmoid_branch()); } +// --------------------------------------------------------------------------- +// The PUBLISHED spelling of the schedule: `attention.head_count_kv` as a +// per-layer `array[i32]`, with no `layer_types` anywhere in the file +// (#2243, #2177). + +namespace { + +// The published `unsloth/GLM-5.3-Flash-GGUF` UD-Q2_K_XL `head_count_kv`, read +// out of shard 1's own KV block on 2026-08-29 (key index 21, `array[i32]`, +// n=46): `0` on the 34 KDA layers and `1` on the 11 DSA layers at 3, 7, ..., 43 +// AND on entry 45, which is the multi-token-prediction block +// (`nextn_predict_layers = 1`, `block_count = 46`, `num_hidden_layers = 45`). +// That last entry is why the file has to be READ: it is MLA-shaped and +// `45 % 4 == 1`, so it breaks the stride a synthesized schedule would apply. +std::vector PublishedHeadCountKv() { + std::vector v(46, 0); + for (int i = 3; i <= 43; i += 4) v[static_cast(i)] = 1; + v[45] = 1; + return v; +} + +// llama.cpp's own predicate, `b10451:src/models/kimi-linear.cpp:18`: +// `is_recr_impl[i] = hparams.n_head_kv(i) == 0`, "KDA layers are recurrent". +std::vector KindsOf(const std::vector& kv) { + std::vector out; + for (int32_t n : kv) { + out.push_back(n == 0 ? Glm5NextLayerKind::kLinearAttention + : Glm5NextLayerKind::kDeepseekSparseAttention); + } + return out; +} + +// The `idx % 4 != 3` pattern the reader used to fall back to. Present so the +// non-stride case can assert what a SYNTHESIZED schedule would have been, not +// merely that the read one is right. +std::vector SynthesizedKinds(int64_t n) { + std::vector out; + for (int64_t i = 0; i < n; ++i) { + out.push_back(i % 4 != 3 ? Glm5NextLayerKind::kLinearAttention + : Glm5NextLayerKind::kDeepseekSparseAttention); + } + return out; +} + +std::string PerLayerGguf(int64_t n_layers, const Glm5NextGgufArrays& arrays, + const std::vector& layer_types = {}) { + return PublishedShapeGguf(n_layers, layer_types, /*head_count_kv=*/64, + /*with_tokenizer=*/false, arrays); +} + +} // namespace + +TEST_CASE("glm5_next: the published GGUF states its schedule ONLY in head_count_kv") { + // Before this change the builder read `attention.head_count_kv` with + // `OptInt`, whose `default:` arm threw `key glm5next.attention.head_count_kv + // is not an integer` on the array form, and then REQUIRED a `layer_types` no + // published artifact carries. Both refusals stood between this project and + // the only artifact of this model that exists. + const std::vector kv = PublishedHeadCountKv(); + REQUIRE(kv.size() == 46u); + Glm5NextGgufArrays arrays; + arrays.head_count_kv = kv; + arrays.swiglu_clamp_exp = std::vector(46, 10.0f); + arrays.swiglu_clamp_shexp = std::vector(46, 10.0f); + arrays.nextn_predict_layers = 1; + + const Glm5NextParams p = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(46, arrays))); + + // BLOCKS ARE NOT LAYERS, and this is asserted as the RELATIONSHIP llama.cpp + // writes -- `block_count = num_hidden_layers + nextn_predict_layers` + // (`b10451:conversion/exaone.py:134`) -- rather than as the bare 45. A file + // with a different `nextn_predict_layers` therefore cannot pass this case + // unchanged, which a literal could not tell us. + constexpr int64_t kBlocks = 46; + constexpr int64_t kMtp = 1; + CHECK(p.num_hidden_layers == kBlocks - kMtp); + // ...and 45 is exactly what the released `config.json` declares, so the two + // sources of this one model agree on its depth. + CHECK(p.num_hidden_layers == 45); + CHECK(static_cast(p.layer_types.size()) == kBlocks - kMtp); + CHECK(static_cast(p.mlp_layer_types.size()) == kBlocks - kMtp); + CHECK(static_cast(p.indexer_types.size()) == kBlocks - kMtp); + + // The schedule is the array's BACKBONE entries, and the MTP block's entry is + // DROPPED rather than kept as a 46th layer. This is the sharp end of it: + // entry 45 is a `1` and entry 44 is a `0`, so a reader that forgot to + // truncate ends its stack with a DSA layer built out of the MTP block -- + // which would run, and would produce plausible tokens. + const std::vector backbone(kv.begin(), kv.begin() + 45); + CHECK(p.layer_types == KindsOf(backbone)); + CHECK(kv[44] == 0); + CHECK(kv[45] == 1); + CHECK(p.layer_types.back() == Glm5NextLayerKind::kLinearAttention); + + // 34 KDA and 11 DSA over the BACKBONE -- the same split the `config.json` + // case asserts at the top of this file. The file's TWELFTH MLA-shaped block + // is the MTP one and is not a layer of this model. + CHECK(p.num_kda_layers() == 34); + CHECK(p.num_dsa_layers() == 11); + + // Over the 45 backbone layers the published array AGREES with `idx % 4 == 3` + // exactly. That agreement is the coincidence this row keeps warning about, so + // it is asserted here rather than left implied -- and it is why the case + // below, not this one, is what proves the values are read. + CHECK(p.layer_types == SynthesizedKinds(45)); + + // The array is a SCHEDULE and not a KV-head count. Its non-zero entries are + // `1`; taking that as `num_key_value_heads` would refuse the published file + // with upstream's GQA message about a number this file never states. + CHECK(p.num_attention_heads == 64); + CHECK(p.num_key_value_heads == 64); + CHECK(p.swiglu_limit == doctest::Approx(10.0)); + + // THE SUBTRACTION IS LIVE. The same 46-entry file that declares no MTP block + // is a 46-layer model with twelve MLA-shaped layers, and entry 45 is then a + // layer rather than a dropped block. If `nextn_predict_layers` were ignored, + // this and the case above could not differ. + Glm5NextGgufArrays no_mtp = arrays; + no_mtp.nextn_predict_layers = 0; + const Glm5NextParams q = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(46, no_mtp))); + CHECK(q.num_hidden_layers == kBlocks); + CHECK(q.layer_types == KindsOf(kv)); + CHECK(q.num_kda_layers() == 34); + CHECK(q.num_dsa_layers() == 12); + CHECK(q.layer_types.back() == Glm5NextLayerKind::kDeepseekSparseAttention); + // ...and THAT one is not the stride, because `45 % 4 == 1`. + CHECK(q.layer_types != SynthesizedKinds(46)); +} + +TEST_CASE("glm5_next: a GGUF and a config.json of the SAME model resolve identically") { + // The cross-source assertion. `block_count = 46` with + // `nextn_predict_layers = 1` and `num_hidden_layers = 45` are two spellings + // of one model's depth, and W1's whole design claim is that both sources meet + // one parser. Reading `block_count` into `num_hidden_layers` broke that + // silently: the config.json resolved a 45-layer stack and the GGUF a + // 46-layer one, and nothing downstream would have refused the extra layer. + Glm5NextGgufArrays arrays; + arrays.head_count_kv = PublishedHeadCountKv(); + arrays.nextn_predict_layers = 1; + const Glm5NextParams from_gguf = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(46, arrays))); + const Glm5NextParams from_json = ParseGlm5NextParams(PublishedConfig()); + + CHECK(from_gguf.num_hidden_layers == from_json.num_hidden_layers); + CHECK(from_gguf.layer_types == from_json.layer_types); + CHECK(from_gguf.num_kda_layers() == from_json.num_kda_layers()); + CHECK(from_gguf.num_dsa_layers() == from_json.num_dsa_layers()); + CHECK(from_gguf.num_attention_heads == from_json.num_attention_heads); + CHECK(from_gguf.num_key_value_heads == from_json.num_key_value_heads); +} + +TEST_CASE("glm5_next: more MTP blocks than blocks is refused") { + Glm5NextGgufArrays arrays; + arrays.head_count_kv = std::vector(8, 0); + arrays.head_count_kv[3] = 1; + arrays.head_count_kv[7] = 1; + arrays.nextn_predict_layers = 8; + const std::string message = RefusalForGguf(PerLayerGguf(8, arrays)); + CHECK(message.find("block_count is 8") != std::string::npos); + CHECK(message.find("nextn_predict_layers is 8") != std::string::npos); + CHECK(message.find("num_hidden_layers + nextn_predict_layers") != + std::string::npos); +} + +TEST_CASE("glm5_next: the schedule is READ, and a non-stride file proves it") { + // THE DECISIVE CASE. The published checkpoint's 45 model layers happen to + // follow `idx % 4 == 3` exactly, so on that file a synthesized schedule and a + // read one agree and nothing can tell them apart. This file deliberately does + // not follow the stride, so only a reader that looks at the values gets it + // right. Without this case the fix is untested (#2177). + const std::vector kv = {1, 0, 1, 0, 0, 0, 0, 1}; + Glm5NextGgufArrays arrays; + arrays.head_count_kv = kv; + + const Glm5NextParams p = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(8, arrays))); + + CHECK(p.layer_types == KindsOf(kv)); + CHECK(p.layer_types[0] == Glm5NextLayerKind::kDeepseekSparseAttention); + CHECK(p.layer_types[3] == Glm5NextLayerKind::kLinearAttention); + CHECK(p.num_kda_layers() == 5); + CHECK(p.num_dsa_layers() == 3); + // And it is NOT the pattern the old fallback would have produced: those two + // blocks alone are the whole difference between reading and guessing. + const std::vector guessed = SynthesizedKinds(8); + CHECK(p.layer_types != guessed); + CHECK(guessed[0] == Glm5NextLayerKind::kLinearAttention); + CHECK(guessed[3] == Glm5NextLayerKind::kDeepseekSparseAttention); +} + +TEST_CASE("glm5_next: layer_types and head_count_kv must agree, and a clash is refused") { + // Two statements of one schedule. Preferring either silently loads a model + // whose attention kind is wrong on the blocks where they differ, which is the + // defect class this row has no token gate to catch. + Glm5NextGgufArrays clash; + clash.head_count_kv = std::vector(8, 0); + clash.head_count_kv[3] = 1; + clash.head_count_kv[6] = 1; // the string array says 7, not 6 + const std::string message = + RefusalForGguf(PerLayerGguf(8, clash, PublishedLayerTypes(8))); + CHECK(message.find("states its layer schedule twice") != std::string::npos); + CHECK(message.find("block 6") != std::string::npos); + CHECK(message.find("layer_types") != std::string::npos); + CHECK(message.find("attention.head_count_kv") != std::string::npos); + + // A DISCRIMINATOR, not a blanket refusal of any file carrying both. When the + // two agree the file loads, and it resolves to what the array alone gives. + Glm5NextGgufArrays agree; + agree.head_count_kv = std::vector(8, 0); + agree.head_count_kv[3] = 1; + agree.head_count_kv[7] = 1; + const Glm5NextParams both = ParseGlm5NextParams( + ConfigFromGgufBytes(PerLayerGguf(8, agree, PublishedLayerTypes(8)))); + const Glm5NextParams array_only = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(8, agree))); + CHECK(both.layer_types == array_only.layer_types); + CHECK(both.layer_types == KindsOf(agree.head_count_kv)); + + // `full_attention` is a legal `layer_types` spelling that upstream rewrites + // to `deepseek_sparse_attention`, and it carries KV heads, so it must NOT + // read as a disagreement with a non-zero entry. + std::vector rewritten = PublishedLayerTypes(8); + rewritten[3] = "full_attention"; + rewritten[7] = "full_attention"; + const Glm5NextParams full = ParseGlm5NextParams( + ConfigFromGgufBytes(PerLayerGguf(8, agree, rewritten))); + CHECK(full.layer_types == array_only.layer_types); +} + +TEST_CASE("glm5_next: a per-layer array whose length is not block_count is refused") { + Glm5NextGgufArrays short_kv; + short_kv.head_count_kv = std::vector(7, 0); + const std::string kv_message = RefusalForGguf(PerLayerGguf(8, short_kv)); + CHECK(kv_message.find("glm5next.attention.head_count_kv") != + std::string::npos); + CHECK(kv_message.find("per-layer array of 7 entries") != std::string::npos); + CHECK(kv_message.find("block_count is 8") != std::string::npos); + + Glm5NextGgufArrays short_clamp; + short_clamp.head_count_kv = std::vector(8, 0); + short_clamp.swiglu_clamp_exp = std::vector(9, 10.0f); + const std::string clamp_message = RefusalForGguf(PerLayerGguf(8, short_clamp)); + CHECK(clamp_message.find("glm5next.swiglu_clamp_exp") != std::string::npos); + CHECK(clamp_message.find("per-layer array of 9 entries") != std::string::npos); + CHECK(clamp_message.find("block_count is 8") != std::string::npos); +} + +TEST_CASE("glm5_next: a GGUF that states NO schedule is refused, naming both keys") { + // The obligation the removed `ReqStrArray` carried has moved, not gone: a + // file that states the interleave in neither spelling is still refused rather + // than defaulted onto a plausible pattern. + const std::string message = + RefusalForGguf(PublishedShapeGguf(8, /*layer_types=*/{})); + CHECK(message.find("states no per-layer attention schedule") != + std::string::npos); + CHECK(message.find("glm5next.layer_types") != std::string::npos); + CHECK(message.find("glm5next.attention.head_count_kv") != std::string::npos); +} + +TEST_CASE("glm5_next: swiglu_clamp_exp and _shexp are read in the ARRAY form too") { + // Both keys are per-layer `array[f32]` in the published artifact, so reading + // only the scalar form refused it one key after `head_count_kv` did. + Glm5NextGgufArrays arrays; + arrays.head_count_kv = std::vector(8, 0); + arrays.head_count_kv[3] = 1; + arrays.head_count_kv[7] = 1; + // NOT 10.0: that is the reader's own default, so asserting it would pass + // whether or not the array was ever read. + arrays.swiglu_clamp_exp = std::vector(8, 7.25f); + arrays.swiglu_clamp_shexp = std::vector(8, 7.25f); + const Glm5NextParams p = + ParseGlm5NextParams(ConfigFromGgufBytes(PerLayerGguf(8, arrays))); + CHECK(p.swiglu_limit == doctest::Approx(7.25)); + + // A NON-UNIFORM array is a clamp this config cannot hold: upstream has one + // `swiglu_limit`. Taking element 0 would clamp eight layers with a number the + // file states for one of them. + Glm5NextGgufArrays ragged = arrays; + ragged.swiglu_clamp_exp[5] = 3.5f; + const std::string ragged_message = RefusalForGguf(PerLayerGguf(8, ragged)); + CHECK(ragged_message.find("glm5next.swiglu_clamp_exp") != std::string::npos); + CHECK(ragged_message.find("for block 5") != std::string::npos); + CHECK(ragged_message.find("ONE `swiglu_limit`") != std::string::npos); + + // `swiglu_clamp_shexp` is READ and not merely tolerated: were it ignored, a + // file stating two different clamps would resolve silently to the first. + Glm5NextGgufArrays split = arrays; + split.swiglu_clamp_shexp = std::vector(8, 4.5f); + const std::string split_message = RefusalForGguf(PerLayerGguf(8, split)); + CHECK(split_message.find("glm5next.swiglu_clamp_exp") != std::string::npos); + CHECK(split_message.find("glm5next.swiglu_clamp_shexp") != std::string::npos); + CHECK(split_message.find("routed and the shared expert") != std::string::npos); +} + TEST_CASE("glm5_next: a tensor inventory that CONTRADICTS layer_types is refused") { // Absence proves nothing on a sharded or partial file, so this is a // CONTRADICTION check and not a completeness check. A `blk.N` that carries diff --git a/tests/vllm/models/test_qwen3_dflash_block_route.cpp b/tests/vllm/models/test_qwen3_dflash_block_route.cpp index f32a2472ba..e95bf2bbc0 100644 --- a/tests/vllm/models/test_qwen3_dflash_block_route.cpp +++ b/tests/vllm/models/test_qwen3_dflash_block_route.cpp @@ -392,16 +392,16 @@ TEST_CASE("dflash block route: an EMPTY context is byte-identical") { // shipped — so the assertion is on `DflashBlockPagedArgsOf`, the pure builder // the production call now routes through. TEST_CASE("dflash block paged args: the HOST metadata is populated (#2252)") { - const int64_t ctx_len = 1200; + const int64_t pool_capacity = 26208; // pages x page rows, the store's bound const int64_t tq = 9; const vllm::detail::DflashBlockPagedHostMeta hm = - vllm::detail::DflashBlockPagedHostMetaOf(ctx_len, tq); + vllm::detail::DflashBlockPagedHostMetaOf(pool_capacity, tq); // One request, so the query_start_loc is exactly [0, tq). CHECK(hm.qsl[0] == 0); CHECK(hm.qsl[1] == static_cast(tq)); - // The EXTENDED bound the read addresses, not the committed context length. - CHECK(hm.max_seq_len == static_cast(ctx_len + tq)); + // The POOL bound, which every replay of the captured graph still satisfies. + CHECK(hm.max_seq_len == static_cast(pool_capacity)); const vt::PagedAttentionArgs pa = vllm::detail::DflashBlockPagedArgsOf( /*scale=*/0.125F, /*causal=*/true, /*sliding_window=*/0, tq, hm); @@ -410,17 +410,50 @@ TEST_CASE("dflash block paged args: the HOST metadata is populated (#2252)") { REQUIRE(pa.query_start_loc_host != nullptr); CHECK(pa.query_start_loc_host[0] == 0); CHECK(pa.query_start_loc_host[1] == static_cast(tq)); - CHECK(pa.max_seq_len == static_cast(ctx_len + tq)); + CHECK(pa.max_seq_len == static_cast(pool_capacity)); // It must point INTO the caller-owned meta, not at a temporary. CHECK(pa.query_start_loc_host == hm.qsl.data()); } +// THE PROPERTY, not the value. This call is captured into a CUDA graph once and +// replayed as the context GROWS, so every host value baked into those args must +// be the same for every replay. The first #2252 fix used `ctx_len + tq` here, +// which is exact at capture and stale on every replay after it: measured as an +// illegal memory access inside `cudaGraphLaunch` (exit 134), against a clean +// exit 0 from the same binary with `VT_DFLASH_PAGED=0`. +// +// A value test alone would not have caught that -- `ctx_len + tq` passes any +// single-point assertion. Only INVARIANCE across context lengths does. +TEST_CASE("dflash block paged args: host metadata is REPLAY-STABLE (#2252)") { + const int64_t pool_capacity = 4096; + const int64_t tq = 8; + + const vllm::detail::DflashBlockPagedHostMeta hm = + vllm::detail::DflashBlockPagedHostMetaOf(pool_capacity, tq); + + // The bound must cover the LARGEST sequence any replay can present. A graph + // captured while the context is short is replayed until the store is full, so + // the worst case is the store's own capacity. + const int32_t worst_case_replay_seq = static_cast(pool_capacity); + CHECK(hm.max_seq_len >= worst_case_replay_seq); + + // And the defect, stated executably rather than in prose: the bound the first + // fix shipped is derived from the context AT CAPTURE, and for any capture that + // happens before the store fills, that bound is smaller than a later replay's + // sequence. This is the comparison the GPU reported as an illegal access. + for (const int64_t capture_ctx : {int64_t{0}, int64_t{16}, int64_t{1200}}) { + const int32_t stale_bound = static_cast(capture_ctx + tq); + CHECK(stale_bound < worst_case_replay_seq); // every capture-time bound is short + CHECK(hm.max_seq_len >= worst_case_replay_seq); // the shipped one never is + } +} + // The builder took over the mask and scale wiring, so those must still arrive // intact — a refactor that fixed the sync and silently dropped the window would // be a wrong ANSWER, which is worse than the slow path it replaced. TEST_CASE("dflash block paged args: mask, scale and uniform qlen still flow (#2252)") { const vllm::detail::DflashBlockPagedHostMeta hm = - vllm::detail::DflashBlockPagedHostMetaOf(/*ctx_len=*/64, /*tq=*/4); + vllm::detail::DflashBlockPagedHostMetaOf(/*pool_capacity=*/4096, /*tq=*/4); const vt::PagedAttentionArgs full = vllm::detail::DflashBlockPagedArgsOf( /*scale=*/0.5F, /*causal=*/false, /*sliding_window=*/0, /*tq=*/4, hm); @@ -443,5 +476,5 @@ TEST_CASE("dflash block paged args: mask, scale and uniform qlen still flow (#22 } // And the host metadata is not disturbed by the mask arm. REQUIRE(swa.query_start_loc_host != nullptr); - CHECK(swa.max_seq_len == 68); + CHECK(swa.max_seq_len == 4096); } diff --git a/tests/vllm/models/test_qwen4_exp_forward.cpp b/tests/vllm/models/test_qwen4_exp_forward.cpp new file mode 100644 index 0000000000..f2d3bced65 --- /dev/null +++ b/tests/vllm/models/test_qwen4_exp_forward.cpp @@ -0,0 +1,241 @@ +// MODEL-MM-QWEN4-EXP W5b — the assembled `Qwen4ExpTextModel` layer loop, and +// the loader/op compositions nothing in this tree had ever run together. +// +// Issue #2031, campaign issue #1978, spec `.agents/specs/qwen4-exp-flash-next.md`. +// +// WHY THIS SUITE EXISTS SEPARATELY FROM THE ELEVEN THAT PRECEDE IT. Every +// qwen4_exp suite before this one gates ONE side of a seam: the loader suite +// asserts what `LoadQwen4ExpFromGguf` produced, and the four device-op suites +// assert what `vt::Qwen4Exp*` computes from operands the TEST built. Neither can +// see a disagreement about what those operands MEAN, and this row has carried +// exactly one such disagreement since W5b-2 (#2218): the loader stores every +// gamma in the RAW HuggingFace parameterization and `vt::Qwen4ExpGatedResidual` +// documented the folded one. A gate that composes the two is the only +// instrument that can see it, because both halves are individually correct. +// +// ORACLE. vLLM registers `qwen4_exp` at no revision, so the algorithm oracle is +// transformers **5.16.0**, this row's accepted lane pin, reached here through +// the W2/W3 HOST references (`qwen4_exp_hc.cpp`, `qwen4_exp_ple.cpp`) that were +// themselves gated against it golden-for-golden. +#include + +#include +#include +#include +#include + +#include "support/qwen4_exp_gguf_fixture.h" + +#include "vllm/model_executor/models/qwen4_exp_hc.h" +#include "vllm/model_executor/models/qwen4_exp_weights.h" +#include "vt/dtype.h" +#include "vt/ops.h" + +using namespace qwen4_exp_fixture; // NOLINT(build/namespaces) + +namespace { + +vt::Queue CpuQ() { return vt::Queue{vt::Device{vt::DeviceType::kCPU, 0}, nullptr}; } + +vt::Tensor MakeT(void* p, vt::DType dt, const std::vector& shape) { + vt::Tensor t; + t.data = p; + t.dtype = dt; + t.device = vt::Device{vt::DeviceType::kCPU, 0}; + t.rank = static_cast(shape.size()); + int64_t stride = 1; + for (int i = t.rank - 1; i >= 0; --i) { + t.shape[i] = shape[static_cast(i)]; + t.stride[i] = stride; + stride *= t.shape[i]; + } + return t; +} + +// A loaded weight's VALUES, widened to f32 without rounding. bf16 -> f32 is +// exact, so the host oracle below is fed the same numbers the device op reads +// and any difference between them is the op's arithmetic and never the operand. +std::vector AsF32(const vllm::OwnedTensor& t) { + int64_t n = 1; + for (int i = 0; i < t.rank; ++i) n *= t.shape[i]; + std::vector out(static_cast(n)); + if (t.dtype == vt::DType::kBF16) { + const auto* p = reinterpret_cast(t.bytes.data()); + for (int64_t i = 0; i < n; ++i) out[static_cast(i)] = vt::BF16ToF32(p[i]); + } else if (t.dtype == vt::DType::kF32) { + const auto* p = reinterpret_cast(t.bytes.data()); + for (int64_t i = 0; i < n; ++i) out[static_cast(i)] = p[i]; + } else { + FAIL("unexpected weight dtype"); + } + return out; +} + +float MaxAbsDiff(const std::vector& a, const std::vector& b) { + REQUIRE(a.size() == b.size()); + float m = 0.0f; + for (size_t i = 0; i < a.size(); ++i) m = std::max(m, std::fabs(a[i] - b[i])); + return m; +} + +} // namespace + +// --- the loader/op polarity composition (#2218) ------------------------------ + +TEST_CASE( + "qwen4_exp forward: a LOADED hc_norm gamma drives the gated-residual op to " + "the value the FILE asked for") { + // WHY THIS CANNOT BE DONE WITH A HAND-BUILT GAMMA, which is what + // `test_qwen4_exp_hc_device.cpp` does and why the defect survived eleven + // waves. The polarity question is "what does the number in + // `Qwen4ExpGatedResidualWeights::hc_norm` MEAN", and only the loader can + // answer it. A test that builds the operand itself has already chosen the + // answer and is then asserting its own choice — the tautological-fixture + // shape this campaign produced five of. + // + // The chain under test, end to end: + // ggml-org/llama.cpp#27742 writes `hc_norm.weight` WITH the `+1` fold, so + // the file carries `1 + w_hf` -> `LoadNormBf16(..., unshift=true)` + // inverts it, so the model holds the RAW `w_hf` -> the op must put the + // 1 back, because upstream's `Qwen4ExpTextRMSNorm.forward` is + // `output * (1.0 + self.weight.float())` (modeling_qwen4_exp.py:173-178). + // + // The oracle is therefore driven with the FILE's gamma and the op with the + // MODEL's, and the two must agree. Hand the op a raw gamma under the old + // "this op never adds 1" contract and every hyper-connection norm scales by + // `w_hf`, which the fixture puts in [0, 1) and the released checkpoint puts + // within an ulp or two of ZERO — a plausible tensor, never a crash, and no + // token gate on any hardware this project owns could see it. + TempFile f(BuildFixture()); + const vllm::GgufFile g = vllm::GgufFile::Open(f.path()); + std::unique_ptr model; + REQUIRE_NOTHROW(model = LoadThroughRegistry(g)); + const vllm::Qwen4ExpWeights& w = + vllm::ModelAs( + *model, "Qwen4ExpForConditionalGeneration") + .weights(); + REQUIRE(w.layers.size() == static_cast(kLayers)); + + const int64_t T = 3; + const int64_t flat = kStream; + const float eps = 1e-6f; + + // Both hyper-connection sites of layer 0 AND the model-level `use_combine` + // mixer, because the mixer is the one call with a null `block_inject` and a + // polarity defect there lands directly on `lm_head`. + struct Site { + std::string name; + const vllm::Qwen4ExpGatedResidualWeights* w; + int64_t tag; + }; + const std::vector sites{ + {"layer0.attn_hc", &w.layers[0].attn_hc, HcNormTag(0, "attn")}, + {"layer0.mlp_hc", &w.layers[0].mlp_hc, HcNormTag(0, "ffn")}, + {"model.mixer", &w.mixer, kMixerNormTag}, + }; + + for (const Site& s : sites) { + INFO("site ", s.name); + const vllm::Qwen4ExpGatedResidualWeights& gw = *s.w; + REQUIRE(gw.hc_norm.rank == 1); + REQUIRE(gw.hc_norm.shape[0] == flat); + + // THE MODEL'S gamma: what the loader left in the weight, raw. + const std::vector model_gamma = AsF32(gw.hc_norm); + // THE FILE'S gamma: `1 + w_hf`, the value ggml-org/llama.cpp#27742 wrote. + // Reconstructed from the fixture's own generator rather than re-read from + // the file, so this expectation is independent of the loader under test. + std::vector file_gamma(static_cast(flat)); + for (int64_t i = 0; i < flat; ++i) + file_gamma[static_cast(i)] = NormValue(i, s.tag); + + // The precondition this whole case rests on, asserted rather than assumed: + // the loader really did remove the fold, so the two gammas differ by + // exactly one everywhere. If this ever fails the loader changed polarity + // and the rest of the case is measuring something else. + for (int64_t i = 0; i < flat; ++i) { + REQUIRE(model_gamma[static_cast(i)] + 1.0f == + doctest::Approx(file_gamma[static_cast(i)])); + } + // ... and they are far enough apart that no tolerance can absorb the + // difference. `w_hf` is in [0, 1) and `1 + w_hf` in [1, 2), so the smallest + // ratio between them is 1.0 / 1.996 and the largest is unbounded. + REQUIRE(MaxAbsDiff(model_gamma, file_gamma) == doctest::Approx(1.0f)); + + const std::vector down = AsF32(gw.down); + const std::vector up = AsF32(gw.up); + const std::vector inject = gw.has_inject ? AsF32(gw.inject) : std::vector{}; + + // A hyper stream with per-branch scale separation, so a norm that collapses + // toward zero cannot be mistaken for a small numerical difference. + std::vector hyper(static_cast(T * flat)); + for (int64_t t = 0; t < T; ++t) { + for (int64_t p = 0; p < flat; ++p) { + hyper[static_cast(t * flat + p)] = + 0.37f * std::sin(0.11f * static_cast(p + 7 * t)) + + 0.05f * static_cast((p / kH) + 1); + } + } + + // ── the op ────────────────────────────────────────────────────────────── + std::vector mixed(static_cast(T * kH), 0.0f); + std::vector injection(static_cast(T * kHcCount), 0.0f); + vt::Tensor t_hyper = MakeT(hyper.data(), vt::DType::kF32, {T, flat}); + vt::Tensor t_mixed = MakeT(mixed.data(), vt::DType::kF32, {T, kH}); + vt::Tensor t_inj = MakeT(injection.data(), vt::DType::kF32, {T, kHcCount}); + vt::Tensor t_w = gw.hc_norm.View(); + vt::Tensor t_down = gw.down.View(); + vt::Tensor t_up = gw.up.View(); + vt::Tensor t_inject = gw.has_inject ? gw.inject.View() : vt::Tensor{}; + + vt::Qwen4ExpGatedResidualArgs args; + args.hc_count = kHcCount; + args.hidden_size = kH; + args.lowrank = kHcLowrank; + args.eps = eps; + vt::Queue q = CpuQ(); + vt::Qwen4ExpGatedResidual(q, t_mixed, gw.has_inject ? &t_inj : nullptr, t_hyper, t_w, + t_down, t_up, gw.has_inject ? &t_inject : nullptr, args); + + // ── the oracle, driven with the FILE's gamma ──────────────────────────── + vllm::qwen4_exp::GatedResidualWeights ow; + ow.hc_norm_weight = file_gamma; // vLLM form == what the file carried + ow.mix_down = down; + ow.mix_up = up; + ow.block_inject = inject; + std::vector want_mixed(static_cast(T * kH)); + std::vector want_inj(static_cast(T * kHcCount)); + for (int64_t t = 0; t < T; ++t) { + const std::vector row(hyper.begin() + static_cast(t * flat), + hyper.begin() + static_cast((t + 1) * flat)); + const vllm::qwen4_exp::GatedResidualResult r = + vllm::qwen4_exp::GatedResidualForward(row, ow, kHcCount, kH, eps); + for (int64_t h = 0; h < kH; ++h) + want_mixed[static_cast(t * kH + h)] = r.mixed_input[static_cast(h)]; + if (gw.has_inject) { + for (int64_t j = 0; j < kHcCount; ++j) + want_inj[static_cast(t * kHcCount + j)] = + r.injection_weights[static_cast(j)]; + } + } + + // `mixed` IS THE DISCRIMINATOR AND `injection` IS NOT — said out loud, + // because a reader counting green assertions would count both. The + // injection logit is `inject . normed / hc` over a 128-wide row of the + // fixture's `inject` ramp, which reaches ~10^4 whichever gamma is used, so + // `2 * sigmoid(.)` saturates at 2.0 under BOTH polarities and the check + // below passes either way. It is kept as an equality check on the arm's + // OTHER properties (shape, per-token layout, the `has_inject` split), and + // it is asserted saturated so the day it stops being saturated is loud. + CHECK(MaxAbsDiff(mixed, want_mixed) < 1e-5f); + if (gw.has_inject) { + CHECK(MaxAbsDiff(injection, want_inj) < 1e-5f); + for (float v : injection) { + REQUIRE_MESSAGE(v == doctest::Approx(2.0f), + "the injection sigmoid is expected SATURATED at this " + "fixture; if it is not, this arm now discriminates " + "polarity and the comment above is stale"); + } + } + } +} diff --git a/tests/vllm/models/test_qwen4_exp_gguf_weights.cpp b/tests/vllm/models/test_qwen4_exp_gguf_weights.cpp index d413cdbe68..9e0bc7ce7f 100644 --- a/tests/vllm/models/test_qwen4_exp_gguf_weights.cpp +++ b/tests/vllm/models/test_qwen4_exp_gguf_weights.cpp @@ -59,6 +59,8 @@ #include "vt/dtype.h" #include "vt/quant.h" +#include "support/qwen4_exp_gguf_fixture.h" // the ONE synthetic `qwen4exp` file + #include "qwen4_exp_gguf_manifest.inc" namespace { @@ -70,383 +72,11 @@ using gguf_test::StrKv; using gguf_test::TempFile; using gguf_test::U32Kv; -// ── the tiny fixture geometry ──────────────────────────────────────────────── -// -// Every dimension is the smallest one that keeps a STRUCTURE the released -// config has and that a smaller value would erase: -// -// * `kNumKHeads` is 2, not 1. The V-head reorder maps grouped head `k*R + r` -// to tiled head `r*K + k`; at K = 1 that is the identity, so a fixture with -// one key head cannot tell a correct un-reorder from no un-reorder at all. -// * `kLayers` is 4 with `full_attention_interval` 4, which is the released -// 3-linear-then-1-sparse pattern at its shortest: layers 0..2 are Gated -// DeltaNet and layer 3 is QSA, so both arms of the per-layer branch run. -// * `kHeadsPerNgram` is 1, and `kPleRow` (below) is a multiple of 32, so -// `head_dim_per_ngram` is 96 — three whole Q8_0 blocks. The n-gram table is -// the ONE gather this model keeps quantized (W6a, #1989), and a table whose -// row is not a whole number of blocks could not exercise that at all. -constexpr int64_t kH = 64; // hidden_size -constexpr int64_t kLayers = 4; // 0,1,2 linear_attention; 3 qwen_sparse_attention -constexpr int64_t kVocab = 16; -constexpr int64_t kHcCount = 2; -constexpr int64_t kHcLowrank = 8; -constexpr int64_t kStream = kHcCount * kH; // the residual stream width, 128 -constexpr int64_t kExperts = 2; -constexpr int64_t kExpertsPerTok = 1; -constexpr int64_t kMoeI = 8; -constexpr int64_t kSharedI = 8; -constexpr int64_t kQHeads = 2; -constexpr int64_t kKvHeads = 1; -constexpr int64_t kHeadDim = 8; -constexpr int64_t kRotaryDim = 4; -constexpr int64_t kIdxHeads = 2; -constexpr int64_t kIdxKvHeads = 1; -constexpr int64_t kIdxHeadDim = 8; -constexpr int64_t kIdxBudget = 8; -constexpr int64_t kCompressRatio = 4; -constexpr int64_t kNumKHeads = 2; // linear_num_key_heads -// SIX, not four, and the reason is a mutation this gate failed before it was -// six. The V-head reorder maps grouped head `k*R + r` to tiled head `r*K + k`. -// At K == R that permutation is its OWN INVERSE, so a loader that applied the -// map in the wrong direction produced byte-identical output and the whole -// reorder suite stayed green (mutation M5). K = 2 with R = 3 is the smallest -// pair where the map and its inverse differ, and it is also the released -// model's own ratio: 16 key heads to 48 value heads is R = 3. -constexpr int64_t kNumVHeads = 6; // linear_num_value_heads -constexpr int64_t kLinHeadDim = 8; // linear_{key,value}_head_dim -constexpr int64_t kConvKernel = 4; -constexpr int64_t kNgramSize = 3; -constexpr int64_t kHeadsPerNgram = 1; -constexpr int64_t kPleLayer = 1; // 0-based, and a linear_attention layer -constexpr int64_t kEosTokenId = 3; - -constexpr int64_t kKeyDim = kNumKHeads * kLinHeadDim; // 16 -constexpr int64_t kValueDim = kNumVHeads * kLinHeadDim; // 48 -constexpr int64_t kConvDim = 2 * kKeyDim + kValueDim; // 80 -constexpr int64_t kNgramHeads = (kNgramSize - 1) * kHeadsPerNgram; // 2 -// 96, and it is DELIBERATELY NEITHER `kH / kNgramHeads` NOR `kH`. This is the -// fixture shape that gates `ple_embed_dim`, and neither value it replaced could. -// -// The GGUF states the PER-HEAD row width and HF states the TOTAL; the builder -// reconstructs the total as `ple_row * ngram_heads`, and `ParseQwen4ExpParams` -// falls back to `hidden_size` when the total is absent. On the RELEASED config -// those two happen to coincide (160 * 16 == 2560 == hidden_size), which is the -// coincidence #2064 was filed about. A fixture that DEFINES `kPleRow` as -// `kH / kNgramHeads` reproduces that coincidence by construction, so deleting -// the builder's `text["ple_embed_dim"]` line left the whole suite green -// (mutation MUT-C). -// -// 64 broke MUT-C but left a SECOND coincidence standing, because `kH` is also -// 64: a builder that wrote `hidden_size * ngram_heads` instead of -// `ple_row * ngram_heads` still produced 128, the correct total, and that -// mutation survived the whole suite (MUT-D). At 96 the correct total is 192, -// the `hidden_size` product is 128 and the bare `hidden_size` fallback is 64, -// so all three are distinct and each wrong one refuses the file by shape — -// which is what makes the builder's line observable at all. -// -// 96 rather than any other triply-distinct value because -// `head_dim_per_ngram() == kPleEmbedDim / kNgramHeads` must stay a whole number -// of Q8_0 blocks: the n-gram table is the one gather this model keeps -// quantized, and a ragged row cannot be kept at all. 96 is three blocks, and it -// is the smallest multiple of 32 that is neither `kH` nor `kH / kNgramHeads`. -constexpr int64_t kPleRow = 96; -// The TOTAL width, HF's own `ple_embed_dim`. 192 != kH, which is the point. -constexpr int64_t kPleEmbedDim = kPleRow * kNgramHeads; // 192 -static_assert(kPleEmbedDim != kH, - "the fixture must not reproduce the released checkpoint's " - "ple_embed_dim == hidden_size coincidence (#2064)"); -static_assert(kPleEmbedDim != kH * kNgramHeads, - "the fixture must not let `hidden_size * ngram_heads` stand in " - "for `ple_row * ngram_heads` (#2064)"); -static_assert(kPleRow % 32 == 0, "an n-gram row must be whole Q8_0 blocks"); -// The two head vocabularies the fixture STATES, the way a real `qwen4exp` file -// does (`qwen4exp.ple.head_vocab_sizes`). Their sum is 52 and -// `make_ngram_vocab_size_divisible_by` defaults to 128, so the padded table is -// 128 rows. 23 and 29 are the successive primes after 19, which is what the HF -// derivation would produce from `ngram_vocab_size_base = 20` — stated here so -// the two routes into `NgramTableRows` are the same arithmetic on a small -// config, and the released-config case gates them at 320001536. -constexpr int64_t kNgramHead0Vocab = 23; -constexpr int64_t kNgramHead1Vocab = 29; -constexpr int64_t kNgramRows = 128; - -// One `tag` per NORM tensor, so a cross-wired pair reads a different sequence. -// The per-layer ones are offset by layer as well, so a loader that read layer 0's -// gamma into layer 3 would be visible too. -constexpr int64_t kMixerNormTag = 1; -constexpr int64_t kQNormTag = 2; -constexpr int64_t kKNormTag = 3; -constexpr int64_t kIdxQNormTag = 4; -constexpr int64_t kIdxKNormTag = 5; -constexpr int64_t kPleNormKeyTag = 6; -constexpr int64_t kPleNormQueryTag = 7; -constexpr int64_t kPleNormConvTag = 8; -inline int64_t HcNormTag(int64_t layer, const char* side) { - return 10 + 2 * layer + (side[0] == 'a' ? 0 : 1); -} -inline int64_t SsmNormTag(int64_t layer) { return 30 + layer; } - -std::string Blk(int64_t l, const char* suffix) { - return "blk." + std::to_string(l) + "." + suffix; -} - -bool IsLinear(int64_t l) { return ((l + 1) % 4) != 0; } +// The synthetic file, its geometry and `LoadThroughRegistry` live in +// `tests/support/qwen4_exp_gguf_fixture.h` — ONE builder, shared with the W5b +// forward suite. See that header for why every dimension is the value it is. +using namespace qwen4_exp_fixture; // NOLINT(build/namespaces) — the fixture IS this suite's vocabulary -// ── deterministic payloads ─────────────────────────────────────────────────── - -std::string F32Bytes(const std::vector& v) { - std::string s(v.size() * 4, '\0'); - std::memcpy(s.data(), v.data(), v.size() * 4); - return s; -} - -// A distinguishable value per element: no two positions of any tensor share a -// value, so a permutation defect cannot hide behind a repeated number. -std::vector Ramp(int64_t n, float base) { - std::vector v(static_cast(n)); - for (int64_t i = 0; i < n; ++i) - v[static_cast(i)] = base + static_cast(i); - return v; -} - -std::string RampF32(int64_t n, float base) { return F32Bytes(Ramp(n, base)); } - -// NORM gammas get their own generator, and the reason is a measurement rather -// than tidiness. The `+1` fold this loader inverts is a subtraction of ONE, and -// bf16's step is 16 by the time a plain ramp reaches 3001 — so on a gamma -// written as `3001 + i` the fold and its absence round to the SAME bf16 value -// and the check passes either way. Every value here is `1 + k/128` with -// `k` in [0, 127], which bf16 represents exactly, and so is `k/128` after the -// fold is removed. `tag` gives each tensor its own sequence so a cross-wired -// pair (norm_key read into norm_query) is visible. -float NormValue(int64_t i, int64_t tag) { - return 1.0F + static_cast((i + 13 * tag) % 128) / 128.0F; -} - -std::string NormF32(int64_t n, int64_t tag) { - std::vector v(static_cast(n)); - for (int64_t i = 0; i < n; ++i) v[static_cast(i)] = NormValue(i, tag); - return F32Bytes(v); -} - -// Q8_0 payload for `rows x 32` — one block per row, encoded the way -// `DequantGgufRowToF32` reads it back: an f16 scale then 32 int8 codes. -std::string Q8_0Bytes(int64_t rows, int64_t cols) { - REQUIRE(cols % 32 == 0); - const int64_t blocks = rows * (cols / 32); - std::string s(static_cast(blocks) * 34, '\0'); - auto* p = reinterpret_cast(s.data()); - for (int64_t b = 0; b < blocks; ++b) { - const uint16_t half = vt::F32ToF16(0.5F); - std::memcpy(p + b * 34, &half, 2); - for (int64_t i = 0; i < 32; ++i) - p[b * 34 + 2 + i] = static_cast(static_cast((b + i) % 100 - 50)); - } - return s; -} - -// ── the synthetic file ─────────────────────────────────────────────────────── - -// `drop` names a tensor to OMIT and `bad_shape` one to write at a wrong shape, -// so the refusal cases enter through the same builder the happy path does. A -// second builder would be free to disagree with this one, and then the refusal -// cases would be testing the second builder. -struct FixtureOpts { - std::string drop; - std::string bad_shape; - // W5c (#2031): make `attention.compress_ratios` DISAGREE between two sparse - // layers. The file states the ratio per LAYER while HF states one value, so - // the config builder takes the first non-zero and requires the rest to - // match; a mixed schedule that silently first-wins would size the QSA - // indexer side cache for one ratio while another layer compressed at a - // different one. - // - // It DOUBLES `block_count`, and that is what makes the defect expressible at - // all. The miniature is four layers at `full_attention_interval` 4, so it has - // exactly ONE sparse layer and one non-zero ratio, which cannot disagree with - // itself; and a stray non-zero on a LINEAR layer is caught one check earlier - // by "compress_ratios disagrees with the full_attention_interval schedule". - // Eight layers give two sparse ones, 3 and 7, so the array can be - // schedule-consistent AND non-uniform. Only `Qwen4ExpHfConfigFromGguf` is - // driven with this option — it reads metadata and never walks the per-layer - // tensors, which stay at four layers. - bool mixed_compress_ratios = false; -}; - -void Add(GgufModelBuilder& b, const FixtureOpts& o, const std::string& name, - std::vector ne, uint32_t ggml_type, const std::string& data) { - if (name == o.drop) return; - if (name == o.bad_shape) { - // One extra row: a shape a reader that only checks rank would accept. - ne.back() += 1; - const int64_t elems_per_row = - static_cast(ne.front()); - return b.AddTensor(name, ne, ggml_type, - data + std::string(static_cast(elems_per_row) * 4, '\0')); - } - b.AddTensor(name, ne, ggml_type, data); -} - -std::string BuildFixture(const FixtureOpts& o = {}) { - GgufModelBuilder b; - b.AddKv(StrKv("general.architecture", "qwen4exp")); - b.AddKv(U32Kv("qwen4exp.embedding_length", kH)); - const int64_t layers_kv = o.mixed_compress_ratios ? kLayers * 2 : kLayers; - b.AddKv(U32Kv("qwen4exp.block_count", layers_kv)); - b.AddKv(U32Kv("qwen4exp.attention.head_count", kQHeads)); - b.AddKv(U32Kv("qwen4exp.attention.head_count_kv", kKvHeads)); - b.AddKv(U32Kv("qwen4exp.attention.key_length", kHeadDim)); - b.AddKv(U32Kv("qwen4exp.attention.value_length", kHeadDim)); - b.AddKv(U32Kv("qwen4exp.context_length", 256)); - b.AddKv(F32Kv("qwen4exp.attention.layer_norm_rms_epsilon", 1e-6F)); - b.AddKv(F32Kv("qwen4exp.rope.freq_base", 10000.0F)); - b.AddKv(U32Kv("qwen4exp.rope.dimension_count", kRotaryDim)); - b.AddKv(U32Kv("qwen4exp.expert_count", kExperts)); - b.AddKv(U32Kv("qwen4exp.expert_used_count", kExpertsPerTok)); - b.AddKv(U32Kv("qwen4exp.expert_feed_forward_length", kMoeI)); - b.AddKv(U32Kv("qwen4exp.expert_shared_feed_forward_length", kSharedI)); - b.AddKv(U32Kv("qwen4exp.ssm.group_count", kNumKHeads)); - b.AddKv(U32Kv("qwen4exp.ssm.time_step_rank", kNumVHeads)); - b.AddKv(U32Kv("qwen4exp.ssm.state_size", kLinHeadDim)); - b.AddKv(U32Kv("qwen4exp.ssm.conv_kernel", kConvKernel)); - b.AddKv(U32Kv("qwen4exp.ssm.inner_size", kValueDim)); - b.AddKv(U32Kv("qwen4exp.full_attention_interval", 4)); - b.AddKv(U32Kv("qwen4exp.hyper_connection.count", kHcCount)); - b.AddKv(U32Kv("qwen4exp.hyper_connection.low_rank", kHcLowrank)); - b.AddKv(U32Kv("qwen4exp.attention.indexer.head_count", kIdxHeads)); - b.AddKv(U32Kv("qwen4exp.attention.indexer.key_length", kIdxHeadDim)); - b.AddKv(U32Kv("qwen4exp.attention.indexer.top_k", kIdxBudget)); - b.AddKv(U32Kv("qwen4exp.embedding_length_per_layer_input", kPleRow)); - b.AddKv(U32Kv("qwen4exp.ple.ngram_size", kNgramSize)); - b.AddKv(U32Kv("qwen4exp.ple.heads_per_ngram", kHeadsPerNgram)); - b.AddKv(U32Kv("qwen4exp.ple.conv_kernel", kConvKernel)); - b.AddKv(U32Kv("qwen4exp.ple.eos_token_id", kEosTokenId)); - b.AddKv(I32ArrayKv("qwen4exp.ple.head_vocab_sizes", - {static_cast(kNgramHead0Vocab), - static_cast(kNgramHead1Vocab)})); - b.AddKv(I32ArrayKv("qwen4exp.ple.head_offsets", - {0, static_cast(kNgramHead0Vocab)})); - b.AddKv(I32ArrayKv("qwen4exp.ple.layers", {static_cast(kPleLayer)})); - std::vector ratios; - for (int64_t i = 0; i < layers_kv; ++i) - ratios.push_back(IsLinear(i) ? 0 : static_cast(kCompressRatio)); - if (o.mixed_compress_ratios) { - // The LAST sparse layer compresses at a different ratio from the first, so - // the array still agrees with the schedule and no longer agrees with - // itself. - ratios.back() = static_cast(kCompressRatio) * 2; - } - b.AddKv(I32ArrayKv("qwen4exp.attention.compress_ratios", ratios)); - - // Tensor dims are in GGUF `ne` order (inner/fastest dim first), which is the - // REVERSE of the torch [out, in] order the reader hands back. - Add(b, o, "token_embd.weight", {kH, kVocab}, 0, RampF32(kH * kVocab, 1.0F)); - Add(b, o, "output.weight", {kH, kVocab}, 0, RampF32(kH * kVocab, 2.0F)); - Add(b, o, "per_layer_token_embd.weight", {kPleRow, kNgramRows}, 8, - Q8_0Bytes(kNgramRows, kPleRow)); - Add(b, o, "output_hc_norm.weight", {kStream}, 0, - NormF32(kStream, kMixerNormTag)); - Add(b, o, "output_hc_down.weight", {kStream, kHcLowrank}, 0, - RampF32(kStream * kHcLowrank, 3.0F)); - Add(b, o, "output_hc_up.weight", {kHcLowrank, kStream}, 0, - RampF32(kStream * kHcLowrank, 4.0F)); - - for (int64_t l = 0; l < kLayers; ++l) { - const float base = static_cast(l * 1000 + 1); - for (const char* side : {"attn", "ffn"}) { - const std::string p = std::string("hc_") + side + "_"; - Add(b, o, Blk(l, (p + "norm.weight").c_str()), {kStream}, 0, - NormF32(kStream, HcNormTag(l, side))); - Add(b, o, Blk(l, (p + "down.weight").c_str()), {kStream, kHcLowrank}, 0, - RampF32(kStream * kHcLowrank, base)); - Add(b, o, Blk(l, (p + "up.weight").c_str()), {kHcLowrank, kStream}, 0, - RampF32(kStream * kHcLowrank, base)); - Add(b, o, Blk(l, (p + "inject.weight").c_str()), {kStream, kHcCount}, 0, - RampF32(kStream * kHcCount, base)); - } - Add(b, o, Blk(l, "ffn_gate_inp.weight"), {kH, kExperts}, 0, - RampF32(kH * kExperts, base)); - Add(b, o, Blk(l, "ffn_gate_inp_shexp.weight"), {kH}, 0, RampF32(kH, base)); - Add(b, o, Blk(l, "ffn_gate_exps.weight"), {kH, kMoeI, kExperts}, 0, - RampF32(kH * kMoeI * kExperts, base)); - Add(b, o, Blk(l, "ffn_up_exps.weight"), {kH, kMoeI, kExperts}, 0, - RampF32(kH * kMoeI * kExperts, base)); - Add(b, o, Blk(l, "ffn_down_exps.weight"), {kMoeI, kH, kExperts}, 0, - RampF32(kH * kMoeI * kExperts, base)); - Add(b, o, Blk(l, "ffn_gate_shexp.weight"), {kH, kSharedI}, 0, - RampF32(kH * kSharedI, base)); - Add(b, o, Blk(l, "ffn_up_shexp.weight"), {kH, kSharedI}, 0, - RampF32(kH * kSharedI, base)); - Add(b, o, Blk(l, "ffn_down_shexp.weight"), {kSharedI, kH}, 0, - RampF32(kH * kSharedI, base)); - - if (IsLinear(l)) { - Add(b, o, Blk(l, "attn_qkv.weight"), {kH, kConvDim}, 0, - RampF32(kH * kConvDim, base)); - Add(b, o, Blk(l, "attn_gate.weight"), {kH, kValueDim}, 0, - RampF32(kH * kValueDim, base)); - Add(b, o, Blk(l, "ssm_alpha.weight"), {kH, kNumVHeads}, 0, - RampF32(kH * kNumVHeads, base)); - Add(b, o, Blk(l, "ssm_beta.weight"), {kH, kNumVHeads}, 0, - RampF32(kH * kNumVHeads, base)); - Add(b, o, Blk(l, "ssm_conv1d.weight"), {kConvKernel, kConvDim}, 0, - RampF32(kConvDim * kConvKernel, base)); - Add(b, o, Blk(l, "ssm_norm.weight"), {kLinHeadDim}, 0, - NormF32(kLinHeadDim, SsmNormTag(l))); - Add(b, o, Blk(l, "ssm_out.weight"), {kValueDim, kH}, 0, - RampF32(kH * kValueDim, base)); - // `ssm_a` is stored as -exp(A_log); the loader recovers log(-x). Negative - // by construction, and distinct per head. - std::vector a(static_cast(kNumVHeads)); - for (int64_t i = 0; i < kNumVHeads; ++i) - a[static_cast(i)] = -static_cast(i + 1); - Add(b, o, Blk(l, "ssm_a"), {kNumVHeads}, 0, F32Bytes(a)); - Add(b, o, Blk(l, "ssm_dt.bias"), {kNumVHeads}, 0, - RampF32(kNumVHeads, base)); - } else { - Add(b, o, Blk(l, "attn_q.weight"), {kH, kQHeads * kHeadDim * 2}, 0, - RampF32(kH * kQHeads * kHeadDim * 2, base)); - Add(b, o, Blk(l, "attn_k.weight"), {kH, kKvHeads * kHeadDim}, 0, - RampF32(kH * kKvHeads * kHeadDim, base)); - Add(b, o, Blk(l, "attn_v.weight"), {kH, kKvHeads * kHeadDim}, 0, - RampF32(kH * kKvHeads * kHeadDim, base)); - Add(b, o, Blk(l, "attn_output.weight"), {kQHeads * kHeadDim, kH}, 0, - RampF32(kH * kQHeads * kHeadDim, base)); - Add(b, o, Blk(l, "attn_q_norm.weight"), {kHeadDim}, 0, - NormF32(kHeadDim, kQNormTag)); - Add(b, o, Blk(l, "attn_k_norm.weight"), {kHeadDim}, 0, - NormF32(kHeadDim, kKNormTag)); - Add(b, o, Blk(l, "indexer.q_proj.weight"), {kH, kIdxHeads * kIdxHeadDim}, - 0, RampF32(kH * kIdxHeads * kIdxHeadDim, base)); - Add(b, o, Blk(l, "indexer.k_proj.weight"), - {kH, kIdxKvHeads * kIdxHeadDim}, 0, - RampF32(kH * kIdxKvHeads * kIdxHeadDim, base)); - Add(b, o, Blk(l, "indexer.q_norm.weight"), {kIdxHeadDim}, 0, - NormF32(kIdxHeadDim, kIdxQNormTag)); - Add(b, o, Blk(l, "indexer.k_norm.weight"), {kIdxHeadDim}, 0, - NormF32(kIdxHeadDim, kIdxKNormTag)); - } - - if (l == kPleLayer) { - // [stream, ple_embed_dim] and [hidden_size, ple_embed_dim] in TORCH - // order, so the GGUF `ne` is reversed. Both are ple_embed_dim wide and - // NOT hidden_size wide, which is what MUT-C now runs into. - Add(b, o, Blk(l, "ple_key.weight"), {kPleEmbedDim, kStream}, 0, - RampF32(kPleEmbedDim * kStream, base)); - Add(b, o, Blk(l, "ple_value.weight"), {kPleEmbedDim, kH}, 0, - RampF32(kPleEmbedDim * kH, base)); - Add(b, o, Blk(l, "ple_norm_key.weight"), {kStream}, 0, - NormF32(kStream, kPleNormKeyTag)); - Add(b, o, Blk(l, "ple_norm_query.weight"), {kStream}, 0, - NormF32(kStream, kPleNormQueryTag)); - Add(b, o, Blk(l, "ple_norm_conv.weight"), {kStream}, 0, - NormF32(kStream, kPleNormConvTag)); - Add(b, o, Blk(l, "ple_conv1d.weight"), {kConvKernel, kStream}, 0, - RampF32(kStream * kConvKernel, base)); - } - } - return b.Build(); -} // The production entry point, reached the way a user reaches it: the GGUF // architecture dispatch builds the config, the registry resolves the @@ -503,21 +133,6 @@ std::set FileNames(const vllm::GgufFile& g) { return out; } -// `ModelRegistry::Load`, not `reg.factory->load_weights`, and the difference is -// load-bearing rather than stylistic. `Load` resolves the architecture, refuses -// an unsupported FP8-block quantization, runs `parse_config` and THEN the weight -// loader — which is the sequence `LoadedEngine::FromModelDir` runs at -// `entrypoints/model_loader.cpp` (`ModelSource::FromGguf(gguf)` -> -// `ModelRegistry::Load(config, gguf_source)`). Calling the hook directly skips -// `parse_config`, and that skip is exactly what hid #2064: the config builder -// and the config VALIDATOR had never been composed, so a file that built a -// config fine was refused the moment anything parsed it. -std::unique_ptr LoadThroughRegistry( - const vllm::GgufFile& g) { - const vllm::HfConfig config = vllm::Qwen4ExpHfConfigFromGguf(g); - const vllm::ModelSource source = vllm::ModelSource::FromGguf(g); - return vllm::ModelRegistry::Load(config, source); -} } // namespace diff --git a/tests/vllm/models/test_qwen4_exp_hc_device.cpp b/tests/vllm/models/test_qwen4_exp_hc_device.cpp index ea572c50cd..bccec204ff 100644 --- a/tests/vllm/models/test_qwen4_exp_hc_device.cpp +++ b/tests/vllm/models/test_qwen4_exp_hc_device.cpp @@ -87,13 +87,23 @@ Tensor MakeT(void* data, DType dt, const std::vector& shape) { return t; } -// The `1 + w_hf` fold. The goldens store the HuggingFace gamma (zero-init, so -// `output * (1.0 + weight)`); the op takes vLLM's parameterization, in which the -// kernel multiplies by `w` and knows nothing about the offset. `HcNormWeightFromHf` -// is the ONE home of that transform and this test goes through it rather than -// adding 1.0 by hand, so a change to the fold's polarity reddens here too. -std::vector VllmNorm(const float* w_hf, int64_t n) { - return vllm::qwen4_exp::HcNormWeightFromHf(std::vector(w_hf, w_hf + n)); +// The gamma the OP takes, and since #2218 that is the goldens' own value with +// nothing done to it. The goldens store the HuggingFace parameter (zero-init, +// so upstream spells the norm `output * (1.0 + weight)`) and +// `vt::Qwen4ExpGatedResidual` now adds the 1 itself, the same way +// `vt::RmsNorm(gemma=true)` and `vt::Qwen4ExpQsaCompress` do for this +// architecture's other gammas — so a loaded `Qwen4ExpWeights` can be handed +// straight to it. `vllm::qwen4_exp::HcNormWeightFromHf` remains the +// `w_hf -> 1 + w_hf` bridge for the HOST reference, whose `GroupedRmsNorm` +// keeps vLLM's `out * w` form and which `test_qwen4_exp_hc.cpp` drives; this +// suite compares the op against the transformers goldens directly, so it no +// longer needs the transform at all. +// +// A PASS-THROUGH THAT IS NOT DECORATION: it names, at every call site, WHICH +// parameterization the op is being handed, which is the entire content of +// #2218. An edit that reintroduces a fold here has to say so out loud. +std::vector OpGamma(const float* w_hf, int64_t n) { + return std::vector(w_hf, w_hf + n); } struct Case { @@ -135,7 +145,7 @@ const Case kCaseD{"D", 6, 4, 5, 2, 1e-6f, kD_norm_w_hf, kD_down, kD_up, void RunReadCase(const Case& c) { Queue q = CpuQ(); const int64_t flat = c.hc * c.hidden; - std::vector w = VllmNorm(c.norm_w_hf, flat); + std::vector w = OpGamma(c.norm_w_hf, flat); std::vector hyper(c.hyper, c.hyper + c.T * flat); std::vector down(c.down, c.down + c.lowrank * flat); std::vector up(c.up, c.up + flat * c.lowrank); @@ -234,7 +244,7 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: the per-token rows are independent") { Queue q = CpuQ(); const Case& c = kCaseA; const int64_t flat = c.hc * c.hidden; - std::vector w = VllmNorm(c.norm_w_hf, flat); + std::vector w = OpGamma(c.norm_w_hf, flat); std::vector down(c.down, c.down + c.lowrank * flat); std::vector up(c.up, c.up + flat * c.lowrank); std::vector inject_w(c.inject, c.inject + c.hc * flat); @@ -300,7 +310,7 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: bf16 storage rounds ONCE, on the store") { for (size_t i = 0; i < src.size(); ++i) out[i] = vt::F32ToBF16(src[i]); return out; }; - const std::vector w = bf16_exact(VllmNorm(c.norm_w_hf, flat)); + const std::vector w = bf16_exact(OpGamma(c.norm_w_hf, flat)); const std::vector hyper = bf16_exact({c.hyper, c.hyper + c.T * flat}); const std::vector down = bf16_exact({c.down, c.down + c.lowrank * flat}); const std::vector up = bf16_exact({c.up, c.up + flat * c.lowrank}); @@ -384,8 +394,13 @@ TEST_CASE("vt::Qwen4ExpGatedResidual agrees with the host reference at MODEL WID }; std::vector hyper(static_cast(T * kFlat)); for (float& v : hyper) v = next(); - std::vector w(static_cast(kFlat)); - for (float& v : w) v = 1.0f + 0.1f * next(); + // THE OP TAKES `w_hf`, THE HOST REFERENCE TAKES `1 + w_hf` (#2218). The same + // numbers reach the same arithmetic either way — the draw below is centred on + // zero and the fold puts it back on one — but the two arms are handed + // DIFFERENT parameterizations of it, which is what keeps this an agreement + // check between two implementations rather than between two spellings. + std::vector w_hf(static_cast(kFlat)); + for (float& v : w_hf) v = 0.1f * next(); std::vector down(static_cast(R * kFlat)); for (float& v : down) v = 0.02f * next(); std::vector up(static_cast(kFlat * R)); @@ -396,7 +411,7 @@ TEST_CASE("vt::Qwen4ExpGatedResidual agrees with the host reference at MODEL WID std::vector mixed(static_cast(T * H), 0.0f); std::vector inj(static_cast(T * HC), 0.0f); Tensor t_hyper = MakeT(hyper.data(), DType::kF32, {T, kFlat}); - Tensor t_w = MakeT(w.data(), DType::kF32, {kFlat}); + Tensor t_w = MakeT(w_hf.data(), DType::kF32, {kFlat}); Tensor t_down = MakeT(down.data(), DType::kF32, {R, kFlat}); Tensor t_up = MakeT(up.data(), DType::kF32, {kFlat, R}); Tensor t_inject = MakeT(inject.data(), DType::kF32, {HC, kFlat}); @@ -412,7 +427,7 @@ TEST_CASE("vt::Qwen4ExpGatedResidual agrees with the host reference at MODEL WID args); vllm::qwen4_exp::GatedResidualWeights hw; - hw.hc_norm_weight = w; + hw.hc_norm_weight = vllm::qwen4_exp::HcNormWeightFromHf(w_hf); hw.mix_down = down; hw.mix_up = up; hw.block_inject = inject; @@ -509,11 +524,14 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: the grouped norm needs a WIDER-THAN-f32 ac state = state * 6364136223846793005ULL + 1442695040888963407ULL; return static_cast(static_cast(state >> 33)) / 2147483648.0f; }; - std::vector hyper(static_cast(kFlat)), w(static_cast(kFlat)); + // `w_hf` is the RAW gamma the op takes (#2218); the double reference below + // spells the `1 +` itself, IN f32, so both arms describe the same multiplier + // bit for bit and the only thing this case widens is the reduction it is about. + std::vector hyper(static_cast(kFlat)), w_hf(static_cast(kFlat)); for (int64_t j = 0; j < HC; ++j) { for (int64_t d = 0; d < H; ++d) { hyper[static_cast(j * H + d)] = (d == 0) ? dominant[j] : 1.0f; - w[static_cast(j * H + d)] = 1.0f + 0.5f * next(); + w_hf[static_cast(j * H + d)] = 0.5f * next(); } } std::vector down(static_cast(R * kFlat), 0.0f); @@ -521,7 +539,7 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: the grouped norm needs a WIDER-THAN-f32 ac std::vector mixed(static_cast(T * H), 0.0f); Tensor t_hyper = MakeT(hyper.data(), DType::kF32, {T, kFlat}); - Tensor t_w = MakeT(w.data(), DType::kF32, {kFlat}); + Tensor t_w = MakeT(w_hf.data(), DType::kF32, {kFlat}); Tensor t_down = MakeT(down.data(), DType::kF32, {R, kFlat}); Tensor t_up = MakeT(up.data(), DType::kF32, {kFlat, R}); Tensor t_mixed = MakeT(mixed.data(), DType::kF32, {T, H}); @@ -534,8 +552,18 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: the grouped norm needs a WIDER-THAN-f32 ac vt::Qwen4ExpGatedResidual(q, t_mixed, nullptr, t_hyper, t_w, t_down, t_up, nullptr, args); - // The reference, in full double. `sigmoid(0)` is 0.5 exactly in any precision, - // so the only thing this widens is the reduction. + // The reference, in full double EXCEPT the gamma fold. `sigmoid(0)` is 0.5 + // exactly in any precision, so the reduction is the only thing left for the + // widening to isolate -- provided the fold does not quietly widen with it. + // Upstream folds in f32: `Qwen4ExpTextRMSNorm.forward` is + // `output * (1.0 + self.weight.float())` (`modeling_qwen4_exp.py:177`), where + // the Python `1.0` is a weak scalar and the promotion stays fp32, and the + // kernel mirrors that with `1.0f + LoadF32At(hc_norm_w, ...)`. Folding in + // double here instead would leave the two arms up to a float ulp apart on the + // multiplier, and this case's band would absorb the difference silently -- a + // tolerance covering a dtype gap, which is the shape AGENTS.md "Inherit vLLM + // defaults" warns a token gate cannot see. So the `+ 1` is spelled `1.0f` and + // widened AFTERWARDS, matching the kernel exactly. std::vector want(static_cast(H), 0.0); for (int64_t j = 0; j < HC; ++j) { double ss = 0.0; @@ -545,8 +573,9 @@ TEST_CASE("vt::Qwen4ExpGatedResidual: the grouped norm needs a WIDER-THAN-f32 ac } const double r = 1.0 / std::sqrt(ss / static_cast(H) + static_cast(kEps)); for (int64_t d = 0; d < H; ++d) { + const float w_folded = 1.0f + w_hf[static_cast(j * H + d)]; want[static_cast(d)] += - 0.5 * hyper[static_cast(j * H + d)] * r * w[static_cast(j * H + d)]; + 0.5 * hyper[static_cast(j * H + d)] * r * static_cast(w_folded); } } for (double& v : want) v /= static_cast(HC); diff --git a/tests/vt/test_tenstorrent_backend.cpp b/tests/vt/test_tenstorrent_backend.cpp index 545dc9c941..523674bf5f 100644 --- a/tests/vt/test_tenstorrent_backend.cpp +++ b/tests/vt/test_tenstorrent_backend.cpp @@ -4145,6 +4145,162 @@ TEST_CASE("kTENSTORRENT W4 EnsureDevice2D bulk bf16 staging: route, bytes, views backend.Free(ma32); } +// ==== BACKEND-TENSTORRENT-QWEN35 W5 (#2244): allocation-free staging ========= +// W4's profile left ~23% of the staging chain inside tt-metal per-upload +// internal work: UploadRowsBf16 built a NEW ttnn::Tensor via from_span on +// every staging upload, paying a fresh MeshBuffer allocation, cluster/chip +// discovery and tensor-attribute creation for identical geometry every step. +// W5 allocates the device buffer once per staging slot (lifecycle tied to the +// slot structures, under the #1486 never-destroy rule for static caches) and +// re-uploads by packing the host bytes (the exact from_span packing) and +// writing them through the mesh command queue into the resident buffer. +// +// This case pins the ROUTE (the persistent counters: cold slot allocates ONCE, +// a re-staged slot must NOT reallocate), the BYTES (the device copy equals the +// window's bf16 bits bit-for-bit after an in-place rewrite, so the persistent +// buffer provably carries the NEW bytes) and the f32 arm (still excluded — a +// genuine conversion never enters the bf16 persistent route). The W4 counters +// keep counting every bulk bf16 staging regardless of sub-route. +TEST_CASE("kTENSTORRENT W5 EnsureDevice2D persistent staging buffer: route, reuse, bytes") { + if (!TenstorrentPresent()) { + MESSAGE("SKIPPED: no Tenstorrent device on this box"); + return; + } + REQUIRE(vt::OpRegistered(vt::OpId::kMatmulBT, DeviceType::kTENSTORRENT)); + Backend& backend = vt::GetBackend(DeviceType::kTENSTORRENT); + using vt::tenstorrent::GetStagingStats; + using vt::tenstorrent::ResetStagingStats; + constexpr int64_t M = 5, K = 64, N = 16; + auto widen = [](uint16_t u) { + uint32_t bits = static_cast(u) << 16; + float f; std::memcpy(&f, &bits, 4); return f; + }; + auto f32bits = [](float f) { + uint32_t b; std::memcpy(&b, &f, 4); return b; + }; + // Two distinguishable bf16 bit patterns for the in-place rewrite leg. + auto pattern = [](std::vector& v, uint16_t base) { + for (size_t i = 0; i < v.size(); ++i) + v[i] = static_cast(base + (i % 5)); + }; + + // 1) Cold slot: the FIRST bulk upload allocates the per-slot persistent + // buffer and serves the upload through it. + std::vector ha(M * K), hb(N * K); + pattern(ha, 0x3C00); + pattern(hb, 0x3F80); + void* ma = backend.Alloc(M * K * 2); + void* mb = backend.Alloc(N * K * 2); + void* mo = backend.Alloc(M * N * 4); + Queue q = backend.CreateQueue(); + backend.Copy(q, ma, ha.data(), M * K * 2); + backend.Copy(q, mb, hb.data(), N * K * 2); + Tensor a = Tensor::Contiguous(ma, vt::DType::kBF16, Device{DeviceType::kTENSTORRENT, 0}, {M, K}); + Tensor b = Tensor::Contiguous(mb, vt::DType::kBF16, Device{DeviceType::kTENSTORRENT, 0}, {N, K}); + Tensor o = Tensor::Contiguous(mo, vt::DType::kF32, Device{DeviceType::kTENSTORRENT, 0}, {M, N}); + auto mm = reinterpret_cast(vt::GetOp(vt::OpId::kMatmulBT, DeviceType::kTENSTORRENT)); + + ResetStagingStats(); + mm(q, o, a, b); // a is cold → allocates; b is cold → allocates + vt::tenstorrent::StagingStats s = GetStagingStats(); + CHECK_MESSAGE(s.uploads_bulk_bf16 == 2, + "both bf16 operands still stage through the bulk route, got " + << s.uploads_bulk_bf16); + CHECK_MESSAGE(s.uploads_persistent_bf16 == 2, + "both bulk uploads must be served by the persistent route, got " + << s.uploads_persistent_bf16); + CHECK_MESSAGE(s.uploads_persistent_allocs == 2, + "two cold slots must allocate one persistent buffer each, got " + << s.uploads_persistent_allocs); + CHECK_MESSAGE(s.staged_persistent_bf16_bytes == + static_cast((M * K + N * K) * 2), + "persistent bytes: got " << s.staged_persistent_bf16_bytes); + { + std::vector dev = vt::tenstorrent::DebugDeviceReadbackF32(q, a); + REQUIRE(static_cast(dev.size()) == M * K); + for (int64_t i = 0; i < M * K; ++i) + CHECK_MESSAGE(f32bits(dev[static_cast(i)]) == f32bits(widen(ha[static_cast(i)])), + "cold persistent buffer carries the wrong bits at " << i); + } + + // 2) Rewrite the SAME master in place and restage: the persistent buffer + // must be REUSED (zero new allocations) and must carry the NEW bytes — + // a stale in-place write cannot pass the readback. + pattern(ha, 0x3800); // different bit pattern entirely + backend.Copy(q, ma, ha.data(), M * K * 2); // MarkHostWritten drops the shadow + ResetStagingStats(); + mm(q, o, a, b); // a restages (shadow dropped); b's shadow is still resident + s = GetStagingStats(); + CHECK_MESSAGE(s.uploads_bulk_bf16 == 1, + "only the rewritten master restages, got " + << s.uploads_bulk_bf16); + CHECK_MESSAGE(s.uploads_persistent_allocs == 0, + "a re-staged slot must REUSE its persistent buffer, got " + << s.uploads_persistent_allocs << " new allocations"); + CHECK_MESSAGE(s.uploads_persistent_bf16 == 1, + "the restage must be one in-place persistent write, got " + << s.uploads_persistent_bf16); + CHECK_MESSAGE(s.staged_persistent_bf16_bytes == static_cast(M * K * 2), + "the in-place write must count the rewritten bytes, got " + << s.staged_persistent_bf16_bytes); + { + std::vector dev = vt::tenstorrent::DebugDeviceReadbackF32(q, a); + REQUIRE(static_cast(dev.size()) == M * K); + for (int64_t i = 0; i < M * K; ++i) + CHECK_MESSAGE(f32bits(dev[static_cast(i)]) == f32bits(widen(ha[static_cast(i)])), + "persistent buffer did not carry the rewritten bytes at " << i); + } + + // 3) Geometry change on the same slot: the resident buffer cannot serve a + // different staging shape — reallocate, and COUNT the reallocation. + std::vector ha1(K); + pattern(ha1, 0x4000); + void* mo1 = backend.Alloc(N * 4); + backend.Copy(q, ma, ha1.data(), K * 2); + Tensor a1 = Tensor::Contiguous(ma, vt::DType::kBF16, Device{DeviceType::kTENSTORRENT, 0}, {1, K}); + Tensor o1 = Tensor::Contiguous(mo1, vt::DType::kF32, Device{DeviceType::kTENSTORRENT, 0}, {1, N}); + ResetStagingStats(); + mm(q, o1, a1, b); // a1 is the SAME base slot, staged at a new [1, K] shape + s = GetStagingStats(); + CHECK_MESSAGE(s.uploads_bulk_bf16 == 1, "geometry change still bulk-stages, got " + << s.uploads_bulk_bf16); + CHECK_MESSAGE(s.uploads_persistent_allocs == 1, + "a staging-geometry change must reallocate the persistent " + "buffer exactly once, got " << s.uploads_persistent_allocs); + CHECK_MESSAGE(s.uploads_persistent_bf16 == 1, + "the new geometry stages through the persistent route, got " + << s.uploads_persistent_bf16); + { + std::vector dev = vt::tenstorrent::DebugDeviceReadbackF32(q, a1); + REQUIRE(static_cast(dev.size()) == K); + for (int64_t i = 0; i < K; ++i) + CHECK_MESSAGE(f32bits(dev[static_cast(i)]) == f32bits(widen(ha1[static_cast(i)])), + "reallocated persistent buffer carries wrong bits at " << i); + } + + // 4) The f32 arm keeps out of the persistent bf16 route entirely (the + // f32 logits GEMM output keeps its declared dtype). + std::vector a32(M * K); + for (size_t i = 0; i < a32.size(); ++i) a32[i] = widen(ha[static_cast(i)]); + void* ma32 = backend.Alloc(M * K * 4); + backend.Copy(q, ma32, a32.data(), M * K * 4); + Tensor a32t = Tensor::Contiguous(ma32, vt::DType::kF32, Device{DeviceType::kTENSTORRENT, 0}, {M, K}); + ResetStagingStats(); + mm(q, o, a32t, b); // only a32 stages; b is resident + s = GetStagingStats(); + CHECK_MESSAGE(s.uploads_persistent_bf16 == 0, + "an f32 master must not enter the persistent bf16 route, got " + << s.uploads_persistent_bf16); + CHECK_MESSAGE(s.uploads_persistent_allocs == 0, + "an f32 master must not allocate a persistent bf16 buffer, got " + << s.uploads_persistent_allocs); + CHECK_MESSAGE(s.staged_f32_elems == static_cast(M * K), + "f32 master stages through the f32 path, got " + << s.staged_f32_elems); + backend.Free(ma); backend.Free(mb); backend.Free(mo); backend.Free(mo1); + backend.Free(ma32); +} + // ==== BACKEND-TENSTORRENT-QWEN35 W3 (#2201): the GDN reviewer leftovers ====== // (a) the state d2h counter must see BOTH remaining download paths — the // EnsureGdnCacheDevice slow-path refresh and the CommitConvTransposed