[Feature][Spec Decode] Support sampling mask replay for MRV2 MTP (#59359)
Signed-off-by: Cuiping Cheng <chengcuipingswu@163.com> Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Cuiping Cheng <chengcuipingswu@163.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vx120 committed
Oct 1, 2026 at 13:51 UTC
70be6aae19587a251987c405b5bdf2d540da5991
13 files changed
+583
-68
docs/training/sampling_mask.md
+4
-1
@@ -63,9 +63,12 @@ The mask is also available via the `/inference/v1/generate` HTTP endpoint:
63
| `top_k > 0` | Bounds mask size; pure top-p can produce vocab-sized masks |
64
| Model Runner V2 | Required by the async D2H copy pipeline |
65
66
+Speculative decoding is supported; each emitted token gets the support of the
67
+target distribution at its position.
68
+
69
The engine rejects unsupported combinations at startup or request time:
70
68
-- Speculative decoding
71
+- Speculative decoding with adaptive verification
72
- Diffusion models
73
- Custom logits processors (engine-level `--logits-processors`)
74
tests/test_config.py
+91
@@ -147,6 +147,97 @@ def test_rocm_mm_prefix_lm_disables_chunked_mm_input(
147
assert config.scheduler_config.disable_chunked_mm_input is expected
148
149
150
+def _sampling_replay_config(
151
+ *,
152
+ return_sampling_mask: bool = True,
153
+ use_v2_model_runner: bool = True,
154
+ speculative_method: str | None = None,
155
+ rejection_sample_method: str = "standard",
156
+ adaptive: bool = False,
157
+ is_diffusion: bool = False,
158
+ logits_processors: list[str] | None = None,
159
+ logprobs_mode: str = "processed_logprobs",
160
+):
161
+ speculative_config = None
162
+ if speculative_method is not None:
163
+ speculative_config = SimpleNamespace(
164
+ method=speculative_method,
165
+ enable_adaptive_verification=adaptive,
166
+ rejection_sample_method=rejection_sample_method,
167
+ )
168
+ return SimpleNamespace(
169
+ model_config=SimpleNamespace(
170
+ return_sampling_mask=return_sampling_mask,
171
+ is_diffusion=is_diffusion,
172
+ logits_processors=logits_processors or [],
173
+ logprobs_mode=logprobs_mode,
174
+ ),
175
+ use_v2_model_runner=use_v2_model_runner,
176
+ speculative_config=speculative_config,
177
+ )
178
+
179
+
180
+@pytest.mark.parametrize(
181
+ ("config", "message"),
182
+ [
183
+ (_sampling_replay_config(), None),
184
+ *(
185
+ (_sampling_replay_config(speculative_method=method), None)
186
+ for method in (
187
+ "mtp",
188
+ "eagle",
189
+ "eagle3",
190
+ "dflash",
191
+ "dspark",
192
+ "draft_model",
193
+ )
194
+ ),
195
+ *(
196
+ (
197
+ _sampling_replay_config(
198
+ speculative_method="mtp",
199
+ rejection_sample_method=rejection_sample_method,
200
+ ),
201
+ None,
202
+ )
203
+ for rejection_sample_method in ("standard", "block", "synthetic")
204
+ ),
205
+ (
206
+ _sampling_replay_config(
207
+ return_sampling_mask=False, speculative_method="dspark", adaptive=True
208
+ ),
209
+ None,
210
+ ),
211
+ (
212
+ _sampling_replay_config(speculative_method="dspark", adaptive=True),
213
+ "requires fixed verification boundaries",
214
+ ),
215
+ (
216
+ _sampling_replay_config(is_diffusion=True),
217
+ "does not support diffusion models",
218
+ ),
219
+ (
220
+ _sampling_replay_config(logits_processors=["custom"]),
221
+ "does not support custom logits processors",
222
+ ),
223
+ (
224
+ _sampling_replay_config(logprobs_mode="raw_logprobs"),
225
+ "requires logprobs_mode='processed_logprobs'",
226
+ ),
227
+ (
228
+ _sampling_replay_config(use_v2_model_runner=False),
229
+ "requires Model Runner V2",
230
+ ),
231
+ ],
232
+)
233
+def test_sampling_replay_config(config, message):
234
+ if message is None:
235
+ VllmConfig._verify_sampling_replay_config(config)
236
+ else:
237
+ with pytest.raises(ValueError, match=message):
238
+ VllmConfig._verify_sampling_replay_config(config)
239
+
240
+
241
def test_kda_recoverssm_derivation_is_revalidated():
242
config = SimpleNamespace(
243
cache_config=SimpleNamespace(
tests/v1/core/test_scheduler.py
+43
@@ -908,6 +908,49 @@ def test_update_from_output_routes_sampling_masks_by_request():
908
assert all(out.new_sampling_mask.offsets is None for out in outputs)
909
910
911
+def test_update_from_output_routes_multi_position_sampling_masks():
912
+ scheduler = create_scheduler()
913
+ scheduler.return_sampling_mask = True
914
+ requests = create_requests(num_requests=2, max_tokens=10)
915
+ for req in requests:
916
+ req.num_computed_tokens = req.num_tokens
917
+ scheduler.requests[req.request_id] = req
918
+ scheduler.running.append(req)
919
+ req.status = RequestStatus.RUNNING
920
+
921
+ scheduler_output = SchedulerOutput(
922
+ scheduled_new_reqs=[],
923
+ scheduled_cached_reqs=CachedRequestData.make_empty(),
924
+ num_scheduled_tokens={req.request_id: 1 for req in requests},
925
+ total_num_scheduled_tokens=2,
926
+ scheduled_encoder_inputs={},
927
+ scheduled_spec_decode_tokens={},
928
+ num_common_prefix_blocks=[],
929
+ finished_req_ids=set(),
930
+ free_encoder_mm_hashes=[],
931
+ )
932
+ model_output = ModelRunnerOutput(
933
+ req_ids=[req.request_id for req in requests],
934
+ req_id_to_index={req.request_id: i for i, req in enumerate(requests)},
935
+ sampled_token_ids=[[1, 2], [3, 4, 5]],
936
+ logprobs=None,
937
+ prompt_logprobs_dict={},
938
+ pooler_output=[],
939
+ sampling_masks=SamplingMaskLists(
940
+ token_ids=np.array([1, 6, 2, 3, 7, 8, 4, 5, 9], dtype=np.int32),
941
+ offsets=np.array([0, 2, 3, 6, 7, 9]),
942
+ cu_num_generated_tokens=[0, 2, 5],
943
+ ),
944
+ )
945
+
946
+ outputs = scheduler.update_from_output(scheduler_output, model_output)[0].outputs
947
+
948
+ assert [out.new_sampling_mask.to_nested_list() for out in outputs] == [
949
+ [[1, 6], [2]],
950
+ [[3, 7, 8], [4], [5, 9]],
951
+ ]
952
+
953
+
954
def test_stop_via_update_from_output():
955
"""Test stopping behavior through update_from_output."""
956
scheduler = create_scheduler(num_speculative_tokens=1)
tests/v1/engine/test_output_processor.py
+26
@@ -35,6 +35,7 @@ from vllm.v1.engine.output_processor import (
35
RequestState,
36
)
37
from vllm.v1.metrics.stats import IterationStats, PrefillStats, SchedulerStats
38
+from vllm.v1.outputs import SamplingMaskLists
39
40
41
@pytest.mark.parametrize("flat_logprobs", [False, True])
@@ -62,6 +63,31 @@ def test_delta_output_without_new_tokens_returns_empty_logprobs(
63
assert len(output.logprobs) == 0
64
65
66
+def test_completion_output_preserves_each_sampling_mask_position() -> None:
67
+ state = RequestState.__new__(RequestState)
68
+ state.detokenizer = MagicMock()
69
+ state.detokenizer.get_next_output_text.return_value = ""
70
+ state.logprobs_processor = MagicMock()
71
+ state.logprobs_processor.logprobs = None
72
+ state.logprobs_processor.cumulative_logprob = None
73
+ state.output_kind = RequestOutputKind.DELTA
74
+ state.request_index = 0
75
+ state.sampling_mask_chunks = [
76
+ SamplingMaskLists(
77
+ token_ids=np.array([10, 11, 20]),
78
+ offsets=np.array([0, 2, 3]),
79
+ ),
80
+ SamplingMaskLists(token_ids=np.array([30, 31, 32])),
81
+ ]
82
+ state.routed_experts_chunks = []
83
+ state.spec_decode_metrics = None
84
+
85
+ output = state._new_completion_output([1, 2, 3], FinishReason.LENGTH, None)
86
+
87
+ assert output.sampling_mask is not None
88
+ assert output.sampling_mask.token_ids == [[10, 11], [20], [30, 31, 32]]
89
+
90
+
91
def _ref_convert_id_to_token(
92
tokenizer: TokenizerLike,
93
token_id: int,
tests/v1/test_outputs.py
+67
-3
@@ -13,6 +13,7 @@ from vllm.v1.outputs import (
13
LogprobsLists,
14
LogprobsTensors,
15
ModelRunnerOutput,
16
+ SamplingMaskLists,
17
)
18
from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p
19
from vllm.v1.worker.gpu.sample.output import SamplingMaskTensors
@@ -63,8 +64,6 @@ def test_logprobs_tensors_tolists_with_tensor_boundaries():
64
65
66
def test_sampling_mask_lists_to_nested_list():
66
- from vllm.v1.outputs import SamplingMaskLists
67
-
67
mask = SamplingMaskLists(
68
token_ids=np.array([10, 11, 12, 20, 21]),
69
offsets=np.array([0, 3, 5]),
@@ -76,6 +75,22 @@ def test_sampling_mask_lists_to_nested_list():
75
assert SamplingMaskLists(np.array([7, 9])).to_nested_list() == [[7, 9]]
76
77
78
+def test_sampling_mask_lists_slices_multiple_positions_by_request():
79
+ masks = SamplingMaskLists(
80
+ token_ids=np.array([10, 11, 20, 30, 31, 32, 40]),
81
+ offsets=np.array([0, 2, 3, 6, 7]),
82
+ cu_num_generated_tokens=[0, 1, 3, 4],
83
+ )
84
+
85
+ single = masks.slice_request(0, 1)
86
+ multi = masks.slice_request(1, 2)
87
+
88
+ assert single.to_nested_list() == [[10, 11]]
89
+ assert single.offsets is None
90
+ assert multi.to_nested_list() == [[20], [30, 31, 32]]
91
+ assert multi.cu_num_generated_tokens is None
92
+
93
+
94
@pytest.mark.parametrize("max_num_kept", [512, 20_001])
95
@pytest.mark.skipif(
96
current_platform.is_xpu(),
@@ -102,7 +117,10 @@ def test_sampling_mask_tensors_match_finite_support(max_num_kept):
117
expected[1] = expected[7] = []
118
119
tensors = SamplingMaskTensors.from_logits(
105
- logits.to(DEVICE_TYPE), num_sampled_tokens.to(DEVICE_TYPE), max_num_kept
120
+ logits.to(DEVICE_TYPE),
121
+ torch.arange(len(sizes) + 1, device=DEVICE_TYPE, dtype=torch.int32),
122
+ num_sampled_tokens.to(DEVICE_TYPE),
123
+ max_num_kept,
124
)
125
126
assert tensors.token_ids.shape[1] == min(max_num_kept, MAX_COMPACT_SUPPORT)
@@ -127,6 +145,7 @@ def test_sampling_mask_matches_processed_top_k_top_p_support():
145
146
tensors = SamplingMaskTensors.from_logits(
147
processed_logits,
148
+ cu_num_logits=torch.tensor([0, 1], dtype=torch.int32, device=DEVICE_TYPE),
149
num_sampled_tokens=torch.tensor([1], device=DEVICE_TYPE),
150
max_num_kept=3,
151
)
@@ -135,6 +154,50 @@ def test_sampling_mask_matches_processed_top_k_top_p_support():
154
assert result.to_nested_list() == [expected_token_ids]
155
156
157
+def test_sampling_mask_tensors_uses_request_boundaries():
158
+ logits = torch.full((6, 8), -float("inf"), device=DEVICE_TYPE)
159
+ logits[0, [1, 3]] = 0
160
+ logits[2, [2, 4, 6]] = 0
161
+ logits[5, [0, 7]] = 0
162
+ counts = torch.tensor([1, 0, 1], device=DEVICE_TYPE, dtype=torch.int32)
163
+
164
+ tensors = SamplingMaskTensors.from_logits(
165
+ logits,
166
+ torch.tensor([0, 2, 5, 6], device=DEVICE_TYPE, dtype=torch.int32),
167
+ counts,
168
+ max_num_kept=3,
169
+ )
170
+
171
+ assert tensors.tolists().to_nested_list() == [[1, 3], [], [0, 7]]
172
+
173
+
174
+def test_sampling_mask_tensors_multirow_request_layout():
175
+ rows_per_request = 4
176
+ counts = np.array([1, 2, 4, 0], dtype=np.int32)
177
+ logits = torch.full((16, 16), -float("inf"), device=DEVICE_TYPE)
178
+ expected = []
179
+ for row in [0, 4, 5, 8, 9, 10, 11]:
180
+ kept = torch.tensor(
181
+ [row % 16, (row + 3) % 16, (row + 7) % 16], device=DEVICE_TYPE
182
+ ).unique(sorted=True)
183
+ logits[row, kept] = 0
184
+ expected.append(kept.tolist())
185
+
186
+ tensors = SamplingMaskTensors.from_logits(
187
+ logits,
188
+ torch.arange(0, 17, 4, device=DEVICE_TYPE, dtype=torch.int32),
189
+ torch.from_numpy(counts).to(DEVICE_TYPE),
190
+ max_num_kept=2,
191
+ rows_per_request=rows_per_request,
192
+ )
193
+ result = tensors.to_cpu_nonblocking().tolists(counts)
194
+
195
+ assert tensors.token_ids.shape == (16, 2)
196
+ assert tensors.rows_per_request == rows_per_request
197
+ assert result.to_nested_list() == expected
198
+ assert result.cu_num_generated_tokens == [0, 1, 3, 7, 7]
199
+
200
+
201
def test_sampling_mask_preserves_top_k_boundary_ties():
202
"""When the kept support is wider than `max_num_kept` (e.g. a top-k
203
boundary tie keeps more than k logits), the mask must fall back to the
@@ -147,6 +210,7 @@ def test_sampling_mask_preserves_top_k_boundary_ties():
210
211
tensors = SamplingMaskTensors.from_logits(
212
processed_logits,
213
+ cu_num_logits=torch.tensor([0, 1], dtype=torch.int32, device=DEVICE_TYPE),
214
num_sampled_tokens=torch.tensor([1], device=DEVICE_TYPE),
215
max_num_kept=3,
216
)
tests/v1/worker/test_gpu_rejection_sampler_chunking.py
+207
-29
@@ -10,12 +10,53 @@ import torch
10
11
from vllm.config.model import PROCESSED_LOGPROBS_MODES, LogprobsMode
12
from vllm.platforms import current_platform
13
+from vllm.v1.worker.gpu.sample.output import SamplingMaskTensors
14
from vllm.v1.worker.gpu.spec_decode.rejection_sampler import (
15
RejectionSampler,
16
_iter_request_chunks,
17
)
18
19
20
+def _make_input_batch(
21
+ cu_num_logits_np: np.ndarray,
22
+ idx_mapping_np: np.ndarray,
23
+ device: torch.device,
24
+) -> SimpleNamespace:
25
+ """Minimal InputBatch fields read by RejectionSampler._verify_in_chunks."""
26
+ num_reqs = len(idx_mapping_np)
27
+ num_logits_per_req = np.diff(cu_num_logits_np)
28
+ return SimpleNamespace(
29
+ num_reqs=num_reqs,
30
+ cu_num_logits_np=cu_num_logits_np,
31
+ cu_num_logits=torch.from_numpy(cu_num_logits_np).to(device),
32
+ idx_mapping_np=idx_mapping_np,
33
+ idx_mapping=torch.from_numpy(idx_mapping_np).to(device),
34
+ expanded_idx_mapping=torch.from_numpy(
35
+ np.repeat(idx_mapping_np, num_logits_per_req)
36
+ ).to(device),
37
+ expanded_local_pos=torch.from_numpy(
38
+ np.concatenate(
39
+ [np.arange(count, dtype=np.int32) for count in num_logits_per_req]
40
+ )
41
+ ).to(device),
42
+ seq_lens_cpu_upper_bound=torch.full((num_reqs,), 64, dtype=torch.int32),
43
+ )
44
+
45
+
46
+def _make_rejection_sampler(
47
+ sampler: SimpleNamespace, num_speculative_steps: int
48
+) -> RejectionSampler:
49
+ """RejectionSampler for standard, fixed-boundary, unwatermarked verification."""
50
+ rejection_sampler = object.__new__(RejectionSampler)
51
+ rejection_sampler.sampler = sampler
52
+ rejection_sampler.num_speculative_steps = num_speculative_steps
53
+ rejection_sampler.enable_adaptive_verification = False
54
+ rejection_sampler.synthetic_conditional_rates = None
55
+ rejection_sampler.use_block_verification = False
56
+ rejection_sampler.watermark_key = None
57
+ return rejection_sampler
58
+
59
+
60
def test_iter_request_chunks_preserves_request_boundaries():
61
cu_num_logits = np.array([0, 3, 4, 11, 13], dtype=np.int32)
62
@@ -33,28 +74,11 @@ def test_chunked_scores_match_full_batch(logprobs_mode: str):
74
cu_num_logits_np = np.array([0, 3, 4, 8, 10], dtype=np.int32)
75
num_logits_per_req = np.diff(cu_num_logits_np)
76
idx_mapping_np = np.array([7, 2, 9, 1], dtype=np.int32)
36
- input_batch = SimpleNamespace(
37
- num_reqs=4,
38
- cu_num_logits_np=cu_num_logits_np,
39
- cu_num_logits=torch.from_numpy(cu_num_logits_np).to(device),
40
- idx_mapping_np=idx_mapping_np,
41
- idx_mapping=torch.from_numpy(idx_mapping_np).to(device),
42
- expanded_idx_mapping=torch.from_numpy(
43
- np.repeat(idx_mapping_np, num_logits_per_req)
44
- ).to(device),
45
- expanded_local_pos=torch.from_numpy(
46
- np.concatenate(
47
- [np.arange(count, dtype=np.int32) for count in num_logits_per_req]
48
- )
49
- ).to(device),
50
- seq_lens_cpu_upper_bound=torch.from_numpy(
51
- np.array([10, 20, 30, 40], dtype=np.int32)
52
- ),
77
+ input_batch = _make_input_batch(cu_num_logits_np, idx_mapping_np, device)
78
+ rejection_sampler = _make_rejection_sampler(
79
+ SimpleNamespace(logprobs_mode=logprobs_mode, return_sampling_mask=False),
80
+ num_speculative_steps=3,
81
)
54
- rejection_sampler = object.__new__(RejectionSampler)
55
- rejection_sampler.sampler = SimpleNamespace(logprobs_mode=logprobs_mode)
56
- rejection_sampler.num_speculative_steps = 3
57
- rejection_sampler.enable_adaptive_verification = False
82
83
def fake_verify(
84
self,
@@ -75,14 +99,16 @@ def test_chunked_scores_match_full_batch(logprobs_mode: str):
99
rejection_sampler._verify = MethodType(fake_verify, rejection_sampler)
100
logits = torch.arange(170, dtype=torch.float32, device=device).view(10, 17)
101
78
- sampled, num_sampled, chunked_logprobs = rejection_sampler._verify_in_chunks(
79
- logits,
80
- input_batch,
81
- draft_logits=None,
82
- draft_sampled=torch.arange(10, device=device),
83
- pos=torch.arange(10, device=device),
84
- max_chunk_logits=5,
85
- max_num_logprobs=2,
102
+ sampled, num_sampled, chunked_logprobs, sampling_mask_tensors = (
103
+ rejection_sampler._verify_in_chunks(
104
+ logits,
105
+ input_batch,
106
+ draft_logits=None,
107
+ draft_sampled=torch.arange(10, device=device),
108
+ pos=torch.arange(10, device=device),
109
+ max_chunk_logits=5,
110
+ max_num_logprobs=2,
111
+ )
112
)
113
score_logits = logits + 1 if logprobs_mode in PROCESSED_LOGPROBS_MODES else logits
114
full_logprobs = rejection_sampler._get_logprobs_tensors(
@@ -111,3 +137,155 @@ def test_chunked_scores_match_full_batch(logprobs_mode: str):
137
chunked_logprobs.cu_num_generated_tokens
138
== full_logprobs.cu_num_generated_tokens
139
)
140
+ assert sampling_mask_tensors is None
141
+
142
+
143
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
144
+def test_replay_on_off_preserves_rejection_sampling_and_rng(monkeypatch):
145
+ device = torch.device("cuda")
146
+ num_reqs = 3
147
+ num_speculative_steps = 3
148
+ rows_per_request = num_speculative_steps + 1
149
+ vocab_size = 8
150
+ cu_num_logits_np = np.arange(0, 13, rows_per_request, dtype=np.int32)
151
+ idx_mapping_np = np.arange(num_reqs, dtype=np.int32)
152
+ input_batch = _make_input_batch(cu_num_logits_np, idx_mapping_np, device)
153
+ processed_logits = torch.zeros((12, vocab_size), device=device)
154
+ processed_logits[0, 0] = -float("inf")
155
+ processed_logits[rows_per_request + 1, 1] = -float("inf")
156
+ draft_logits = torch.zeros(
157
+ (num_reqs, num_speculative_steps, vocab_size), device=device
158
+ )
159
+ draft_sampled = torch.zeros(
160
+ (num_reqs, rows_per_request), dtype=torch.int64, device=device
161
+ )
162
+ draft_sampled[:, 1:] = torch.tensor([0, 1, 2], device=device)
163
+ draft_sampled = draft_sampled.flatten()
164
+ sampler = SimpleNamespace(
165
+ logprobs_mode="processed_logprobs",
166
+ return_sampling_mask=False,
167
+ sampling_states=SimpleNamespace(
168
+ top_k=SimpleNamespace(np=np.full(num_reqs, vocab_size)),
169
+ temperature=SimpleNamespace(gpu=torch.ones(num_reqs, device=device)),
170
+ seeds=SimpleNamespace(
171
+ gpu=torch.arange(num_reqs, dtype=torch.int64, device=device)
172
+ ),
173
+ ),
174
+ use_fp64_gumbel=False,
175
+ apply_sampling_params=lambda logits, *_: logits,
176
+ )
177
+ rejection_sampler = _make_rejection_sampler(sampler, num_speculative_steps)
178
+ pos = torch.arange(12, dtype=torch.int32, device=device)
179
+
180
+ pack_calls: list[tuple[list[int], int, int]] = []
181
+ pack = SamplingMaskTensors.from_logits.__func__
182
+
183
+ def track_pack(
184
+ cls,
185
+ logits,
186
+ cu_num_logits,
187
+ num_sampled_tokens,
188
+ max_num_kept,
189
+ rows_per_request=1,
190
+ ):
191
+ pack_calls.append(
192
+ (cu_num_logits.cpu().tolist(), max_num_kept, rows_per_request)
193
+ )
194
+ return pack(
195
+ cls,
196
+ logits,
197
+ cu_num_logits,
198
+ num_sampled_tokens,
199
+ max_num_kept,
200
+ rows_per_request,
201
+ )
202
+
203
+ monkeypatch.setattr(
204
+ SamplingMaskTensors,
205
+ "from_logits",
206
+ classmethod(track_pack),
207
+ )
208
+
209
+ rng_before = torch.cuda.get_rng_state()
210
+ off_sampled, off_counts, off_logprobs, off_masks = (
211
+ rejection_sampler._verify_in_chunks(
212
+ processed_logits,
213
+ input_batch,
214
+ draft_logits,
215
+ draft_sampled,
216
+ pos,
217
+ max_chunk_logits=8,
218
+ max_num_logprobs=0,
219
+ )
220
+ )
221
+ rng_after_off = torch.cuda.get_rng_state()
222
+ assert pack_calls == []
223
+
224
+ sampler.return_sampling_mask = True
225
+ on_sampled, on_counts, on_logprobs, on_masks = rejection_sampler._verify_in_chunks(
226
+ processed_logits,
227
+ input_batch,
228
+ draft_logits,
229
+ draft_sampled,
230
+ pos,
231
+ max_chunk_logits=8,
232
+ max_num_logprobs=0,
233
+ )
234
+ rng_after_on = torch.cuda.get_rng_state()
235
+
236
+ assert pack_calls == [
237
+ ([0, 4, 8], vocab_size, rows_per_request),
238
+ ([0, 4], vocab_size, rows_per_request),
239
+ ]
240
+ assert off_masks is None and on_masks is not None
241
+ assert on_masks.rows_per_request == rows_per_request
242
+ assert on_masks.token_ids.shape == (num_reqs * rows_per_request, vocab_size)
243
+ assert on_masks.packed_mask.shape[0] == num_reqs * rows_per_request
244
+ assert off_counts.tolist() == [1, 2, 4]
245
+ assert torch.equal(off_counts, on_counts)
246
+ counts = off_counts.cpu().tolist()
247
+ for req_idx, count in enumerate(counts):
248
+ assert torch.equal(off_sampled[req_idx, :count], on_sampled[req_idx, :count])
249
+
250
+ mapped_rows = [
251
+ int(input_batch.cu_num_logits_np[req_idx]) + slot_idx
252
+ for req_idx, count in enumerate(counts)
253
+ for slot_idx in range(count)
254
+ ]
255
+ assert off_logprobs is not None and on_logprobs is not None
256
+ assert torch.equal(
257
+ off_logprobs.logprob_token_ids[mapped_rows],
258
+ on_logprobs.logprob_token_ids[mapped_rows],
259
+ )
260
+ assert torch.equal(
261
+ off_logprobs.logprobs[mapped_rows], on_logprobs.logprobs[mapped_rows]
262
+ )
263
+ assert torch.equal(
264
+ off_logprobs.selected_token_ranks[mapped_rows],
265
+ on_logprobs.selected_token_ranks[mapped_rows],
266
+ )
267
+ assert torch.equal(rng_before, rng_after_off)
268
+ assert torch.equal(rng_after_off, rng_after_on)
269
+
270
+ masks = (
271
+ on_masks.to_cpu_nonblocking().tolists(on_counts.cpu().numpy()).to_nested_list()
272
+ )
273
+ expected_masks = [
274
+ torch.isfinite(processed_logits[row]).nonzero().flatten().tolist()
275
+ for row in mapped_rows
276
+ ]
277
+ emitted = [
278
+ int(on_sampled[req_idx, slot_idx])
279
+ for req_idx, count in enumerate(on_counts.cpu().tolist())
280
+ for slot_idx in range(count)
281
+ ]
282
+ expected_logprobs = torch.stack(
283
+ [
284
+ torch.log_softmax(processed_logits[row], dim=-1)[token_id]
285
+ for row, token_id in zip(mapped_rows, emitted)
286
+ ]
287
+ )
288
+
289
+ assert masks == expected_masks
290
+ assert all(token_id in mask for token_id, mask in zip(emitted, masks))
291
+ assert torch.equal(on_logprobs.logprobs[mapped_rows, 0], expected_logprobs)
vllm/config/vllm.py
+8
-2
@@ -1265,9 +1265,15 @@ class VllmConfig:
1265
return
1266
if not self.use_v2_model_runner:
1267
raise ValueError("sampling distribution replay requires Model Runner V2")
1268
- if self.speculative_config is not None:
1268
+ speculative_config = self.speculative_config
1269
+ if (
1270
+ speculative_config is not None
1271
+ and speculative_config.enable_adaptive_verification
1272
+ ):
1273
raise ValueError(
1270
- "sampling distribution replay does not support speculative decoding"
1274
+ "sampling distribution replay with speculative decoding "
1275
+ "requires fixed verification boundaries; disable adaptive "
1276
+ "verification"
1277
)
1278
if model_config.is_diffusion:
1279
raise ValueError(
vllm/v1/engine/output_processor.py
+5
-1
@@ -441,7 +441,11 @@ class RequestState:
441
sampling_mask = None
442
if finished and self.sampling_mask_chunks:
443
sampling_mask = SamplingMask(
444
- [chunk.token_ids.tolist() for chunk in self.sampling_mask_chunks]
444
+ [
445
+ position
446
+ for chunk in self.sampling_mask_chunks
447
+ for position in chunk.to_nested_list()
448
+ ]
449
)
450
451
# Concatenate routed experts on finish
vllm/v1/outputs.py
+10
-4
@@ -57,19 +57,25 @@ class LogprobsLists(NamedTuple):
57
58
59
class SamplingMaskLists(NamedTuple):
60
- """CSR sampling masks; a step slice holds one position (``offsets=None``)."""
60
+ """CSR sampling masks; a request slice may contain multiple positions."""
61
62
# [num_kept_tokens]
63
token_ids: np.ndarray
64
# [num_positions + 1], or None for a single position
65
offsets: np.ndarray | None = None
66
- # Unused with one position per request; kept for the wire layout.
66
+ # [num_requests + 1] for multi-position request batches.
67
cu_num_generated_tokens: list[int] | None = None
68
69
def slice_request(self, req_idx: int, num_positions: int) -> "SamplingMaskLists":
70
- assert num_positions == 1 and self.offsets is not None
70
+ assert self.offsets is not None
71
+ cu = self.cu_num_generated_tokens
72
+ start = req_idx if cu is None else cu[req_idx]
73
+ end = start + num_positions
74
+ lo, hi = self.offsets[start], self.offsets[end]
75
+ if num_positions == 1:
76
+ return SamplingMaskLists(self.token_ids[lo:hi])
77
return SamplingMaskLists(
72
- self.token_ids[self.offsets[req_idx] : self.offsets[req_idx + 1]]
78
+ self.token_ids[lo:hi], self.offsets[start : end + 1] - lo
79
)
80
81
def to_nested_list(self) -> list[list[int]]:
vllm/v1/worker/gpu/async_utils.py
+1
-1
@@ -185,7 +185,7 @@ class AsyncOutput(AsyncModelRunnerOutput):
185
186
if self.sampling_mask_tensors is not None:
187
self.model_runner_output.sampling_masks = (
188
- self.sampling_mask_tensors.tolists()
188
+ self.sampling_mask_tensors.tolists(self.num_sampled_tokens_np)
189
)
190
191
if self.num_nans is not None:
vllm/v1/worker/gpu/sample/output.py
+70
-21
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from __future__ import annotations
4
5
+from collections.abc import Sequence
6
from dataclasses import dataclass
7
from typing import NamedTuple
8
@@ -27,6 +28,7 @@ def _compact_sampling_mask_kernel(
28
logits_ptr,
29
logits_row_stride,
30
logits_col_stride,
31
+ cu_num_logits_ptr,
32
num_sampled_tokens_ptr,
33
token_ids_ptr,
34
token_ids_row_stride,
@@ -35,25 +37,32 @@ def _compact_sampling_mask_kernel(
37
counts_ptr,
38
vocab_size,
39
max_num_kept,
40
+ ROWS_PER_REQUEST: tl.constexpr,
41
BLOCK_SIZE: tl.constexpr,
42
):
43
"""Per row: first ``max_num_kept`` finite-logit ids, the full count, the bitmask."""
41
- req_idx = tl.program_id(0)
42
- is_active = tl.load(num_sampled_tokens_ptr + req_idx) > 0
44
+ output_row = tl.program_id(0)
45
+ req_idx = output_row // ROWS_PER_REQUEST
46
+ slot_idx = output_row % ROWS_PER_REQUEST
47
+ if slot_idx >= tl.load(num_sampled_tokens_ptr + req_idx):
48
+ tl.store(counts_ptr + output_row, 0)
49
+ return
50
+ source_row = tl.load(cu_num_logits_ptr + req_idx) + slot_idx
51
+
52
count = tl.zeros((), dtype=tl.int32)
53
54
for start_idx in range(0, vocab_size, BLOCK_SIZE):
55
offsets = start_idx + tl.arange(0, BLOCK_SIZE)
56
logits = tl.load(
48
- logits_ptr + req_idx * logits_row_stride + offsets * logits_col_stride,
57
+ logits_ptr + source_row * logits_row_stride + offsets * logits_col_stride,
58
mask=offsets < vocab_size,
59
other=-float("inf"),
60
)
52
- keep = (logits > -float("inf")) & (logits < float("inf")) & is_active
61
+ keep = (logits > -float("inf")) & (logits < float("inf"))
62
keep_i32 = keep.to(tl.int32)
63
pos = count + tl.cumsum(keep_i32, axis=0) - keep_i32
64
tl.store(
56
- token_ids_ptr + req_idx * token_ids_row_stride + pos,
65
+ token_ids_ptr + output_row * token_ids_row_stride + pos,
66
offsets.to(tl.int32),
67
mask=keep & (pos < max_num_kept),
68
)
@@ -62,12 +71,12 @@ def _compact_sampling_mask_kernel(
71
bits = tl.reshape(keep_i32, (BLOCK_SIZE // 8, 8)) << tl.arange(0, 8)[None, :]
72
byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8)
73
tl.store(
65
- packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets,
74
+ packed_mask_ptr + output_row * packed_mask_row_stride + byte_offsets,
75
tl.sum(bits, axis=1).to(tl.uint8),
76
mask=byte_offsets < tl.cdiv(vocab_size, 8),
77
)
78
70
- tl.store(counts_ptr + req_idx, count)
79
+ tl.store(counts_ptr + output_row, count)
80
81
82
# Bounds the [num_reqs, width] int32 buffer; wider rows use the bitmask instead.
@@ -78,37 +87,46 @@ class SamplingMaskTensors(NamedTuple):
87
"""Device-side masks pending async D2H: compact ids, plus the bitmask as
88
the exact fallback for rows wider than ``max_num_kept``."""
89
81
- # [num_requests, max_num_kept]
90
+ # [num_requests * rows_per_request, max_num_kept]
91
token_ids: torch.Tensor
83
- # [num_requests, ceil(vocab_size / 8)]
92
+ # [num_requests * rows_per_request, ceil(vocab_size / 8)]
93
packed_mask: torch.Tensor
85
- # [num_requests]
94
+ # [num_requests * rows_per_request]
95
counts: torch.Tensor
96
vocab_size: int
97
+ # Fixed request-major output slots. Ordinary sampling has one slot.
98
+ rows_per_request: int = 1
99
100
@classmethod
101
def from_logits(
102
cls,
103
logits: torch.Tensor,
104
+ cu_num_logits: torch.Tensor,
105
num_sampled_tokens: torch.Tensor,
106
max_num_kept: int,
107
+ rows_per_request: int = 1,
108
) -> SamplingMaskTensors:
96
- """Capture the finite-logit support of every row with a sampled token."""
97
- num_reqs, vocab_size = logits.shape
109
+ """Capture committed target supports in fixed request-major slots."""
110
+ num_reqs = num_sampled_tokens.shape[0]
111
+ vocab_size = logits.shape[1]
112
max_num_kept = min(max_num_kept, vocab_size, MAX_COMPACT_SUPPORT)
113
+ num_output_rows = num_reqs * rows_per_request
114
device = logits.device
115
116
token_ids = torch.empty(
102
- (num_reqs, max_num_kept), dtype=torch.int32, device=device
117
+ (num_output_rows, max_num_kept), dtype=torch.int32, device=device
118
)
119
packed_mask = torch.empty(
105
- (num_reqs, (vocab_size + 7) // 8), dtype=torch.uint8, device=device
120
+ (num_output_rows, (vocab_size + 7) // 8),
121
+ dtype=torch.uint8,
122
+ device=device,
123
)
107
- counts = torch.empty(num_reqs, dtype=torch.int32, device=device)
108
- _compact_sampling_mask_kernel[(num_reqs,)](
124
+ counts = torch.empty(num_output_rows, dtype=torch.int32, device=device)
125
+ _compact_sampling_mask_kernel[(num_output_rows,)](
126
logits,
127
logits.stride(0),
128
logits.stride(1),
129
+ cu_num_logits,
130
num_sampled_tokens,
131
token_ids,
132
token_ids.stride(0),
@@ -117,9 +135,23 @@ class SamplingMaskTensors(NamedTuple):
135
counts,
136
vocab_size,
137
max_num_kept,
138
+ ROWS_PER_REQUEST=rows_per_request,
139
BLOCK_SIZE=8192,
140
)
122
- return cls(token_ids, packed_mask, counts, vocab_size)
141
+ return cls(token_ids, packed_mask, counts, vocab_size, rows_per_request)
142
+
143
+ @classmethod
144
+ def cat(cls, chunks: Sequence[SamplingMaskTensors]) -> SamplingMaskTensors:
145
+ """Join request-contiguous chunks that share one output layout."""
146
+ first = chunks[0]
147
+ assert len({(c.vocab_size, c.rows_per_request) for c in chunks}) == 1
148
+ return cls(
149
+ torch.cat([chunk.token_ids for chunk in chunks]),
150
+ torch.cat([chunk.packed_mask for chunk in chunks]),
151
+ torch.cat([chunk.counts for chunk in chunks]),
152
+ first.vocab_size,
153
+ first.rows_per_request,
154
+ )
155
156
def to_cpu_nonblocking(self) -> SamplingMaskTensors:
157
if self.token_ids.device.type == "cpu":
@@ -129,15 +161,31 @@ class SamplingMaskTensors(NamedTuple):
161
self.packed_mask.to("cpu", non_blocking=True),
162
self.counts.to("cpu", non_blocking=True),
163
self.vocab_size,
164
+ self.rows_per_request,
165
)
166
134
- def tolists(self) -> SamplingMaskLists:
135
- """CSR over all requests; rows without a sampled token are empty."""
167
+ def tolists(
168
+ self, num_sampled_tokens: np.ndarray | None = None
169
+ ) -> SamplingMaskLists:
170
+ """Convert fixed output rows to the scheduler's CSR representation."""
171
counts = self.counts.cpu().numpy()
172
token_ids = self.token_ids.cpu().numpy()
173
packed_mask = self.packed_mask.cpu().numpy()
174
width = token_ids.shape[1]
175
176
+ cu_num_generated_tokens = None
177
+ if self.rows_per_request == 1:
178
+ sampled_rows = np.arange(len(counts))
179
+ else:
180
+ assert num_sampled_tokens is not None
181
+ active_slots = (
182
+ np.arange(self.rows_per_request)[None, :] < num_sampled_tokens[:, None]
183
+ )
184
+ sampled_rows = np.flatnonzero(active_slots)
185
+ cu_num_generated_tokens = np.cumsum(
186
+ np.concatenate(([0], num_sampled_tokens))
187
+ ).tolist()
188
+
189
def support(row: int) -> np.ndarray:
190
if counts[row] <= width:
191
return token_ids[row, : counts[row]]
@@ -147,7 +195,8 @@ class SamplingMaskTensors(NamedTuple):
195
)
196
return np.flatnonzero(bits).astype(np.int32, copy=False)
197
150
- supports = [support(row) for row in range(len(counts))]
198
+ supports = [support(row) for row in sampled_rows]
199
offsets = np.zeros(len(supports) + 1, dtype=np.int64)
200
np.cumsum([len(s) for s in supports], out=offsets[1:])
153
- return SamplingMaskLists(np.concatenate(supports), offsets)
201
+ flat = np.concatenate([np.empty(0, dtype=np.int32), *supports])
202
+ return SamplingMaskLists(flat, offsets, cu_num_generated_tokens)
vllm/v1/worker/gpu/sample/sampler.py
+4
-1
@@ -203,7 +203,10 @@ class Sampler:
203
# Size by the validated top_k batch max; wider supports use the bitmask.
204
max_num_kept = int(np.max(self.sampling_states.top_k.np[idx_mapping_np]))
205
sampling_mask_tensors = SamplingMaskTensors.from_logits(
206
- processed_logits, num_sampled, max_num_kept
206
+ processed_logits,
207
+ input_batch.cu_num_logits,
208
+ num_sampled,
209
+ max_num_kept,
210
)
211
212
# These are GPU tensors.
vllm/v1/worker/gpu/spec_decode/rejection_sampler.py
+47
-5
@@ -18,7 +18,7 @@ from vllm.v1.worker.gpu.input_batch import (
18
)
19
from vllm.v1.worker.gpu.metrics.logits import get_num_nans
20
from vllm.v1.worker.gpu.sample.logprob import compute_topk_scores
21
-from vllm.v1.worker.gpu.sample.output import SamplerOutput
21
+from vllm.v1.worker.gpu.sample.output import SamplerOutput, SamplingMaskTensors
22
from vllm.v1.worker.gpu.sample.sampler import Sampler
23
from vllm.v1.worker.gpu.sample.states import NO_LOGPROBS
24
from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
@@ -296,7 +296,12 @@ class RejectionSampler:
296
max_chunk_logits: int,
297
max_num_logprobs: int,
298
verify_draft_sampled: torch.Tensor | None = None,
299
- ) -> tuple[torch.Tensor, torch.Tensor, LogprobsTensors | None]:
299
+ ) -> tuple[
300
+ torch.Tensor,
301
+ torch.Tensor,
302
+ LogprobsTensors | None,
303
+ SamplingMaskTensors | None,
304
+ ]:
305
if verify_draft_sampled is None:
306
verify_draft_sampled = draft_sampled
307
cu_num_logits_np = input_batch.cu_num_logits_np
@@ -316,6 +321,14 @@ class RejectionSampler:
321
sampled_chunks: list[torch.Tensor] = []
322
num_sampled_chunks: list[torch.Tensor] = []
323
logprobs_chunks: list[LogprobsTensors] = []
324
+ sampling_mask_chunks: list[SamplingMaskTensors] = []
325
+ max_num_kept = None
326
+ if self.sampler.return_sampling_mask:
327
+ max_num_kept = int(
328
+ np.max(
329
+ self.sampler.sampling_states.top_k.np[input_batch.idx_mapping_np]
330
+ )
331
+ )
332
333
for start, end in request_chunks:
334
lo = int(cu_num_logits_np[start])
@@ -346,13 +359,31 @@ class RejectionSampler:
359
)
360
if chunk_logprobs is not None:
361
logprobs_chunks.append(chunk_logprobs)
362
+ if max_num_kept is not None:
363
+ sampling_mask_chunks.append(
364
+ SamplingMaskTensors.from_logits(
365
+ processed_logits,
366
+ chunk_cu_num_logits,
367
+ num_sampled,
368
+ max_num_kept,
369
+ self.num_speculative_steps + 1,
370
+ )
371
+ )
372
del processed_logits
373
sampled_chunks.append(sampled)
374
num_sampled_chunks.append(num_sampled)
375
376
if len(sampled_chunks) == 1:
377
logprobs_tensors = logprobs_chunks[0] if logprobs_chunks else None
355
- return sampled_chunks[0], num_sampled_chunks[0], logprobs_tensors
378
+ sampling_mask_tensors = (
379
+ sampling_mask_chunks[0] if sampling_mask_chunks else None
380
+ )
381
+ return (
382
+ sampled_chunks[0],
383
+ num_sampled_chunks[0],
384
+ logprobs_tensors,
385
+ sampling_mask_tensors,
386
+ )
387
388
logprobs_tensors = None
389
if logprobs_chunks:
@@ -366,7 +397,12 @@ class RejectionSampler:
397
398
sampled = torch.cat(sampled_chunks)
399
num_sampled = torch.cat(num_sampled_chunks)
369
- return sampled, num_sampled, logprobs_tensors
400
+ sampling_mask_tensors = (
401
+ SamplingMaskTensors.cat(sampling_mask_chunks)
402
+ if sampling_mask_chunks
403
+ else None
404
+ )
405
+ return sampled, num_sampled, logprobs_tensors, sampling_mask_tensors
406
407
def __call__(
408
self,
@@ -400,7 +436,12 @@ class RejectionSampler:
436
input_batch.idx_mapping_np
437
)
438
chunk_logit_limit = get_max_chunk_logits(logits.shape[1])
403
- sampled, num_sampled, logprobs_tensors = self._verify_in_chunks(
439
+ (
440
+ sampled,
441
+ num_sampled,
442
+ logprobs_tensors,
443
+ sampling_mask_tensors,
444
+ ) = self._verify_in_chunks(
445
logits,
446
input_batch,
447
draft_logits,
@@ -425,4 +466,5 @@ class RejectionSampler:
466
num_nans=num_nans,
467
num_sampled=num_sampled,
468
num_rejected=num_rejected,
469
+ sampling_mask_tensors=sampling_mask_tensors,
470
)