Skip to content

[Perf][Attention] DSA candidate-block kernels walk each row's live context: 23.63 s to 22.52 s per 1000 requests on 8 x B200 at TP=8 - #57459

Closed
TarzanZhao wants to merge 1 commit into
vllm-project:mainfrom
TarzanZhao:perf/dsa-candidate-blocks-live-range
Closed

TarzanZhao wants to merge 1 commit into
vllm-project:mainfrom
TarzanZhao:perf/dsa-candidate-blocks-live-range

Conversation

@TarzanZhao

Copy link
Copy Markdown

Summary

The sparse-attention candidate-block kernels size their work by the width of the logits buffer instead of by the request's context. On the decode path that buffer is [rows, max_model_len], and full CUDA graphs capture it at the worst-case width, so on DeepSeek-V4.1-Flash (max_model_len 1,048,576, candidate_block_size 8) every decode step writes -inf into 1,048,576 columns and scores 131,072 blocks for each request in the batch. On ShareGPT prompts of a few hundred tokens, nearly all of that is past the end of the request's live context. This PR derives the launch shape from the tensor shapes and has each program stride over its row's own [start, end). On one node of 8 x B200 at TP=8, vllm bench throughput over 1000 ShareGPT requests goes from 18,805 to 19,731 total tokens/s, 1.049x, 23.630 s to 22.518 s. I have not measured other GPUs, other tensor-parallel sizes, other models or online serving.

Half of this change is already upstream. dabc4362b4, "[ROCm][DSV4.1][Perf] Stride the DSA decode candidate mask over the live context" (#56628), merged on 2026-09-14, does the same thing for the ROCm decode path in a forked copy of the mask kernel, and its comment says why it stopped there:

The shared kernel in model_executor/kernels/attention/dsa/candidate_blocks.py launches one program per 1024-column tile, which is 1024 of them per row at max_model_len 1048576 with only the first few doing work. This is a fixed count that strides instead, measured on gfx950; it is not a portable choice, which is why this variant lives here rather than replacing the shared one.

Taking the program count from the tensor shapes rather than hard-coding one removes that objection, so one kernel can serve both platforms.

  1. The candidate mask strides over each row's live [0, end). _mask_candidates_kernel ran on a (rows, cdiv(width, 1024)) grid and touched every column of the buffer. It now runs on (rows, k), with k from _row_programs(rows, tiles), and loops until it reaches the row's end, so the launch shape is fixed for CUDA-graph replay while the trip count follows the context length. Columns at or past end are no longer written, which the row top-k tolerates because it bounds its scan by the same ends, and the producer already runs with clean_logits=False, so that tail is unsanitized whenever a row has no candidates.
  2. Block scoring covers the row's own blocks. _block_scores_kernel scored cdiv(width, block_size) blocks per row on the same kind of grid; it now scores the blocks inside the row's range on a (rows, k) grid, and the newest-block pin keeps its semantics, including the case where a row's end runs past the buffer width. scores is allocated with new_full(-inf) rather than new_empty, so the block top-k reads for the rest exactly the value the old kernel stored there, and that fill is rows x nblocks floats against the rows x width floats the old kernel read.

_candidate_flags_kernel is deliberately left alone. It clears one byte per block, which at this model's candidate_block_size is 1/32 of the bytes the mask writes, so it is not where the time is; and #56628 added a ROCm caller that imports it and calls it with its current signature, which I cannot test on this node.

_ROW_PROGRAMS = 8 and _TOTAL_PROGRAMS = 8192 are a conservative choice, not a swept one: a full batch of 1024 rows keeps 8 programs per row, and a single long-context row still gets the 1024 programs the old code launched.

Speedup Result

Measured on one node of 8 x NVIDIA B200, tensor parallel 8, base commit 9612f77077. The metric is the benchmark's own total tokens per second over the 1000 timed requests, 444,318 tokens in every run.

baseline (9612f77077) this PR change
total tokens/s, 1000 requests 18,805 (18,775 to 18,821) 19,731 (19,708 to 19,765) 1.049x
elapsed time, the same runs 23.630 s 22.518 s −1.112 s
run-to-run spread 0.25 % 0.29 % the two ranges do not overlap and are about 20x smaller than the gap
runs behind those means 4 of 7 5 of 7 runs that compiled a kernel for the first time inside the timed window are excluded
GPU KV cache size 56,924,359 tokens 56,924,359 tokens unchanged
weight load, engine init (outside the metric) 47 to 50 s, 107 to 110 s the same

Three of the seven baseline runs and two of the seven runs of this branch compiled a kernel for the first time inside the timed window: on the baseline side two DeepGEMM sm100_fp8_gemm_1d1d compiles and one CuTeDSL compile, on this side one of each. Against the three stalled baseline runs the ratio would read 1.07x, 1.19x or 1.33x. I am not claiming those numbers: this branch has a stalled mode of its own, and the compiles are a warm-up effect rather than anything these kernels do. Every run on both sides compiles exactly one Triton kernel inside the window, CombineTopkSwaIndicesKernel on the first decode step, so one compile is the normal state and two is a stall. All 14 runs are listed under Details.

Correctness Verification

I ran the unmodified base and this branch on the same prompts with the same setup, and compared 291 recorded values per run: on each of the 8 tensor-parallel ranks, the fp32 mean and max of |x| of the model output hidden states and of the logits, the sum of the argmax token ids and the batch shape for the first five real steps; plus the KV cache block count per rank and the prompt and generated token totals of the run. The tolerances were fixed before the runs, at the call sites of the commit that adds the recording: 0.1 % relative on the three float reductions, exact on everything else.

All 291 of 291 values are identical, not merely inside tolerance. The 0.1 % is slack I declared in advance in case a rounding change appeared, and none did. That is what the kernel-level comparison predicted: the candidate block ids this change produces are the same ids, and the mask it writes inside each row's live span is the same mask.

The recording code is not in this PR. It lives on two branches of my fork, perf/dsa-candidate-blocks-live-range-verify-base (the base plus the recording commit) and perf/dsa-candidate-blocks-live-range-verify (this head plus the same commit), so neither side of the comparison runs on a looser build than the other.

recorded baseline vs this PR tolerance
prompt and generated tokens of the run identical, 226,786 and 217,532 exact
KV cache blocks per rank identical on all 8 ranks exact
hidden state mean of |x|, first 5 steps, all ranks identical 0.1 % relative
hidden state max of |x|, first 5 steps, all ranks identical 0.1 % relative
logits mean of |x|, first 5 steps, all ranks identical 0.1 % relative
sum of the argmax token ids, first 5 steps, all ranks identical exact
logits rows, tokens and requests per step, all ranks identical exact

--enable-expert-parallel is outside this comparison and outside both timed arms. It changes what the model computes at this commit, on 144 of the same 291 values, which I have not run down and which is unrelated to this change.

Test Plan

  • pytest tests/model_executor/layers/test_mla_short_prefill_indexer.py on this branch: 19 passed, 7 skipped, including the block that captures apply_candidate_mask in a CUDA graph and replays it.
  • That file needed one change, which is the second file in this PR. test_candidate_kernels_preserve_packed_bounds_and_padding asserted the old full-width sanitizing and fails 4 of its 6 parameter sets against these kernels. It now compares over each row's live span and asserts that the tail past the row's end is left as it was.
  • pytest tests/kernels/attention/test_rocm_aiter_candidate_mask.py: 9 skipped, the file is ROCm-only. The ROCm path is unmodified but untested on this node.
  • Old kernels against new on the same inputs, over 7 shape cases: decode, spec-decode row repeat, prefill with starts, end past the buffer width, empty rows, block_size 16 with an odd width, and a single row over a 1M-column buffer. select_candidate_blocks is bit-identical in all seven and apply_candidate_mask is bit-identical over every row's [0, end); the buffers differ only past end. That script is a one-off and is not part of this PR.
  • ruff check and ruff format are clean on both files.
  • End to end, vllm bench throughput on deepseek-ai/DeepSeek-V4.1-Flash with the ShareGPT dataset, interleaved between the two arms; the full command is under Details.

AI assistance, as docs/contributing/README.md asks: the code and this description were written with Claude Code, and the commit carries a Co-Authored-By trailer alongside its DCO sign-off.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update, such as updating supported_models.md and examples for a new model.
Details: hardware, model, full command, per-run numbers, scope

Hardware and environment. One node, 8 x NVIDIA B200 (sm_100), CUDA 13 toolkit. Python 3.12.14, torch 2.13.0+cu132, vLLM main at 9612f77077 as an editable install with the precompiled kernel wheel published for that same commit (VLLM_USE_PRECOMPILED=1, no source build), flashinfer-python 0.6.18.post1, the vendored DeepGEMM. Nothing is installed or tuned on top of the engine defaults. Weights sit on a shared mount under HF_HOME; the ShareGPT file is staged to node-local disk before each run.

Model and job. deepseek-ai/DeepSeek-V4.1-Flash as published, with DeepSeek Sparse Attention, max_model_len 1,048,576. Tensor parallel 8, no speculative decoding, no decode or prefill context parallel, no expert parallel, engine defaults elsewhere (max_num_batched_tokens 16384, max_num_seqs 1024, CUDA graphs FULL_AND_PIECEWISE). Each run is one process: weights 47 to 50 s, engine init 107 to 110 s once the FlashInfer autotune cache exists, 200 warm-up prompts at seed 43, then the 1000 ShareGPT prompts of seed 42 in about 23 s. The token totals are the same in every run: 226,786 prompt and 217,532 generated, 444,318 in the throughput figure.

Measurement. Both arms are worktrees of the same clone, one at 9612f77077 and one at that commit plus the single commit of this PR, and both run the command below with the same persistent VLLM_CACHE_ROOT (DeepGEMM and FlashInfer autotune caches) and TRITON_CACHE_DIR, the same node-local copy of the dataset, and the same seeds. Runs are interleaved in pairs, one process each, with all 8 GPUs at 0 MiB and nothing else on the node before each run; the arm that goes first alternates from pair to pair. Seven pairs, over two hours. Two warm-up runs on a cold cache come first and are not part of the measurement. A run counts as stalled when the modification time of a DeepGEMM cache entry falls inside the timed window, or when the JIT monitor reports a second Triton or CuTeDSL compile there.

The command, identical in both arms apart from which worktree is on PYTHONPATH:

export HF_HOME=<hf cache> HF_HUB_OFFLINE=1 CUDA_HOME=/usr/local/cuda
export PATH=$CUDA_HOME/bin:$PATH LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH
export VLLM_CACHE_ROOT=<persistent dir>      # deep_gemm/ and flashinfer_autotune_cache/, reused across runs
export TRITON_CACHE_DIR=<persistent dir>
export TMPDIR=<node-local dir> CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
vllm bench throughput --model deepseek-ai/DeepSeek-V4.1-Flash --tensor-parallel-size 8 \
  --backend vllm --dataset-name sharegpt \
  --dataset-path <ShareGPT_V4.3_unfiltered_cleaned_split.json, staged to node-local disk> \
  --num-prompts 1000 --seed 42 --num-warmups 200 --output-json <out>.json

There is no launcher config: vllm bench throughput starts the engine in-process with the multiprocess executor and 8 workers. Timing is the benchmark's own Throughput: line and its elapsed_time.

Every measured run, in the order they ran. "In-window compiles" counts kernels compiled for the first time inside the timed window, beyond the one Triton kernel that every run compiles there.

pair arm elapsed s total tokens/s in-window compiles
1 baseline 29.85 14,883.32 2 DeepGEMM, excluded
1 this PR 22.48 19,765.21 none
2 baseline 23.63 18,805.35 none
2 this PR 22.54 19,708.15 none
3 baseline 26.80 16,580.28 1 DeepGEMM, excluded
3 this PR 25.86 17,178.48 1 DeepGEMM, excluded
4 this PR 22.53 19,718.45 none
4 baseline 23.61 18,818.96 none
5 baseline 24.06 18,468.44 1 CuTeDSL, excluded
5 this PR 22.53 19,721.18 none
6 this PR 22.51 19,740.55 none
6 baseline 23.67 18,775.25 none
7 baseline 23.61 18,821.34 none
7 this PR 22.92 19,382.33 1 CuTeDSL, excluded

The DeepGEMM cache grew from 34 to 43 entries over these runs and had not converged, which is why the stalls keep appearing on both sides.

Scope.

  • No ROCm and no XPU hardware here. _candidate_flags_kernel, which rocm_aiter_mla_sparse.py imports, is untouched, and the ROCm test file skips on NVIDIA, so that path is unmodified but unexercised. xpu_fp8_paged_mqa_logits feeds the same kernels and was not run.
  • One model and one shape: DeepSeek-V4.1-Flash, TP=8, ShareGPT 1000 prompts, no speculative decoding, no DCP or PCP, no expert parallel. The prefill-side call runs on chunk-width logits, where the waste is small because the width is small; the benchmark exercises it but I did not time it separately.
  • The grid constants were not swept. Only the wide-batch end (8 programs per row) is timed; the single-row end rests on the kernel-level comparison, not on timing. A sweep might find more, and would also say whether [ROCm][DSV4.1][Perf] Stride the DSA decode candidate mask over the live context #56628's gfx950 choice of 128 is portable.
  • "Nothing reads a row's tail" is argued, not proved exhaustively. It rests on the row top-k taking the same bounds the mask does, on the producer already running with clean_logits=False, and on [ROCm][DSV4.1][Perf] Stride the DSA decode candidate mask over the live context #56628 making the same argument for the decode path. End to end it holds for all 291 recorded values in this configuration. A consumer that scans past a row's end in a configuration I did not run would see stale values instead of -inf.
  • Peak GPU memory was not sampled. The KV cache size is identical in both arms and the change allocates the same scores buffer as before, now filled rather than left uninitialized.
  • CUDA-graph capture is exercised by the benchmark and by the test's capture and replay block, but only at the batch sizes these runs produced.
  • No torch.profiler trace was taken for this comparison; the per-run numbers above are the benchmark's own output.

…grid

_mask_candidates_kernel and _block_scores_kernel launched one program per
1024 columns (or 128 blocks) of the logits buffer width, for every row. The
decode logits are [rows, max_model_len] and are captured inside full CUDA
graphs at the worst-case width, so on DeepSeek-V4.1 (1M context) every decode
step wrote -inf into a million columns per request and scored 131k blocks per
request, almost all of them outside the request's live context.

The kernels now take a grid of (rows, k) programs, k derived from the tensor
shapes alone, and stride over the row's own [start, end) range; the launch
shape stays fixed for CUDA graph replay while the work follows the context
length. Columns at or past a row's end are left untouched -- nothing reads
them, because the row top-k takes the same bounds -- and `scores` is
pre-filled with -inf so the block top-k sees the values the old kernel wrote.
The newest-block pin and the packed-column clamp keep their semantics,
including a pin past the buffer width.

This is what vllm-project#56628 did for the ROCm decode path, where it had to fork the
mask kernel because the count it chose was measured on gfx950. Doing it in
the shared kernel with a shape-derived count serves both.

test_candidate_kernels_preserve_packed_bounds_and_padding asserted the old
full-width sanitizing, so it now compares over each row's live span and
checks that the tail past the end is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATw7g6cxq9N2LtnMratsru

Signed-off-by: Hexu Zhao <hexu.zhao@impossible.place>

@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.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant