[Feature][Spec Decode] Support sampling mask replay for MRV2 MTP - #54166
chengcuiping wants to merge 4 commits into
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: 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. 🚀 |
|
Could you also validate dspark & dflash? |
|
Hi, what gpu are you using to do the test? |
|
I think the code is a bit too complicated, could you try simplify it as much as possible? |
|
Hi! Could you consider the situation when rejection_sample_method="synthetic"? It may accept a draft token outside the target sampling mask |
|
Thanks @aoshen02 and @vx120 — both of these are helpful points. The current validation was run on a machine with 4× NVIDIA A100-SXM4-80GB GPUs. The TP1 tests used one GPU, while the TP2 tests used two. I’ll add the full driver, CUDA, PyTorch, and Triton versions to the PR so the environment is clear and reproducible. For I’ll also trim the patch down to what is strictly needed for the fixed-boundary MTP path, and remove unrelated optimizations and redundant test code where possible. Since dFlash and DSpark share the MRV2 rejection sampler, I’ll run regression tests for both as well. I’ll follow up with a smaller diff and the exact validation results. |
85c730b to
1304ed7
Compare
|
Thanks @aoshen02 and @vx120 — I pushed the review revision in What changed:
Requested model regressions (
MTP replay validation used local
Performance (5 full warmups + 5 measured repetitions per scenario): c16 400.126 -> 383.915 tok/s (-4.05%), p95 33.485 -> 33.389 ms; c64 1,373.666 -> 1,200.580 tok/s (-12.60%), p95 34.220 -> 41.421 ms. Replay-on D2H was 61,360.4 bytes/committed token; replay-off was zero. This remains an opt-in correctness/metadata tradeoff, not a performance claim. Hardware correction/clarification: the host has 8 x NVIDIA A100-SXM4-80GB GPUs (81,920 MiB each); four were exposed to the test suite via Final focused results: config 11 passed (55 with |
|
It copies all K + 1 mask rows to cpu and only filter out uncommitted rows afterward, so the D2H overhead will increase with larger K or lower acceptance rates. |
TheEpicDolphin
left a comment
There was a problem hiding this comment.
Left a few suggestions to simplify further
| @classmethod | ||
| def from_speculative_logits( | ||
| cls, | ||
| logits: torch.Tensor, | ||
| cu_num_logits: torch.Tensor, | ||
| num_sampled_tokens: torch.Tensor, | ||
| rows_per_request: int, | ||
| ) -> SamplingMaskTensors: | ||
| """Pack committed target supports into fixed request-major slots.""" | ||
| num_reqs = num_sampled_tokens.shape[0] | ||
| vocab_size = logits.shape[1] | ||
| packed_width = (vocab_size + 7) // 8 | ||
| num_output_rows = num_reqs * rows_per_request | ||
| packed_mask = torch.empty( | ||
| (num_output_rows, packed_width), | ||
| dtype=torch.uint8, | ||
| device=logits.device, | ||
| ) | ||
| counts = torch.empty(num_output_rows, dtype=torch.int32, device=logits.device) | ||
| if num_output_rows: | ||
| _pack_sampling_mask_kernel[(num_output_rows,)]( | ||
| logits, | ||
| logits.stride(0), | ||
| logits.stride(1), | ||
| cu_num_logits, | ||
| num_sampled_tokens, | ||
| packed_mask, | ||
| packed_mask.stride(0), | ||
| counts, | ||
| vocab_size, | ||
| ROWS_PER_REQUEST=rows_per_request, | ||
| USE_REQUEST_BOUNDARIES=True, | ||
| BLOCK_SIZE=8192, | ||
| ) | ||
| return cls(packed_mask, counts, vocab_size, rows_per_request) |
There was a problem hiding this comment.
I don't think a separate from_speculative_logits is necessary. You can make from_logits generalize to num_output_rows instead of num_reqs, like this:
@classmethod
def from_logits(
cls,
logits: torch.Tensor,
cu_num_logits: torch.Tensor,
num_sampled_tokens: torch.Tensor,
rows_per_request: int = 1,
) -> SamplingMaskTensors:
num_reqs = num_sampled_tokens.shape[0]
vocab_size = logits.shape[1]
packed_width = (vocab_size + 7) // 8
num_output_rows = num_reqs * rows_per_request
...
And then the USE_REQUEST_BOUNDARIES branch in _pack_sampling_mask_kernel is no longer necessary, because it uses cu_num_logits like so:
@triton.jit
def _pack_sampling_mask_kernel(
logits_ptr,
logits_row_stride,
logits_col_stride,
cu_num_logits_ptr,
num_sampled_tokens_ptr,
packed_mask_ptr,
packed_mask_row_stride,
counts_ptr,
vocab_size,
ROWS_PER_REQUEST: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
output_row = tl.program_id(0)
req_idx = output_row // ROWS_PER_REQUEST
slot_idx = output_row % ROWS_PER_REQUEST
source_row = tl.load(cu_num_logits_ptr + req_idx) + slot_idx
request_end = tl.load(cu_num_logits_ptr + req_idx + 1)
is_active = (slot_idx < tl.load(num_sampled_tokens_ptr + req_idx)) & (
source_row < request_end
)
count = tl.zeros((), dtype=tl.int32)
for start_idx in range(0, vocab_size, BLOCK_SIZE):
# ... loop body unchanged ...
The only call-site change would be making SamplingMaskTensors.from_logits in sampler.py take the input_batch.cu_num_logits.
There was a problem hiding this comment.
Thanks @TheEpicDolphin — I implemented both simplifications from review 5081705043 in the pushed revision.
- SamplingMaskTensors now has the single generalized from_logits(logits, cu_num_logits, num_sampled_tokens, rows_per_request=1) entry point.
- from_speculative_logits and the USE_REQUEST_BOUNDARIES kernel branch are removed.
- Ordinary sampling passes input_batch.cu_num_logits.
- MTP passes chunk-local boundaries with fixed K+1 rows per request.
- The batch-sharded validation from the companion comment is now outside the speculative_config branch.
Focused config/output/rejection tests, RNG parity 5/5, TP1/TP2 correctness, and static checks all pass. I have left the review threads unresolved for reviewer confirmation.
| if self.parallel_config.enable_batch_sharded_sampling: | ||
| raise ValueError( | ||
| "sampling distribution replay with speculative decoding " | ||
| "does not support batch-sharded sampling" | ||
| ) |
There was a problem hiding this comment.
Batch-sharded sampling can be enabled without speculative decoding, so the raise should happen outside of the if speculative_config is not None: branch.
There was a problem hiding this comment.
Done — I moved the batch-sharded sampling check outside the speculative_config is not None branch, so Sampling Distribution Replay now rejects batch-sharded sampling even when speculative decoding is disabled. I also added a non-speculative batch-sharded configuration test to cover this case. I’ll leave the thread unresolved for your confirmation. Thanks!
|
This pull request has merge conflicts that must be resolved before it can be |
1304ed7 to
24b03ef
Compare
|
@vx120 I tested exact GPU-side row compaction, but I did not include it because the c64 result crossed the 2% no-go threshold. Using the same frozen baseline and five measured repetitions per scenario:
Both implementations reduced copied rows from 2,024 to 1,024 and bytes per committed token from 61,360.40625 to 31,044, but both regressed the c64 gate beyond 2%. The pushed clean candidate therefore retains the existing asynchronous fixed request-major K+1 D2H behavior and contains no GPU row compaction. |
|
Re: #54166 (comment) — the rebase is complete. The branch was rebased onto frozen upstream main 003e343 and the final verified remote head is 24b03ef. GitHub now reports the PR as mergeable. |
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
TheEpicDolphin
left a comment
There was a problem hiding this comment.
Thanks for the changes! I think you have to update this doc section now that MTP is supported.
| sampled_rows = np.arange(len(counts)) | ||
| else: | ||
| assert num_sampled_tokens is not None | ||
| num_sampled_tokens = np.asarray(num_sampled_tokens) |
There was a problem hiding this comment.
This seems redundant because num_sampled_tokens is already an np.ndarray
There was a problem hiding this comment.
Removed the redundant np.asarray(num_sampled_tokens). The
np.ndarray | None contract and the non-null assertion for multi-row masks
remain unchanged.
| is_active = (slot_idx < tl.load(num_sampled_tokens_ptr + req_idx)) & ( | ||
| source_row < request_end | ||
| ) |
There was a problem hiding this comment.
nit: You can early return for inactive rows to save on vocab iteration:
if not is_active:
tl.store(counts_ptr + output_row, 0)
return
count = tl.zeros((), dtype=tl.int32)
for start_idx in range(0, vocab_size, BLOCK_SIZE):
...
There was a problem hiding this comment.
Added the inactive-program early return after storing a zero count, and removed
the now-redundant is_active conditions from the vocabulary loop. The focused
GPU mask tests compiled the Triton kernel and passed for both timing arms.
| # Preserve D2H/proposal overlap unless mask replay requires copies to | ||
| # begin after the proposal collectives. |
There was a problem hiding this comment.
Why do we need to delay the async output copies until after the draft proposal collectives? Is it for performance reasons? If so, have we measured how much it costs us to overlap them?
There was a problem hiding this comment.
The data-flow audit found no dependency requiring delayed AsyncOutput:
packing finishes on the main stream before sample() returns,
copy_stream.wait_stream(main_stream) orders D2H, the source tensors stay
referenced, and neither postprocessing nor proposal receives or mutates the
mask buffers.
In the replay-on DELAYED vs OVERLAPPED single-variable A/B, each shape used 10
balanced full-workload pairs after 6 warm-ups. At c16, OVERLAPPED changed median
throughput from 407.053 to 430.433 tok/s (+5.744%, paired-bootstrap 95% CI
[+4.779%, +6.985%]) and p95 run-mean step latency from 62.449 to 59.135 ms
(-5.306%, CI [-6.240%, -4.230%]). At c64, throughput changed from 934.106 to
1035.080 tok/s (+10.810%, CI [+9.430%, +12.073%]) and p95 run-mean step latency
from 100.728 to 95.814 ms (-4.879%, CI [-9.751%, -4.435%]).
Both arms passed the focused, TP1/TP2, captured-oracle, dFlash, and dSpark
gates. The final code therefore restores upstream's direct pre-postprocess,
pre-proposal AsyncOutput(...) construction and removes the helper, delay
flag, optional-output branch, and unsupported ordering claim.
Extend the compact sampling-mask representation with fixed request-major MTP rows while preserving the ordinary single-token fast path and exact packed-mask fallback. Route one support per committed token and gate replay to fixed-boundary MTP verification. Preserve upstream AsyncOutput construction before postprocessing and proposal so output D2H, including replay masks, overlaps the next MTP proposal without late mutable state. Assisted-by: OpenAI Codex Signed-off-by: Cuiping Cheng <chengcuipingswu@163.com>
f590870 to
d121ee5
Compare
|
Documentation preview: https://vllm--54166.org.readthedocs.build/en/54166/ |
|
Addressing review 5147530908: I updated the sampling-mask documentation to |
|
✅ @chengcuiping, CI is now available for this PR.
|
|
/ci run |
|
✅ Triggered Buildkite CI #87827 for commit |
|
/ci run |
|
✅ Triggered Buildkite CI #87982 for commit |
There was a problem hiding this comment.
LGTM. Thank you for this feature support! cc: @aoshen02
The failing tests do not appear to be caused by this PR's changes.
|
谁让你到处卖钱在这里恶心全开源社区的?俊福 |
|
This pull request has merge conflicts that must be resolved before it can be |
|
Closing this PR - I've also deleted the comment with baidu link since we cannot guarantee the security nor the safety of the files you're sharing. |

Summary
This updates sampling-mask replay for fixed-boundary MTP speculative decoding on
top of the compact sampling-mask implementation merged in #54901. It is not a
duplicate of #54901: that PR introduced ordinary compact masks, while this PR
adds emitted-token-aligned MTP replay and its validation.
The final head is
d121ee5513dbdbfda190ef7ee32fb5a0ba174a2e, based on frozenupstream
mainat6fbb00b18874e27ba7d7adc0a3b8e93fee763ab1. I did notrebase solely to chase unrelated upstream commits. The final series is one
signed commit touching 13 files (
+601/-66).Scope and design
bitmask fallback; do not introduce a second ordinary sampling-mask path.
batch-sharded-sampling validation.
explicit cumulative row boundaries.
a zero count. Active-row token IDs, packed masks, and counts are unchanged.
position in a fixed MTP verification chunk, then route exactly one support for
every emitted accepted, recovered, or bonus token.
aligned one-to-one with emitted token IDs.
AsyncOutputconstruction beforepostprocess_sampled()andspeculator.propose(). The complete output D2H,including replay masks, can therefore overlap proposal. There is no
mask-specific deferred state, second production event, late mutation, helper,
or delayed-output branch.
Adaptive verification, non-MTP draft methods, synthetic/block verification,
diffusion/custom samplers, logprob-incompatible modes, and MRV2-incompatible
modes remain rejected.
AsyncOutput ordering audit and isolated A/B
The static data-flow audit found no dependency requiring delayed D2H:
Sampler.sample()andRejectionSampler._verify_in_chunks()finishconstructing replay masks on the main stream before
GPUModelRunner.sample()returns.
AsyncOutputenters its copy stream and callscopy_stream.wait_stream(main_stream), ordering every copy after the maskpacking already enqueued at its construction point.
AsyncOutputretains the sampler output and source GPU tensor referencesuntil copy completion.
postprocess_sampled()updates request/model state andspeculator.propose()consumes sampling state and token/hidden-state inputs;neither receives or mutates the sampling-mask buffers.
and is not D2H/collective-conflict evidence.
I then compared OVERLAPPED against the DELAYED arm of this PR; this is not a
comparison against upstream or replay-off and does not claim a general
throughput gain. The worktrees' tracked source was byte-identical except for
AsyncOutputtiming. Both used local Qwen3.5-9B, MTP K=3, temperature 0.8,top-k 5, top-p 0.9, identical per-request seeds and prompts, and replay enabled.
The balanced order used 6 complete warm-ups and 10 measured full-workload pairs
at both c16 and c64 (20 independent pairs total). Bootstrap intervals use
50,000 paired resamples, pairing by shape and run ID.
Throughput is 1,024 completed output tokens divided by full-workload elapsed
time. Each run's mean engine-step latency is elapsed time divided by its actual
engine-step count (41 at c16, 11 at c64); the reported p95 is the nearest-rank
p95 across the 10 independent run means, which is the maximum with n=10.
The throughput runs carried no diagnostic instrumentation. CUDA event data came
from a separate run in detached diagnostic worktrees and is shown below.
Separate CUDA event diagnostics
Here, a negative copy-completion value means D2H completed before proposal
ended.
The isolated correctness gates passed for both arms, and neither c16 nor c64 has
a credible regression over 2%, so the final design is OVERLAPPED.
TP2 nondeterminism boundary and exact oracle
The original TP2 cross-engine result is baseline-nondeterministic, so it is not
presented as replay-off/on exact evidence:
16-token warm-up covering the measured shape, prefill, decode, top-k/top-p,
causal-conv, and Triton GDN paths.
exact (three distinct output digests). A fresh replay-off A/B pair had zero
token mismatches but nine selected processed-logprob mismatches; this is not
treated as a correctness pass.
rank-local LM-head logits before the TP collective, logits processing,
rejection sampling, mask packing, and output D2H.
supports_batch_invariance, soVLLM_BATCH_INVARIANTwas not enabled orbypassed.
Exact replay correctness instead uses a real TP2 production-call-path oracle.
From a measured MTP rejection call, it captured complete processed target
logits, draft tokens/probabilities, request boundaries, accepted-count inputs,
effective stateless RNG seeds/positions, and CUDA device generator state/offset.
With device RNG state restored before each replay-off/on arm, all 5/5
repetitions were exact for sampled token IDs, selected processed-logprobs and
ranks, accepted/recovered/bonus decisions, counts, post-call RNG state/offset,
and processed inputs. Input mutation, mask membership, row alignment, support
alignment, and exact probability-reconstruction failures were all zero.
Final validation
All review-revision gates used the two isolated source arms derived from remote
head
f590870478adf4b895e56eb1361c2bb11c2b1449. The local amended commitcontains the passing OVERLAPPED arm.
8 output/mask, 2 scheduler, 1 output-processor, and 6 GPU rejection/chunking
tests. The inactive-row early return compiled and ran through the Triton mask
tests in both arms.
processed-logprob differences; zero mask membership/alignment failures;
acceptance metrics exact (mean acceptance length 2.2278481013, draft
acceptance rate 0.4092827004).
warm-up: 512 output rows; zero membership, row-alignment, or support-alignment
failures; captured-input oracle exact 5/5. There was no hang, timeout, NCCL
collective sequence divergence, or rank-ordering failure.
DELAYED/OVERLAPPED token IDs, selected processed-logprobs, and acceptance
metrics exact.
DELAYED/OVERLAPPED token IDs, selected processed-logprobs, and acceptance
metrics exact.
.venv/bin/python -m compileall,git diff --check, every changed-filepre-commit hook, and
pre-commit run mypy-3.12 --all-files --hook-stage manualpassed.Signed-off-bytrailerare
Cuiping Cheng <chengcuipingswu@163.com>.The host exposed 8 NVIDIA A100-SXM4-80GB GPUs. Formal A/B used two persistent
workers on host GPU 0 (UUID
GPU-1714bc68-6be5-ad06-8969-674ddc0dd73e);CUDA_VISIBLE_DEVICES=0mapped that device tocuda:0in each worker. TP1used one GPU per run (host GPU 1 mapped to process-local
cuda:0). TP2 usedtwo GPUs per run:
CUDA_VISIBLE_DEVICES=2,3mapped host GPUs 2 and 3 to TPranks' process-local
cuda:0andcuda:1. Replay-off dFlash used host GPUs0/1 for DELAYED/OVERLAPPED, and dSpark used host GPUs 4/5.
GPU validation used PyTorch
2.13.0+cu129, CUDA 12.9, and Triton 3.7.1. TheCodex inner sandbox masked devices, so GPU commands ran in the existing
GPU-visible host context after repeating the device checks; no environment
reinstall or model download was performed.
AI assistance disclosure
OpenAI Codex assisted with upstream-diff analysis, semantic migration, test
development, static ordering audit, isolated performance/ordering diagnostics,
and validation orchestration. I reviewed and understand the final changes and
take responsibility for them.