cuda: fold the recurrent-state gather into the GATED_DELTA_NET kernel, per-context registry (+3.8% TG on Ada) - #220
professorpalmer wants to merge 2 commits into
Conversation
… (+3.8% TG on Ada) build_rs gathers each layer's live recurrent state via GET_ROWS(cache, s_copy) into a temp (3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on an RTX 4070 the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the identical GEMVs in attention layers. When the GET_ROWS output has no consumer other than (optionally through a RESHAPE) the GDN node's state input, the graph evaluator skips the GET_ROWS node and records the gather (cache base, ids, row stride) for that GDN node. The kernel then indexes the cache row s_ids[seq] directly; no temp, no kernel, no writeback. Single-sequence only: with several sequences a gathered row may alias a row another sequence writes in the same op. GGML_CUDA_GDN_GATHER_FUSION=0 disables it. The registrations live in the evaluating ggml_backend_cuda_context (ggml_cuda_gdn_gather_context, beside the concurrent-stream state), keyed by node pointer and reset at the start of every graph evaluation, so two contexts or host threads decoding at once never see each other's skips. This replaces the process-wide static map in the first revision of PrismML-Eng#215. Independent of the activation layout change; split out of PrismML-Eng#215 so that layout can be compared with PrismML-Eng#218 on its own. RTX 4070 12 GB, Ternary-Bonsai-2-27B-PTQ1_0, llama-bench -fa 1 -ctk/-ctv q4_0, stock clocks, three alternating rounds of r=3: tg128 56.4 -> 58.5 t/s on top of PrismML-Eng#215 (+3.8%). pp512 unchanged. test-backend-ops CUDA0 vs CPU: GATED_DELTA_NET 39/39 supported cases, GET_ROWS unchanged. 400 greedy tokens identical with GGML_CUDA_GDN_GATHER_FUSION=0 and 1.
|
@MrFadiAi thanks — same fusion, two backend shapes, useful to have both written down. Why this PR is a graph skip and not
bf16 state pool. Orthogonal and not in this PR. The CUDA kernel still takes Vulkan kernel layout translating 1:1 is noted. Once CUDA has a rows |
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Graph elision and CUDA capture behavior require targeted regression coverage and hardware validation.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Fuses recurrent-state gathering into CUDA GATED_DELTA_NET execution to reduce kernel launches and memory traffic.
Changes:
- Detects eligible single-sequence gather graphs.
- Stores gather metadata per backend context.
- Reads selected cache rows directly in the CUDA kernel.
| File | Description |
|---|---|
| ggml/src/ggml-cuda/ggml-cuda.cu | Detects and skips eligible gathers. |
| ggml/src/ggml-cuda/gated_delta_net.cuh | Documents the fused-gather interface. |
| ggml/src/ggml-cuda/gated_delta_net.cu | Adds direct cache-row reads. |
| ggml/src/ggml-cuda/common.cuh | Adds the per-context gather registry. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // sequences a gathered row may alias a row another sequence writes in the same op. | ||
| // GGML_CUDA_GDN_GATHER_FUSION=0 disables it. The registration lives in the evaluating context | ||
| // (ctx.gdn_gathers()), so concurrent contexts or threads never see each other's skips. | ||
| static bool ggml_cuda_try_gdn_gather_skip(ggml_backend_cuda_context & ctx, const ggml_cgraph * cgraph, int node_idx) { |
There was a problem hiding this comment.
Fair ask. test-backend-ops already covers GDN numerics on the kernel, but it does not emit this GET_ROWS+RESHAPE+GDN graph, so it never hits the matcher or CUDA-graph replay of the skipped launch. I am not stuffing a graph-capture case into this PR; I will add one as a follow-up that runs with the fusion on and off. Disable with GGML_CUDA_GDN_GATHER_FUSION=0 in the meantime.
| continue; | ||
| } | ||
|
|
||
| // recurrent-state gather folded into the GDN kernel (no temp, no kernel) |
There was a problem hiding this comment.
Correct, and the comment was wrong. 80132df now says the allocator still reserved the unused temp; the fusion only skips the kernel and its traffic.
The graph allocator still reserves that temp; the fusion only skips the kernel and its traffic.
|
Reviewed 80132df against the previous global-registry concern. The gather map is now backend-context-owned and reset for graph evaluation/capture. The matcher checks both GET_ROWS and intermediate RESHAPE consumer counts. I also checked an explicitly exported reshape with a real GGML graph: ggml_set_output propagates the output flag to the underlying gather, and the matcher declines the skip. No new finding in this focused review. CUDA execution, graph replay and concurrent-context stress have not been rerun here; the performance figures from the older combined branch should not be treated as measurements of this exact head. |
|
Thank you for walking the context-owned map and the Agreed that the older combined-branch timings should not be read as this head. The +3.8% TG figure was measured with the fusion on the 4070 before the split; this PR is the same matcher and kernel path, now with the registry on |
perf(cuda): the recurrent-state GET_ROWS folds into GATED_DELTA_NET's load, f32 or q8_0 cache (PrismML-Eng#220 plus -cts q8_0)
|
Landed as part of #221 ( Still open, and I have not forgotten it: the |
|
The test I owed here is #296: |

Summary
Split out of #215 at the reviewers' request so the activation-layout change there can be compared with #218 on its own. This PR is only the recurrent-state gather fusion for
GATED_DELTA_NET, with the registry moved into the backend context (the P1 from the #215 review).build_rsgathers each layer's live recurrent state viaGET_ROWS(cache, s_copy)into a temp (3 MB per layer for Bonsai 2 27B) that only the GDN kernel reads. Traced in-graph with CUPTI on an RTX 4070, the gather kernel costs ~8 us/layer, and its dirty output sits in L2 until the FFN weight stream evicts it, which made the gate/up GEMVs in GDN layers ~15% slower than the identical GEMVs in attention layers.ggml_cuda_try_gdn_gather_skiprecognisesGET_ROWS -> [RESHAPE] -> GATED_DELTA_NET src[5]with no other consumer of the gathered tensor, skips the GET_ROWS node, and records(cache base, ids, row stride)for that GDN node. The kernel then readscache[s_ids[seq]]directly: no temp, no kernel, no writeback. Single-sequence only (with several sequences a gathered row could alias a row another sequence writes in the same op).GGML_CUDA_GDN_GATHER_FUSION=0disables it.Registry ownership
Registrations live in
ggml_backend_cuda_context::gdn_gather_context(ggml_cuda_gdn_gather_context, next to the existingggml_cuda_stream_context), keyed by node pointer, reset by the evaluating context at the start of each graph evaluation/capture and read byggml_cuda_op_gated_delta_netthrough thectxit already receives. No static state, no cross-context or cross-thread visibility: two contexts decoding at once each see only their own skips, so the interleaving described in the #215 review (A registers, B clears, A looks up) cannot occur.Measurements
RTX 4070 12 GB,
Ternary-Bonsai-2-27B-PTQ1_0.gguf, CUDA 13.3,llama-bench -ngl 99 -fa 1 -ctk q4_0 -ctv q4_0 -r 3, stock clocks, three alternating rounds, means:GGML_CUDA_GDN_GATHER_FUSION=0)The gain is from removing 64 gather kernels and their L2 writebacks per token, so it is independent of which PTQ1_0 activation layout lands; it applies equally on top of #218.
Correctness
test-backend-ops -b CUDA0 -o GATED_DELTA_NET: 39/39 supported cases vs CPU.GET_ROWSuntouched.GGML_CUDA_GDN_GATHER_FUSION=0and=1.Scope
CUDA graph capture and eager evaluation both go through the same skip; under graphs the kernel arguments (cache base, ids pointer) are baked into the captured graph exactly as the GET_ROWS node's would have been, and
s_copyis a graph input whose contents are read at kernel time. HIP/MUSA: the fusion is pure host-side graph logic plus two kernel arguments; the kernel change is a pointer select onstate_in_offset. Not tested on HIP hardware.