feat(vulkan): both GDN recurrences go native; prefill amortises the state tile - #152
feat(vulkan): both GDN recurrences go native; prefill amortises the state tile#152localai-bot wants to merge 1 commit into
Conversation
…e state tile kGdnPrefill and kGdnDecode were the last two heavy GDN ops on Vulkan's portable CPU reference tier, and on Qwen3.6-27B they are where the time is: six sibling glue ops landing natively moved the fallback count 11 -> 5 without moving speed, because these two recurrences dominate. PORTED, NOT DERIVED. Per-step arithmetic is 1:1 from cpu_ops.cpp:1280-1311 GdnHeadTokenStep. Dispatch shape and state handling come from our own cuda_gdn.cu:2417-2503 GdnDecodeFusedKernel: one workgroup per (sequence, value-head, BV-value-tile), the [BV,Dk] state slice staged into shared memory by a COALESCED load and written back once, NW lanes per value row splitting the Dk contraction and reducing their partials. BV=16 / NW=8 is what Vulkan's GUARANTEED 16 KB of shared memory allows at VT_TG=128 (BV=32 needs 18 KB). Both shaders share one step body, vt_gdn_recurrence.glsl, so they cannot drift. THE ONE STRUCTURAL ADDITION, and it is the prefill lever: CUDA's fused kernel is a DECODE kernel, so its load/store pair brackets a single step. Prefill runs the SAME staged tile through the whole token range, so a 512-token prompt touches the [Dv,Dk] state twice instead of ~2048 times. That is legal because the VALUE ROW is an independent axis -- row vi of S depends only on itself, k, q, decay, beta and v[vi] -- which the CUDA kernel already exploits as its grid.x value tiling. Only the sequence position is sequential, and it stays sequential, inside the workgroup. A LATENT SEAM DEFECT HAD TO BE FIXED FIRST. GetOpFallback threw for any native kernel that declined per-call, because the reference tier installs only on a GetOp MISS and an op with a native kernel never misses; the pre-existing vt_paged_attn fp8-KV decline had the same hole and had simply never been exercised. op_provider.cpp now installs the tier on that path, and drains the backend before handing a HOST kernel device memory -- GetOp's own drain is keyed on the SELECTED provider, which here is the declining native one. MEASURED, llvmpipe, CORRECTNESS ONLY -- no speed number is claimed, and none is measurable on a software rasteriser. NMSE vs the CPU oracle in the same binary: prefill out 1.47e-14, prefill carried state 6.43e-15, bf16 arm 0; decode (indexed cache) out 1.64e-14 and cache 3.31e-15, decode (compact state) out 1.75e-14. test_vulkan_backend 25/25 cases, 1020/1020 assertions. test_opt_paged_engine under VLLM_CPP_DEVICE=vulkan stays 6/6 token-exact (96/96) with 0 declines. test_op_provider 11/11 and test_backend_cross_device 11/11. The gates assert the MECHANISM as well as the numbers (PipelineExistsFor plus the provider's last_selected), because on a unified-memory device the reference tier returns answers IDENTICAL to the oracle and a numbers-only gate would pass with no shader at all. Shapes are picked against the tile geometry rather than for roundness: Dv=19 leaves a 3-row second tile, Dk=20 leaves lane 7 with an EMPTY partial, Hv/Hk=3, and one sequence is zero-length. NOT MEASURED: the 27B prefill/decode re-run on GB10 and the reference-tier count that goes with it. Both are owed, and they are the only thing that can turn the structure above into a result. NOT TAKEN: rewriting the two passes as o = decay*(S@q) + v'*(k@q) would read the state once instead of twice, but it is an algebraic reassociation that moves the numbers off the CPU reference's rounding for a saving that cannot be measured here. Recorded in the spec, not shipped. FOLLOWING_AGENTS_PROTOCOL Assisted-by: Claude Code:claude-opus-5 [Claude Code]
feat(vulkan): both GDN recurrences go native; prefill amortises the state tile kGdnPrefill and kGdnDecode go native. Vulkan modules 22 -> 24, GDN family 6 -> 8, 27B reference-tier ops 5 -> 3. Ported rather than derived: per-step arithmetic from cpu_ops.cpp GdnHeadTokenStep, dispatch shape and state handling from the existing CUDA GdnDecodeFusedKernel. The one structural addition is that prefill runs the staged state tile through the whole token range instead of bracketing a single step, which is legal because the VALUE ROW is an independent axis -- only sequence position is sequential. Also fixes a latent seam defect: GetOpFallback threw for any native kernel that declines per-call, because the reference tier installs only on a GetOp miss and an op with a native kernel never misses. It now installs on that path AND drains the backend before a host kernel touches device memory. The pre-existing paged_attn fp8-KV decline had the identical hole. Gates re-run independently: test_vulkan_backend 25/25 (1020 assertions), opt-125m STRICT 6/6 token-exact with 0 declines, clean -Werror, SPIR-V byte-identical. No speed claim -- llvmpipe only. The chunked WY-representation prefill llama.cpp uses is NOT ported and remains the next lever. # Conflicts: # docs/BENCHMARKS.md
|
LANDED on Gates re-run independently on a clean reconfigured build before merging, not taken on report:
Pre-existing red, not from this branch: Closing per the PR-disposition protocol. |
Both GDN recurrences go native. Vulkan modules 22 → 24, GDN family 6 → 8, 27B reference-tier ops 5 → 3.
Ported, not derived
Per-step arithmetic 1:1 from
cpu_ops.cpp:1280-1311 GdnHeadTokenStep. Dispatch shape and state handling from the existing CUDA kernelcuda_gdn.cu:2417-2503 GdnDecodeFusedKernel: one workgroup per (sequence, value-head, BV-tile), the[BV,Dk]state slice staged into shared memory by a coalesced load and written back once,NWlanes per value row splitting theDkcontraction.BV=16 / NW=8is forced by Vulkan's guaranteed 16 KB shared budget atVT_TG=128(BV=32 would need 18,048 B); the footprint is 10,304 B. Both.compfiles share one step body so they cannot drift.The one structural addition
CUDA's fused kernel is a decode kernel — its load/store pair brackets a single step. Prefill runs the same staged tile through the whole token range, so a 512-token prompt touches the
[Dv,Dk]state twice instead of ~2048 times.That is legal because the value row is an independent axis — row
viofSdepends only on itself,k,q,decay,beta,v[vi]— which CUDA already exploits as itsgrid.xvalue tiling. Only sequence position is sequential, and it stays sequential inside the workgroup.A latent seam defect fixed on the way
GetOpFallbackthrew for any native kernel that declines per-call: the reference tier installs only on aGetOpmiss, and an op with a native kernel never misses. So a declining kernel had nothing to fall back to — defeating the entire point of decline-and-fall-back.op_provider.cppnow installs the tier on that path and drains the backend before handing a host kernel device memory (GetOp's own drain keys on the selected provider, which here is the declining native one, so it never fired — a silent stale-read under command-buffer batching).The pre-existing
vt_paged_attnfp8-KV decline has the identical hole and had simply never been exercised.Gates — re-run independently, not taken on report
test_vulkan_backend25/25, 1020/1020 assertions. NMSE vs the CPU oracle (tolerance 5e-4):Shapes are chosen against the tile:
Dv=19(3-row second tile),Dk=20(lane 7 contributes an empty partial), one zero-length sequence, a negativestate_idx, untouched cache slotsmemcmp'd. Every case asserts the mechanism as well as the numbers.opt-125mSTRICT 6/6 token-exact (96/96), 0 declines-Werror, 0 warnings; SPIR-V reproduces byte-for-byteWhat is NOT claimed
maxComputeWorkGroupCountguard — decode passes 65,535 workgroups at batch ≳ 256. Flagged as a backend-wide gap (FlatGroupCounthas the same hole), not fixed in this row.Pre-existing red, not from this branch:
check-fusion-consistencyongemma4_moe.cpp— verified identical onorigin/main.🤖 Generated with Claude Code