Skip to content

Commit 50ba4bc

Browse files
vx120aoshen02codexclaude
authored
[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>
1 parent 10bcad2 commit 50ba4bc

24 files changed

Lines changed: 556 additions & 5 deletions

File tree

docs/training/sampling_mask.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ pub struct EngineCoreOutput {
123123
/// frontend grows one.
124124
#[serde(default)]
125125
pub mm_cache_miss_hashes: Option<Vec<String>>,
126+
#[serde(default)]
127+
pub new_sampling_mask: Option<OpaqueValue>,
126128
}
127129

128130
impl EngineCoreOutput {
@@ -440,6 +442,7 @@ mod tests {
440442
routed_experts: None,
441443
num_nans_in_logits: 0,
442444
mm_cache_miss_hashes: None,
445+
new_sampling_mask: None,
443446
},
444447
],
445448
scheduler_stats: None,

rust/src/engine-core-client/src/tests/client.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2498,6 +2498,8 @@ fn python_msgpack_fixtures_match_rust_encoding() {
24982498
let defaults_request_hex = lines.next().expect("missing defaults request fixture line");
24992499
let multimodal_request_hex = lines.next().expect("missing multimodal request fixture line");
25002500
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");
25012503
let inline_logprobs_frames = lines.next().expect("missing inline logprobs fixture line");
25022504
let multipart_logprobs_frames = lines.next().expect("missing multipart logprobs fixture line");
25032505
let inline_prompt_frames = lines.next().expect("missing inline prompt logprobs fixture line");
@@ -2508,6 +2510,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
25082510
let request_bytes = hex::decode(request_hex).unwrap();
25092511
let multimodal_request_bytes = hex::decode(multimodal_request_hex).unwrap();
25102512
let outputs_bytes = hex::decode(outputs_hex).unwrap();
2513+
let sampling_mask_outputs_bytes = hex::decode(sampling_mask_outputs_hex).unwrap();
25112514

25122515
let decoded_request: EngineCoreRequest = rmp_serde::from_slice(&request_bytes).unwrap();
25132516
let expected_request = sample_request();
@@ -2571,6 +2574,16 @@ fn python_msgpack_fixtures_match_rust_encoding() {
25712574
decode_value(&rmp_serde::to_vec_named(&expected_multimodal_request.mm_features).unwrap());
25722575
assert_eq!(python_mm_features, rust_mm_features);
25732576

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+
25742587
let decoded_outputs: EngineCoreOutputs = rmp_serde::from_slice(&outputs_bytes).unwrap();
25752588
expect_test::expect![[r#"
25762589
RequestBatch(
@@ -2598,6 +2611,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
25982611
routed_experts: None,
25992612
num_nans_in_logits: 0,
26002613
mm_cache_miss_hashes: None,
2614+
new_sampling_mask: None,
26012615
},
26022616
],
26032617
scheduler_stats: None,

rust/src/engine-core-client/src/tests/python_compat.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class EngineCoreOutput(
9898
routed_experts: object | None = None
9999
num_nans_in_logits: int = 0
100100
mm_cache_miss_hashes: list[str] | None = None
101+
new_sampling_mask: object | None = None
101102

102103

103104
class EngineCoreOutputs(
@@ -202,6 +203,29 @@ class EngineCoreOutputs(
202203
finished_requests={"req-1"},
203204
)
204205

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+
205229

206230
def encode_ndarray(
207231
array: np.ndarray,
@@ -422,6 +446,7 @@ class EngineCoreReadyResponse:
422446
print(msgspec.msgpack.encode(defaults_request).hex())
423447
print(msgpack.packb(multimodal_request_wire, use_bin_type=True).hex())
424448
print(msgspec.msgpack.encode(outputs).hex())
449+
print(msgspec.msgpack.encode(outputs_with_sampling_mask).hex())
425450
print(" ".join(frame.hex() for frame in encode_output_frames(inline_logprobs)))
426451
print(
427452
" ".join(

tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest_asyncio
99
from transformers import AutoTokenizer
1010

11+
import vllm.envs as envs
1112
from tests.utils import RemoteOpenAIServer
1213
from vllm.config import ModelConfig
1314
from vllm.config.utils import getattr_iter
@@ -107,6 +108,49 @@ async def test_generate_endpoint(client):
107108
resp.raise_for_status()
108109
data = resp.json()
109110
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
110154

111155

112156
@pytest.mark.asyncio

tests/v1/core/test_async_scheduler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
291291
scheduler.vllm_config = Mock()
292292
scheduler.vllm_config.model_config.enable_return_routed_experts = False
293293
scheduler.enable_return_routed_experts = False
294+
scheduler.return_sampling_mask = False
294295
scheduler.recompute_kv_load_failures = False
295296
scheduler.defer_block_free = False
296297
scheduler.make_stats = Mock(return_value=None)

tests/v1/core/test_scheduler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3281,6 +3281,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
32813281
scheduler.vllm_config = Mock()
32823282
scheduler.vllm_config.model_config.enable_return_routed_experts = False
32833283
scheduler.enable_return_routed_experts = False
3284+
scheduler.return_sampling_mask = False
32843285
scheduler.recompute_kv_load_failures = False
32853286
scheduler.defer_block_free = False
32863287
scheduler.make_stats = Mock(return_value=None)

tests/v1/test_outputs.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
33
from unittest import TestCase
44

5+
import numpy as np
56
import torch
67

78
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
811

912

1013
def test_logprobs_tensors_cat():
@@ -30,6 +33,76 @@ def test_logprobs_tensors_cat():
3033
assert LogprobsTensors.cat([first]) is first
3134

3235

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+
33106
class TestLogprobsLists(TestCase):
34107
def setUp(self):
35108
self.logprobsLists = LogprobsLists(

vllm/config/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ class ModelConfig:
241241
flexibility."""
242242
enable_return_routed_experts: bool = False
243243
"""Whether to return routed experts."""
244+
return_sampling_mask: bool = False
245+
"""Whether to return the post-processing token support for each sample."""
244246
max_logprobs: int = Field(default=20, ge=-1)
245247
"""Maximum number of log probabilities to return when `logprobs` is
246248
specified in `SamplingParams`. The default value comes the default for the
@@ -422,6 +424,7 @@ def compute_hash(self) -> str:
422424
"tokenizer_revision",
423425
"spec_target_max_model_len",
424426
"enforce_eager",
427+
"return_sampling_mask",
425428
"logprobs_mode",
426429
"use_fp64_gumbel",
427430
"disable_cascade_attn",

0 commit comments

Comments
 (0)