perf(cuda,engine): batched GDN trunk — fused delta-net scan + batched-query SDPA (#114-B)#117
Conversation
…-query SDPA (#114-B) Collapse the per-position GDN-hybrid prefill trunk into batched CUDA launches, bit-identical to the per-token TrunkLayerSequential path. #111 GEMM-batched the trunk projections but left conv1d, the delta-net recurrence, KV-append and SDPA per-position over N via CudaBackend.View, so the trunk dominated prefill and grew with context (profiled ~2.3s@startPos0 -> 16s+ at ~25K cumulative tokens). New CUDA kernels (CudaTextKernels.cs): - llm_gdn_recurrence_scan: one block per v-head loops the N positions internally, carrying per-head state in global memory — the fused *sequential* scan (NOT the parallel chunked-scan, which would reorder FP reductions and break parity). - llm_gdn_conv1d_decode_batched (+ _state_update_batched), _l2_norm_per_head_batched, _tile_heads_batched: per-stage batched over N. - llm_kv_append_batched (+bf16) and llm_full_seq_attention (+bf16): the N prompt queries attend over their causal prefix in one launch (shared-scores path, startPos+N <= 4096; falls back to the per-position loop beyond). Host wiring (CudaHybridGdnForwardPass): GdnBlockBatched / AttnBlockBatched use the batched paths behind SHARPI_BATCHED_GDN_SCAN / SHARPI_BATCHED_ATTN (default on, A/B-toggleable like SHARPI_BATCHED_TRUNK). Parity: 6 new per-kernel bit-exactness tests (CudaGdnBatchedTrunkTests), plus the existing BatchedTrunk / BatchedPrefill (single+multi-chunk) / MtpDecoder_GreedyParity oracles — full Tests.ForwardPass suite green (0 failures). Perf (warm, 4070 Ti, ~4K-ctx A/B vs the #111 per-position trunk): trunk -35..-50% and ~5x flatter context-growth slope; end-to-end prefill +17% (Qwen3.6-35B-A3B / -MTP) to +31% (Carnice). README throughput table re-benchmarked at a uniform ~1K-token warm-cache context across all on-disk rows (prefill column); decode left as the generation-start rate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements batched GDN trunk and batched-query scaled dot-product attention (SDPA) kernels (Issue #114-B) to collapse per-position decode launches into single batched launches, significantly improving prompt prefill performance. It includes a new benchmarking script (bench-allrows-1k.ps1) and updates the README with consistent ~1K-token prefill measurements. Additionally, a comprehensive suite of bit-exactness tests is added to ensure the batched implementations match sequential outputs. Review feedback identified a potential stack buffer overflow in the llm_gdn_conv1d_state_update_batched CUDA kernel where the local array tmp is hardcoded to size 4, recommending a larger size and a safety guard for larger kernel sizes.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| int retained = kernel_size - 1; | ||
| float tmp[4]; // K-1 <= 4 for our models | ||
| #pragma unroll 4 |
There was a problem hiding this comment.
The local array tmp is hardcoded to size 4 with the assumption that kernel_size - 1 <= 4. If a model with a larger kernel_size is loaded, this will cause a stack buffer overflow/corruption in the CUDA kernel. To prevent undefined behavior or GPU crashes, we should increase the array size to a safe maximum (e.g., 16) and add a guard to safely handle cases where retained exceeds this limit.
int retained = kernel_size - 1;
if (retained > 16) return;
float tmp[16];
#pragma unroll…lback observability, host-wiring oracle From the pr-review-toolkit cycle on PR #117: - comment-analyzer: refresh TrunkLayerBatched/GdnBlockBatched/AttnBlockBatched summaries that still claimed the recurrence/SDPA stay per-position (they batch by default since #114-B). - silent-failure-hunter (MED): log once when the batched-query SDPA falls back to the per-position path past the 4096 shared-scores window (bit-identical, just slower) so the perf cliff is observable. - pr-test-analyzer (sev 9): add BatchedGdnScanAndAttn_BitwiseMatchesPerPosition_Carnice — the first model-level oracle that toggles BatchedGdnScanEnabled/BatchedAttnEnabled off-vs-on, proving the GdnBlockBatched/AttnBlockBatched host glue (strides/offsets) is bit-identical to the per-position View-loop fallback. Plus conv/scan N=1 (max state-aliasing edge) and the exact startPos+N==4096 attention boundary in the per-kernel tests. All 7 added/expanded tests green (bit-identical). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issue #114 direction (B): batch the per-position GDN-hybrid prefill trunk
#114-A (register-tiled N-input routed-MoE dots, PR #116) made the routed MoE flat
with context. With that merged, the trunk became the dominant prefill cost and
it grew with context: profiled on a 4070 Ti (
SHARPI_PREFILL_PROFILE=1), trunk≈1.9s at startPos 0, climbing ~+0.5ms/token to ~20s at ~34K cumulative tokens.
Cause: #111 GEMM-batched the trunk projections but deliberately left conv1d,
the delta-net recurrence (
GdnRecurrenceDecode), KV-append and SDPA per-positionover N via
CudaBackend.View. GDN launches dominate ~12:1 over attention, so thetrunk was launch-overhead bound at short context and under-utilised the GPU at long
context.
CudaBackend.FullSeqAttentionpreviously threw.What this does
Collapses those per-position launches into batched kernels, bit-identical to the
per-token
TrunkLayerSequentialpath.New CUDA kernels (
CudaTextKernels.cs):llm_gdn_recurrence_scan— one block per v-head loops the N positions internally,carrying per-head state in global memory. This is the fused sequential scan, not
the parallel chunked-scan (which reorders FP reductions and would break parity).
llm_gdn_conv1d_decode_batched(+_state_update_batched),_l2_norm_per_head_batched,_tile_heads_batched— each GDN sub-stage batched over N.llm_kv_append_batched(+bf16) andllm_full_seq_attention(+bf16) — the N promptqueries attend over their causal prefix in one launch (shared-scores path,
startPos+N ≤ 4096; falls back to the per-position loop beyond — wave-based >4096batched SDPA is a noted follow-up).
Host wiring (
CudaHybridGdnForwardPass):GdnBlockBatched/AttnBlockBatcheduse the batched paths behind
SHARPI_BATCHED_GDN_SCAN/SHARPI_BATCHED_ATTN(default on, A/B-toggleable like
SHARPI_BATCHED_TRUNK).Parity (hard constraint)
CudaGdnBatchedTrunkTests): conv1d +state-advance, L2-norm, tile-heads, fused recurrence scan (output and final
state), batched KV-append + SDPA (fp32 and bf16) — all bit-identical to the
single-token kernels.
BatchedTrunk_BitwiseMatchesSequentialTrunk_Carnice,BatchedPrefill_BitwiseMatchesSequential_Carnice(single + multi-chunk),MtpDecoder_GreedyParity_LlamaCpp.Tests.ForwardPasssuite: 0 failures. (A process-exit crash during aVulkan-shader test's teardown is a pre-existing, non-deterministic GPU-driver flake,
unrelated to these CUDA changes.)
Performance (warm, RTX 4070 Ti)
A/B vs the #111 per-position trunk (
SHARPI_BATCHED_GDN_SCAN/_ATTNoff vs on):(+330ms → +64ms per 512-token chunk);
routedMoEunchanged (perf(engine,cpu,cuda): remaining GDN-hybrid prefill headroom after #111/#112 (N-input MoE dots + per-position recurrence/SDPA batching) #114-A preserved).batched kernels need real context to amortise over).
README
The throughput table's Prefill t/s column was previously measured on ~10–60-token
prompts (launch-overhead noise). Re-benchmarked every on-disk row at a uniform
~1K-token warm-cache context so the column is comparable; decode left as the
near-zero-ctx generation rate (added a methodology note). Llama-4 Scout (not on disk)
and the Qwen3-Coder Vulkan-hybrid row (errored at 1K) keep their prior values, annotated.
🤖 Generated with Claude Code