Skip to content

[DSA] Make the fused top-k exact on an overflowing threshold bin - #37941

Open
xiaofei-zheng wants to merge 2 commits into
sgl-project:mainfrom
xiaofei-zheng:feature/xiaofei/topk-exact-threshold-bin
Open

xiaofei-zheng wants to merge 2 commits into
sgl-project:mainfrom
xiaofei-zheng:feature/xiaofei/topk-exact-threshold-bin

Conversation

@xiaofei-zheng

@xiaofei-zheng xiaofei-zheng commented Sep 4, 2026

Copy link
Copy Markdown

Motivation

The coarse histogram bin comes from the top bits of the fp16 cast of each score, so one bin spans a quarter-binade. Phase 3 stages threshold-bin candidates under if (count_eq < kMaxNumTie) and phase 4 clips tie_count = min(equal_count, kMaxNumTie), so once a threshold bin holds more than 2048 elements handle_tie ranks whichever arrived first and the kernel returns a wrong selected-value multiset with no diagnostic. This is the shared DSA indexer top-k, serving every model routed through srt/layers/attention/dsa.

Found while profiling GLM-5.2 decode on MI355X: device-side counters over an agentic serving run enter the truncation path on ~0.04% of rows across all 8 ranks (556–598 of 1.1–1.5M per rank), largest threshold bin 4139 against the 2048 cap, none of those rows bit-identical. #35257 reports the same defect independently from the CUDA side — referenced, not closed.

Modifications

Refine the overflowing bin on the exact key instead of truncating it: up to four 8-bit radix passes over the order-preserving key from extract_exact_bin. It exits once the refined set fits the staging buffer, or once all 32 bits are consumed — the key is injective on fp32 bit patterns, so the survivors are bit-identical by then and truncating them is legitimate. A min/max reduction skips the refinement when the set is already bit-identical.

The fast path branches on equal_count > kMaxNumTie and is otherwise unchanged; kMaxNumTie, kBlockSize and the shared-memory footprint are untouched.

Folded in from #37942 (closed): the ROCm __launch_bounds__ occupancy argument now states the physical wave floor, since HIP reads it as waves per SIMD where CUDA reads it as blocks per SM. Codegen-neutral alone; it matters because the plausible wrong reading caps the allocator at 64 VGPRs and would spill at the 77 this change needs.

Accuracy Tests

Pre-existing cases draw from torch.randn and tolerate MAX_PERMIT_ERROR = 5, so they never reach the overflow and pass on the truncating kernel. This adds narrow, narrow_bin and tiny distributions that collapse a row into one coarse bin, on a register-path (6×8192) and a streaming-path (4×32768) shape, asserting exactly. Against the unfixed kernel:

suite result on the unfixed kernel
278 pre-existing cases 278 passed
6 new narrow / narrow_bin / tiny 6 failed, 227–9198 wrong selections each
2 new all_equal controls 2 passed

all_equal overflows too, but bit-identically, so any subset is correct and it must keep passing — that asymmetry pins the fault to truncation rather than mis-binning. All 286 pass with this change; a separate 50-case gate moves 29/50 to 50/50.

Benchmarking and Profiling

MI355X (gfx950), weighted J over the captured GLM-5.2 decode shapes, graph-replayed:

c4 c10
before 22.398 µs 29.393 µs
after 22.336 µs 29.367 µs

A paired decode A/B measures −0.50% / −0.27% step time against a 0.04–0.08% within-arm spread, i.e. parity. This is a correctness fix, not a speedup. Register usage goes 57 → 78 VGPRs with zero spills, and the LDS footprint is unchanged at 27392 B.

Per review feedback the refinement was cut from up to ten input passes to at most four (one to seed the first histogram, then one per round, each fusing the previous round's emit). Kernel time on MI355X, 200 iterations after warmup:

distribution shape before after
randn (no overflow, hot path) 6×8192 5.604 µs 5.596 µs −0.1%
randn (no overflow, hot path) 4×32768 12.507 µs 12.475 µs −0.3%
narrow_bin (every row refines) 6×8192 34.825 µs 22.482 µs −35.4%
narrow_bin (every row refines) 4×32768 75.559 µs 53.223 µs −29.6%
two_values (worst case, 32 bits) 6×8192 53.509 µs 36.354 µs −32.1%
two_values (worst case, 32 bits) 4×32768 168.130 µs 119.571 µs −28.9%

The hot path is untouched; only rows that actually overflow reach the refinement.

Known gap

The CUDA-only TopKCluster path still truncates; refining there needs cluster-wide rather than block-local histogram and emit counters, so it is left for a follow-up. #35257 quotes only the register and streaming sites, so that third one is unreported. Related but distinct, #36807 covers the same class of truncation in the AOT topk.cu.

Checklist

Known gap

The CUDA-only TopKCluster path still truncates. Refining there is a different routine: the candidate set is split across kClusterSize ranks, so each radix pass needs a cluster-wide histogram all-reduce and a cluster-wide emit counter rather than the block-local ones used here. Left for a follow-up rather than grown into this PR — happy to open a tracking issue.


CI States

Latest PR Test (Base): 🚫 Run #34665472810
Latest PR Test (Extra): ❌ Run #34665472708
Latest PR Test (AMD ROCm 10): ❌ Run #34665472796

@xiaofei-zheng
xiaofei-zheng force-pushed the feature/xiaofei/topk-exact-threshold-bin branch 2 times, most recently from 8a746f3 to 3dbc304 Compare September 4, 2026 07:02
@xiaofei-zheng xiaofei-zheng changed the title [deepseek_v4] Make the fused top-k exact on an overflowing threshold bin [DSA] Make the fused top-k exact on an overflowing threshold bin Sep 4, 2026
@xiaofei-zheng
xiaofei-zheng force-pushed the feature/xiaofei/topk-exact-threshold-bin branch 3 times, most recently from ae5db33 to 7a83d7f Compare September 8, 2026 06:48
@xiaofei-zheng

xiaofei-zheng commented Sep 9, 2026

Copy link
Copy Markdown
Author

@HaiShaw could you add the run-ci label here when you get a chance?

The gate currently exits with Missing required label 'run-ci', so every test job is skipped and this PR has never actually been exercised by CI — only lint and the other label-independent checks have run.

For context: this is a correctness fix in the fused DSA indexer top-k, found while profiling our own GLM-5.2 decode runs on MI355X. When a threshold coarse bin holds more than kMaxNumTie (2048) candidates, the collect pass keeps whichever arrived first and the kernel silently returns a wrong top-k set; device-side counters put that on ~0.04% of rows on a real serving run, with the largest observed bin at 4139. #35257 and #36807 report related problems in this area. #37942 has been folded into this PR and closed, so this is now the only one to look at.

@HaiShaw

HaiShaw commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

@BBuf @yuan-luo @DarkSharpness please review

@HaiShaw

HaiShaw commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci label Sep 9, 2026

@DarkSharpness DarkSharpness left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments on performance

Comment on lines +586 to +620
// Early-out for a bit-identical candidate set: the radix passes cannot
// separate it and would only re-derive what phase 3 already staged. One
// distinct exact key means the candidates are interchangeable, so any
// kMaxNumTie-subset is correct, including the one already in tie.values.
// Comparing min against max of the key is exact for any value, and costs
// the one scan that non-degenerate overflow rows pay on top.
{
uint32_t key_min = 0xFFFFFFFFu;
uint32_t key_max = 0u;
for_each_input(problem.in, problem.seq_len, [&](float val, uint32_t) {
if (val >= v_lo && val < v_hi) {
const auto key = extract_exact_bin(val);
key_min = min(key_min, key);
key_max = max(key_max, key);
}
});
// Reduce per-thread extrema: two atomics per thread, not per candidate.
if (tx == 0) {
handle->histogram[0][0] = 0xFFFFFFFFu;
handle->histogram[0][1] = 0u;
}
__syncthreads();
atomicMin(&handle->histogram[0][0], key_min);
atomicMax(&handle->histogram[0][1], key_max);
__syncthreads();
const bool bit_identical = handle->histogram[0][0] == handle->histogram[0][1];
__syncthreads(); // all threads read the scratch before the loop clears it
if (bit_identical) {
// equal_count > kMaxNumTie on entry, so phase 3 filled the whole buffer.
const auto above_count = smem->count_gt;
const auto remain_topk = above_count < topk ? topk - above_count : 0;
handle_tie(smem->tie.values, problem, above_count, kMaxNumTie, remain_topk, handle);
return;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this pass? It's not common that all keys are equal in fp32. We should optimize for hot path. This actually adds to additional linear scan cost in normal pass.

Comment on lines +627 to +659
for (uint32_t round = 0; round < 4 && cand_count > kMaxNumTie && remain > 0; ++round) {
const uint32_t shift = 24 - round * 8;

if (tx < kRadixSize) handle->histogram[0][tx] = 0;
__syncthreads();
for_each_input(problem.in, problem.seq_len, [&](float val, uint32_t) {
if (val >= v_lo && val < v_hi) {
const auto key = extract_exact_bin(val);
if ((key & mask) == prefix) atomicAdd(&handle->histogram[0][(key >> shift) & 0xFFu], 1);
}
});
__syncthreads();

refine_find_threshold(cand_count, remain, handle);
const auto match = handle->match;

for_each_input(problem.in, problem.seq_len, [&](float val, uint32_t idx) {
if (val >= v_lo && val < v_hi) {
const auto key = extract_exact_bin(val);
if ((key & mask) == prefix && ((key >> shift) & 0xFFu) > match.bin) {
const auto pos = atomicAdd(&smem->count_gt, 1);
if (pos < topk) [[likely]]
problem.emit(pos, idx);
}
}
});

prefix |= match.bin << shift;
mask |= 0xFFu << shift;
remain -= match.above_count;
cand_count = match.equal_count;
__syncthreads(); // `match` is read above and overwritten by the next pass
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this efficient enough? This would involve 8 linear pass. Please reduce that to at most 4 passes.

Comment on lines +663 to +673
if (tx == 0) smem->count_eq = 0;
__syncthreads();
for_each_input(problem.in, problem.seq_len, [&](float val, uint32_t idx) {
if (val >= v_lo && val < v_hi) {
const auto key = extract_exact_bin(val);
if ((key & mask) == prefix) {
const auto slot = atomicAdd(&smem->count_eq, 1);
if (slot < kMaxNumTie) smem->tie.values[slot] = {val, idx};
}
}
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, this passes should be eliminated i guess

@DarkSharpness DarkSharpness left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments on performance

@xiaofei-zheng
xiaofei-zheng force-pushed the feature/xiaofei/topk-exact-threshold-bin branch from 7e0bc53 to c05fe16 Compare September 10, 2026 07:28
@xiaofei-zheng

Copy link
Copy Markdown
Author

@DarkSharpness thanks — all three addressed in c05fe16, and measured on MI355X.

The min/max early-out is gone. You are right that it was paying a scan on every refinement to serve a degenerate case. Without it the radix rounds still terminate correctly on an all-equal row: they consume all 32 bits and then truncate a bit-identical set, which is exact. Removing it also freed the tie staging buffer, which this path does not need — it re-derives the candidates rather than trusting what the collect pass staged there — so the buffer now carries the histogram.

Down to at most four passes. Two changes got there. The radix is 12 bits instead of 8, using that freed buffer (it is exactly kRefineSize uint32 wide, so the shared-memory footprint is unchanged at 27392 B), which covers the 32-bit key in three rounds instead of four. And each round's emit is fused with the next round's histogram: one scan classifies a candidate as above / equal / below the round's threshold digit and does both. So the cost is one pass to seed the first histogram plus one per round — four worst case, two if the bin resolves in the first round.

The staging pass is eliminated too, fused into the last round's emit the same way.

Kernel time, 200 iterations after warmup:

distribution shape before after
randn (no overflow, hot path) 6×8192 5.604 µs 5.596 µs −0.1%
randn (no overflow, hot path) 4×32768 12.507 µs 12.475 µs −0.3%
narrow_bin (every row refines) 6×8192 34.825 µs 22.482 µs −35.4%
narrow_bin (every row refines) 4×32768 75.559 µs 53.223 µs −29.6%
two_values (worst case, 32 bits) 6×8192 53.509 µs 36.354 µs −32.1%
two_values (worst case, 32 bits) 4×32768 168.130 µs 119.571 µs −28.9%

The hot path is unchanged, as it should be — it still only pays the equal_count > kMaxNumTie comparison.

On correctness after the rewrite: the suite is 288/288 on this branch. Run against the unfixed kernel on main it is 8 failed / 2 passed, the two passes being the all_equal controls, so the tests still discriminate. two_values is new and covers the exit the early-out used to stand in front of: two adjacent fp32 values at roughly half the row each, so the refinement has to consume all 32 bits and still has more than kMaxNumTie survivors at that point. Register usage is 78 VGPRs with zero spills, up from 57 on main.

@xiaofei-zheng
xiaofei-zheng force-pushed the feature/xiaofei/topk-exact-threshold-bin branch from c05fe16 to fcd3905 Compare September 10, 2026 10:53
@xiaofei-zheng

Copy link
Copy Markdown
Author

@DarkSharpness when you have a moment — the performance changes you asked for are in fcd39053bd, and CI is now green on the jobs that cover this kernel.

The branch has also been rebased onto latest main. That turned out to matter: the CI maintenance-mode gate was rejecting the old base (Required base commit 3700c4ee26a1 ... your PR is diverged), which failed check-changes on the PR Test and PR Test Extra workflows and silently skipped the whole NVIDIA pipeline. So until the rebase this change had only ever been exercised on AMD. Both sides now run:

call-jit-kernel-tests / jit-kernel-unit-test (0) pass — includes test_topk_v2.py
call-jit-kernel-tests / jit-kernel-unit-test (1) pass
call-jit-kernel-tests / jit-kernel-b200-test pass
call-jit-kernel-tests / jit-kernel-multigpu-unit-test pass
call-jit-kernel-tests / jit-kernel-benchmark-test pass
jit-kernel-unit-test-amd (rocm10) pass — includes test_topk_v2.py
jit-kernel-benchmark-test-amd (rocm10) pass

The remaining red checks are unrelated to this diff, as far as I can tell:

  • base-b-test-1-gpu-small (1..3) time out after 30 min on test_spec_eagle_topk.py (TestEagle3Topk16.test_acc_length), which brings up a Llama-3.1-8B + EAGLE3 server and carries est_time=876. The topk there is speculative_eagle_topk, not the DSA indexer.
  • stage-a-test-1-gpu-small-amd fails in test_wave_attention_kernels.py: grouped decode attention returns nan, the cos_sim > 0.99 assertion trips and the process then aborts on glibc heap corruption. That job runs three test files and none of them is a top-k test, so this kernel never executes in it.
  • The rest are fail-fast cancellations of sibling shards plus the pr-gate / *-finish aggregators.

Happy to dig into any of those if you would rather they were green first.

xiaofei-zheng and others added 2 commits September 12, 2026 01:37
The coarse bin comes from the top bits of the fp16 cast, so one bin spans a
quarter-binade. Phase 3 stages threshold-bin candidates under
`if (count_eq < kMaxNumTie)` and phase 4 clips `min(equal_count, kMaxNumTie)`,
so an overflowing bin leaves `handle_tie` ranking an arrival-order subset and
the kernel emits a wrong selected-value multiset with no diagnostic.

Found while profiling GLM-5.2 decode on MI355X. The same defect is reported
independently from the CUDA side in sgl-project#35257, which quotes the same two sites;
referenced here rather than closed by this commit.

Refine the overflowing bin on the exact key instead: up to four 8-bit radix
passes over the order-preserving key from `extract_exact_bin`, exiting once the
refined set fits the staging buffer or once all 32 bits are consumed, where the
survivors are bit-identical and truncating them is legitimate. A min/max
reduction detects a bit-identical set up front and skips the refinement. The
fast path branches on `equal_count > kMaxNumTie` and is otherwise unchanged;
kMaxNumTie, kBlockSize and the shared-memory footprint are untouched.

Also pin the ROCm `__launch_bounds__` occupancy argument to the physical wave
floor: HIP reads it as waves per SIMD where CUDA reads it as blocks per SM, so
passing kOccupancy asks for 2 waves/SIMD, below the 4 that a 1024-thread block
already forces. Codegen-neutral on its own, but the wrong reading (8 waves/SIMD)
caps the allocator at 64 VGPRs, which binds nothing at main's 57 and spills at
the 77 this change needs.

The pre-existing tests draw from `torch.randn` and carry MAX_PERMIT_ERROR = 5,
so they pass unchanged on the truncating kernel. Added narrow, narrow_bin and
tiny distributions that collapse a row into one coarse bin, on a register-path
and a streaming-path shape, asserting with no tie tolerance. On the unfixed
kernel the 6 new cases fail with 227-9198 wrong selections each while the 2
all_equal controls pass; all 286 cases pass with this change. A separate 50-case
gate moves from 29/50 to 50/50.

Performance is parity: a paired decode A/B measures -0.50% / -0.27% step time
against a 0.04-0.08% within-arm spread. On gfx950 the kernels go from 57 to 77
VGPRs with zero spills.

Known gap: the CUDA-only `TopKCluster` path still truncates. Refining there
needs cluster-wide rather than block-local histogram and emit counters, so it is
left for a follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
Review feedback: the refinement cost up to ten full passes over the input --
one min/max scan, four rounds of histogram + emit, one staging scan.

Drop the bit-identical early-out. It was a pure optimization for a degenerate
case; without it the radix rounds still terminate correctly on such a row,
consuming all 32 bits and then truncating a bit-identical set, which is exact.
Removing it also frees the tie staging buffer -- this path re-derives the
candidates rather than trusting what the collect pass staged there -- to carry
the histogram.

Widen the radix from 8 to 12 bits by putting the histogram in that buffer. It
is exactly kRefineSize uint32 wide, so the shared-memory footprint is
unchanged, and 12 bits cover the 32-bit key in three rounds instead of four.

Fuse each round's emit pass with the next round's histogram pass, and the last
round's emit with the survivor staging pass: one scan classifies a candidate as
above / equal / below the round's threshold digit and does the emit and the
histogram (or the staging) together.

Worst case is now four passes -- one to seed the first histogram, then one per
round. A row whose bin resolves in the first round costs two. Measured on
MI355X: the hot path is unchanged (5.596 vs 5.604 us at 6x8192, 12.475 vs 12.507
at 4x32768) and the refinement path drops 29-35% (narrow_bin 34.825 -> 22.482
and 75.559 -> 53.223; two_values 53.509 -> 36.354 and 168.130 -> 119.571).

Add a two_values distribution to the exactness tests: two adjacent fp32 values
at roughly half the row each, so the refinement has to consume all 32 bits and
still has more than kMaxNumTie survivors at that point. That is the
key-exhausted exit, now with nothing in front of it, reached from a row that is
not degenerate -- all_equal reaches the same exit but any subset of it is
trivially correct.

Co-authored-by: Cursor <cursoragent@cursor.com>
@xiaofei-zheng
xiaofei-zheng force-pushed the feature/xiaofei/topk-exact-threshold-bin branch from fcd3905 to 9a57b69 Compare September 12, 2026 01:39
@xiaofei-zheng

xiaofei-zheng commented Sep 13, 2026

Copy link
Copy Markdown
Author

@HaiShaw thanks for the earlier approval. Could you help nudge this one along, or advise on how to proceed?

@DarkSharpness requested changes on Sep 9 with three performance comments. All three are addressed in 9a57b69a20 and measured on MI355X — the hot path is unchanged and the refinement path drops 29–35%. I replied with the numbers on Sep 10 and again on Sep 11 after a rebase, but there has been no response in four days, and the CHANGES_REQUESTED is still recorded against the old commit 7a83d7fdd5.

Every CI job that exercises this kernel is now green on both vendors, and mergeable is true again. Happy to keep waiting if a re-review is coming — just flagging that the requested work is done.

@DarkSharpness

Copy link
Copy Markdown
Collaborator

Hi. Could you please temporarily hold on until #38798 is merged? Sorry we're actively working on that and it should land within next few days. I will check this PR in detail later. This topk kernel is very sensitive in register usage on CUDA, and any seemingly irrelevant test might lead to wild performance data, so I will take a very detailed look at this PR later. Please stay tuned. Thanks :)

Leoyzen added a commit to Leoyzen/sglang that referenced this pull request Sep 14, 2026
…verflowing threshold bin

The coarse histogram bin is derived from the top bits of the fp16 cast of each
score, so one bin spans about a quarter-binade. Phase 3 stages threshold-bin
candidates into the fixed kMaxNumTie = 2048 staging buffer and phase 4 clips
tie_count = min(count_eq, kMaxNumTie), so once a threshold bin holds more than
2048 elements handle_tie ranks whichever arrived first and the kernel returns a
wrong selected-value multiset with no diagnostic. This is the shared DSA
indexer top-k used by DeepSeek-V4.1 decode, prefill and the DeepGEMM candidate
path, and it fails silently: every emitted index is a valid KV position, so
nothing downstream detects the wrong selection.

Refine the overflowing bin on the exact key instead of truncating it: up to
three radix rounds (12/12/8 bits) over the order-preserving key from
extract_exact_bin. It exits once the refined set fits the staging buffer, or
once all 32 bits are consumed -- the key is injective on fp32 bit patterns, so
the survivors are bit-identical by then and truncating them is legitimate. The
fast path branches on count_eq > kMaxNumTie and is otherwise unchanged;
kMaxNumTie, kBlockSize and the shared-memory footprint are untouched (the
refinement histogram overlays the dead tie staging buffer in a union).

This is a correctness fix, not a speedup: the hot path is unchanged.

The CUDA-only TopKCluster path still truncates; refining there needs
cluster-wide rather than block-local histogram and emit counters, and is left
for a follow-up.

Ported from sgl-project#37941, which does not
apply cleanly after sgl-project#38829/sgl-project#39098 refactored this file. Identifier adaptations:
smem->tie.handle -> smem->tie_handle, smem->tie.values -> smem->tie_values,
smem->tie.refine_hist -> smem->refine_hist; the local sites name the counters
count_gt/count_eq rather than above_count/equal_count. The PR's ROCm
__launch_bounds__ hunk is intentionally dropped (CUDA-only deployment).

Verified on H20 (SM90, cc 9.0):
- The new test fails 8/10 on the unpatched kernel (the two all_equal controls
  pass, pinning the fault to truncation) and passes 10/10 patched.
- test/registered/kernels/ops/attention/test_topk_v2.py: 298 passed.
- cuobjdump resource usage and sizeof(Smem) are byte-identical to baseline:
  31-32 registers, zero spills, shared memory unchanged.
@xiaofei-zheng

Copy link
Copy Markdown
Author

Sounds good, we'll hold.

Two things worth flagging while we wait.

The truncation this PR fixes is unchanged in #38798 — the if (pos < kMaxNumTie) drop in the collect pass and the min(count_eq, kMaxNumTie) clip are both still there, on the register, streaming and cluster paths. So the kernel will still return a wrong top-k set when a threshold coarse bin overflows.

And please don't try to carry this change into #38798; it is safer for us to re-port it afterwards. The refinement re-reads candidates from global memory and relies on for_each_input visiting exactly the elements the collect pass counted. #38798 pads the last vector with padding_value() and compensates with atomicSub on the histogram, which breaks that invariant — easy to miss in a mechanical merge, and the failure mode would be a silently wrong refinement.

On your register point: we only have MI355X here, so every number in this PR is gfx950 (57 to 78 VGPRs, no spills, LDS unchanged). We cannot measure the CUDA side at all. If that is the main concern, it would help if someone with an NVIDIA box could check it — or tell us which shapes and metric you want and we will get them into the PR another way.

Ping me when #38798 lands and I'll rebase, re-measure and re-request review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants