Skip to content

[Perf][DCP] Sparse indexer: key only significant bits in the DCP top-k merge - #64129

Open
NickLucche wants to merge 2 commits into
vllm-project:mainfrom
NickLucche:dcp-topk-compact-keys
Open

NickLucche wants to merge 2 commits into
vllm-project:mainfrom
NickLucche:dcp-topk-compact-keys

Conversation

@NickLucche

@NickLucche NickLucche commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Overview

Since #61149 (DeepGEMM 2026-09-30), the SM100 MQA logits of the sparse indexer are BF16. Under DCP they are upcast to FP32 before the top-k, so the low 16 bits of every candidate score that reaches the DCP top-k merge are zero. The merge selector, StableTopKFromGatheredCandidatesKernel, radix-sorts the 64-bit key score << 32 | ~token_id in 11-bit passes. BF16 scores almost always tie at the top-k boundary. The radix then also walks the 16 zero score bits and the constant high bits of the token id, and each of those passes funnels every remaining candidate into a single histogram bin.

This PR packs only the bits that carry information into the key: the 16 high score bits when the logits were BF16, and the token-id bits that positions below max_model_len need. The selected set is unchanged.

Claims

  • Removes the DCP top-k merge cost that [Kernel][Build] Bump DeepGEMM to the 26/09/30 release and adapt the sparse indexer #61149 added. It applies to every DSA model with a sparse indexer under DCP on SM100 (DeepSeek-V3.2, GLM-5.x).
    • With BF16 scores, the selector costs 1.6-1.7x what it costs with FP32 scores.
    • With this PR it is back to the FP32 cost. Per call at top-2048 on GB300:
      • DCP4: 16.7 → 10.6 us (FP32: 10.4), and 21.0 → 11.5 us on concentrated scores;
      • DCP8: 24.8 → 14.5 us (FP32: 14.3), and 32.2 → 19.0 us.
  • GLM-5.3, TP4 DCP4 on one GB300 node, 60k-token prompts, 4 runs per variant on 2 nodes:
    • the selector goes from 21.0 to 14.2 us per call in decode (21 calls per step);
    • c1 TPOT p50 drops from 13.55 to 13.44 ms (-0.8%);
    • c2 TPOT p50 drops from 17.77 to 17.65 ms;
    • c8 ITL p50 drops from 19.30 to 19.13 ms.
  • No behavior change:
    • The selection uses the same total order: score at the precision it was computed in, then the smaller token id.
    • Main and this PR select the same set:
      • in unit tests with forced boundary ties;
      • on 28.4M rows merged in GLM-5.3 runs and checked against main's selector. 97% of the rows with more than 2048 candidates had a tie at the boundary.
    • FP32 logits keep 32 score bits.
    • GSM8K is within run-to-run noise.

Validation

Serving: GLM-5.3 (zai-org/GLM-5.3, FP8)

Setup:

  • TP4 + DCP4 on one GB300 node (4 GPUs), model runner V2, default DCP backend (a2a);
  • --attention-backend FLASHMLA_SPARSE --kv-cache-dtype fp8_ds_mla;
  • --max-model-len 65536 --max-num-batched-tokens 8192 --max-num-seqs 64 --no-enable-prefix-caching;
  • -cc.cudagraph_mode=FULL_DECODE_ONLY: decode steps replay full CUDA graphs. With the default FULL_AND_PIECEWISE, main itself did not start on this setup: the ranks diverged during PIECEWISE capture. That is unrelated to this PR.
  • vllm bench serve --dataset-name random --random-input-len 60000 --random-output-len 1024 --ignore-eos, with 6 prompts at c1, 12 at c2 and 24 at c8.

Method:

  • Two Slurm jobs on two nodes. Each job runs four servers on the same node: main, PR, main, PR and PR, main, PR, main.
  • Values are the means over the 4 runs of each variant. The per-run spread of c1 TPOT p50 is 0.01-0.03 ms.
  • main is 803bc13.
c1 main c1 this PR c2 main c2 this PR c8 main c8 this PR
TPOT p50 (ms) 13.55 13.44 17.77 17.65 52.52 51.91
TPOT p99 (ms) 13.65 13.47 20.65 20.46 61.50 61.07
ITL p50 (ms) 13.64 13.51 15.09 14.95 19.30 19.13
TTFT p50 (ms) 6673 6609 10059 9997 16284 16405
output tok/s 49.84 50.29 72.41 72.96 116.53 117.29
  • Per run, c1 TPOT p50 is 13.55-13.56 ms on main and 13.43-13.46 ms with this PR.
  • At c8, TPOT includes the steps that carry 8192-token prefill chunks of the other 60k-token prompts, so it varies by ±1 ms between runs. ITL p50 is the plain decode step: 19.23-19.42 ms on main and 19.04-19.21 ms with this PR.
  • c8 TTFT depends on how the prefills interleave and is within its run-to-run spread (14.6-17.3 s).

GLM-5.3 has 21 sparse-indexer layers per step (index_topk_freq=4). Models with an indexer in every layer, like DeepSeek-V3.2, or DCP8 with 2x the candidates, save proportionally more per step.

Kernel time in the model (torch profiler)

Same setup, 60k-token prompts, 8192 candidates per row (4 ranks × top-2048). The table gives the median per call on ranks 0 and 2 (they agree within 0.1 us). Each decode trace covers 29-40 steps (609-840 selector calls).

main this PR
merge selector (StableTopKFromGatheredCandidatesKernel), decode c1 (1 row) 19.4 us 13.8 us
merge selector, decode c8 (8 rows) 21.0 us 14.2 us
merge selector per decode step (21 calls), c1 / c8 409 / 441 us 290 / 298 us
merge selector, first 8192-token prefill chunk (8192 rows) 649 us 449 us
local top-k cooperative_topk (control), c1 / c8 9.6 / 9.9 us 9.7 / 9.9 us

The per-step saving (119 us at c1, 143 us at c8) matches the measured TPOT/ITL gain. The prefill-chunk saving is where the ~1% lower c1 TTFT comes from: every prefill chunk runs the merge in each of the 21 indexer layers.

Microbenchmark

All-gather selector alone on one GB300, top-2048, ids drawn from a 64k context (16 id bits). us per call, CUDA graph of 100 launches. "Wide" scores are N(0, 3²). "Narrow" scores are 60 + N(0, 4²): they are more concentrated, so more of them tie at BF16 precision. "set == ref" checks the selection against an FP64 reference.

DCP rows scores main this PR set == ref
4 1 FP32 wide 10.4 9.3 yes
4 1 BF16 wide 16.7 10.6 yes
4 1 BF16 narrow 21.0 11.5 yes
4 8 BF16 narrow 20.6 11.9 yes
8 1 FP32 wide 14.3 12.7 yes
8 1 BF16 wide 24.8 14.5 yes
8 1 BF16 narrow 32.2 19.0 yes
8 8 BF16 narrow 32.7 19.1 yes

FP32 logits keep 32 score bits and gain a little from the shorter id field.

Correctness

  • Selection on real model data:
    • A check-only build of this PR (not part of the diff) re-ran main's selector (64-bit key) on the same gathered candidates after every DCP merge and compared the selected sets. It ran in eager mode, so it could sync.
    • Workload: GLM-5.3 TP4 DCP4, serving the greedy prompts below (up to 51k tokens), 200 GSM8K questions and 8 random 60k-token requests at c8. Prefill chunks and decode steps are both covered.
    • Two runs, counted on DCP rank 0: 28.4M merged rows.
      • 24.3M of them had more than 2048 valid candidates.
      • 23.7M (97%) had a tie at the top-k boundary.
      • 0 rows differed from main's selection.
    • The same check on GLM-5.3-Flash (zai-org/GLM-5.3-Flash, kpool indexer, top-512 pools out of 2048 candidates), TP4 DCP4 with --cp-kv-cache-interleave-size 64, covered the long greedy prompts and 4 random 60k-token requests at c8.
      • It checked 4.65M rows: 4.45M had more than 512 candidates, and 4.23M had a tie at the boundary.
      • 0 mismatches.
  • GSM8K (tests/evals/gsm8k/gsm8k_eval.py, 1319 questions, 5-shot, completions, max concurrency 64), same setup as above:
runs accuracy
main 3 90.8, 91.4, 91.4
this PR 2 91.8, 91.6
  • Greedy (temperature=0), sequential at c1, 38 prompts: 34 short ones (128 tokens each) and 4 real-text prompts of 8k, 20k, 38k and 51k tokens (256 tokens each). This setup is not deterministic run to run, even on main:

    • main, the same server queried twice: 11/38 and 16/38 outputs differ;
    • main across server restarts: 14/38 and 15/38;
    • this PR, the same server twice: 12/38;
    • this PR vs main: 11/38, 13/38 and 16/38.

    Most of the differing prompts are short ones. There every valid candidate is selected, so this PR cannot change their result. The outputs diverge after a common prefix, as numerical noise does. So greedy identity cannot separate the two here. The check on real model data above can.

Tests

.venv/bin/python -m pytest tests/v1/attention/test_indexer_dcp_localize.py
pre-commit run --files <changed files>
this PR main
tests/v1/attention/test_indexer_dcp_localize.py (GB300) 117 passed (incl. 3 new) 114 passed
pre-commit (ruff-check, ruff-format, typos, mypy, ...) all hooks pass

New test test_cutedsl_gathered_select_compact_keys_match_reference[16384|131072|1048576]:

  • BF16-valued scores: random, 64 distinct values (so the boundary tie spans hundreds of ids), all negative, padding mixed in, and all tied;
  • token ids up to num_positions - 1;
  • it checks that the default 64-bit key and the compact key (score_bits=16, id_bits=dcp_topk_id_bits(num_positions)) both select the FP64 reference set.

The server logs show no CuTeDSL JIT compilation during inference with this PR (jit_monitor_mode=warn), on GLM-5.3 and on GLM-5.3-Flash (kpool, max_model_len // 4 id bits). So the new warm-up keys match the runtime keys.

Details

Root cause.

  • [Kernel][Build] Bump DeepGEMM to the 26/09/30 release and adapt the sparse indexer #61149 made the SM100 MQA logits (fp8_fp4_mqa_logits, fp8_fp4_paged_mqa_logits) BF16. sparse_attn_indexer upcasts them (as_fp32_logits) whenever DCP is on.
  • StableTopKFromGatheredCandidatesKernel builds score_key << 32 | ~token_id and runs up to 6 radix passes (11 bits each, the last one 9). It stops at the first pass whose threshold bin fits.
  • With FP32 scores, the first 1-2 passes usually resolve the boundary. A BF16 score is complete after the first 16 key bits (passes 1-2), and it often ties at the boundary. The radix then continues:
    • pass 3 sees the 10 low score bits, which are all zero, plus the top id bit;
    • pass 4 sees id bits 30-20, which are constant because ids are below max_model_len (16 bits for a 64k context);
    • only passes 5-6 separate the tied candidates.
    • In the passes that separate nothing, every remaining candidate falls into one histogram bin, and the shared-memory atomics serialize on it.

Fix.

  • CompileKey gains score_bits and id_bits.
  • The key is score_key >> (32 - score_bits) followed by the low id_bits of ~token_id, packed at the top of the 64-bit word. With BF16 logits and a 64k context, that is 32 bits and 3 passes.
  • Padding keys stay 0 and still decode to -1.
  • The order is unchanged:
    • the dropped score bits are zero for upcast BF16 values;
    • ~id & mask preserves the order of ids below 2**id_bits.
  • sparse_attn_indexer (DSA) and the GLM-5.3-Flash kpool indexer pass the following:
    • score_bits=16 when the raw logits are BF16;
    • id_bits from max_model_len, or max_pool_len for kpool pool ids.
    • On a DCP rank whose prefill chunk is empty (no logits to inspect), score_bits follows the platform. That keeps it on the warmed-up key. 32 bits would also select correctly.
  • Warm-up compiles the SM100 key: 16 score bits, and id bits for max_model_len // index_kpool.
  • The last radix pass can now be narrower than the 512-thread block. For example, a 30-bit key gives passes of 11, 11 and 8 bits. The bin scan now covers whole block slices; bins at or above num_bins stay zero.

Scope.

  • Only DCP > 1 with BF16 indexer logits (SM100) changes speed. FP32 logits keep 32 score bits.
  • DCP = 1 does not run the merge.
  • No new host syncs: score_bits comes from the logits dtype and the platform, and id_bits from max_model_len. All three are host-side metadata.
  • No new CI step: the new test runs in the existing B200 "V1 Attention" step (pytest v1/attention), which has CuTeDSL.

Why this is not a duplicate

AI assistance

AI assistance was used to investigate the regression, write the change and its test, and run the benchmarks and evals above. The submitter reviewed every changed line and the measurements.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

…k merge

Since the DeepGEMM bump in vllm-project#61149 the SM100 MQA logits are BF16 and are
upcast to FP32 for the DCP top-k merge, so the low 16 bits of every
candidate score are zero. The all-gather selector radix-sorts the 64-bit
key score << 32 | ~token_id in 11-bit passes. BF16 scores almost always
tie at the top-k boundary, so the radix then walks the zero score bits and
the constant high token-id bits, and each of those passes funnels every
remaining candidate into one histogram bin.

Pack only the significant bits at the top of the key: the high 16 score
bits when the logits were BF16, and the id bits that positions below
max_model_len (max_pool_len for kpool indexers) need. The selected set is
unchanged; FP32 logits keep 32 score bits. Final radix passes can now be
narrower than the 512-thread block, so the bin scan covers whole block
slices.

Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>
@NickLucche
NickLucche requested a review from zyongye as a code owner October 27, 2026 20:28

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@NickLucche

Copy link
Copy Markdown
Member Author

cc @GirasoleY @LucasWilkinson

@NickLucche

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #97780 for commit 7e8776b6892d.

ℹ️ This PR is 39 commits behind upstream main. Merged upstream main into this branch as 7e8776b6892d before starting CI. Pull the branch before pushing more changes.

@mergify

mergify Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @NickLucche.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

This branch has not been deployed

No deployments
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.

2 participants