[Feature] Mask Replay (#49577)
Signed-off-by: vx120 <893600387@qq.com> Signed-off-by: vx120 <57470515+vx120@users.noreply.github.com> Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com>
vx120 committed
Aug 13, 2026 at 16:44 UTC
50ba4bc6b2cacd70c4711a1904dd7ad9740a578b
24 files changed
+556
-5
docs/training/sampling_mask.md
new
+113
@@ -0,0 +1,113 @@
1
+# Sampling Mask (Distribution Replay)
2
+
3
+When using top-k/top-p sampling for RL rollouts (e.g. GRPO), there is a
4
+systematic mismatch between the truncated distribution the sampler actually
5
+drew from and the full-vocabulary softmax used to compute log-probabilities
6
+during training. The **sampling mask** feature closes this gap by returning
7
+the exact set of token IDs that survived top-k/top-p/min-p filtering at each
8
+generation step, so the training side can normalize over the same support.
9
+
10
+## Background
11
+
12
+This feature implements the **Keep Sampling Mask** strategy described in the
13
+[DeepSeek-V3.2 technical report](https://huggingface.co/deepseek-ai/DeepSeek-V3.2/blob/main/assets/paper.pdf)
14
+(Section 3.3). The key insight: top-k/top-p truncation during rollout sampling
15
+introduces a mismatch between the action spaces of `π_old` and `π_θ`, which
16
+violates the principles of importance sampling and destabilizes training. By
17
+preserving the truncation masks from `π_old` and applying them to `π_θ` during
18
+training, both policies share identical action subspaces. DeepSeek reports that
19
+combining top-p sampling with the Keep Sampling Mask strategy effectively
20
+preserves language consistency during RL training.
21
+
22
+## Quick start
23
+
24
+```bash
25
+vllm serve <model> \
26
+ --return-sampling-mask \
27
+ --logprobs-mode processed_logprobs
28
+```
29
+
30
+```python
31
+from vllm import LLM, SamplingParams
32
+
33
+llm = LLM(model, return_sampling_mask=True,
34
+ logprobs_mode="processed_logprobs")
35
+output = llm.generate(
36
+ "The capital of France is",
37
+ SamplingParams(temperature=1.0, top_k=50, top_p=0.95, logprobs=1),
38
+)
39
+mask = output[0].outputs[0].sampling_mask
40
+# mask.token_ids: [[187, 326, 512], [42, 88], ...]
41
+# mask.token_ids[i] = token IDs in the sampling support for generated token i
42
+```
43
+
44
+The mask is also available via the `/inference/v1/generate` HTTP endpoint:
45
+
46
+```json
47
+{
48
+ "choices": [{
49
+ "token_ids": [187, 42, 303],
50
+ "sampling_mask": [[187, 326, 512], [42, 88], [303, 11, 22]],
51
+ "finish_reason": "stop"
52
+ }]
53
+}
54
+```
55
+
56
+## Requirements
57
+
58
+| Requirement | Reason |
59
+| --- | --- |
60
+| `--return-sampling-mask` | Engine-level opt-in (disables FlashInfer sampler) |
61
+| `--logprobs-mode processed_logprobs` | Returned logprobs are normalized over the nucleus, not full vocab |
62
+| `temperature > 0` | Greedy has no truncated distribution |
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
+The engine rejects unsupported combinations at startup or request time:
67
+
68
+- Speculative decoding
69
+- Diffusion models
70
+- Custom logits processors (engine-level `--logits-processors`)
71
+
72
+## How it works
73
+
74
+1. The sampler applies all logit processors (penalties, logit bias, bad words,
75
+ temperature, min-p) and then top-k/top-p filtering, which sets excluded
76
+ logits to `-inf`.
77
+2. After sampling, `torch.isfinite(processed_logits)` identifies the surviving
78
+ token IDs — this is the sampling mask.
79
+3. The mask is transferred GPU → CPU asynchronously alongside sampled tokens.
80
+4. On request completion, per-step masks are merged and converted to
81
+ `list[list[int]]` for the response.
82
+
83
+## RL training usage
84
+
85
+The training side needs two things for the importance ratio `π_θ/π_old`:
86
+
87
+**`π_old(a|s)` — old policy's nucleus-normalized logprob:**
88
+Already returned by vLLM when `--logprobs-mode processed_logprobs` is set.
89
+The `log_softmax` is computed over processed logits (where filtered tokens
90
+are `-inf`), so the denominator only includes the nucleus.
91
+
92
+**`π_θ(a|s)` — current policy's nucleus-normalized logprob:**
93
+Computed by the training framework using the mask:
94
+
95
+```python
96
+# mask_ids: list[int], the sampling support for this token
97
+# logits: the training model's raw logits for this position
98
+keep = torch.zeros(vocab_size, dtype=torch.bool)
99
+keep[mask_ids] = True
100
+masked_logits = logits.masked_fill(~keep, float("-inf"))
101
+log_prob = log_softmax(masked_logits)[sampled_token_id]
102
+```
103
+
104
+Both sides normalize over the same token set, so the importance ratio is
105
+consistent.
106
+
107
+## Limitations
108
+
109
+- **Engine-level flag:** `--return-sampling-mask` globally disables the
110
+ FlashInfer fused sampler. All requests pay the cost of the PyTorch sampling
111
+ path, even if they don't need the mask.
112
+- **No streaming support:** The mask is returned only in the final response,
113
+ not in intermediate streaming chunks.
rust/src/engine-core-client/src/protocol/output.rs
+3
@@ -123,6 +123,8 @@ pub struct EngineCoreOutput {
123
/// frontend grows one.
124
#[serde(default)]
125
pub mm_cache_miss_hashes: Option<Vec<String>>,
126
+ #[serde(default)]
127
+ pub new_sampling_mask: Option<OpaqueValue>,
128
}
129
130
impl EngineCoreOutput {
@@ -440,6 +442,7 @@ mod tests {
442
routed_experts: None,
443
num_nans_in_logits: 0,
444
mm_cache_miss_hashes: None,
445
+ new_sampling_mask: None,
446
},
447
],
448
scheduler_stats: None,
rust/src/engine-core-client/src/tests/client.rs
+14
@@ -2498,6 +2498,8 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2498
let defaults_request_hex = lines.next().expect("missing defaults request fixture line");
2499
let multimodal_request_hex = lines.next().expect("missing multimodal request fixture line");
2500
let outputs_hex = lines.next().expect("missing outputs fixture line");
2501
+ let sampling_mask_outputs_hex =
2502
+ lines.next().expect("missing sampling mask outputs fixture line");
2503
let inline_logprobs_frames = lines.next().expect("missing inline logprobs fixture line");
2504
let multipart_logprobs_frames = lines.next().expect("missing multipart logprobs fixture line");
2505
let inline_prompt_frames = lines.next().expect("missing inline prompt logprobs fixture line");
@@ -2508,6 +2510,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2510
let request_bytes = hex::decode(request_hex).unwrap();
2511
let multimodal_request_bytes = hex::decode(multimodal_request_hex).unwrap();
2512
let outputs_bytes = hex::decode(outputs_hex).unwrap();
2513
+ let sampling_mask_outputs_bytes = hex::decode(sampling_mask_outputs_hex).unwrap();
2514
2515
let decoded_request: EngineCoreRequest = rmp_serde::from_slice(&request_bytes).unwrap();
2516
let expected_request = sample_request();
@@ -2571,6 +2574,16 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2574
decode_value(&rmp_serde::to_vec_named(&expected_multimodal_request.mm_features).unwrap());
2575
assert_eq!(python_mm_features, rust_mm_features);
2576
2577
+ let decoded_sampling_mask_outputs: EngineCoreOutputs =
2578
+ rmp_serde::from_slice(&sampling_mask_outputs_bytes).unwrap();
2579
+ let sampling_mask_output =
2580
+ &decoded_sampling_mask_outputs.as_request_batch().unwrap().outputs[0];
2581
+ assert!(sampling_mask_output.mm_cache_miss_hashes.is_none());
2582
+ assert!(matches!(
2583
+ sampling_mask_output.new_sampling_mask.as_ref(),
2584
+ Some(Value::Array(fields)) if fields.len() == 3
2585
+ ));
2586
+
2587
let decoded_outputs: EngineCoreOutputs = rmp_serde::from_slice(&outputs_bytes).unwrap();
2588
expect_test::expect![[r#"
2589
RequestBatch(
@@ -2598,6 +2611,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2611
routed_experts: None,
2612
num_nans_in_logits: 0,
2613
mm_cache_miss_hashes: None,
2614
+ new_sampling_mask: None,
2615
},
2616
],
2617
scheduler_stats: None,
rust/src/engine-core-client/src/tests/python_compat.py
+25
@@ -98,6 +98,7 @@ class EngineCoreOutput(
98
routed_experts: object | None = None
99
num_nans_in_logits: int = 0
100
mm_cache_miss_hashes: list[str] | None = None
101
+ new_sampling_mask: object | None = None
102
103
104
class EngineCoreOutputs(
@@ -202,6 +203,29 @@ outputs = EngineCoreOutputs(
203
finished_requests={"req-1"},
204
)
205
206
+sampling_mask_wire = [
207
+ [
208
+ "<i4",
209
+ [5],
210
+ msgpack.ExtType(3, np.array([2, 12, 16, 17, 18], dtype=np.int32).tobytes()),
211
+ ],
212
+ [
213
+ "<i8",
214
+ [2],
215
+ msgpack.ExtType(3, np.array([0, 5], dtype=np.int64).tobytes()),
216
+ ],
217
+ None,
218
+]
219
+outputs_with_sampling_mask = EngineCoreOutputs(
220
+ outputs=[
221
+ EngineCoreOutput(
222
+ request_id="req-mask",
223
+ new_token_ids=[16],
224
+ new_sampling_mask=sampling_mask_wire,
225
+ )
226
+ ]
227
+)
228
+
229
230
def encode_ndarray(
231
array: np.ndarray,
@@ -422,6 +446,7 @@ print(msgspec.msgpack.encode(request).hex())
446
print(msgspec.msgpack.encode(defaults_request).hex())
447
print(msgpack.packb(multimodal_request_wire, use_bin_type=True).hex())
448
print(msgspec.msgpack.encode(outputs).hex())
449
+print(msgspec.msgpack.encode(outputs_with_sampling_mask).hex())
450
print(" ".join(frame.hex() for frame in encode_output_frames(inline_logprobs)))
451
print(
452
" ".join(
tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py
+44
@@ -8,6 +8,7 @@ import pytest
8
import pytest_asyncio
9
from transformers import AutoTokenizer
10
11
+import vllm.envs as envs
12
from tests.utils import RemoteOpenAIServer
13
from vllm.config import ModelConfig
14
from vllm.config.utils import getattr_iter
@@ -107,6 +108,49 @@ async def test_generate_endpoint(client):
108
resp.raise_for_status()
109
data = resp.json()
110
assert "choices" in data
111
+ assert data["choices"][0].get("sampling_mask") is None
112
+
113
+
114
+@pytest.mark.asyncio
115
+@pytest.mark.skipif(
116
+ envs.VLLM_USE_RUST_FRONTEND,
117
+ reason="sampling mask output is not supported by the Rust frontend",
118
+)
119
+@pytest.mark.parametrize(
120
+ "server",
121
+ [["--return-sampling-mask", "--logprobs-mode", "processed_logprobs"]],
122
+ indirect=True,
123
+)
124
+async def test_generate_sampling_mask(client):
125
+ top_k = 5
126
+ payload = {
127
+ "model": MODEL_NAME,
128
+ "token_ids": [1, 2, 3],
129
+ "sampling_params": {
130
+ "max_tokens": 5,
131
+ "temperature": 0.8,
132
+ "top_k": top_k,
133
+ "top_p": 0.9,
134
+ "ignore_eos": True,
135
+ "seed": 0,
136
+ },
137
+ "stream": False,
138
+ }
139
+ resp = await client.post(GEN_ENDPOINT, json=payload)
140
+ resp.raise_for_status()
141
+ choice = resp.json()["choices"][0]
142
+
143
+ token_ids = choice["token_ids"]
144
+ sampling_mask = choice["sampling_mask"]
145
+ assert sampling_mask is not None
146
+ assert len(token_ids) == len(sampling_mask)
147
+
148
+ vocab_size = get_vocab_size(MODEL_NAME)
149
+ for token_id, support in zip(token_ids, sampling_mask):
150
+ assert support
151
+ assert len(support) == len(set(support))
152
+ assert all(0 <= support_token_id < vocab_size for support_token_id in support)
153
+ assert token_id in support
154
155
156
@pytest.mark.asyncio
tests/v1/core/test_async_scheduler.py
+1
@@ -291,6 +291,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
291
scheduler.vllm_config = Mock()
292
scheduler.vllm_config.model_config.enable_return_routed_experts = False
293
scheduler.enable_return_routed_experts = False
294
+ scheduler.return_sampling_mask = False
295
scheduler.recompute_kv_load_failures = False
296
scheduler.defer_block_free = False
297
scheduler.make_stats = Mock(return_value=None)
tests/v1/core/test_scheduler.py
+1
@@ -3281,6 +3281,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3281
scheduler.vllm_config = Mock()
3282
scheduler.vllm_config.model_config.enable_return_routed_experts = False
3283
scheduler.enable_return_routed_experts = False
3284
+ scheduler.return_sampling_mask = False
3285
scheduler.recompute_kv_load_failures = False
3286
scheduler.defer_block_free = False
3287
scheduler.make_stats = Mock(return_value=None)
tests/v1/test_outputs.py
+73
@@ -2,9 +2,12 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from unittest import TestCase
4
5
+import numpy as np
6
import torch
7
8
from vllm.v1.outputs import LogprobsLists, LogprobsTensors
9
+from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p
10
+from vllm.v1.worker.gpu.sample.output import SamplingMaskTensors
11
12
13
def test_logprobs_tensors_cat():
@@ -30,6 +33,76 @@ def test_logprobs_tensors_cat():
33
assert LogprobsTensors.cat([first]) is first
34
35
36
+def test_sampling_mask_tensors_tolist():
37
+ tensors = SamplingMaskTensors(
38
+ packed_mask=torch.tensor(
39
+ [[0b00101000], [0b00000000], [0b10000000]],
40
+ dtype=torch.uint8,
41
+ ),
42
+ counts=torch.tensor([2, 0, 1], dtype=torch.int32),
43
+ vocab_size=8,
44
+ )
45
+
46
+ result = tensors.tolists(np.array([1, 0, 1]))
47
+
48
+ assert result.token_ids.tolist() == [3, 5, 7]
49
+ assert result.offsets.tolist() == [0, 2, 3]
50
+ assert result.cu_num_generated_tokens == [0, 1, 1, 2]
51
+
52
+
53
+def test_sampling_mask_lists_to_nested_list():
54
+ from vllm.v1.outputs import SamplingMaskLists
55
+
56
+ mask = SamplingMaskLists(
57
+ token_ids=np.array([10, 11, 12, 20, 21]),
58
+ offsets=np.array([0, 3, 5]),
59
+ )
60
+
61
+ nested = mask.to_nested_list()
62
+
63
+ assert nested == [[10, 11, 12], [20, 21]]
64
+
65
+
66
+def test_sampling_mask_tensors_from_logits():
67
+ tensors = SamplingMaskTensors.from_logits(
68
+ logits=torch.tensor(
69
+ [
70
+ [1.0, float("-inf"), 2.0],
71
+ [3.0, 4.0, float("-inf")],
72
+ [float("-inf"), 5.0, 6.0],
73
+ ],
74
+ device="cuda",
75
+ ),
76
+ num_sampled_tokens=torch.tensor([1, 0, 1], device="cuda"),
77
+ )
78
+
79
+ result = tensors.tolists(np.array([1, 0, 1]))
80
+
81
+ assert result.token_ids.tolist() == [0, 2, 1, 2]
82
+ assert result.offsets.tolist() == [0, 2, 4]
83
+ assert result.cu_num_generated_tokens == [0, 1, 1, 2]
84
+
85
+
86
+def test_sampling_mask_matches_processed_top_k_top_p_support():
87
+ processed_logits = apply_top_k_top_p(
88
+ logits=torch.tensor([[6.0, 5.0, 4.0, 4.0, 4.0, 2.0, 1.0, 0.0]], device="cuda"),
89
+ k=torch.tensor([3], device="cuda"),
90
+ p=torch.tensor([0.9], device="cuda"),
91
+ )
92
+ expected_token_ids = (
93
+ torch.isfinite(processed_logits[0]).nonzero().flatten().tolist()
94
+ )
95
+ assert 0 < len(expected_token_ids) < processed_logits.shape[1]
96
+
97
+ tensors = SamplingMaskTensors.from_logits(
98
+ processed_logits,
99
+ num_sampled_tokens=torch.tensor([1], device="cuda"),
100
+ )
101
+ result = tensors.tolists(np.array([1]))
102
+
103
+ assert result.to_nested_list() == [expected_token_ids]
104
+
105
+
106
class TestLogprobsLists(TestCase):
107
def setUp(self):
108
self.logprobsLists = LogprobsLists(
vllm/config/model.py
+3
@@ -241,6 +241,8 @@ class ModelConfig:
241
flexibility."""
242
enable_return_routed_experts: bool = False
243
"""Whether to return routed experts."""
244
+ return_sampling_mask: bool = False
245
+ """Whether to return the post-processing token support for each sample."""
246
max_logprobs: int = Field(default=20, ge=-1)
247
"""Maximum number of log probabilities to return when `logprobs` is
248
specified in `SamplingParams`. The default value comes the default for the
@@ -422,6 +424,7 @@ class ModelConfig:
424
"tokenizer_revision",
425
"spec_target_max_model_len",
426
"enforce_eager",
427
+ "return_sampling_mask",
428
"logprobs_mode",
429
"use_fp64_gumbel",
430
"disable_cascade_attn",
vllm/config/vllm.py
+27
@@ -1039,6 +1039,31 @@ class VllmConfig:
1039
"expandable_segments is automatically disabled)."
1040
)
1041
1042
+ def _verify_sampling_replay_config(self) -> None:
1043
+ model_config = self.model_config
1044
+ if model_config is None or not model_config.return_sampling_mask:
1045
+ return
1046
+ if not self.use_v2_model_runner:
1047
+ raise ValueError("sampling distribution replay requires Model Runner V2")
1048
+ if self.speculative_config is not None:
1049
+ raise ValueError(
1050
+ "sampling distribution replay does not support speculative decoding"
1051
+ )
1052
+ if model_config.is_diffusion:
1053
+ raise ValueError(
1054
+ "sampling distribution replay does not support diffusion models"
1055
+ )
1056
+ if model_config.logits_processors:
1057
+ raise ValueError(
1058
+ "sampling distribution replay does not support custom logits processors"
1059
+ )
1060
+ if model_config.logprobs_mode != "processed_logprobs":
1061
+ raise ValueError(
1062
+ "sampling distribution replay requires "
1063
+ "logprobs_mode='processed_logprobs' so that returned logprobs "
1064
+ "are normalized over the same nucleus as the sampling mask"
1065
+ )
1066
+
1067
def __post_init__(self):
1068
"""Verify configs are valid & consistent with each other."""
1069
@@ -1080,6 +1105,8 @@ class VllmConfig:
1105
"connectors (PD disaggregation, KV cache offload)."
1106
)
1107
1108
+ self._verify_sampling_replay_config()
1109
+
1110
if self.lora_config is not None:
1111
self.lora_config.verify_with_model_config(self.model_config)
1112
vllm/engine/arg_utils.py
+6
@@ -426,6 +426,7 @@ class EngineArgs:
426
427
model: str = ModelConfig.model
428
enable_return_routed_experts: bool = ModelConfig.enable_return_routed_experts
429
+ return_sampling_mask: bool = ModelConfig.return_sampling_mask
430
model_weights: str = ModelConfig.model_weights
431
served_model_name: str | list[str] | None = ModelConfig.served_model_name
432
tokenizer: str | None = ModelConfig.tokenizer
@@ -874,6 +875,10 @@ class EngineArgs:
875
"--enable-return-routed-experts",
876
**model_kwargs["enable_return_routed_experts"],
877
)
878
+ model_group.add_argument(
879
+ "--return-sampling-mask",
880
+ **model_kwargs["return_sampling_mask"],
881
+ )
882
model_group.add_argument("--max-logprobs", **model_kwargs["max_logprobs"])
883
model_group.add_argument("--logprobs-mode", **model_kwargs["logprobs_mode"])
884
model_group.add_argument("--use-fp64-gumbel", **model_kwargs["use_fp64_gumbel"])
@@ -1748,6 +1753,7 @@ class EngineArgs:
1753
allow_deprecated_quantization=self.allow_deprecated_quantization,
1754
enforce_eager=self.enforce_eager,
1755
enable_return_routed_experts=self.enable_return_routed_experts,
1756
+ return_sampling_mask=self.return_sampling_mask,
1757
max_logprobs=self.max_logprobs,
1758
logprobs_mode=self.logprobs_mode,
1759
use_fp64_gumbel=self.use_fp64_gumbel,
vllm/entrypoints/llm.py
+4
@@ -163,6 +163,8 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin):
163
dictionary or an AttentionConfig instance. If a dictionary, it will
164
be converted to an AttentionConfig. Allows specifying the attention
165
backend and other attention-related settings.
166
+ return_sampling_mask: Return each sampled token's post-processing
167
+ support set. Requires Model Runner V2 and processed log probabilities.
168
spec_method: Top-level alias for `speculative_config["method"]`.
169
spec_model: Top-level alias for `speculative_config["model"]`.
170
spec_tokens: Top-level alias for
@@ -201,6 +203,7 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin):
203
offload_params: set[str] | None = None,
204
enforce_eager: bool = False,
205
enable_return_routed_experts: bool = False,
206
+ return_sampling_mask: bool = False,
207
disable_custom_all_reduce: bool = False,
208
hf_token: bool | str | None = None,
209
hf_overrides: HfOverrides | None = None,
@@ -317,6 +320,7 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin):
320
offload_params=offload_params or set(),
321
enforce_eager=enforce_eager,
322
enable_return_routed_experts=enable_return_routed_experts,
323
+ return_sampling_mask=return_sampling_mask,
324
disable_custom_all_reduce=disable_custom_all_reduce,
325
hf_token=hf_token,
326
hf_overrides=hf_overrides,
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+2
@@ -210,6 +210,7 @@ class GenerateResponseChoice(BaseModel):
210
# ``None`` if (a) the request was aborted before any forward pass,
211
# or (b) ``enable_return_routed_experts`` is off server-side.
212
routed_experts: str | None = None
213
+ sampling_mask: list[list[int]] | None = None
214
215
@field_validator("token_ids")
216
@classmethod
@@ -225,6 +226,7 @@ class GenerateResponseStreamChoice(BaseModel):
226
finish_reason: str | None = None
227
token_ids: list[int] | None = None
228
routed_experts: str | None = None
229
+ sampling_mask: list[list[int]] | None = None
230
231
232
class GenerateStreamResponse(BaseModel):
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+5
@@ -315,12 +315,17 @@ class ServingTokens(GenerateBaseServing):
315
np.save(buf, output.routed_experts)
316
routed_experts_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
317
318
+ sampling_mask = None
319
+ if output.sampling_mask is not None:
320
+ sampling_mask = output.sampling_mask.token_ids
321
+
322
choice_data = GenerateResponseChoice(
323
index=output.index,
324
logprobs=logprobs,
325
finish_reason=output.finish_reason if output.finish_reason else "stop",
326
token_ids=as_list(output.token_ids),
327
routed_experts=routed_experts_b64,
328
+ sampling_mask=sampling_mask,
329
)
330
331
choices.append(choice_data)
vllm/outputs.py
+15
@@ -18,6 +18,17 @@ from vllm.v1.metrics.stats import RequestStateStats
18
logger = init_logger(__name__)
19
20
21
+@dataclass
22
+class SamplingMask:
23
+ """Per-token sampling support sets aligned with completion token IDs.
24
+
25
+ Each inner list contains the vocabulary token IDs that survived
26
+ top-k / top-p / min-p filtering for the corresponding generated token.
27
+ """
28
+
29
+ token_ids: list[list[int]]
30
+
31
+
32
@dataclass
33
class CompletionOutput:
34
"""The output data of one completion output of a request.
@@ -30,6 +41,8 @@ class CompletionOutput:
41
output text.
42
logprobs: The log probabilities of the top probability words at each
43
position if the logprobs are requested.
44
+ sampling_mask: The post-processing token support set for each generated
45
+ token, if requested.
46
finish_reason: The reason why the sequence is finished.
47
stop_reason: The stop string or token id that caused the completion
48
to stop, None if the completion finished for some other reason
@@ -46,6 +59,7 @@ class CompletionOutput:
59
finish_reason: str | None = None
60
stop_reason: int | str | None = None
61
lora_request: LoRARequest | None = None
62
+ sampling_mask: SamplingMask | None = None
63
64
def finished(self) -> bool:
65
return self.finish_reason is not None
@@ -56,6 +70,7 @@ class CompletionOutput:
70
f"text={self.text!r}, "
71
f"token_ids={self.token_ids}, "
72
f"routed_experts={self.routed_experts}, "
73
+ f"sampling_mask={self.sampling_mask}, "
74
f"cumulative_logprob={self.cumulative_logprob}, "
75
f"logprobs={self.logprobs}, "
76
f"finish_reason={self.finish_reason}, "
vllm/v1/core/sched/scheduler.py
+10
@@ -326,6 +326,7 @@ class Scheduler(SchedulerInterface):
326
self.enable_return_routed_experts = (
327
vllm_config.model_config.enable_return_routed_experts
328
)
329
+ self.return_sampling_mask = vllm_config.model_config.return_sampling_mask
330
331
if self.enable_return_routed_experts:
332
assert self.dcp_world_size == 1 and self.pcp_world_size == 1, (
@@ -1809,6 +1810,7 @@ class Scheduler(SchedulerInterface):
1810
1811
stopped = False
1812
new_logprobs = None
1813
+ new_sampling_mask = None
1814
new_token_ids = generated_token_ids
1815
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
1816
kv_transfer_params = None
@@ -1938,6 +1940,13 @@ class Scheduler(SchedulerInterface):
1940
):
1941
new_logprobs = logprobs.slice_request(req_index, len(new_token_ids))
1942
1943
+ if self.return_sampling_mask:
1944
+ sampling_masks = model_runner_output.sampling_masks
1945
+ if new_token_ids and sampling_masks is not None:
1946
+ new_sampling_mask = sampling_masks.slice_request(
1947
+ req_index, len(new_token_ids)
1948
+ )
1949
+
1950
if num_nans_in_logits is not None and req_id in num_nans_in_logits:
1951
request.num_nans_in_logits = num_nans_in_logits[req_id]
1952
@@ -1951,6 +1960,7 @@ class Scheduler(SchedulerInterface):
1960
new_token_ids=new_token_ids,
1961
finish_reason=finish_reason,
1962
new_logprobs=new_logprobs,
1963
+ new_sampling_mask=new_sampling_mask,
1964
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
1965
pooling_output=pooler_output,
1966
stop_reason=request.stop_reason,
vllm/v1/engine/__init__.py
+3
-1
@@ -17,7 +17,7 @@ from vllm.multimodal.inputs import MultiModalFeatureSpec
17
from vllm.pooling_params import PoolingParams
18
from vllm.sampling_params import SamplingParams
19
from vllm.v1.metrics.stats import PrefillStats, SchedulerStats
20
-from vllm.v1.outputs import LogprobsLists, LogprobsTensors
20
+from vllm.v1.outputs import LogprobsLists, LogprobsTensors, SamplingMaskLists
21
from vllm.v1.serial_utils import UtilityResult
22
23
# Type for pause_generation mode parameter.
@@ -217,6 +217,8 @@ class EngineCoreOutput(
217
# so `array_like` positional serialization stays backward compatible.
218
mm_cache_miss_hashes: list[str] | None = None
219
220
+ new_sampling_mask: SamplingMaskLists | None = None
221
+
222
@property
223
def finished(self) -> bool:
224
return self.finish_reason is not None
vllm/v1/engine/input_processor.py
+11
@@ -108,6 +108,17 @@ class InputProcessor:
108
self.tokenizer,
109
)
110
111
+ if self.model_config.return_sampling_mask:
112
+ if params.temperature <= 0:
113
+ raise ValueError(
114
+ "sampling distribution replay requires temperature > 0"
115
+ )
116
+ if params.top_k <= 0:
117
+ raise ValueError(
118
+ "sampling distribution replay requires top_k > 0 to "
119
+ "bound sampling mask size, reduce transfer overhead, "
120
+ "and avoid potential OOMs"
121
+ )
122
if params.thinking_token_budget is not None and (
123
self.vllm_config.reasoning_config is None
124
or not self.vllm_config.reasoning_config.enabled
vllm/v1/engine/output_processor.py
+13
@@ -17,6 +17,7 @@ from vllm.outputs import (
17
PoolingOutput,
18
PoolingRequestOutput,
19
RequestOutput,
20
+ SamplingMask,
21
)
22
from vllm.sampling_params import RequestOutputKind
23
from vllm.tokenizers import TokenizerLike
@@ -37,6 +38,7 @@ from vllm.v1.metrics.stats import (
38
RequestStateStats,
39
SchedulerStats,
40
)
41
+from vllm.v1.outputs import SamplingMaskLists
42
43
# shared empty CPU tensor used as a placeholder pooling output
44
EMPTY_CPU_TENSOR = torch.empty(0, device="cpu")
@@ -178,6 +180,7 @@ class RequestState:
180
181
# Routed experts accumulation (prompt + sample chunks)
182
self.routed_experts_chunks: list[np.ndarray] = []
183
+ self.sampling_mask_chunks: list[SamplingMaskLists] = []
184
185
# Stream Interval
186
self.stream_interval = stream_interval
@@ -406,6 +409,11 @@ class RequestState:
409
if delta and logprobs:
410
logprobs = logprobs[-len(token_ids) :]
411
412
+ sampling_mask = None
413
+ if finished and self.sampling_mask_chunks:
414
+ merged = SamplingMaskLists.merge(self.sampling_mask_chunks)
415
+ sampling_mask = SamplingMask(merged.to_nested_list())
416
+
417
# Concatenate routed experts on finish
418
routed_experts = None
419
if finished and self.routed_experts_chunks:
@@ -416,6 +424,7 @@ class RequestState:
424
text=text,
425
token_ids=token_ids,
426
routed_experts=routed_experts,
427
+ sampling_mask=sampling_mask,
428
logprobs=logprobs,
429
cumulative_logprob=self.logprobs_processor.cumulative_logprob,
430
finish_reason=str(finish_reason) if finished else None,
@@ -652,6 +661,10 @@ class OutputProcessor:
661
if pooling_output is None:
662
assert req_state.detokenizer is not None
663
assert req_state.logprobs_processor is not None
664
+ if engine_core_output.new_sampling_mask is not None:
665
+ req_state.sampling_mask_chunks.append(
666
+ engine_core_output.new_sampling_mask
667
+ )
668
# 2) Detokenize the token ids into text and perform stop checks.
669
stop_string = req_state.detokenizer.update(
670
new_token_ids, finish_reason == FinishReason.STOP
vllm/v1/outputs.py
+42
@@ -50,6 +50,45 @@ class LogprobsLists(NamedTuple):
50
)
51
52
53
+class SamplingMaskLists(NamedTuple):
54
+ # [num_kept_tokens]
55
+ token_ids: np.ndarray
56
+ # [num_generated_tokens + 1]
57
+ offsets: np.ndarray
58
+ # [num_reqs + 1]
59
+ cu_num_generated_tokens: list[int] | None = None
60
+
61
+ def slice_request(self, req_idx: int, num_positions: int) -> "SamplingMaskLists":
62
+ if self.cu_num_generated_tokens is None:
63
+ start_idx = req_idx
64
+ else:
65
+ start_idx = self.cu_num_generated_tokens[req_idx]
66
+ end_idx = start_idx + num_positions
67
+ flat_start = self.offsets[start_idx]
68
+ flat_end = self.offsets[end_idx]
69
+ return SamplingMaskLists(
70
+ self.token_ids[flat_start:flat_end],
71
+ self.offsets[start_idx : end_idx + 1] - flat_start,
72
+ None,
73
+ )
74
+
75
+ def to_nested_list(self) -> list[list[int]]:
76
+ """Convert CSR representation to ``list[list[int]]``."""
77
+ return [
78
+ self.token_ids[int(self.offsets[i]) : int(self.offsets[i + 1])].tolist()
79
+ for i in range(len(self.offsets) - 1)
80
+ ]
81
+
82
+ @staticmethod
83
+ def merge(chunks: Sequence["SamplingMaskLists"]) -> "SamplingMaskLists":
84
+ token_ids = np.concatenate([chunk.token_ids for chunk in chunks])
85
+ counts = np.concatenate([np.diff(chunk.offsets) for chunk in chunks])
86
+ offsets = np.empty(len(counts) + 1, dtype=np.int64)
87
+ offsets[0] = 0
88
+ np.cumsum(counts, dtype=np.int64, out=offsets[1:])
89
+ return SamplingMaskLists(token_ids, offsets)
90
+
91
+
92
class LogprobsTensors(NamedTuple):
93
# [num_reqs x num_generated_tokens, max_num_logprobs + 1]
94
logprob_token_ids: torch.Tensor
@@ -307,6 +346,9 @@ class ModelRunnerOutput:
346
# ``None`` when ``enable_return_routed_experts`` is off.
347
routed_experts: RoutedExpertsLists | None = None
348
349
+ # ``None`` when ``return_sampling_mask`` is off.
350
+ sampling_masks: SamplingMaskLists | None = None
351
+
352
@staticmethod
353
def with_kv_conn_output_only(
354
kv_connector_output: KVConnectorOutput | None,
vllm/v1/worker/gpu/async_utils.py
+11
-1
@@ -17,7 +17,7 @@ from vllm.v1.outputs import (
17
PoolerOutput,
18
RoutedExpertsTensors,
19
)
20
-from vllm.v1.worker.gpu.sample.output import SamplerOutput
20
+from vllm.v1.worker.gpu.sample.output import SamplerOutput, SamplingMaskTensors
21
from vllm.v1.worker.utils import raise_if_nan_logits
22
23
if TYPE_CHECKING:
@@ -147,6 +147,11 @@ class AsyncOutput(AsyncModelRunnerOutput):
147
if sampler_output.num_nans is not None:
148
self.num_nans = async_copy_to_np(sampler_output.num_nans)
149
self.num_sampled_tokens_np = async_copy_to_np(num_sampled_tokens)
150
+ self.sampling_mask_tensors: SamplingMaskTensors | None = None
151
+ if sampler_output.sampling_mask_tensors is not None:
152
+ self.sampling_mask_tensors = (
153
+ sampler_output.sampling_mask_tensors.to_cpu_nonblocking()
154
+ )
155
self.routed_experts_cpu: RoutedExpertsTensors | None = None
156
if routed_experts is not None:
157
self.routed_experts_cpu = routed_experts.to_cpu_nonblocking()
@@ -172,6 +177,11 @@ class AsyncOutput(AsyncModelRunnerOutput):
177
del token_ids[num_tokens:]
178
self.model_runner_output.sampled_token_ids = sampled_token_ids
179
180
+ if self.sampling_mask_tensors is not None:
181
+ self.model_runner_output.sampling_masks = (
182
+ self.sampling_mask_tensors.tolists(self.num_sampled_tokens_np)
183
+ )
184
+
185
if self.num_nans is not None:
186
self.model_runner_output.num_nans_in_logits = dict(
187
zip(self.model_runner_output.req_ids, self.num_nans.tolist())
vllm/v1/worker/gpu/model_runner.py
+1
@@ -398,6 +398,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
398
num_speculative_tokens=self.decode_query_len,
399
use_fp64_gumbel=self.model_config.use_fp64_gumbel,
400
reasoning_config=self.vllm_config.reasoning_config,
401
+ return_sampling_mask=self.model_config.return_sampling_mask,
402
)
403
custom = self.model_state.custom_sampler(self.sampler)
404
vllm/v1/worker/gpu/sample/output.py
+116
-1
@@ -1,10 +1,15 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from __future__ import annotations
4
+
5
from dataclasses import dataclass
6
+from typing import NamedTuple
7
8
+import numpy as np
9
import torch
10
7
-from vllm.v1.outputs import LogprobsTensors
11
+from vllm.triton_utils import tl, triton
12
+from vllm.v1.outputs import LogprobsTensors, SamplingMaskLists
13
14
15
@dataclass
@@ -14,3 +19,113 @@ class SamplerOutput:
19
num_nans: torch.Tensor | None
20
num_sampled: torch.Tensor | None
21
num_rejected: torch.Tensor | None = None
22
+ sampling_mask_tensors: SamplingMaskTensors | None = None
23
+
24
+
25
+@triton.jit
26
+def _pack_sampling_mask_kernel(
27
+ logits_ptr,
28
+ logits_row_stride,
29
+ logits_col_stride,
30
+ num_sampled_tokens_ptr,
31
+ packed_mask_ptr,
32
+ packed_mask_row_stride,
33
+ counts_ptr,
34
+ vocab_size,
35
+ BLOCK_SIZE: tl.constexpr,
36
+):
37
+ req_idx = tl.program_id(0)
38
+ is_active = tl.load(num_sampled_tokens_ptr + req_idx) > 0
39
+ count = tl.zeros((), dtype=tl.int32)
40
+
41
+ for start_idx in range(0, vocab_size, BLOCK_SIZE):
42
+ offsets = start_idx + tl.arange(0, BLOCK_SIZE)
43
+ valid = offsets < vocab_size
44
+ logits = tl.load(
45
+ logits_ptr + req_idx * logits_row_stride + offsets * logits_col_stride,
46
+ mask=valid,
47
+ other=-float("inf"),
48
+ )
49
+ keep = (logits > -float("inf")) & (logits < float("inf")) & is_active
50
+ count += tl.sum(keep).to(tl.int32)
51
+
52
+ keep = tl.reshape(keep.to(tl.int32), (BLOCK_SIZE // 8, 8))
53
+ bit_shifts = tl.arange(0, 8)[None, :]
54
+ packed = tl.sum(keep << bit_shifts, axis=1).to(tl.uint8)
55
+ byte_offsets = start_idx // 8 + tl.arange(0, BLOCK_SIZE // 8)
56
+ tl.store(
57
+ packed_mask_ptr + req_idx * packed_mask_row_stride + byte_offsets,
58
+ packed,
59
+ mask=byte_offsets < tl.cdiv(vocab_size, 8),
60
+ )
61
+
62
+ tl.store(counts_ptr + req_idx, count)
63
+
64
+
65
+class SamplingMaskTensors(NamedTuple):
66
+ """Bit-packed device-side sampling mask data pending async D2H."""
67
+
68
+ # [num_requests, ceil(vocab_size / 8)]
69
+ packed_mask: torch.Tensor
70
+ # [num_requests]
71
+ counts: torch.Tensor
72
+ vocab_size: int
73
+
74
+ @classmethod
75
+ def from_logits(
76
+ cls,
77
+ logits: torch.Tensor,
78
+ num_sampled_tokens: torch.Tensor,
79
+ ) -> SamplingMaskTensors:
80
+ """Pack the finite-logit support for requests that sampled tokens."""
81
+ num_reqs, vocab_size = logits.shape
82
+ packed_width = (vocab_size + 7) // 8
83
+
84
+ packed_mask = torch.empty(
85
+ (num_reqs, packed_width), dtype=torch.uint8, device=logits.device
86
+ )
87
+ counts = torch.empty(num_reqs, dtype=torch.int32, device=logits.device)
88
+ _pack_sampling_mask_kernel[(num_reqs,)](
89
+ logits,
90
+ logits.stride(0),
91
+ logits.stride(1),
92
+ num_sampled_tokens,
93
+ packed_mask,
94
+ packed_mask.stride(0),
95
+ counts,
96
+ vocab_size,
97
+ BLOCK_SIZE=8192,
98
+ )
99
+
100
+ return cls(packed_mask, counts, vocab_size)
101
+
102
+ def to_cpu_nonblocking(self) -> SamplingMaskTensors:
103
+ if self.packed_mask.device.type == "cpu":
104
+ return self
105
+ return SamplingMaskTensors(
106
+ self.packed_mask.to("cpu", non_blocking=True),
107
+ self.counts.to("cpu", non_blocking=True),
108
+ self.vocab_size,
109
+ )
110
+
111
+ def tolists(self, num_sampled_tokens: np.ndarray) -> SamplingMaskLists:
112
+ """Convert the packed masks to the scheduler's CSR representation."""
113
+ sampled_rows = np.flatnonzero(num_sampled_tokens)
114
+ counts = self.counts.cpu().numpy()[sampled_rows]
115
+ offsets = np.empty(len(counts) + 1, dtype=np.int64)
116
+ offsets[0] = 0
117
+ np.cumsum(counts, dtype=np.int64, out=offsets[1:])
118
+ unpacked = np.unpackbits(
119
+ self.packed_mask.cpu().numpy()[sampled_rows],
120
+ axis=1,
121
+ count=self.vocab_size,
122
+ bitorder="little",
123
+ )
124
+ token_ids = np.nonzero(unpacked)[1].astype(np.int32, copy=False)
125
+ return SamplingMaskLists(
126
+ token_ids=token_ids,
127
+ offsets=offsets,
128
+ cu_num_generated_tokens=np.cumsum(
129
+ np.concatenate(([0], num_sampled_tokens))
130
+ ).tolist(),
131
+ )
vllm/v1/worker/gpu/sample/sampler.py
+13
-2
@@ -22,7 +22,7 @@ from vllm.v1.worker.gpu.sample.logprob import (
22
LogprobTokenIdsState,
23
compute_topk_scores,
24
)
25
-from vllm.v1.worker.gpu.sample.output import SamplerOutput
25
+from vllm.v1.worker.gpu.sample.output import SamplerOutput, SamplingMaskTensors
26
from vllm.v1.worker.gpu.sample.penalties import PenaltiesState
27
from vllm.v1.worker.gpu.sample.states import NO_LOGPROBS, SamplingStates
28
from vllm.v1.worker.gpu.sample.thinking_budget import ThinkingBudgetState
@@ -40,6 +40,7 @@ class Sampler:
40
num_speculative_tokens: int = 1,
41
use_fp64_gumbel: bool = False,
42
reasoning_config: ReasoningConfig | None = None,
43
+ return_sampling_mask: bool = False,
44
):
45
self.logprobs_mode = logprobs_mode
46
self.compute_nans = envs.VLLM_COMPUTE_NANS_IN_LOGITS # False by default.
@@ -53,7 +54,10 @@ class Sampler:
54
self.logprob_token_ids_state = LogprobTokenIdsState(max_num_reqs, device)
55
self.thinking_budget_state = ThinkingBudgetState(req_states, reasoning_config)
56
self.num_speculative_tokens = num_speculative_tokens
56
- self.use_flashinfer = flashinfer_sampler_supported()
57
+ self.return_sampling_mask = return_sampling_mask
58
+ self.use_flashinfer = (
59
+ not return_sampling_mask and flashinfer_sampler_supported()
60
+ )
61
62
def add_request(
63
self, req_idx: int, prompt_len: int, sampling_params: SamplingParams
@@ -137,6 +141,12 @@ class Sampler:
141
self.req_states.prefill_len.gpu,
142
)
143
144
+ sampling_mask_tensors = None
145
+ if self.return_sampling_mask:
146
+ sampling_mask_tensors = SamplingMaskTensors.from_logits(
147
+ processed_logits, num_sampled
148
+ )
149
+
150
# These are GPU tensors.
151
sampler_output = SamplerOutput(
152
# The sampled tokens are expanded to 2D tensor with shape
@@ -147,6 +157,7 @@ class Sampler:
157
num_nans=num_nans,
158
num_sampled=num_sampled,
159
num_rejected=num_rejected,
160
+ sampling_mask_tensors=sampling_mask_tensors,
161
)
162
return sampler_output
163