Skip to content

perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k) - #874

Merged
skyrocket2026 merged 1 commit into
gittensor-ai-lab:mainfrom
James-CUDA:perf/dspark-fa-cpasync
Aug 18, 2026
Merged

perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k)#874
skyrocket2026 merged 1 commit into
gittensor-ai-lab:mainfrom
James-CUDA:perf/dspark-fa-cpasync

Conversation

@James-CUDA

@James-CUDA James-CUDA commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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.async before consuming the current one, so the copy runs underneath
the compute.

Proof of speedup

  • Tested on RTX 5090 (sm_120)

Decode tok/s (end-to-end, ctx=4096):

decode tok/s
before (main) 30.31
after (this PR) 37.32

Prefill pp tok/s — n/a, this PR targets decode only.

Numbers come from runtime/examples/dspark_tau_check.cpp, not bench/scripts/bench.sh: the latter
benchmarks a GGUF, and the path this PR touches is the NVFP4 DSpark decode. dspark_tau_check is
end-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=0 is main's behaviour exactly; the shallow-tile arm is included only to show
where the remaining headroom was:

$ SPARKINFER_FA_TILE_DEEP=1 SPARKINFER_FA_PIPE=0 build/runtime/dspark_tau_check \
      models_q38_modelopt dspark 4096 <4096 prompt ids>      # main
$ SPARKINFER_FA_TILE_DEEP=1 SPARKINFER_FA_PIPE=1 ...          # this PR

arm              dspark_tps  ar_tps    accept    lossless
tile8            26.5467     26.6901   1.0828    1
tile32 (main)    30.3137     30.6892   1.0828    1
tile32+pipe      37.3181     38.3079   1.0828    1

1.23x over main. LOSSLESS 1 on every arm and acceptance identical to four decimals (1.0828) —
pure speed, no numerical drift.

Why there was still headroom

nsys at ctx=4096 attributes 61.2% of decode GPU time to fa_split_gqa_kernel at 1.11 ms per
call. At n_splits=1 the grid is one block per (seq, kv_head, split) = 4 blocks on 170 SMs, and the
staging loop is plain __ldg: blockDim(192) x 16 B is ~12 KB of loads in flight before each
barrier 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.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. 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.async copies bytes verbatim and cannot dequantize, so an int8 pool still takes
the dequant-into-smem path.

Double buffering needs 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 is refused the error is cleared and the
synchronous tile runs instead — the fallback is prior behaviour, not a failed launch.
SPARKINFER_FA_PIPE=0 restores synchronous staging.

@James-CUDA
James-CUDA marked this pull request as draft August 18, 2026 20:26
@James-CUDA
James-CUDA marked this pull request as ready for review August 18, 2026 20:35
…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.
@James-CUDA James-CUDA changed the title perf(dspark): overlap KV staging with compute via cp.async (1.41x dspark-decode@4k) perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k) Aug 18, 2026
@James-CUDA
James-CUDA force-pushed the perf/dspark-fa-cpasync branch from e898255 to 3d2bbed Compare August 18, 2026 20:36
@skyrocket2026 skyrocket2026 added eval-dspark:XL DSpark decode@128 eval tier eval:XL sparkinfer auto-eval verdict: XL labels Aug 18, 2026
@skyrocket2026

Copy link
Copy Markdown
Member

sparkinfer DSpark auto-eval — eval-dspark:XL

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.

@skyrocket2026 skyrocket2026 added the dspark-merge-first DSpark eval merge ordering label Aug 18, 2026
@skyrocket2026
skyrocket2026 merged commit a009199 into gittensor-ai-lab:main Aug 18, 2026
4 of 5 checks passed
@skyrocket2026

Copy link
Copy Markdown
Member

Auto-merged as the round's dspark-merge-first winner — verified same-box 128-token decode speedup over main, accuracy-gated vs llama.cpp.

@skyrocket2026 skyrocket2026 removed the dspark-merge-first DSpark eval merge ordering label Aug 18, 2026
skyrocket2026 added a commit that referenced this pull request Aug 19, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

eval:XL sparkinfer auto-eval verdict: XL eval-dspark:XL DSpark decode@128 eval tier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants