perf(qwen3.8): split the flash-decode KV range across warp-groups - #877
Conversation
sparkinfer DSpark auto-eval —
|
| metric | value |
|---|---|
| label | eval-dspark:L |
| scored at | DSpark speculative decode @ ctx=4k on the ModelOpt NVFP4 checkpoint |
| PR DSpark tok/s | 48.13 |
| main DSpark tok/s | 43.06 |
| speedup vs main | 1.118× (+11.7%) |
| PR AR tok/s (floor) | 54.33 |
| main AR tok/s (floor) | 47.39 |
| AR vs main (floor) | +14.6% |
| DSpark vs AR | 0.886× — 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 |
| mean accept τ floor | ✅ 1.0847 vs main 1.0847 (bar ≥95%) |
| qwen3.8 (ModelOpt) guard @16k | ✅ no regression (decode+prefill) — decode 89.4 tok/s · prefill 13544 pp |
| qwen3.6 guard @16k | ✅ no regression (decode+prefill) — decode 474.3 tok/s · prefill 28358 pp |
| PPL PR / main | 3.204 / 3.204 |
| Polaris receipt | collected, not signed (no key configured) |
| commit | 9a13964df |
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 flash-decode block runs six warps. At a small split count the launcher issues
dim3(num_kv_heads * n_splits, num_seqs)= ~4 CTAs, so the whole decode attention runs on fourSMs of 170. #874 hid the staging latency; it did not change how few warps do the walking. Split
the block's KV range across three warp-groups, each with its own tile and its own
(m, l, acc),merged once at the end.
Proof of speedup
sm_120)Decode tok/s (
dspark_tau_check, ctx=4096, 128 new tokens — the scored harness):τ is deliberately the first row. This PR cannot buy throughput with acceptance: it never touches
the draft, the proposal depth, the engage thresholds or the idle logic. AR_TPS — which does not
depend on acceptance at all — rises 12.1%, and that is the honest measure of the change.
Mechanism
Each of the KVG warp-groups walks a contiguous stripe of the block's KV range with its own
double-buffered cp.async tile and its own running
(m, l, acc). At the end the groups are foldedwith the standard log-sum-exp rescale, in ascending group index. The block still emits exactly one
split's partials per q-head, so
n_splits, thepart_m/part_l/part_acclayout and the combinekernel are all untouched.
Exactness. Within a stripe the token order is unchanged and the merge formula is fixed, so the
result is deterministic run to run. It is a different summation order than the single-group walk —
the same reassociation the existing
n_splits > 1combine already performs — and LOSSLESS holds onevery run because both legs of the harness share it.
Shared memory
sm_120has 128 KB of L1/shared per SM, ~100 KB addressable as dynamic smem (NOT the 228 KB ofdatacenter Blackwell). KVG=3 at KT=12 needs 72 KB of tiles plus ~19 KB of merge scratch = 91 KB.
That is what bounds the group count: KVG=4 needs KT<=8, and KVG=3 at KT>=16 does not fit. Opt-in is via
cudaFuncSetAttributeand falls back to #874's kernel if refused, so a device with a smaller capkeeps exactly today's behaviour.
Scope
num_seqs == 1, and only where perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k) #874's pipeline is already selected.SPARKINFER_FA_KVG=1restores main's kernel for A/B in one binary; the launcher alsocascades 3 groups -> 2 -> perf(dspark): overlap KV staging with compute via cp.async (1.23x dspark-decode@4k) #874's kernel if an opt-in is refused.
Overlap with recent PRs
Builds on #872 (deep tile) and #874 (cp.async pipeline) rather than overlapping them: both changed
what one block does with its stripe, this changes how many warps a block puts on the range. No
shared code beyond the kernel both are in.