Skip to content

[Perf][Model Runner V2] Compact sampling masks on GPU instead of unpacking the full-vocab bitmask on CPU - #54901

Merged
ywang96 merged 10 commits into
vllm-project:mainfrom
aoshen02:perf/sampling-mask-gpu-compact
Sep 4, 2026
Merged

ywang96 merged 10 commits into
vllm-project:mainfrom
aoshen02:perf/sampling-mask-gpu-compact

Conversation

@aoshen02

@aoshen02 aoshen02 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Purpose

--return-sampling-mask (#49577) made decode throughput collapse under load. PrimeIntellect reported ~2x RL step time and higher engine imbalance after integrating it (PrimeIntellect-ai/prime-rl#3431).

Root cause: SamplingMaskTensors.tolists() ran np.unpackbits + np.nonzero over a [num_reqs, vocab] bitmask on every step. That is an O(num_reqs × vocab) CPU pass in the worker's async output thread, independent of how many tokens the nucleus actually keeps. With a 151k vocab it costs ~17 ms at 64 requests and ~130 ms at 512, so once it exceeds the GPU step time the engine is CPU-bound and throughput stops scaling with batch size. Because the cost grows with in-flight requests, the busiest engine in a multi-engine deployment gets slower per step and holds its KV longer, which is the imbalance prime-rl observed.

Fix: compact the support on the GPU. The Triton kernel now writes each row's finite-logit ids in ascending order into a [num_reqs, max_top_k] int32 buffer via a per-block prefix sum (max_top_k is the largest top_k in the batch; sampling-mask requests already require top_k > 0). Only that buffer plus per-row counts are copied to the host, and tolists() becomes a boolean gather over num_reqs × max_top_k elements. The bit-packed mask is still produced and copied, but it is only read on the host for rows whose support exceeds max_top_k (the top-k kernel keeps ties at the boundary), so the returned support is unchanged.

Test Plan

Unit tests (tests/v1/test_outputs.py -k sampling_mask) updated for the new tensor layout, plus new cases for the tie-overflow fallback and a multi-block vocab.

Kernel-level equivalence against the old implementation (one GB200, vocab 151552, fp32 logits with a random support of up to k ids per row, torch.cuda.synchronize() around from_logits + to_cpu_nonblocking for the GPU column and wall-clock around tolists for the CPU column). Note that the bit-packed mask is still produced and copied to the host in the new code; only the host-side unpack is gone:

rows kept ids old CPU tolists new CPU tolists GPU + D2H (old / new) equal
64 12k 16.4 ms 0.17 ms 0.41 / 0.39 ms yes
512 106k 127.7 ms 0.45 ms 0.94 / 0.93 ms yes
1024 205k 256.1 ms 0.85 ms 1.80 / 1.71 ms yes

Serving A/B, Qwen2.5-1.5B-Instruct on one GB200, Model Runner V2, --logprobs-mode processed_logprobs --max-num-seqs 1024, all clients sending temperature=1, top_p=0.95, top_k=512, logprobs=1, 128-token random prompts (a worst case: the nucleus keeps ~220 ids per position), 256 output tokens, /v1/completions. "no mask" and "mask after" were measured side by side in the same run; "mask before" is from an earlier run on the same machine:

concurrency no mask (tok/s) mask before (tok/s) mask after (tok/s)
64 19,418 2,562 15,366
256 23,244 2,659 17,971
512 22,204 2,627 15,952
1024 19,768 2,667 18,452

GLM-4.5-Air TP=4 (the model from the prime-rl report), vllm/vllm-openai:nightly-aarch64 container, same client settings:

4x GB200, --max-num-seqs 1024 --max-model-len 32768, 512 output tokens, natural-language prompt (the nucleus keeps ~6-8 ids per position on average):

concurrency endpoint no mask (tok/s, step) mask before (tok/s, step) mask after (tok/s, step)
64 /v1/completions 5,120 (12.5 ms) 3,106 (20.6 ms) 5,006 (12.8 ms)
256 /v1/completions 6,813 (37.6 ms) 3,132 (81.7 ms) 6,682 (38.3 ms)
512 /v1/completions 6,871 (74.5 ms) 3,121 (164.1 ms) 6,727 (76.1 ms)
256 /inference/v1/generate 6,478 (39.5 ms) 3,046 (84.0 ms) 6,266 (40.9 ms)

Before the fix the mask path is flat at ~3.1k tok/s regardless of concurrency, i.e. CPU-bound; after it, throughput is within 2-3% of no-mask.

Test Result

See above. Follow-up commits (review feedback): the frontend now builds the response list[list[int]] straight from the per-step slices instead of re-assembling CSR; the compact row width is clamped to MAX_COMPACT_SUPPORT = 2048 so a request with top_k near the vocab size cannot make the batch allocate [num_reqs, vocab] int32 (wider rows still round-trip exactly through the bitmask); return_sampling_mask combined with --enable-batch-sharded-sampling is now rejected at startup because gather_sampler_output() does not forward the masks (#53826 can lift this); and each per-step request slice carries a single token_ids array (offsets=None), halving the per-request ndarray encode/decode between engine core and API server. SamplingMaskLists stays a 3-field NamedTuple, so the rust client's opaque 3-element wire shape is unchanged.

The remaining gap on the small model is frontend work proportional to the number of finished requests (chunk merge and the list[list[int]] response), which is a few percent at the step time of a 100B-class model.

Duplicate-work check

Searched open PRs for sampling mask, return_sampling_mask, SamplingMaskTensors on 2026-09-02. #53826 (batch-sharded sampling drops masks) and #54166 (MTP support) touch the same file but neither addresses the CPU unpack cost; this change keeps the SamplingMaskTensors / SamplingMaskLists interfaces used by both.

AI assistance (Claude Code) was used for this change; the submitter reviewed every line and ran the tests above.

🤖 Generated with Claude Code

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

@mergify mergify Bot added the mrv2 Model Runner V2 specific label Sep 2, 2026
@aoshen02 aoshen02 added the ready ONLY add when PR is ready to merge/full CI is needed label Sep 2, 2026
@aoshen02

aoshen02 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

/ci run

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #86837 for commit f6f7ac9363fd.

aoshen02 and others added 7 commits September 2, 2026 11:51
…cking the full-vocab bitmask on CPU

SamplingMaskTensors.tolists() ran np.unpackbits over a [num_reqs, vocab]
bitmask on every step, an O(num_reqs * vocab) CPU pass in the worker's
async output thread that grew to ~130 ms/step at 512 requests and capped
throughput regardless of the GPU step time.

Compact the support on the GPU into [num_reqs, max_top_k] int32 ids with a
per-row prefix sum, D2H only that, and keep the bit-packed mask solely as
the exact fallback for rows whose support exceeds the batch top_k bound
(top-k ties).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
… array per request per step

- Clamp the compact row width to MAX_COMPACT_SUPPORT (2048) so a request
  with top_k near the vocab size cannot make the batch allocate
  [num_reqs, vocab] int32; wider rows go through the exact bitmask.
- Fail configuration when return_sampling_mask is combined with
  enable_batch_sharded_sampling: gather_sampler_output() does not forward
  SamplingMaskTensors, so masks would silently come back as None.
- SamplingMaskLists.offsets is optional: a per-step request slice carries
  only its token_ids, halving the per-request ndarray encode/decode
  between engine core and API server; merge/to_nested_list handle both
  forms and to_nested_list converts with a single tolist().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
Build each row's support with one helper that reads the compact row or,
for overflow rows, the bitmask; the vectorized fast path and the overflow
loop did the same work in two branches. Same cost (0.46 ms at 512 rows).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
Model Runner V2 emits one position per request per step and speculative
decoding is rejected with sampling replay, so slice_request asserts
num_positions == 1 and merge stacks single-position chunks; the
multi-position branches were dead. Drop the impossible max(1, ...) clamp
and the concatenate-based cu_num_generated_tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
The frontend concatenated the per-step slices into CSR only to split
them back into list[list[int]]; convert each slice with one tolist()
instead and drop SamplingMaskLists.merge. Fix the kernel docstring (the
compact row holds at most max_num_kept ids) and cover the no-sampled-row
step in the tolists test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
The kernel already zeroes the count of rows that sampled nothing, so
tolists() can emit one CSR row per request and slice_request() indexes
it by req_idx directly; cu_num_generated_tokens is no longer needed.
Inline the kernel's single-use temporaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
@aoshen02

aoshen02 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

/ci run

@aoshen02
aoshen02 force-pushed the perf/sampling-mask-gpu-compact branch from f6f7ac9 to 9c10bd5 Compare September 2, 2026 11:53
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #86861 for commit 9c10bd530cdb.

Replace the hand-built tensor tests with one reference test: for rows of
every support size (empty, one, around the compact width, past the cap,
the whole vocab) the mask must equal the finite-logit set, and unsampled
rows must be empty. Add a scheduler test that three requests each get
their own sampler row, and make the e2e test check that the returned
logprob mass lives exactly on the support and that it fits top_k.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
@aoshen02

aoshen02 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

/ci run

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #86866 for commit 8c8330e54ad5.

@mergify mergify Bot added the scheduler label Sep 2, 2026
@aoshen02

aoshen02 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

/ci run

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #86972 for commit bde999c087e7.

Signed-off-by: vx120 <893600387@qq.com>
@njhill

njhill commented Sep 3, 2026

Copy link
Copy Markdown
Member

Core changes LGTM

@aoshen02

aoshen02 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Core changes LGTM

Any update you think we should do?

@njhill njhill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @aoshen02

@njhill

njhill commented Sep 3, 2026

Copy link
Copy Markdown
Member

/ci run

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #87164 for commit 7d5a97ce4be2.

@ywang96
ywang96 merged commit 560ef78 into vllm-project:main Sep 4, 2026
116 of 118 checks passed
@aoshen02
aoshen02 deleted the perf/sampling-mask-gpu-compact branch September 4, 2026 02:04
ItsRoy69 pushed a commit to ItsRoy69/vllm that referenced this pull request Sep 10, 2026
…cking the full-vocab bitmask on CPU (vllm-project#54901)

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Signed-off-by: vx120 <893600387@qq.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: vx120 <893600387@qq.com>
Signed-off-by: Jyotirmoy Roy <jyotirmoyroy649@gmail.com>
yinli-systems pushed a commit to yinli-systems/single-gpu-inference-lab that referenced this pull request Sep 17, 2026
…LM #54901

Upstream vllm-project/vllm#54901 (merged 2026-09-04, shipped in 0.29.1)
diagnosed the same host-side np.unpackbits bottleneck and landed the same
top_k-bounded compact layout before this work was done against v0.29.0.
README, artifact READMEs, notes, status ledger and installer now state that;
the patch is for 0.29.0 only. The residual-mask wording no longer asserts a
cause for the five differing masks; it reports the bitmap-vs-bitmap control
and states that the experiment does not attribute them to the compact layout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mrv2 Model Runner V2 specific ready ONLY add when PR is ready to merge/full CI is needed scheduler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants