perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k) - #874
Conversation
…ark-decode@4k)
The GQA-6 staging loop is strictly serial: stage -> __syncthreads -> compute ->
__syncthreads. DRAM latency is exposed once per tile, so at a small split count -- where one
block walks the whole key range -- the block spends most of its time waiting rather than
computing. Staging a deeper tile puts more loads in flight per barrier, but it does not
change that the two phases never overlap.
Double-buffer the staged K/V and issue the NEXT tile's global->shared copies with cp.async
before consuming the current one, so the copy runs underneath the compute.
Implemented as a separate kernel (fa_split_gqa_pipe_kernel) rather than as edits to the
synchronous one: the shipped paths keep their exact code, and the two can be A/B'd in one
binary. bf16 KV only -- cp.async copies bytes verbatim and cannot dequantize, so an int8
pool still needs the dequant-into-smem path of the existing kernel.
Double buffering needs 2x the staging shared memory (4 * TILE * 256 * 2 B = 64 KB at
TILE=32), past the 48 KB default, so the launcher opts in once via cudaFuncSetAttribute. If
that opt-in is refused the error is cleared and the synchronous deeper tile runs instead, so
the fallback is the previous behaviour rather than a failed launch.
Bit-identical: cp.async changes WHEN bytes land in shared memory, not their values, and the
token walk is unchanged -- tiles are consecutive, the mainloop still walks start..end in
order, and the online-softmax update is per token in that order -- so every row accumulates
the same keys in the same sequence.
Measured on RTX 5090 at ctx=4096, one binary, env selecting the arm:
arm dspark tok/s AR tok/s accept LOSSLESS
tile 8 26.55 26.69 1.0828 1
tile 32 30.31 30.69 1.0828 1
tile 32 + pipe 37.32 38.31 1.0828 1
1.23x over the deeper tile alone. Acceptance is identical to four decimals on every arm --
pure speed, no numerical drift. SPARKINFER_FA_PIPE=0 restores the synchronous staging.
e898255 to
3d2bbed
Compare
sparkinfer DSpark auto-eval —
|
| metric | value |
|---|---|
| label | eval-dspark:XL |
| scored at | DSpark speculative decode @ ctx=4k on the ModelOpt NVFP4 checkpoint |
| PR DSpark tok/s | 43.05 |
| main DSpark tok/s | 36.28 |
| speedup vs main | 1.187× (+18.6%) |
| PR AR tok/s (floor) | 47.37 |
| main AR tok/s (floor) | 39.24 |
| AR vs main (floor) | +20.7% |
| DSpark vs AR | 0.909× — above 1.0 means speculation finally pays |
| mean accept τ | 1.085 (main 1.085, ceiling 7) |
| accuracy gate | ✅ top1=1.000 (bar >=0.9) · KL=0.0000 (bar <=0.1) |
| losslessness gate | ✅ DSpark matches the AR reference token-for-token, verified across 3 independent runs |
| qwen3.8 (ModelOpt) guard @16k | ✅ no regression (decode+prefill) — decode 89.5 tok/s · prefill 13551 pp |
| qwen3.6 guard @16k | ✅ no regression (decode+prefill) — decode 473.7 tok/s · prefill 28348 pp |
| PPL PR / main | 3.204 / 3.204 |
| Polaris receipt | collected, not signed (no key configured) |
| commit | 3d2bbed5a |
ok
Scored on the pinned eval box vs same-box origin/main: DSpark speculative decode throughput at ctx=4k on the ModelOpt NVFP4 checkpoint, with the AR reference measured in the same process and the same model load. Both a regression in AR decode and any divergence from the AR token sequence are hard REJECTs — a speculative decoder that is fast because it skips verification is not faster, it is wrong. τ is the lever, and the row above reports it against a block_size of 7. This is informational, not a judgment on your PR: a none label just means no measurable DSpark decode@4k speedup was verified, which is expected and fine if that isn't what your change is about. Automated — merge behaviour depends on SPARKINFER_DSPARK_AUTOMERGE.
|
Auto-merged as the round's |
… actually runs it The pin's justification was an attribution error. It claimed "flash-decode's split-K reduction is atomic and therefore order-dependent", citing 27-32/32 agreement at adaptive splits against 32/32 pinned. fa_combine_kernel folds each warp's fixed strided subset of splits in ascending order and then folds warps in ascending index, and flash_decode_split.cu contains no atomic at all. The decode combine is a fixed-order reduction. The nondeterminism that experiment saw was real and came from the two atomic split-K PREFILL GEMMs pinned immediately below it, which were still live at the time and only root-caused afterwards. Decode was blamed for prefill's flake. Verified before removing, at ctx=4096 with the prefill pins still in place: AR-REPS 5 reps, 0 mismatches SPEC-REPS 5 reps, 0 differ-from-first, 0 not-lossless-vs-AR What the pin cost. It leaves the flash-decode grid a fraction of the machine wide -- 35% of decode throughput at ctx=4096 (93.40 tok/s adaptive, 60.43 pinned) -- and every PR was scored in that regime. In one day the eval merged three attention PRs measuring +11.8%, +18.6% and +11.7% against it (#872, #874, #877) while production decode at 4k went 93.69 -> 93.64. Three tiers, nothing delivered. Two contributors diagnosed it correctly before we did (#871, #875), and both were closed -- the diagnosis was right, but the harness is ours to fix. It was also flattering the feature. Pinning splits to 1 penalises AR more than the speculative path, so DSpark looked closer to break-even than it is: the ratio at ctx=4096 is 0.821x, not the 0.909x the pinned harness reported. New baseline, adaptive splits, ctx=4096: dspark 74.05 tok/s, ar 90.19, tau 1.085, lossless across 5 runs. EVAL_SCHEMA_VERSION bumped to v2 so v1 scores are re-evaluated rather than compared against numbers that no longer mean the same thing.
Summary
The GQA-6 KV staging loop is strictly serial —
stage -> __syncthreads -> compute -> __syncthreads— so DRAM latency is exposed once per tile. Staging a deeper tile put more loads in flight per
barrier but left the two phases serial. Double-buffer the staged K/V and issue the next tile's
global->shared copies with
cp.asyncbefore consuming the current one, so the copy runs underneaththe compute.
Proof of speedup
sm_120)Decode tok/s (end-to-end, ctx=4096):
Prefill pp tok/s — n/a, this PR targets decode only.
Numbers come from
runtime/examples/dspark_tau_check.cpp, notbench/scripts/bench.sh: the latterbenchmarks a GGUF, and the path this PR touches is the NVFP4 DSpark decode.
dspark_tau_checkisend-to-end (a full generation, AR and speculative timed in one process), not an isolated-kernel
microbenchmark, and it reports the losslessness gate alongside the throughput.
Same binary on every arm, env-selected, so build and machine variance are out of the comparison.
SPARKINFER_FA_PIPE=0is main's behaviour exactly; the shallow-tile arm is included only to showwhere the remaining headroom was:
1.23x over main.
LOSSLESS 1on every arm and acceptance identical to four decimals (1.0828) —pure speed, no numerical drift.
Why there was still headroom
nsysat ctx=4096 attributes 61.2% of decode GPU time tofa_split_gqa_kernelat 1.11 ms percall. At
n_splits=1the grid is one block per (seq, kv_head, split) = 4 blocks on 170 SMs, and thestaging loop is plain
__ldg:blockDim(192) x 16 Bis ~12 KB of loads in flight before eachbarrier against ~700 ns of DRAM latency, so the block sustains ~7.5 GB/s. The roofline for the bytes
it reads is 18.7 us (4096 keys x 4 kv-heads x 256 x 2 x 2 B = 33.6 MB at 1792 GB/s). A deeper tile
closed part of that; overlapping the copy with the compute closes more.
Why it is bit-identical
cp.asyncchanges when bytes land in shared memory, not their values, and the token walk isunchanged: tiles are consecutive, the mainloop still walks
start..endin order, and theonline-softmax update is per token in that order. Every row therefore accumulates the same keys in
the same sequence.
Cost and fallbacks
A separate kernel (
fa_split_gqa_pipe_kernel), so the synchronous paths keep their exact code.bf16 KV only —
cp.asynccopies bytes verbatim and cannot dequantize, so an int8 pool still takesthe dequant-into-smem path.
Double buffering needs
4 * TILE * 256 * 2 B= 64 KB at TILE=32, past the 48 KB default, so thelauncher opts in once via
cudaFuncSetAttribute. If that is refused the error is cleared and thesynchronous tile runs instead — the fallback is prior behaviour, not a failed launch.
SPARKINFER_FA_PIPE=0restores synchronous staging.