[Rust Frontend] Return sampling masks over gRPC (#56777)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Biswa Panda <biswa.panda@gmail.com>
Biswa Panda committed
Sep 18, 2026 at 01:26 UTC
95d723700b1df07b20263c696c0342b92c188bfe
23 files changed
+570
-27
rust/Cargo.lock
+1
-1
@@ -5941,7 +5941,7 @@ dependencies = [
5941
5942
[[package]]
5943
name = "vllm-proto"
5944
-version = "0.3.0"
5944
+version = "0.4.0"
5945
dependencies = [
5946
"prost",
5947
"prost-types",
rust/proto/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
1
[package]
2
name = "vllm-proto"
3
-version = "0.3.0"
3
+version = "0.4.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Protobuf messages and gRPC bindings for the vLLM Rust frontend"
rust/proto/inference.proto
+2
@@ -160,6 +160,8 @@ message SequenceOutput {
160
161
// Only present in final output for this sequence
162
optional FinishInfo finish_info = 8;
163
+ // Only present in final output when sampling-mask capture is enabled.
164
+ repeated TokenIds sampling_mask = 10;
165
}
166
167
// Prompt info, returned in the first response
rust/src/chat/src/output/default/unified.rs
+1
@@ -438,6 +438,7 @@ mod tests {
438
finish_reason: crate::FinishReason::Stop(None),
439
kv_transfer_params: None,
440
ec_transfer_params: None,
441
+ sampling_mask: None,
442
})),
443
}
444
}
rust/src/chat/src/output/harmony/tests.rs
+1
@@ -58,6 +58,7 @@ fn finished() -> Finished {
58
finish_reason: FinishReason::stop_eos(),
59
kv_transfer_params: None,
60
ec_transfer_params: None,
61
+ sampling_mask: None,
62
}
63
}
64
rust/src/chat/tests/roundtrip.rs
+2
@@ -806,6 +806,7 @@ fn decoded_completion_stream(
806
finish_reason: FinishReason::stop_eos(),
807
kv_transfer_params: None,
808
ec_transfer_params: None,
809
+ sampling_mask: None,
810
})),
811
}
812
});
@@ -817,6 +818,7 @@ fn decoded_completion_stream(
818
finish_reason: FinishReason::stop_eos(),
819
kv_transfer_params: None,
820
ec_transfer_params: None,
821
+ sampling_mask: None,
822
});
823
events.push(DecodedTextEvent::TextDelta {
824
decoded: vllm_text::DecodedText::unattributed(chunk.delta),
rust/src/engine-core-client/src/protocol/logprobs.rs
+1
-1
@@ -1,7 +1,7 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-mod array;
4
+pub(crate) mod array;
5
#[cfg(test)]
6
mod tests;
7
mod wire;
rust/src/engine-core-client/src/protocol/logprobs/array.rs
+1
-1
@@ -50,7 +50,7 @@ pub(super) fn decode_array2_u32(
50
})
51
}
52
53
-pub(super) fn decode_array1_u32(
53
+pub(crate) fn decode_array1_u32(
54
value: WireNdArray,
55
field: &str,
56
frames: &[Bytes],
rust/src/engine-core-client/src/protocol/mod.rs
+1
@@ -30,6 +30,7 @@ pub mod multimodal;
30
pub mod output;
31
pub mod request;
32
pub mod sampling;
33
+pub mod sampling_mask;
34
mod serde_utils;
35
pub mod stats;
36
pub mod structured_outputs;
rust/src/engine-core-client/src/protocol/output.rs
+5
-1
@@ -15,6 +15,7 @@ use super::serde_utils::AllowTrailingFields;
15
use super::utility::UtilityOutput;
16
use crate::error::{Error, Result, ext_value_decode};
17
use crate::protocol::logprobs::MaybeWireLogprobs;
18
+use crate::protocol::sampling_mask::MaybeWireSamplingMask;
19
use crate::protocol::stats::{PrefillStats, SchedulerStats};
20
use crate::protocol::{OpaqueValue, decode_msgpack};
21
@@ -127,7 +128,7 @@ pub struct EngineCoreOutput {
128
#[serde(default)]
129
pub mm_cache_miss_hashes: Option<Vec<String>>,
130
#[serde(default)]
130
- pub new_sampling_mask: Option<OpaqueValue>,
131
+ pub new_sampling_mask: Option<MaybeWireSamplingMask>,
132
/// Per-request speculative-decoding acceptance metrics, set on the final
133
/// output when `--per-request-spec-decode-metrics` is enabled. Opaque here;
134
/// the Rust frontend does not yet surface it in responses.
@@ -150,6 +151,9 @@ impl EngineCoreOutput {
151
self.new_prompt_logprobs_tensors = (self.new_prompt_logprobs_tensors.take())
152
.map(|value| value.resolve(frames, "new_prompt_logprobs_tensors"))
153
.transpose()?;
154
+ self.new_sampling_mask = (self.new_sampling_mask.take())
155
+ .map(|value| value.resolve(frames, "new_sampling_mask"))
156
+ .transpose()?;
157
Ok(())
158
}
159
}
rust/src/engine-core-client/src/protocol/sampling_mask.rs
new
+237
@@ -0,0 +1,237 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use std::ops::{Deref, DerefMut};
5
+
6
+use bytes::Bytes;
7
+use enum_as_inner::EnumAsInner;
8
+use serde::{Deserialize, Deserializer, Serialize};
9
+use serde_tuple::{Deserialize_tuple, Serialize_tuple};
10
+
11
+use crate::error::{Error, Result, bail_ext_value_decode};
12
+use crate::protocol::logprobs::array::decode_array1_u32;
13
+use crate::protocol::tensor::WireNdArray;
14
+
15
+/// Decoded sampling-mask support sets, one row per generated token position.
16
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17
+pub struct SamplingMask {
18
+ pub rows: Vec<Vec<u32>>,
19
+}
20
+
21
+/// Python `SamplingMaskLists` tuple before ndarray raw views are resolved.
22
+#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple)]
23
+pub struct WireSamplingMask {
24
+ token_ids: WireNdArray,
25
+ #[serde(default)]
26
+ offsets: Option<WireNdArray>,
27
+ #[serde(default)]
28
+ cu_num_generated_tokens: Option<Vec<usize>>,
29
+}
30
+
31
+/// Sampling-mask field while it transitions from Python wire data to rows.
32
+#[derive(Debug, Clone, PartialEq, EnumAsInner)]
33
+pub enum MaybeWireSamplingMask {
34
+ Wire(Box<WireSamplingMask>),
35
+ Direct(SamplingMask),
36
+}
37
+
38
+impl Deref for MaybeWireSamplingMask {
39
+ type Target = SamplingMask;
40
+
41
+ fn deref(&self) -> &Self::Target {
42
+ match self {
43
+ Self::Wire(_) => panic!("sampling mask is still in wire format"),
44
+ Self::Direct(value) => value,
45
+ }
46
+ }
47
+}
48
+
49
+impl DerefMut for MaybeWireSamplingMask {
50
+ fn deref_mut(&mut self) -> &mut Self::Target {
51
+ match self {
52
+ Self::Wire(_) => panic!("sampling mask is still in wire format"),
53
+ Self::Direct(value) => value,
54
+ }
55
+ }
56
+}
57
+
58
+impl<'de> Deserialize<'de> for MaybeWireSamplingMask {
59
+ fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
60
+ where
61
+ D: Deserializer<'de>,
62
+ {
63
+ WireSamplingMask::deserialize(deserializer).map(|value| Self::Wire(Box::new(value)))
64
+ }
65
+}
66
+
67
+impl Serialize for MaybeWireSamplingMask {
68
+ fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
69
+ where
70
+ S: serde::Serializer,
71
+ {
72
+ match self {
73
+ Self::Wire(value) => value.serialize(serializer),
74
+ Self::Direct(value) => WireSamplingMask::from_direct(value)
75
+ .map_err(serde::ser::Error::custom)?
76
+ .serialize(serializer),
77
+ }
78
+ }
79
+}
80
+
81
+impl MaybeWireSamplingMask {
82
+ pub(super) fn resolve(self, frames: &[Bytes], field_prefix: &str) -> Result<Self> {
83
+ match self {
84
+ Self::Direct(value) => Ok(Self::Direct(value)),
85
+ Self::Wire(value) => value.resolve(frames, field_prefix).map(Self::Direct),
86
+ }
87
+ }
88
+}
89
+
90
+impl WireSamplingMask {
91
+ fn from_direct(value: &SamplingMask) -> std::result::Result<Self, String> {
92
+ let [row] = value.rows.as_slice() else {
93
+ return Err(format!(
94
+ "per-request engine-core sampling mask must contain exactly one row, got {}",
95
+ value.rows.len()
96
+ ));
97
+ };
98
+ let token_ids = row.iter().map(|&token_id| i64::from(token_id)).collect::<Vec<_>>();
99
+ Ok(Self {
100
+ token_ids: WireNdArray::from_i64(vec![token_ids.len()], token_ids)?,
101
+ offsets: None,
102
+ cu_num_generated_tokens: None,
103
+ })
104
+ }
105
+
106
+ fn resolve(self, frames: &[Bytes], field_prefix: &str) -> Result<SamplingMask> {
107
+ if let Some(indices) = self.cu_num_generated_tokens {
108
+ bail_ext_value_decode!(
109
+ "{field_prefix}.cu_num_generated_tokens: expected None for per-request engine-core sampling-mask payload, got {indices:?}"
110
+ );
111
+ }
112
+
113
+ let token_ids =
114
+ decode_array1_u32(self.token_ids, &format!("{field_prefix}.token_ids"), frames)?;
115
+ let Some(offsets) = self.offsets else {
116
+ return Ok(SamplingMask {
117
+ rows: vec![token_ids],
118
+ });
119
+ };
120
+ let offsets = decode_array1_u32(offsets, &format!("{field_prefix}.offsets"), frames)?;
121
+ if offsets.first().copied() != Some(0) {
122
+ bail_ext_value_decode!("{field_prefix}.offsets: first offset must be zero");
123
+ }
124
+ if offsets.windows(2).any(|pair| pair[0] > pair[1]) {
125
+ bail_ext_value_decode!("{field_prefix}.offsets: offsets must be nondecreasing");
126
+ }
127
+ if offsets.last().copied().map(|value| value as usize) != Some(token_ids.len()) {
128
+ bail_ext_value_decode!(
129
+ "{field_prefix}.offsets: final offset {:?} does not match token_ids length {}",
130
+ offsets.last(),
131
+ token_ids.len()
132
+ );
133
+ }
134
+
135
+ let rows = offsets
136
+ .windows(2)
137
+ .map(|pair| token_ids[pair[0] as usize..pair[1] as usize].to_vec())
138
+ .collect();
139
+ Ok(SamplingMask { rows })
140
+ }
141
+}
142
+
143
+#[cfg(test)]
144
+mod tests {
145
+ use super::*;
146
+ use crate::protocol::dtype::{NumpyDtype, TensorDtype};
147
+ use crate::protocol::output::{
148
+ EngineCoreOutput, RequestBatchOutputs, decode_engine_core_outputs,
149
+ };
150
+ use crate::protocol::tensor::WireArrayData;
151
+ use crate::protocol::{decode_msgpack, encode_msgpack};
152
+
153
+ #[test]
154
+ fn decodes_single_position_without_offsets() {
155
+ let wire = WireSamplingMask {
156
+ token_ids: WireNdArray::from_i64(vec![3], vec![2, 12, 16]).unwrap(),
157
+ offsets: None,
158
+ cu_num_generated_tokens: None,
159
+ };
160
+ let encoded = encode_msgpack(&wire).unwrap();
161
+ let decoded: MaybeWireSamplingMask = decode_msgpack(&encoded).unwrap();
162
+ let decoded = decoded.resolve(&[], "new_sampling_mask").unwrap();
163
+
164
+ assert_eq!(decoded.into_direct().unwrap().rows, vec![vec![2, 12, 16]]);
165
+ }
166
+
167
+ #[test]
168
+ fn decodes_csr_rows_and_rejects_bad_terminal_offset() {
169
+ let wire = WireSamplingMask {
170
+ token_ids: WireNdArray::from_i64(vec![4], vec![2, 12, 16, 18]).unwrap(),
171
+ offsets: Some(WireNdArray::from_i64(vec![3], vec![0, 2, 4]).unwrap()),
172
+ cu_num_generated_tokens: None,
173
+ };
174
+ let decoded = MaybeWireSamplingMask::Wire(Box::new(wire))
175
+ .resolve(&[], "new_sampling_mask")
176
+ .unwrap();
177
+ assert_eq!(
178
+ decoded.into_direct().unwrap().rows,
179
+ vec![vec![2, 12], vec![16, 18]]
180
+ );
181
+
182
+ let malformed = WireSamplingMask {
183
+ token_ids: WireNdArray::from_i64(vec![2], vec![2, 12]).unwrap(),
184
+ offsets: Some(WireNdArray::from_i64(vec![2], vec![0, 1]).unwrap()),
185
+ cu_num_generated_tokens: None,
186
+ };
187
+ let error = MaybeWireSamplingMask::Wire(Box::new(malformed))
188
+ .resolve(&[], "new_sampling_mask")
189
+ .unwrap_err();
190
+ assert!(error.to_string().contains("final offset"));
191
+ }
192
+
193
+ #[test]
194
+ fn decodes_sampling_mask_from_multipart_aux_frames() {
195
+ let output = EngineCoreOutput {
196
+ request_id: "req-multipart-mask".to_string(),
197
+ new_token_ids: vec![16, 18],
198
+ new_sampling_mask: Some(MaybeWireSamplingMask::Wire(Box::new(WireSamplingMask {
199
+ token_ids: WireNdArray {
200
+ dtype: NumpyDtype::little(TensorDtype::I32),
201
+ shape: vec![4],
202
+ data: WireArrayData::AuxIndex(1),
203
+ },
204
+ offsets: Some(WireNdArray {
205
+ dtype: NumpyDtype::little(TensorDtype::I64),
206
+ shape: vec![3],
207
+ data: WireArrayData::AuxIndex(2),
208
+ }),
209
+ cu_num_generated_tokens: None,
210
+ }))),
211
+ ..Default::default()
212
+ };
213
+ let primary = encode_msgpack(&crate::protocol::output::EngineCoreOutputs::from(
214
+ RequestBatchOutputs {
215
+ outputs: vec![output],
216
+ ..Default::default()
217
+ },
218
+ ))
219
+ .unwrap();
220
+ let token_ids =
221
+ [2_i32, 12, 16, 18].into_iter().flat_map(i32::to_le_bytes).collect::<Vec<_>>();
222
+ let offsets = [0_i64, 2, 4].into_iter().flat_map(i64::to_le_bytes).collect::<Vec<_>>();
223
+
224
+ let decoded = decode_engine_core_outputs(&[
225
+ Bytes::from(primary),
226
+ Bytes::from(token_ids),
227
+ Bytes::from(offsets),
228
+ ])
229
+ .unwrap();
230
+ let mask = decoded.as_request_batch().unwrap().outputs[0]
231
+ .new_sampling_mask
232
+ .as_ref()
233
+ .unwrap();
234
+
235
+ assert_eq!(mask.rows, vec![vec![2, 12], vec![16, 18]]);
236
+ }
237
+}
rust/src/engine-core-client/src/tests/client.rs
+6
-6
@@ -2712,15 +2712,15 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2712
decode_value(&rmp_serde::to_vec_named(&expected_multimodal_request.mm_features).unwrap());
2713
assert_eq!(python_mm_features, rust_mm_features);
2714
2715
- let decoded_sampling_mask_outputs: EngineCoreOutputs =
2716
- rmp_serde::from_slice(&sampling_mask_outputs_bytes).unwrap();
2715
+ let decoded_sampling_mask_outputs =
2716
+ decode_engine_core_outputs(&[bytes::Bytes::from(sampling_mask_outputs_bytes)]).unwrap();
2717
let sampling_mask_output =
2718
&decoded_sampling_mask_outputs.as_request_batch().unwrap().outputs[0];
2719
assert!(sampling_mask_output.mm_cache_miss_hashes.is_none());
2720
- assert!(matches!(
2721
- sampling_mask_output.new_sampling_mask.as_ref(),
2722
- Some(Value::Array(fields)) if fields.len() == 3
2723
- ));
2720
+ assert_eq!(
2721
+ sampling_mask_output.new_sampling_mask.as_ref().unwrap().rows,
2722
+ vec![vec![2, 12, 16, 17, 18]]
2723
+ );
2724
2725
let decoded_outputs: EngineCoreOutputs = rmp_serde::from_slice(&outputs_bytes).unwrap();
2726
// Match msgspec's base-schema result for the same extended Python message.
rust/src/engine-core-client/src/tests/python_compat.py
+2
-6
@@ -234,14 +234,10 @@ sampling_mask_wire = [
234
[
235
"<i4",
236
[5],
237
- msgpack.ExtType(3, np.array([2, 12, 16, 17, 18], dtype=np.int32).tobytes()),
238
- ],
239
- [
240
- "<i8",
241
- [2],
242
- msgpack.ExtType(3, np.array([0, 5], dtype=np.int64).tobytes()),
237
+ msgspec.msgpack.Ext(3, np.array([2, 12, 16, 17, 18], dtype=np.int32).tobytes()),
238
],
239
None,
240
+ None,
241
]
242
outputs_with_sampling_mask = EngineCoreOutputs(
243
outputs=[
rust/src/llm/src/error.rs
+8
@@ -12,4 +12,12 @@ pub enum Error {
12
EmptyPromptTokenIds { request_id: String },
13
#[error("engine-core error")]
14
EngineCoreClient(#[from] vllm_engine_core_client::Error),
15
+ #[error(
16
+ "sampling mask for request `{request_id}` has {row_count} rows for {token_count} generated tokens"
17
+ )]
18
+ SamplingMaskTokenCountMismatch {
19
+ request_id: String,
20
+ token_count: usize,
21
+ row_count: usize,
22
+ },
23
}
rust/src/llm/src/output.rs
+31
@@ -12,6 +12,7 @@ use futures::{Stream, StreamExt as _, pin_mut};
12
use serde::{Deserialize, Serialize};
13
use vllm_engine_core_client::protocol::logprobs::Logprobs;
14
use vllm_engine_core_client::protocol::output::{EngineCoreFinishReason, StopReason};
15
+use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
16
use vllm_engine_core_client::{AbortCause, EngineCoreOutputStream};
17
18
use crate::error::Result;
@@ -44,6 +45,8 @@ pub struct CollectedGenerateOutput {
45
/// Connector-specific encoder cache transfer parameters for disaggregated
46
/// serving.
47
pub ec_transfer_params: Option<serde_json::Value>,
48
+ /// Sampling support sets aligned one-to-one with generated token positions.
49
+ pub sampling_mask: Option<SamplingMask>,
50
}
51
52
/// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for
@@ -155,6 +158,8 @@ pub struct GenerateOutput {
158
/// Connector-specific encoder cache transfer parameters for disaggregated
159
/// serving.
160
pub ec_transfer_params: Option<serde_json::Value>,
161
+ /// Sampling support sets aligned one-to-one with `token_ids`.
162
+ pub sampling_mask: Option<SamplingMask>,
163
}
164
165
impl GenerateOutput {
@@ -202,6 +207,7 @@ impl GenerateOutput {
207
cached_token_count: 0,
208
kv_transfer_params: None,
209
ec_transfer_params: None,
210
+ sampling_mask: None,
211
}
212
}
213
}
@@ -271,6 +277,16 @@ impl Stream for GenerateOutputStream {
277
}
278
279
let logprobs = raw.new_logprobs.map(|value| value.into_direct().unwrap());
280
+ let sampling_mask = raw.new_sampling_mask.map(|value| value.into_direct().unwrap());
281
+ if let Some(mask) = sampling_mask.as_ref()
282
+ && mask.rows.len() != raw.new_token_ids.len()
283
+ {
284
+ return Poll::Ready(Some(Err(crate::Error::SamplingMaskTokenCountMismatch {
285
+ request_id: raw.request_id,
286
+ token_count: raw.new_token_ids.len(),
287
+ row_count: mask.rows.len(),
288
+ })));
289
+ }
290
let cached_token_count = raw
291
.prefill_stats
292
.as_ref()
@@ -291,6 +307,7 @@ impl Stream for GenerateOutputStream {
307
cached_token_count,
308
kv_transfer_params: raw.kv_transfer_params,
309
ec_transfer_params: raw.ec_transfer_params,
310
+ sampling_mask,
311
};
312
313
Poll::Ready(Some(Ok(output)))
@@ -341,6 +358,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
358
359
while let Some(output) = stream.next().await.transpose()? {
360
cached_token_count = cached_token_count.max(output.cached_token_count);
361
+ let sampling_mask = output.sampling_mask;
362
if let Some(info) = output.prompt_info {
363
if prompt_token_ids.is_none() {
364
prompt_token_ids = Some(info.prompt_token_ids.to_vec());
@@ -359,6 +377,9 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
377
existing.logprobs = Some(step_logprobs);
378
}
379
}
380
+ if let Some(mut mask) = sampling_mask {
381
+ existing.sampling_mask.get_or_insert_default().rows.append(&mut mask.rows);
382
+ }
383
} else {
384
collected = Some(CollectedGenerateOutput {
385
request_id: output.request_id,
@@ -374,6 +395,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
395
},
396
kv_transfer_params: None,
397
ec_transfer_params: None,
398
+ sampling_mask,
399
});
400
}
401
@@ -387,6 +409,15 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
409
};
410
collected.kv_transfer_params = output.kv_transfer_params;
411
collected.ec_transfer_params = output.ec_transfer_params;
412
+ if let Some(mask) = collected.sampling_mask.as_ref()
413
+ && mask.rows.len() != collected.token_ids.len()
414
+ {
415
+ return Err(crate::Error::SamplingMaskTokenCountMismatch {
416
+ request_id: collected.request_id,
417
+ token_count: collected.token_ids.len(),
418
+ row_count: mask.rows.len(),
419
+ });
420
+ }
421
return Ok(collected);
422
}
423
}
rust/src/llm/tests/generate.rs
+59
-10
@@ -2,7 +2,7 @@
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
use std::collections::BTreeSet;
5
-use std::sync::Once;
5
+use std::sync::{Arc, Once};
6
use std::time::Duration;
7
8
use futures::StreamExt as _;
@@ -18,6 +18,7 @@ use vllm_engine_core_client::protocol::output::{
18
};
19
use vllm_engine_core_client::protocol::request::EngineCoreRequest;
20
use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams;
21
+use vllm_engine_core_client::protocol::sampling_mask::{MaybeWireSamplingMask, SamplingMask};
22
use vllm_engine_core_client::protocol::stats::PrefillStats;
23
use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
24
use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId};
@@ -326,6 +327,11 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() {
327
num_local_cached_tokens: 1,
328
..Default::default()
329
}),
330
+ new_sampling_mask: Some(MaybeWireSamplingMask::Direct(
331
+ SamplingMask {
332
+ rows: vec![vec![1, 33, 99]],
333
+ },
334
+ )),
335
..request_output_with_logprobs(
336
&request.request_id,
337
vec![33],
@@ -334,15 +340,22 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() {
340
Some(prompt_logprobs()),
341
)
342
},
337
- request_output_with_logprobs_and_kv(
338
- &request.request_id,
339
- vec![44],
340
- Some(EngineCoreFinishReason::Stop),
341
- Some(logprobs_for_position(44, -0.3, 1, 88, -0.4)),
342
- None,
343
- Some(serde_json::json!({"connector": "x"})),
344
- None,
345
- ),
343
+ EngineCoreOutput {
344
+ new_sampling_mask: Some(MaybeWireSamplingMask::Direct(
345
+ SamplingMask {
346
+ rows: vec![vec![2, 44, 88]],
347
+ },
348
+ )),
349
+ ..request_output_with_logprobs_and_kv(
350
+ &request.request_id,
351
+ vec![44],
352
+ Some(EngineCoreFinishReason::Stop),
353
+ Some(logprobs_for_position(44, -0.3, 1, 88, -0.4)),
354
+ None,
355
+ Some(serde_json::json!({"connector": "x"})),
356
+ None,
357
+ )
358
+ },
359
],
360
..Default::default()
361
}
@@ -375,6 +388,42 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() {
388
collected.kv_transfer_params,
389
Some(serde_json::json!({"connector": "x"}))
390
);
391
+ assert_eq!(
392
+ collected.sampling_mask,
393
+ Some(SamplingMask {
394
+ rows: vec![vec![1, 33, 99], vec![2, 44, 88]],
395
+ })
396
+ );
397
+}
398
+
399
+#[tokio::test]
400
+async fn collect_output_rejects_partial_sampling_mask() {
401
+ let output = vllm_llm::GenerateOutput {
402
+ request_id: "req-partial-mask".to_string(),
403
+ prompt_info: Some(GeneratePromptInfo {
404
+ prompt_token_ids: Arc::from([11_u32, 22]),
405
+ prompt_logprobs: None,
406
+ }),
407
+ token_ids: vec![33, 44],
408
+ logprobs: None,
409
+ finish_reason: Some(FinishReason::Length),
410
+ cached_token_count: 0,
411
+ kv_transfer_params: None,
412
+ ec_transfer_params: None,
413
+ sampling_mask: Some(SamplingMask {
414
+ rows: vec![vec![1, 33, 99]],
415
+ }),
416
+ };
417
+
418
+ let error = futures::stream::iter([Ok(output)]).collect_output().await.unwrap_err();
419
+ assert!(matches!(
420
+ error,
421
+ Error::SamplingMaskTokenCountMismatch {
422
+ token_count: 2,
423
+ row_count: 1,
424
+ ..
425
+ }
426
+ ));
427
}
428
429
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
rust/src/server/Cargo.toml
+1
@@ -19,6 +19,7 @@ itertools.workspace = true
19
libc.workspace = true
20
llm-multimodal.workspace = true
21
openssl.workspace = true
22
+prost.workspace = true
23
prost-types.workspace = true
24
rmp-serde.workspace = true
25
rmpv.workspace = true
rust/src/server/src/grpc/convert.rs
+44
@@ -316,6 +316,11 @@ pub fn to_sequence_output(
316
_ => (vec![], vec![], vec![]),
317
};
318
319
+ let sampling_mask = finished
320
+ .and_then(|finished| finished.sampling_mask.as_ref())
321
+ .map(|mask| mask.rows.iter().map(|row| pb::TokenIds { ids: row.clone() }).collect())
322
+ .unwrap_or_default();
323
+
324
Ok(pb::SequenceOutput {
325
index: 0, // TODO: multi-sequence (n > 1) not supported
326
text: if opts.output_text {
@@ -333,6 +338,7 @@ pub fn to_sequence_output(
338
ranks: rank_values,
339
candidate_tokens: candidates,
340
finish_info,
341
+ sampling_mask,
342
})
343
}
344
@@ -524,6 +530,7 @@ impl ResponseOpts {
530
mod tests {
531
use prost::Message as _;
532
use vllm_engine_core_client::protocol::output::StopReason;
533
+ use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
534
use vllm_text::{
535
FinishReason, Finished, Prompt, SamplingHints, SamplingLimits, lower_sampling_params,
536
};
@@ -720,6 +727,7 @@ mod tests {
727
finish_reason: reason,
728
kv_transfer_params: None,
729
ec_transfer_params: None,
730
+ sampling_mask: None,
731
}
732
}
733
@@ -804,4 +812,40 @@ mod tests {
812
assert_eq!(finish.finish_reason, PbFinishReason::Stop as i32);
813
assert_eq!(finish.stop_reason, Some(PbStopReason::EosTokenId(30)));
814
}
815
+
816
+ #[test]
817
+ fn sequence_output_only_carries_sampling_mask_on_terminal_output() {
818
+ let mut fin = finished(FinishReason::Length);
819
+ fin.sampling_mask = Some(SamplingMask {
820
+ rows: vec![vec![1, 10], vec![2, 20]],
821
+ });
822
+
823
+ let terminal =
824
+ to_sequence_output("", &[10, 20], None, Some(&fin), &ResponseOpts::default())
825
+ .expect("terminal output");
826
+ let intermediate = to_sequence_output("", &[9], None, None, &ResponseOpts::default())
827
+ .expect("intermediate output");
828
+
829
+ assert_eq!(
830
+ terminal.sampling_mask,
831
+ vec![
832
+ pb::TokenIds { ids: vec![1, 10] },
833
+ pb::TokenIds { ids: vec![2, 20] }
834
+ ]
835
+ );
836
+ assert!(intermediate.sampling_mask.is_empty());
837
+ }
838
+
839
+ #[test]
840
+ fn sampling_mask_uses_additive_proto_tag_ten() {
841
+ let response = pb::SequenceOutput {
842
+ sampling_mask: vec![pb::TokenIds { ids: vec![7, 8] }],
843
+ ..Default::default()
844
+ };
845
+
846
+ assert_eq!(
847
+ response.encode_to_vec(),
848
+ vec![0x52, 0x04, 0x0a, 0x02, 0x07, 0x08]
849
+ );
850
+ }
851
}
rust/src/server/src/grpc/inference.rs
+1
@@ -257,6 +257,7 @@ impl pb::inference_server::Inference for InferenceServiceImpl {
257
finish_reason: collected.finish_reason,
258
kv_transfer_params: collected.kv_transfer_params,
259
ec_transfer_params: collected.ec_transfer_params,
260
+ sampling_mask: collected.sampling_mask,
261
};
262
263
let outputs = convert::to_sequence_output(
rust/src/server/src/routes/inference/generate.rs
+6
@@ -481,6 +481,7 @@ mod tests {
481
cached_token_count: 0,
482
kv_transfer_params: None,
483
ec_transfer_params: None,
484
+ sampling_mask: None,
485
}),
486
Ok(GenerateOutput {
487
request_id: String::new(),
@@ -494,6 +495,7 @@ mod tests {
495
cached_token_count: 2,
496
kv_transfer_params: None,
497
ec_transfer_params: None,
498
+ sampling_mask: None,
499
}),
500
]);
501
@@ -575,6 +577,7 @@ mod tests {
577
cached_token_count: 0,
578
kv_transfer_params: None,
579
ec_transfer_params: None,
580
+ sampling_mask: None,
581
}
582
}
583
@@ -658,6 +661,7 @@ mod tests {
661
kv_transfer_params: None,
662
ec_transfer_params: None,
663
prompt_token_ids: vec![10, 20],
664
+ sampling_mask: None,
665
};
666
667
let response = collect_generate(
@@ -744,6 +748,7 @@ mod tests {
748
kv_transfer_params: None,
749
ec_transfer_params: None,
750
prompt_token_ids: vec![10, 20],
751
+ sampling_mask: None,
752
};
753
754
let response = collect_generate(
@@ -776,6 +781,7 @@ mod tests {
781
},
782
kv_transfer_params: None,
783
ec_transfer_params: None,
784
+ sampling_mask: None,
785
prompt_token_ids,
786
};
787
rust/src/server/src/routes/openai/completions.rs
+6
@@ -694,6 +694,7 @@ mod tests {
694
))),
695
kv_transfer_params: None,
696
ec_transfer_params: None,
697
+ sampling_mask: None,
698
}),
699
)),
700
]);
@@ -806,6 +807,7 @@ mod tests {
807
finish_reason: FinishReason::Length,
808
kv_transfer_params: None,
809
ec_transfer_params: None,
810
+ sampling_mask: None,
811
}),
812
)),
813
]);
@@ -858,6 +860,7 @@ mod tests {
860
finish_reason: FinishReason::Length,
861
kv_transfer_params: None,
862
ec_transfer_params: None,
863
+ sampling_mask: None,
864
}),
865
)),
866
]);
@@ -913,6 +916,7 @@ mod tests {
916
finish_reason: FinishReason::Length,
917
kv_transfer_params: None,
918
ec_transfer_params: None,
919
+ sampling_mask: None,
920
}),
921
)),
922
]);
@@ -985,6 +989,7 @@ mod tests {
989
finish_reason: FinishReason::Length,
990
kv_transfer_params: None,
991
ec_transfer_params: None,
992
+ sampling_mask: None,
993
}),
994
)),
995
]);
@@ -1059,6 +1064,7 @@ mod tests {
1064
finish_reason: FinishReason::Length,
1065
kv_transfer_params: None,
1066
ec_transfer_params: None,
1067
+ sampling_mask: None,
1068
}),
1069
)),
1070
]);
rust/src/text/src/output/decoded.rs
+146
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
9
use tracing::{Level, debug, trace};
10
use vllm_engine_core_client::AbortCause;
11
use vllm_engine_core_client::protocol::output::StopReason;
12
+use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
13
use vllm_llm::{FinishReason, GenerateOutput, TokenUsage};
14
use vllm_tokenizer::{DecodedText, DynTokenizer, IncrementalDecoder};
15
@@ -50,6 +51,8 @@ pub struct Finished {
51
/// Connector-specific encoder cache transfer parameters for disaggregated
52
/// serving.
53
pub ec_transfer_params: Option<serde_json::Value>,
54
+ /// Sampling support sets aligned with all generated token positions.
55
+ pub sampling_mask: Option<SamplingMask>,
56
}
57
58
/// Sample metadata emitted by one engine output update.
@@ -120,6 +123,7 @@ pub async fn decoded_text_event_stream(
123
let mut sampled_token_ids = Vec::new();
124
let mut output_token_count: usize = 0;
125
let mut sampled_logprobs: Option<DecodedLogprobs> = None;
126
+ let mut sampling_mask: Option<SamplingMask> = None;
127
128
while let Some(next) = raw_stream.next().await {
129
let output = next?;
@@ -213,6 +217,7 @@ pub async fn decoded_text_event_stream(
217
218
let mut new_token_ids = output.token_ids;
219
let mut new_logprobs = output.logprobs;
220
+ let mut new_sampling_mask = output.sampling_mask;
221
222
// Trim tokens and logprobs if we matched stop string.
223
if let Some(num_tokens) = truncate_tokens_to {
@@ -220,9 +225,15 @@ pub async fn decoded_text_event_stream(
225
if let Some(logprobs) = &mut new_logprobs {
226
logprobs.positions.truncate(num_tokens);
227
}
228
+ if let Some(mask) = &mut new_sampling_mask {
229
+ mask.rows.truncate(num_tokens);
230
+ }
231
}
232
233
output_token_count += new_token_ids.len();
234
+ if let Some(mut mask) = new_sampling_mask {
235
+ sampling_mask.get_or_insert_default().rows.append(&mut mask.rows);
236
+ }
237
238
let decoded_logprobs = new_logprobs
239
.as_ref()
@@ -246,6 +257,16 @@ pub async fn decoded_text_event_stream(
257
}
258
259
if let Some(reason) = finish_reason {
260
+ if let Some(mask) = sampling_mask.as_ref()
261
+ && mask.rows.len() != output_token_count
262
+ {
263
+ return Err(vllm_llm::Error::SamplingMaskTokenCountMismatch {
264
+ request_id,
265
+ token_count: output_token_count,
266
+ row_count: mask.rows.len(),
267
+ }
268
+ .into());
269
+ }
270
// Flush any remaining buffered text.
271
let (last_chunk, full_decoded) = decoder.flush(truncate_output_to)?;
272
let text_len = full_decoded.text.len();
@@ -293,6 +314,7 @@ pub async fn decoded_text_event_stream(
314
finish_reason: reason,
315
kv_transfer_params,
316
ec_transfer_params,
317
+ sampling_mask,
318
})),
319
})
320
.await;
@@ -354,6 +376,7 @@ mod tests {
376
377
use futures::{Stream, StreamExt as _, stream};
378
use vllm_engine_core_client::AbortCause;
379
+ use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs, TokenLogprob};
380
use vllm_llm::GenerateOutput;
381
use vllm_tokenizer::{TokenAnchor, TokenAttribution, test_utils::TestTokenizer};
382
@@ -384,6 +407,73 @@ mod tests {
407
s.bytes().map(u32::from).collect()
408
}
409
410
+ #[tokio::test]
411
+ async fn sampling_masks_survive_text_decode_and_collection() {
412
+ let prompt: Arc<[u32]> = Arc::from([]);
413
+ let mut first = GenerateOutput::for_test(Some(prompt), vec![b'a' as u32], None);
414
+ first.sampling_mask = Some(SamplingMask {
415
+ rows: vec![vec![1, b'a' as u32]],
416
+ });
417
+ let mut terminal =
418
+ GenerateOutput::for_test(None, vec![b'b' as u32], Some(FinishReason::Length));
419
+ terminal.sampling_mask = Some(SamplingMask {
420
+ rows: vec![vec![2, b'b' as u32]],
421
+ });
422
+ let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
423
+
424
+ let collected = decoded_text_event_stream(
425
+ "test-mask".into(),
426
+ tokenizer,
427
+ stream::iter([Ok(first), Ok(terminal)]),
428
+ TextDecodeOptions::default(),
429
+ false,
430
+ )
431
+ .collect_output()
432
+ .await
433
+ .unwrap();
434
+
435
+ assert_eq!(
436
+ collected.sampling_mask,
437
+ Some(SamplingMask {
438
+ rows: vec![vec![1, b'a' as u32], vec![2, b'b' as u32]],
439
+ })
440
+ );
441
+ }
442
+
443
+ #[tokio::test]
444
+ async fn sampling_mask_mismatch_reuses_llm_error() {
445
+ let prompt: Arc<[u32]> = Arc::from([]);
446
+ let mut terminal = GenerateOutput::for_test(
447
+ Some(prompt),
448
+ vec![b'a' as u32, b'b' as u32],
449
+ Some(FinishReason::Length),
450
+ );
451
+ terminal.sampling_mask = Some(SamplingMask {
452
+ rows: vec![vec![1, b'a' as u32]],
453
+ });
454
+ let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
455
+
456
+ let error = decoded_text_event_stream(
457
+ "test-mask-mismatch".into(),
458
+ tokenizer,
459
+ stream::iter([Ok(terminal)]),
460
+ TextDecodeOptions::default(),
461
+ false,
462
+ )
463
+ .collect_output()
464
+ .await
465
+ .unwrap_err();
466
+
467
+ assert!(matches!(
468
+ error,
469
+ crate::Error::Llm(vllm_llm::Error::SamplingMaskTokenCountMismatch {
470
+ token_count: 2,
471
+ row_count: 1,
472
+ ..
473
+ })
474
+ ));
475
+ }
476
+
477
fn opts(stop: &[&str], min_tokens: u32) -> TextDecodeOptions {
478
TextDecodeOptions {
479
stop_strings: Some(stop.iter().map(|s| s.to_string()).collect()),
@@ -472,6 +562,7 @@ mod tests {
562
finish_reason: FinishReason::Length,
563
kv_transfer_params: None,
564
ec_transfer_params: None,
565
+ sampling_mask: None,
566
}),
567
),
568
],
@@ -503,6 +594,7 @@ mod tests {
594
))),
595
kv_transfer_params: None,
596
ec_transfer_params: None,
597
+ sampling_mask: None,
598
}),
599
),
600
],
@@ -576,6 +668,60 @@ mod tests {
668
assert!(output.finish_reason.is_stop());
669
}
670
671
+ #[tokio::test]
672
+ async fn stream_stop_string_mid_chunk_keeps_token_logprob_mask_alignment() {
673
+ let token_ids = ascii_tokens("abcxyz");
674
+ let mut raw = GenerateOutput::for_test(
675
+ Some(Arc::from([])),
676
+ token_ids.clone(),
677
+ Some(FinishReason::Length),
678
+ );
679
+ raw.logprobs = Some(Logprobs {
680
+ positions: token_ids
681
+ .iter()
682
+ .map(|&token_id| PositionLogprobs {
683
+ entries: vec![TokenLogprob {
684
+ token_id,
685
+ logprob: -0.25,
686
+ rank: 1,
687
+ }],
688
+ })
689
+ .collect(),
690
+ });
691
+ raw.sampling_mask = Some(SamplingMask {
692
+ rows: token_ids.iter().map(|&token_id| vec![token_id, token_id + 100]).collect(),
693
+ });
694
+ let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
695
+
696
+ let output = decoded_text_event_stream(
697
+ "test-mid-chunk-stop".into(),
698
+ tokenizer,
699
+ stream::iter([Ok(raw)]),
700
+ opts(&["cx"], 0),
701
+ false,
702
+ )
703
+ .collect_output()
704
+ .await
705
+ .unwrap();
706
+
707
+ assert_eq!(output.text, "ab");
708
+ assert!(output.finish_reason.is_stop());
709
+ assert_eq!(output.token_ids, ascii_tokens("abcx"));
710
+ assert_eq!(output.logprobs.as_ref().unwrap().positions.len(), 4);
711
+ assert_eq!(
712
+ output.sampling_mask,
713
+ Some(SamplingMask {
714
+ rows: vec![
715
+ vec![b'a' as u32, b'a' as u32 + 100],
716
+ vec![b'b' as u32, b'b' as u32 + 100],
717
+ vec![b'c' as u32, b'c' as u32 + 100],
718
+ vec![b'x' as u32, b'x' as u32 + 100],
719
+ ],
720
+ })
721
+ );
722
+ assert_eq!(output.usage.output_token_count, 4);
723
+ }
724
+
725
#[tokio::test]
726
async fn stream_stop_string_at_end() {
727
let output = run_to_completion(ascii_tokens("abcxyz"), opts(&["xyz"], 0)).await;
rust/src/text/src/output/mod.rs
+7
@@ -17,6 +17,7 @@ mod logprobs;
17
use std::sync::Arc;
18
19
use futures::{StreamExt as _, pin_mut};
20
+use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
21
22
use crate::{Error, FinishReason, Result, TextOutputStream};
23
@@ -35,6 +36,8 @@ pub struct CollectedTextOutput {
36
/// Connector-specific encoder cache transfer parameters for disaggregated
37
/// serving.
38
pub ec_transfer_params: Option<serde_json::Value>,
39
+ /// Sampling support sets aligned with generated token positions.
40
+ pub sampling_mask: Option<SamplingMask>,
41
}
42
43
#[allow(clippy::manual_async_fn, reason = "specify `Send` bound")]
@@ -90,6 +93,7 @@ impl<T: TextOutputStream> T {
93
usage: vllm_llm::TokenUsage::default(),
94
kv_transfer_params: None,
95
ec_transfer_params: None,
96
+ sampling_mask: None,
97
})
98
};
99
@@ -99,6 +103,7 @@ impl<T: TextOutputStream> T {
103
collected.usage = finished.usage;
104
collected.kv_transfer_params = finished.kv_transfer_params;
105
collected.ec_transfer_params = finished.ec_transfer_params;
106
+ collected.sampling_mask = finished.sampling_mask;
107
return Ok(collected);
108
}
109
}
@@ -173,6 +178,7 @@ mod tests {
178
finish_reason: FinishReason::stop_eos(),
179
kv_transfer_params: None,
180
ec_transfer_params: None,
181
+ sampling_mask: None,
182
})),
183
}),
184
]);
@@ -295,6 +301,7 @@ mod tests {
301
finish_reason: FinishReason::stop_eos(),
302
kv_transfer_params: None,
303
ec_transfer_params: None,
304
+ sampling_mask: None,
305
})),
306
}),
307
]);