Skip to content

feat(vulkan): both GDN recurrences go native; prefill amortises the state tile - #152

Closed
localai-bot wants to merge 1 commit into
mainfrom
row/BACKEND-VULKAN-GDN-CORE
Closed

feat(vulkan): both GDN recurrences go native; prefill amortises the state tile#152
localai-bot wants to merge 1 commit into
mainfrom
row/BACKEND-VULKAN-GDN-CORE

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

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 kernel cuda_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, NW lanes per value row splitting the Dk contraction.

BV=16 / NW=8 is forced by Vulkan's guaranteed 16 KB shared budget at VT_TG=128 (BV=32 would need 18,048 B); the footprint is 10,304 B. Both .comp files 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 vi of S depends only on itself, k, q, decay, beta, v[vi] — which CUDA already exploits as its grid.x value tiling. Only sequence position is sequential, and it stays sequential inside the workgroup.

A latent seam defect fixed on the way

GetOpFallback threw for any native kernel that declines per-call: the reference tier installs only on a GetOp miss, 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.cpp now 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_attn fp8-KV decline has the identical hole and had simply never been exercised.

Gates — re-run independently, not taken on report

test_vulkan_backend 25/25, 1020/1020 assertions. NMSE vs the CPU oracle (tolerance 5e-4):

case NMSE
gdn_prefill out 1.47e-14
gdn_prefill carried state 6.43e-15
gdn_prefill bf16 q/k/v + bf16 out 0
gdn_decode (indexed cache) out 1.64e-14
gdn_decode (compact state) out 1.75e-14

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 negative state_idx, untouched cache slots memcmp'd. Every case asserts the mechanism as well as the numbers.

  • opt-125m STRICT 6/6 token-exact (96/96), 0 declines
  • Clean -Werror, 0 warnings; SPIR-V reproduces byte-for-byte

What is NOT claimed

  • No speed number. llvmpipe only — no GPU here. The design's motivation is arithmetic from shapes (~100 GB of state traffic per 512-token prompt across 24 GDN layers, essentially all removed by the tile amortisation), not a profile.
  • The chunked WY-representation prefill was NOT ported. llama.cpp turns the recurrence into batched matmuls over 64-token chunks; this does not. If the 27B re-run shows prefill still far off, that is the remaining lever and a separate row.
  • Rejected: reassociating the two passes to read the state once — it moves numbers off the CPU reference's rounding for a saving that cannot be measured here.
  • No maxComputeWorkGroupCount guard — decode passes 65,535 workgroups at batch ≳ 256. Flagged as a backend-wide gap (FlatGroupCount has the same hole), not fixed in this row.

Pre-existing red, not from this branch: check-fusion-consistency on gemma4_moe.cpp — verified identical on origin/main.

🤖 Generated with Claude Code

…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]
mudler added a commit that referenced this pull request Aug 8, 2026
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
@localai-bot

Copy link
Copy Markdown
Collaborator Author

LANDED on main in merge commit 502bd779.

Gates re-run independently on a clean reconfigured build before merging, not taken on report: test_vulkan_backend 25/25 (1020 assertions), opt-125m STRICT 6/6 token-exact, 0 declines, clean -Werror. The op_provider.cpp seam change was read line by line — VT_CHECK(next != nullptr) at :389 does guard the deref at :399.

docs/BENCHMARKS.md is a keyed table and needed merge-by-key twice: this branch predates the ROCm row on main, so a union would have dropped it while a take-theirs would have reverted the new Vulkan counts. Kept main's rows and swapped in only the Vulkan one.

Pre-existing red, not from this branch: check-fusion-consistency on gemma4_moe.cpp — verified identical on origin/main before attributing.

Closing per the PR-disposition protocol.

@localai-bot localai-bot closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants