[Rust Frontend] Send multimodal tensors in auxiliary frames (#49341)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: reidliu41 <reid201711@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Reid committed Jul 29, 2026 at 20:21 UTC 242c591d5a38be6c80ff5197e83e4fa34c5f74b5
11 files changed +530 -159
rust/src/chat/src/multimodal/expand.rs
+3 -1
@@ -222,7 +222,9 @@ mod tests {
222 assert_eq!(tensor.shape, vec![expected.len()]);
223 assert_eq!(
224 tensor.data,
225 - WireArrayData::RawView(expected.iter().map(|value| u8::from(*value)).collect())
225 + WireArrayData::RawView(
226 + expected.iter().map(|value| u8::from(*value)).collect::<Vec<_>>().into(),
227 + )
228 );
229 }
230
rust/src/chat/src/multimodal/tensor.rs
+157 -95
@@ -2,28 +2,58 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use std::collections::HashMap;
5 +use std::mem::size_of;
6
7 use half::{bf16, f16};
8 use llm_multimodal::{ModelSpecificValue, PreprocessedEncoderInputs};
9 use vllm_engine_core_client::protocol::dtype::ModelDtype;
10 use vllm_engine_core_client::protocol::multimodal::MmKwargValue as ProtocolKwargValue;
10 -use vllm_engine_core_client::protocol::tensor::{ShapeExt as _, WireTensor};
11 +use vllm_engine_core_client::protocol::tensor::{ShapeExt as _, WireArrayData, WireTensor};
12
13 use crate::error::{Error, Result, bail_multimodal, multimodal};
14
15 +/// Element type retained alongside an encoded tensor during multimodal lowering.
16 +#[derive(Debug, Clone, Copy)]
17 +pub(super) enum TensorKind {
18 + /// 32-bit floating point.
19 + F32,
20 + /// IEEE 16-bit floating point.
21 + F16,
22 + /// Brain floating point.
23 + Bf16,
24 + /// Signed 64-bit integer.
25 + I64,
26 + /// Unsigned 32-bit integer.
27 + U32,
28 +}
29 +
30 +impl TensorKind {
31 + const fn element_size(self) -> usize {
32 + match self {
33 + Self::F32 => size_of::<f32>(),
34 + Self::F16 => size_of::<f16>(),
35 + Self::Bf16 => size_of::<bf16>(),
36 + Self::I64 => size_of::<i64>(),
37 + Self::U32 => size_of::<u32>(),
38 + }
39 + }
40 +
41 + const fn wire_dtype(self) -> &'static str {
42 + match self {
43 + Self::F32 => "float32",
44 + Self::F16 => "float16",
45 + Self::Bf16 => "bfloat16",
46 + Self::I64 => "int64",
47 + Self::U32 => "uint32",
48 + }
49 + }
50 +}
51 +
52 /// Representation for multimodal kwarg values for transformation.
53 #[derive(Debug)]
54 pub(super) enum KwargValue {
17 - /// Float tensor with row-major flat data and shape.
18 - F32Tensor { data: Vec<f32>, shape: Vec<usize> },
19 - /// Float16 tensor with row-major flat data and shape.
20 - F16Tensor { data: Vec<f16>, shape: Vec<usize> },
21 - /// BFloat16 tensor with row-major flat data and shape.
22 - Bf16Tensor { data: Vec<bf16>, shape: Vec<usize> },
23 - /// Signed integer tensor with row-major flat data and shape.
24 - I64Tensor { data: Vec<i64>, shape: Vec<usize> },
25 - /// Unsigned integer tensor with row-major flat data and shape.
26 - U32Tensor { data: Vec<u32>, shape: Vec<usize> },
55 + /// Tensor with row-major flat data and shape.
56 + Tensor { kind: TensorKind, wire: WireTensor },
57 /// Non-tensor kwarg value that is shared or copied as-is.
58 Passthrough(ProtocolKwargValue),
59 }
@@ -67,8 +97,14 @@ impl KwargValue {
97 ModelSpecificValue::Tensor { data, shape } => {
98 Self::from_f32_tensor(data, shape, float_dtype)?
99 }
70 - ModelSpecificValue::IntTensor { data, shape } => Self::I64Tensor { data, shape },
71 - ModelSpecificValue::UintTensor { data, shape } => Self::U32Tensor { data, shape },
100 + ModelSpecificValue::IntTensor { data, shape } => {
101 + let wire = WireTensor::from_i64(shape, data).map_err(Error::Multimodal)?;
102 + Self::tensor(TensorKind::I64, wire)
103 + }
104 + ModelSpecificValue::UintTensor { data, shape } => {
105 + let wire = WireTensor::from_u32(shape, data).map_err(Error::Multimodal)?;
106 + Self::tensor(TensorKind::U32, wire)
107 + }
108 ModelSpecificValue::Int(value) => Self::Passthrough(Int(value)),
109 ModelSpecificValue::Float(value) => Self::Passthrough(Float(value)),
110 ModelSpecificValue::IntVec(values) => {
@@ -93,17 +129,23 @@ impl KwargValue {
129 /// Convert a float tensor to the target float dtype if needed, keeping the
130 /// same shape.
131 fn from_f32_tensor(data: Vec<f32>, shape: Vec<usize>, float_dtype: ModelDtype) -> Result<Self> {
96 - match float_dtype {
97 - ModelDtype::Float16 => Ok(Self::F16Tensor {
98 - data: data.into_iter().map(f16::from_f32).collect(),
99 - shape,
100 - }),
101 - ModelDtype::BFloat16 => Ok(Self::Bf16Tensor {
102 - data: data.into_iter().map(bf16::from_f32).collect(),
103 - shape,
104 - }),
105 - ModelDtype::Float32 => Ok(Self::F32Tensor { data, shape }),
106 - }
132 + let (kind, wire) = match float_dtype {
133 + ModelDtype::Float16 => (
134 + TensorKind::F16,
135 + WireTensor::from_f16(shape, data.into_iter().map(f16::from_f32).collect()),
136 + ),
137 + ModelDtype::BFloat16 => (
138 + TensorKind::Bf16,
139 + WireTensor::from_bf16(shape, data.into_iter().map(bf16::from_f32).collect()),
140 + ),
141 + ModelDtype::Float32 => (TensorKind::F32, WireTensor::from_f32(shape, data)),
142 + };
143 + wire.map(|wire| Self::tensor(kind, wire)).map_err(Error::Multimodal)
144 + }
145 +
146 + fn tensor(kind: TensorKind, wire: WireTensor) -> Self {
147 + debug_assert_eq!(wire.dtype, kind.wire_dtype());
148 + Self::Tensor { kind, wire }
149 }
150 }
151
@@ -111,15 +153,11 @@ impl TryFrom<&KwargValue> for ProtocolKwargValue {
153 type Error = Error;
154
155 fn try_from(value: &KwargValue) -> Result<Self> {
114 - let tensor = match value {
115 - KwargValue::F32Tensor { data, shape } => WireTensor::from_f32(shape.clone(), data),
116 - KwargValue::F16Tensor { data, shape } => WireTensor::from_f16(shape.clone(), data),
117 - KwargValue::Bf16Tensor { data, shape } => WireTensor::from_bf16(shape.clone(), data),
118 - KwargValue::I64Tensor { data, shape } => WireTensor::from_i64(shape.clone(), data),
119 - KwargValue::U32Tensor { data, shape } => WireTensor::from_u32(shape.clone(), data),
156 + let wire = match value {
157 + KwargValue::Tensor { wire, .. } => wire.clone(),
158 KwargValue::Passthrough(value) => return Ok(value.clone()),
159 };
122 - tensor.map(ProtocolKwargValue::Tensor).map_err(Error::Multimodal)
160 + Ok(ProtocolKwargValue::Tensor(wire))
161 }
162 }
163
@@ -127,11 +165,7 @@ impl KwargValue {
165 /// First-axis length for tensor values; `None` for passthrough kwargs.
166 pub(super) fn first_dim(&self) -> Option<usize> {
167 match self {
130 - Self::F32Tensor { shape, .. }
131 - | Self::F16Tensor { shape, .. }
132 - | Self::Bf16Tensor { shape, .. }
133 - | Self::I64Tensor { shape, .. }
134 - | Self::U32Tensor { shape, .. } => shape.first().copied(),
168 + Self::Tensor { wire, .. } => wire.shape.first().copied(),
169 Self::Passthrough(_) => None,
170 }
171 }
@@ -161,30 +195,13 @@ impl KwargValue {
195 end: usize,
196 drop_axis: bool,
197 ) -> Result<ProtocolKwargValue> {
164 - let tensor = match self {
165 - Self::F32Tensor { data, shape } => {
166 - let (shape, data) = slice_first_axis_range(shape, data, start, end, drop_axis)?;
167 - WireTensor::from_f32(shape, data)
168 - }
169 - Self::F16Tensor { data, shape } => {
170 - let (shape, data) = slice_first_axis_range(shape, data, start, end, drop_axis)?;
171 - WireTensor::from_f16(shape, data)
172 - }
173 - Self::Bf16Tensor { data, shape } => {
174 - let (shape, data) = slice_first_axis_range(shape, data, start, end, drop_axis)?;
175 - WireTensor::from_bf16(shape, data)
176 - }
177 - Self::I64Tensor { data, shape } => {
178 - let (shape, data) = slice_first_axis_range(shape, data, start, end, drop_axis)?;
179 - WireTensor::from_i64(shape, data)
180 - }
181 - Self::U32Tensor { data, shape } => {
182 - let (shape, data) = slice_first_axis_range(shape, data, start, end, drop_axis)?;
183 - WireTensor::from_u32(shape, data)
198 + let wire = match self {
199 + Self::Tensor { kind, wire } => {
200 + slice_first_axis_range(wire, kind.element_size(), start, end, drop_axis)
201 }
202 Self::Passthrough(value) => return Ok(value.clone()),
203 };
187 - tensor.map(ProtocolKwargValue::Tensor).map_err(Error::Multimodal)
204 + wire.map(ProtocolKwargValue::Tensor)
205 }
206 }
207
@@ -208,44 +225,53 @@ pub(super) fn flat_range_for_index(
225 /// Read a tensor value as per-image sizes for flat slicing.
226 fn tensor_as_usize_vec(tensor: &KwargValue) -> Result<Vec<usize>> {
227 match tensor {
211 - KwargValue::I64Tensor { data, .. } => data
212 - .iter()
228 + KwargValue::Tensor {
229 + kind: TensorKind::I64,
230 + wire,
231 + } => raw_tensor_bytes(wire, size_of::<i64>())?
232 + .chunks_exact(size_of::<i64>())
233 + .map(|bytes| i64::from_ne_bytes(bytes.try_into().expect("exact int64 chunk")))
234 .map(|value| {
214 - usize::try_from(*value)
235 + usize::try_from(value)
236 .map_err(|_| multimodal!("negative flat tensor size `{value}`"))
237 })
238 .collect(),
218 - KwargValue::U32Tensor { data, .. } => {
219 - Ok(data.iter().map(|value| *value as usize).collect())
220 - }
239 + KwargValue::Tensor {
240 + kind: TensorKind::U32,
241 + wire,
242 + } => Ok(raw_tensor_bytes(wire, size_of::<u32>())?
243 + .chunks_exact(size_of::<u32>())
244 + .map(|bytes| u32::from_ne_bytes(bytes.try_into().expect("exact uint32 chunk")) as usize)
245 + .collect()),
246 _ => Err(multimodal!("flat tensor sizes must be int64 or uint32")),
247 }
248 }
249
250 /// Slice a flat row-major tensor along its first axis.
226 -fn slice_first_axis_range<'a, T>(
227 - shape: &[usize],
228 - data: &'a [T],
251 +fn slice_first_axis_range(
252 + tensor: &WireTensor,
253 + element_size: usize,
254 start: usize,
255 end: usize,
256 drop_axis: bool,
232 -) -> Result<(Vec<usize>, &'a [T])> {
257 +) -> Result<WireTensor> {
258 + let shape = tensor.shape.as_slice();
259 + raw_tensor_bytes(tensor, element_size)?;
260 let first_dim = *shape.first().ok_or_else(|| multimodal!("tensor has no first dimension"))?;
261 if start > end || end > first_dim {
262 bail_multimodal!("invalid tensor slice {start}..{end} for first dimension {first_dim}");
263 }
237 - let expected_len = shape
238 - .checked_numel()
239 - .ok_or_else(|| multimodal!("tensor shape {shape:?} has too many elements"))?;
240 - if expected_len != data.len() {
241 - bail_multimodal!(
242 - "tensor shape {shape:?} expects {expected_len} elements, got {}",
243 - data.len()
244 - );
245 - }
246 - let stride = shape[1..].iter().product::<usize>();
247 - let data_start = start * stride;
248 - let data_end = end * stride;
264 + let stride = shape[1..]
265 + .iter()
266 + .try_fold(1usize, |acc, dim| acc.checked_mul(*dim))
267 + .and_then(|stride| stride.checked_mul(element_size))
268 + .ok_or_else(|| multimodal!("tensor shape {shape:?} byte stride overflowed usize"))?;
269 + let data_start = start
270 + .checked_mul(stride)
271 + .ok_or_else(|| multimodal!("tensor slice start byte offset overflowed usize"))?;
272 + let data_end = end
273 + .checked_mul(stride)
274 + .ok_or_else(|| multimodal!("tensor slice end byte offset overflowed usize"))?;
275 let out_shape = if drop_axis {
276 shape[1..].to_vec()
277 } else {
@@ -253,7 +279,38 @@ fn slice_first_axis_range<'a, T>(
279 shape[0] = end - start;
280 shape
281 };
256 - Ok((out_shape, &data[data_start..data_end]))
282 + let WireArrayData::RawView(data) = &tensor.data else {
283 + return Err(multimodal!("cannot slice an aux tensor buffer"));
284 + };
285 + Ok(WireTensor::from_raw_bytes(
286 + tensor.dtype.clone(),
287 + out_shape,
288 + data.slice(data_start..data_end),
289 + ))
290 +}
291 +
292 +fn raw_tensor_bytes(tensor: &WireTensor, element_size: usize) -> Result<&[u8]> {
293 + let WireArrayData::RawView(data) = &tensor.data else {
294 + return Err(multimodal!("expected an inline tensor buffer"));
295 + };
296 + let expected_bytes = tensor
297 + .shape
298 + .checked_numel()
299 + .and_then(|numel| numel.checked_mul(element_size))
300 + .ok_or_else(|| {
301 + multimodal!(
302 + "tensor shape {:?} byte length overflowed usize",
303 + tensor.shape
304 + )
305 + })?;
306 + if expected_bytes != data.len() {
307 + bail_multimodal!(
308 + "tensor shape {:?} expects {expected_bytes} bytes, got {}",
309 + tensor.shape,
310 + data.len()
311 + );
312 + }
313 + Ok(data)
314 }
315
316 #[cfg(test)]
@@ -262,28 +319,32 @@ mod tests {
319
320 #[test]
321 fn batched_wire_value_at_drops_first_axis() {
265 - let value = KwargValue::F32Tensor {
266 - data: vec![1.0, 2.0, 3.0, 4.0],
267 - shape: vec![2, 2],
268 - };
322 + let data = vec![1.0_f32, 2.0, 3.0, 4.0];
323 + let expected_ptr = data.as_ptr().cast::<u8>().wrapping_add(2 * size_of::<f32>());
324 + let value = KwargValue::tensor(
325 + TensorKind::F32,
326 + WireTensor::from_f32(vec![2, 2], data).unwrap(),
327 + );
328
329 let ProtocolKwargValue::Tensor(tensor) = value.batched_wire_value_at(1).unwrap() else {
330 panic!("expected tensor");
331 };
332
333 assert_eq!(tensor.shape, vec![2]);
334 + let raw_view = tensor.data.into_raw_view().unwrap();
335 + assert_eq!(raw_view.as_ptr(), expected_ptr);
336 assert_eq!(
276 - tensor.data.into_raw_view().unwrap(),
337 + raw_view,
338 [3.0_f32, 4.0].into_iter().flat_map(f32::to_ne_bytes).collect::<Vec<_>>()
339 );
340 }
341
342 #[test]
343 fn flat_wire_value_range_keeps_first_axis() {
283 - let value = KwargValue::U32Tensor {
284 - data: (0..10).collect(),
285 - shape: vec![5, 2],
286 - };
344 + let value = KwargValue::tensor(
345 + TensorKind::U32,
346 + WireTensor::from_u32(vec![5, 2], (0..10_u32).collect()).unwrap(),
347 + );
348
349 let ProtocolKwargValue::Tensor(tensor) = value.flat_wire_value_range(1, 3).unwrap() else {
350 panic!("expected tensor");
@@ -298,10 +359,10 @@ mod tests {
359
360 #[test]
361 fn flat_range_for_index_uses_size_tensor() {
301 - let sizes = KwargValue::I64Tensor {
302 - data: vec![2, 3, 4],
303 - shape: vec![3],
304 - };
362 + let sizes = KwargValue::tensor(
363 + TensorKind::I64,
364 + WireTensor::from_i64(vec![3], vec![2_i64, 3, 4]).unwrap(),
365 + );
366
367 assert_eq!(
368 flat_range_for_index(&sizes, "image_grid_thw", 1).unwrap(),
@@ -311,10 +372,11 @@ mod tests {
372
373 #[test]
374 fn slice_first_axis_range_errors_on_shape_data_mismatch() {
314 - let error = slice_first_axis_range(&[2, 2], &[1.0_f32, 2.0, 3.0], 0, 1, true).unwrap_err();
375 + let tensor = WireTensor::from_raw("float32", vec![2, 2], vec![0; 3 * size_of::<f32>()]);
376 + let error = slice_first_axis_range(&tensor, size_of::<f32>(), 0, 1, true).unwrap_err();
377
378 assert!(
317 - matches!(error, Error::Multimodal(message) if message.contains("expects 4 elements"))
379 + matches!(error, Error::Multimodal(message) if message.contains("expects 16 bytes"))
380 );
381 }
382
rust/src/engine-core-client/src/client.rs
+1 -1
@@ -519,7 +519,7 @@ impl EngineCoreClient {
519 "registered request to engine"
520 );
521
522 - self.inner.send_to_engine(&engine_id, EngineCoreRequestType::Add, &req).await?;
522 + self.inner.send_request_to_engine(&engine_id, req).await?;
523 Ok(())
524 }
525 .await;
rust/src/engine-core-client/src/client/imp.rs
+40 -4
@@ -6,6 +6,7 @@ use std::sync::Arc;
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 use arc_swap::ArcSwapOption;
9 +use bytes::Bytes;
10 use parking_lot::Mutex;
11 use thiserror_ext::AsReport as _;
12 use tokio::runtime::Handle;
@@ -21,17 +22,30 @@ use crate::error::{client_closed, dispatcher_closed, unexpected_dispatcher_outpu
22 use crate::metrics::{LoraInfoExporter, SchedulerStatsRecorder};
23 use crate::protocol::encode_msgpack;
24 use crate::protocol::output::{EngineCoreOutput, EngineCoreOutputs};
24 -use crate::protocol::request::EngineCoreRequestType;
25 +use crate::protocol::request::{EngineCoreRequest, EngineCoreRequestType};
26 use crate::protocol::stats::SchedulerStats;
27 use crate::protocol::utility::UtilityOutput;
28 use crate::transport::{ConnectedEngine, EngineId};
29 use crate::{Error, Result, transport};
30
31 +const MSGPACK_ZERO_COPY_THRESHOLD_ENV: &str = "VLLM_MSGPACK_ZERO_COPY_THRESHOLD";
32 +const DEFAULT_MSGPACK_ZERO_COPY_THRESHOLD: usize = 256;
33 +
34 +fn msgpack_zero_copy_threshold() -> usize {
35 + std::env::var(MSGPACK_ZERO_COPY_THRESHOLD_ENV)
36 + .ok()
37 + .and_then(|value| value.parse().ok())
38 + .unwrap_or(DEFAULT_MSGPACK_ZERO_COPY_THRESHOLD)
39 +}
40 +
41 pub(crate) struct ClientInner {
42 input_send: RouterSendHalf,
43 /// The runtime handle used for sending messages to the engine.
44 handle: Handle,
45 model_name: String,
46 + /// Per-tensor byte threshold loaded from env variable
47 + /// `VLLM_MSGPACK_ZERO_COPY_THRESHOLD` when this inner client is created.
48 + msgpack_zero_copy_threshold: usize,
49 scheduler_stats_recorder: SchedulerStatsRecorder,
50 request_reg: Mutex<RequestRegistry>,
51 utility_reg: Mutex<UtilityRegistry>,
@@ -54,6 +68,7 @@ impl ClientInner {
68 input_send,
69 handle,
70 model_name,
71 + msgpack_zero_copy_threshold: msgpack_zero_copy_threshold(),
72 scheduler_stats_recorder,
73 request_reg: Mutex::new(RequestRegistry::new(engines)),
74 utility_reg: Mutex::new(UtilityRegistry::default()),
@@ -229,9 +244,29 @@ impl ClientInner {
244 where
245 T: serde::Serialize + std::fmt::Debug,
246 {
232 - // TODO: for `EngineCoreRequest`, split outbound tensor raw views into aux
233 - // frames instead of always producing a single msgpack frame.
234 - let payload = encode_msgpack(payload)?;
247 + let payload = Bytes::from(encode_msgpack(payload)?);
248 + self.send_encoded_to_engine(engine_id, request_type, payload, Vec::new()).await
249 + }
250 +
251 + /// Send an add request, moving large tensor buffers into auxiliary frames.
252 + pub async fn send_request_to_engine(
253 + &self,
254 + engine_id: &EngineId,
255 + mut payload: EngineCoreRequest,
256 + ) -> Result<()> {
257 + let aux_frames = payload.extract_aux_frames(self.msgpack_zero_copy_threshold);
258 + let payload = Bytes::from(encode_msgpack(&payload)?);
259 + self.send_encoded_to_engine(engine_id, EngineCoreRequestType::Add, payload, aux_frames)
260 + .await
261 + }
262 +
263 + async fn send_encoded_to_engine(
264 + &self,
265 + engine_id: &EngineId,
266 + request_type: EngineCoreRequestType,
267 + payload: Bytes,
268 + aux_frames: Vec<Bytes>,
269 + ) -> Result<()> {
270 let mut input_send = self.input_send.clone();
271 let engine_id = engine_id.clone();
272
@@ -242,6 +277,7 @@ impl ClientInner {
277 &engine_id,
278 request_type.to_frame(),
279 payload,
280 + aux_frames,
281 )
282 .await
283 })
rust/src/engine-core-client/src/protocol/logprobs.rs
+3 -3
@@ -209,17 +209,17 @@ impl WireLogprobs {
209 logprob_token_ids: WireNdArray {
210 dtype: "<i8".to_string(),
211 shape: vec![rows, cols],
212 - data: WireArrayData::RawView(token_ids),
212 + data: WireArrayData::RawView(token_ids.into()),
213 },
214 logprobs: WireNdArray {
215 dtype: "<f4".to_string(),
216 shape: vec![rows, cols],
217 - data: WireArrayData::RawView(logprobs),
217 + data: WireArrayData::RawView(logprobs.into()),
218 },
219 token_ranks: WireNdArray {
220 dtype: "<i8".to_string(),
221 shape: vec![rows],
222 - data: WireArrayData::RawView(token_ranks),
222 + data: WireArrayData::RawView(token_ranks.into()),
223 },
224 cu_num_generated_tokens: None,
225 })
rust/src/engine-core-client/src/protocol/logprobs/array.rs
+4 -3
@@ -4,6 +4,7 @@
4 use std::io::Cursor;
5
6 use byteorder::{BigEndian, LittleEndian, NativeEndian, ReadBytesExt};
7 +use bytes::Bytes;
8 use itertools::Itertools as _;
9
10 use crate::error::{Error, Result, ext_value_decode};
@@ -126,7 +127,7 @@ pub(super) fn decode_array_metadata<Frame>(
127 field: &str,
128 frames: &[Frame],
129 expected_scalars: &[ScalarType],
129 -) -> Result<(Vec<usize>, Vec<u8>, ScalarType, Endianness)>
130 +) -> Result<(Vec<usize>, Bytes, ScalarType, Endianness)>
131 where
132 Frame: AsRef<[u8]>,
133 {
@@ -171,7 +172,7 @@ pub(super) fn resolve_array_bytes<Frame>(
172 value: WireArrayData,
173 field: &str,
174 frames: &[Frame],
174 -) -> Result<Vec<u8>>
175 +) -> Result<Bytes>
176 where
177 Frame: AsRef<[u8]>,
178 {
@@ -187,7 +188,7 @@ where
188 ),
189 )
190 })?;
190 - Ok(frame.as_ref().to_vec())
191 + Ok(Bytes::copy_from_slice(frame.as_ref()))
192 }
193 }
194 }
rust/src/engine-core-client/src/protocol/multimodal.rs
+39
@@ -3,6 +3,7 @@
3
4 use std::collections::BTreeMap;
5
6 +use bytes::Bytes;
7 use serde::{Deserialize, Serialize};
8 use serde_tuple::{Deserialize_tuple, Serialize_tuple};
9
@@ -105,6 +106,44 @@ pub enum MmKwargValue {
106 List(Vec<MmKwargValue>),
107 }
108
109 +impl MmFeatureSpec {
110 + /// Extract large tensor buffers from this feature in serialized field order.
111 + pub(crate) fn extract_aux_frames(&mut self, aux_frames: &mut Vec<Bytes>, threshold: usize) {
112 + if let Some(data) = &mut self.data {
113 + for elem in data.values_mut() {
114 + elem.extract_aux_frames(aux_frames, threshold);
115 + }
116 + }
117 + if let Some(is_embed) = &mut self.mm_position.is_embed {
118 + is_embed.extract_aux_frame(aux_frames, threshold);
119 + }
120 + }
121 +}
122 +
123 +impl MmFieldElem {
124 + /// Extract large tensor buffers from this field element.
125 + fn extract_aux_frames(&mut self, aux_frames: &mut Vec<Bytes>, threshold: usize) {
126 + if let Some(data) = &mut self.data {
127 + data.extract_aux_frames(aux_frames, threshold);
128 + }
129 + }
130 +}
131 +
132 +impl MmKwargValue {
133 + /// Recursively extract large tensor buffers from this nested value.
134 + fn extract_aux_frames(&mut self, aux_frames: &mut Vec<Bytes>, threshold: usize) {
135 + match self {
136 + Self::Tensor(tensor) => tensor.extract_aux_frame(aux_frames, threshold),
137 + Self::List(values) => {
138 + for value in values {
139 + value.extract_aux_frames(aux_frames, threshold);
140 + }
141 + }
142 + Self::Int(_) | Self::Float(_) => {}
143 + }
144 + }
145 +}
146 +
147 /// Defines how to interpret tensor data belonging to a keyword argument for
148 /// `MultiModalKwargsItems`, and vice versa.
149 ///
rust/src/engine-core-client/src/protocol/request.rs
+97
@@ -137,6 +137,17 @@ impl EngineCoreRequest {
137 }
138 Ok(())
139 }
140 +
141 + /// Extract large request tensors into ordered auxiliary frames.
142 + pub(crate) fn extract_aux_frames(&mut self, threshold: usize) -> Vec<Bytes> {
143 + let mut aux_frames = Vec::new();
144 + if let Some(features) = &mut self.mm_features {
145 + for feature in features {
146 + feature.extract_aux_frames(&mut aux_frames, threshold);
147 + }
148 + }
149 + aux_frames
150 + }
151 }
152
153 #[cfg(test)]
@@ -144,9 +155,15 @@ mod tests {
155 use rmpv::Value;
156
157 use super::*;
158 + use crate::protocol::multimodal::{
159 + MmBatchedField, MmFeatureSpec, MmField, MmFieldElem, MmKwargValue, PlaceholderRange,
160 + };
161 use crate::protocol::sampling::EngineCoreSamplingParams;
162 + use crate::protocol::tensor::{WireArrayData, WireTensor};
163 use crate::protocol::{decode_value, encode_msgpack};
164
165 + const AUX_FRAME_THRESHOLD: usize = 256;
166 +
167 #[test]
168 fn engine_core_request_serializes_as_full_array() {
169 let request = EngineCoreRequest {
@@ -175,4 +192,84 @@ mod tests {
192 assert_eq!(array[10], Value::Nil);
193 assert_eq!(array[11], Value::from(7));
194 }
195 +
196 + #[test]
197 + fn engine_core_request_extracts_large_nested_tensors_in_wire_order() {
198 + let inline = vec![1_u8; AUX_FRAME_THRESHOLD - 1];
199 + let first_aux = vec![2_u8; AUX_FRAME_THRESHOLD];
200 + let second_aux = vec![3_u8; AUX_FRAME_THRESHOLD + 1];
201 + let first_aux_ptr = first_aux.as_ptr();
202 + let second_aux_ptr = second_aux.as_ptr();
203 + let mut request = EngineCoreRequest {
204 + mm_features: Some(vec![MmFeatureSpec {
205 + data: Some(BTreeMap::from([
206 + (
207 + "inline".to_string(),
208 + MmFieldElem {
209 + data: Some(MmKwargValue::Tensor(WireTensor::from_raw(
210 + "uint8",
211 + vec![inline.len()],
212 + inline,
213 + ))),
214 + field: MmField::Batched(MmBatchedField { keep_on_cpu: false }),
215 + },
216 + ),
217 + (
218 + "nested".to_string(),
219 + MmFieldElem {
220 + data: Some(MmKwargValue::List(vec![
221 + MmKwargValue::Int(7),
222 + MmKwargValue::Tensor(WireTensor::from_raw(
223 + "uint8",
224 + vec![first_aux.len()],
225 + first_aux,
226 + )),
227 + ])),
228 + field: MmField::Batched(MmBatchedField { keep_on_cpu: false }),
229 + },
230 + ),
231 + ])),
232 + modality: "image".to_string(),
233 + identifier: "id".to_string(),
234 + mm_position: PlaceholderRange {
235 + offset: 0,
236 + length: second_aux.len(),
237 + is_embed: Some(WireTensor::from_raw(
238 + "bool",
239 + vec![second_aux.len()],
240 + second_aux,
241 + )),
242 + },
243 + mm_hash: None,
244 + }]),
245 + ..EngineCoreRequest::default()
246 + };
247 +
248 + let aux_frames = request.extract_aux_frames(AUX_FRAME_THRESHOLD);
249 +
250 + assert_eq!(aux_frames.len(), 2);
251 + assert_eq!(aux_frames[0].as_ptr(), first_aux_ptr);
252 + assert_eq!(aux_frames[1].as_ptr(), second_aux_ptr);
253 + let feature = &request.mm_features.as_ref().unwrap()[0];
254 + let MmKwargValue::Tensor(inline) =
255 + feature.data.as_ref().unwrap()["inline"].data.as_ref().unwrap()
256 + else {
257 + panic!("expected inline tensor");
258 + };
259 + assert!(matches!(inline.data, WireArrayData::RawView(_)));
260 + let MmKwargValue::List(nested) =
261 + feature.data.as_ref().unwrap()["nested"].data.as_ref().unwrap()
262 + else {
263 + panic!("expected nested tensor list");
264 + };
265 + let MmKwargValue::Tensor(nested_tensor) = &nested[1] else {
266 + panic!("expected nested tensor");
267 + };
268 + assert_eq!(nested_tensor.data, WireArrayData::AuxIndex(1));
269 + assert_eq!(
270 + feature.mm_position.is_embed.as_ref().unwrap().data,
271 + WireArrayData::AuxIndex(2)
272 + );
273 + assert!(request.extract_aux_frames(AUX_FRAME_THRESHOLD).is_empty());
274 + }
275 }
rust/src/engine-core-client/src/protocol/tensor.rs
+98 -44
@@ -1,7 +1,8 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use bytemuck::allocation::pod_collect_to_vec;
4 +use bytemuck::{Pod, cast_slice};
5 +use bytes::Bytes;
6 use enum_as_inner::EnumAsInner;
7 use half::{bf16, f16};
8 use rmpv::Value;
@@ -20,6 +21,21 @@ struct MsgpackExtRef<'a>((i8, ByteSlice<'a>));
21
22 struct ByteSlice<'a>(&'a [u8]);
23
24 +struct PodVec<T: Pod>(Vec<T>);
25 +
26 +impl<T: Pod> AsRef<[u8]> for PodVec<T> {
27 + fn as_ref(&self) -> &[u8] {
28 + cast_slice(&self.0)
29 + }
30 +}
31 +
32 +fn bytes_from_pod_vec<T>(data: Vec<T>) -> Bytes
33 +where
34 + T: Pod + Send + 'static,
35 +{
36 + Bytes::from_owner(PodVec(data))
37 +}
38 +
39 impl Serialize for ByteSlice<'_> {
40 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
41 where
@@ -55,58 +71,63 @@ pub struct WireNdArray {
71
72 impl WireNdArray {
73 /// Build a float32 tensor/ndarray backed by native-endian raw-view bytes.
58 - pub fn from_f32(shape: Vec<usize>, data: impl AsRef<[f32]>) -> Result<Self, String> {
59 - let data = data.as_ref();
74 + ///
75 + /// Takes ownership of the backing buffer without copying its data.
76 + pub fn from_f32(shape: Vec<usize>, data: Vec<f32>) -> Result<Self, String> {
77 validate_element_count(&shape, data.len())?;
61 - Ok(Self {
62 - dtype: "float32".to_string(),
78 + Ok(Self::from_raw_bytes(
79 + "float32",
80 shape,
64 - data: WireArrayData::RawView(pod_collect_to_vec::<f32, u8>(data)),
65 - })
81 + bytes_from_pod_vec(data),
82 + ))
83 }
84
85 /// Build a float16 tensor/ndarray backed by native-endian raw-view bytes.
69 - pub fn from_f16(shape: Vec<usize>, data: impl AsRef<[f16]>) -> Result<Self, String> {
70 - let data = data.as_ref();
86 + ///
87 + /// Takes ownership of the backing buffer without copying its data.
88 + pub fn from_f16(shape: Vec<usize>, data: Vec<f16>) -> Result<Self, String> {
89 validate_element_count(&shape, data.len())?;
72 - Ok(Self {
73 - dtype: "float16".to_string(),
90 + Ok(Self::from_raw_bytes(
91 + "float16",
92 shape,
75 - data: WireArrayData::RawView(pod_collect_to_vec::<f16, u8>(data)),
76 - })
93 + bytes_from_pod_vec(data),
94 + ))
95 }
96
97 /// Build a bfloat16 tensor/ndarray backed by native-endian raw-view bytes.
80 - pub fn from_bf16(shape: Vec<usize>, data: impl AsRef<[bf16]>) -> Result<Self, String> {
81 - let data = data.as_ref();
98 + ///
99 + /// Takes ownership of the backing buffer without copying its data.
100 + pub fn from_bf16(shape: Vec<usize>, data: Vec<bf16>) -> Result<Self, String> {
101 validate_element_count(&shape, data.len())?;
83 - Ok(Self {
84 - dtype: "bfloat16".to_string(),
102 + Ok(Self::from_raw_bytes(
103 + "bfloat16",
104 shape,
86 - data: WireArrayData::RawView(pod_collect_to_vec::<bf16, u8>(data)),
87 - })
105 + bytes_from_pod_vec(data),
106 + ))
107 }
108
109 /// Build an int64 tensor/ndarray backed by native-endian raw-view bytes.
91 - pub fn from_i64(shape: Vec<usize>, data: impl AsRef<[i64]>) -> Result<Self, String> {
92 - let data = data.as_ref();
110 + ///
111 + /// Takes ownership of the backing buffer without copying its data.
112 + pub fn from_i64(shape: Vec<usize>, data: Vec<i64>) -> Result<Self, String> {
113 validate_element_count(&shape, data.len())?;
94 - Ok(Self {
95 - dtype: "int64".to_string(),
114 + Ok(Self::from_raw_bytes(
115 + "int64",
116 shape,
97 - data: WireArrayData::RawView(pod_collect_to_vec::<i64, u8>(data)),
98 - })
117 + bytes_from_pod_vec(data),
118 + ))
119 }
120
121 /// Build a uint32 tensor/ndarray backed by native-endian raw-view bytes.
102 - pub fn from_u32(shape: Vec<usize>, data: impl AsRef<[u32]>) -> Result<Self, String> {
103 - let data = data.as_ref();
122 + ///
123 + /// Takes ownership of the backing buffer without copying its data.
124 + pub fn from_u32(shape: Vec<usize>, data: Vec<u32>) -> Result<Self, String> {
125 validate_element_count(&shape, data.len())?;
105 - Ok(Self {
106 - dtype: "uint32".to_string(),
126 + Ok(Self::from_raw_bytes(
127 + "uint32",
128 shape,
108 - data: WireArrayData::RawView(pod_collect_to_vec::<u32, u8>(data)),
109 - })
129 + bytes_from_pod_vec(data),
130 + ))
131 }
132
133 /// Build a bool tensor/ndarray backed by raw-view bytes.
@@ -118,7 +139,9 @@ impl WireNdArray {
139 Ok(Self {
140 dtype: "bool".to_string(),
141 shape,
121 - data: WireArrayData::RawView(data.iter().map(|value| u8::from(*value)).collect()),
142 + data: WireArrayData::RawView(Bytes::from(
143 + data.into_iter().map(u8::from).collect::<Vec<_>>(),
144 + )),
145 })
146 }
147
@@ -127,12 +150,22 @@ impl WireNdArray {
150 /// Use this as an escape hatch when the caller already owns bytes that
151 /// match the requested `dtype` and `shape`.
152 pub fn from_raw(dtype: impl Into<String>, shape: Vec<usize>, data: Vec<u8>) -> Self {
153 + Self::from_raw_bytes(dtype, shape, Bytes::from(data))
154 + }
155 +
156 + /// Build a tensor/ndarray from an owned immutable raw-view buffer.
157 + pub fn from_raw_bytes(dtype: impl Into<String>, shape: Vec<usize>, data: Bytes) -> Self {
158 Self {
159 dtype: dtype.into(),
160 shape,
161 data: WireArrayData::RawView(data),
162 }
163 }
164 +
165 + /// Move a sufficiently large inline buffer into the ordered auxiliary-frame list.
166 + pub(crate) fn extract_aux_frame(&mut self, aux_frames: &mut Vec<Bytes>, threshold: usize) {
167 + self.data.extract_aux_frame(aux_frames, threshold);
168 + }
169 }
170
171 /// Validate that the number of elements implied by the shape matches the length
@@ -170,7 +203,25 @@ pub enum WireArrayData {
203 /// stored.
204 AuxIndex(usize),
205 /// The raw bytes of this array/tensor.
173 - RawView(Vec<u8>),
206 + RawView(Bytes),
207 +}
208 +
209 +impl WireArrayData {
210 + /// Replace a sufficiently large raw view with its one-based auxiliary-frame index.
211 + fn extract_aux_frame(&mut self, aux_frames: &mut Vec<Bytes>, threshold: usize) {
212 + let Self::RawView(bytes) = self else {
213 + return;
214 + };
215 + if bytes.len() < threshold {
216 + return;
217 + }
218 +
219 + let index = aux_frames.len() + 1;
220 + let bytes = std::mem::replace(self, Self::AuxIndex(index))
221 + .into_raw_view()
222 + .expect("raw view was matched above");
223 + aux_frames.push(bytes);
224 + }
225 }
226
227 impl<'de> Deserialize<'de> for WireArrayData {
@@ -180,7 +231,9 @@ impl<'de> Deserialize<'de> for WireArrayData {
231 {
232 let value = Value::deserialize(deserializer)?;
233 match value {
183 - Value::Ext(tag, bytes) if tag == CUSTOM_TYPE_RAW_VIEW => Ok(Self::RawView(bytes)),
234 + Value::Ext(tag, bytes) if tag == CUSTOM_TYPE_RAW_VIEW => {
235 + Ok(Self::RawView(Bytes::from(bytes)))
236 + }
237 Value::Ext(tag, _) => Err(serde::de::Error::custom(format!(
238 "unsupported extension type code {tag}"
239 ))),
@@ -201,9 +254,6 @@ impl Serialize for WireArrayData {
254 where
255 S: Serializer,
256 {
204 - // TODO: outbound request serialization currently only supports inline
205 - // raw-view bytes. Emitting aux frames needs transport-level plumbing;
206 - // serializing `AuxIndex` here only preserves an already-built reference.
257 match self {
258 Self::AuxIndex(index) => serializer.serialize_u64(*index as u64),
259 Self::RawView(bytes) => {
@@ -221,7 +271,7 @@ mod tests {
271 fn raw_view_serializes_as_msgpack_ext() {
272 let bytes = vec![1, 2, 3, 4];
273 let encoded =
224 - rmp_serde::to_vec_named(&WireArrayData::RawView(bytes.clone())).expect("encode");
274 + rmp_serde::to_vec_named(&WireArrayData::RawView(bytes.clone().into())).expect("encode");
275 let expected = rmp_serde::to_vec_named(&Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes.clone()))
276 .expect("encode expected");
277
@@ -234,11 +284,15 @@ mod tests {
284
285 #[test]
286 fn constructors_build_raw_view_tensors() {
237 - let f32_tensor = WireNdArray::from_f32(vec![2], vec![1.0, 2.5]).unwrap();
287 + let f32_data = vec![1.0, 2.5];
288 + let f32_data_ptr = f32_data.as_ptr().cast::<u8>();
289 + let f32_tensor = WireNdArray::from_f32(vec![2], f32_data).unwrap();
290 assert_eq!(f32_tensor.dtype, "float32");
291 assert_eq!(f32_tensor.shape, vec![2]);
292 + let f32_raw_view = f32_tensor.data.into_raw_view().expect("raw view");
293 + assert_eq!(f32_raw_view.as_ptr(), f32_data_ptr);
294 assert_eq!(
241 - f32_tensor.data.into_raw_view().expect("raw view"),
295 + f32_raw_view,
296 [1.0_f32, 2.5].into_iter().flat_map(f32::to_ne_bytes).collect::<Vec<_>>()
297 );
298
@@ -258,15 +312,15 @@ mod tests {
312 let i64_tensor = WireNdArray::from_i64(vec![1], vec![-7]).unwrap();
313 assert_eq!(i64_tensor.dtype, "int64");
314 assert_eq!(
261 - i64_tensor.data.into_raw_view().expect("raw view"),
262 - (-7_i64).to_ne_bytes()
315 + i64_tensor.data.into_raw_view().expect("raw view").as_ref(),
316 + (-7_i64).to_ne_bytes().as_ref()
317 );
318
319 let u32_tensor = WireNdArray::from_u32(vec![1], vec![42]).unwrap();
320 assert_eq!(u32_tensor.dtype, "uint32");
321 assert_eq!(
268 - u32_tensor.data.into_raw_view().expect("raw view"),
269 - 42_u32.to_ne_bytes()
322 + u32_tensor.data.into_raw_view().expect("raw view").as_ref(),
323 + 42_u32.to_ne_bytes().as_ref()
324 );
325
326 let bool_tensor = WireNdArray::from_bool(vec![2], vec![false, true]).unwrap();
rust/src/engine-core-client/src/tests/client.rs
+81 -1
@@ -32,7 +32,7 @@ use crate::protocol::output::{
32 use crate::protocol::request::{EngineCoreRequest, EngineCoreRequestType};
33 use crate::protocol::sampling::EngineCoreSamplingParams;
34 use crate::protocol::stats::SchedulerStats;
35 -use crate::protocol::tensor::WireTensor;
35 +use crate::protocol::tensor::{WireArrayData, WireTensor};
36 use crate::protocol::utility::{UtilityOutput, UtilityResultEnvelope};
37 use crate::test_utils::{
38 IpcNamespace, setup_bootstrapped_mock_engine, setup_mock_engine_sockets,
@@ -1728,6 +1728,86 @@ async fn client_decodes_multipart_logprob_outputs() {
1728 client.shutdown().await.unwrap();
1729 }
1730
1731 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1732 +async fn client_sends_large_multimodal_tensor_as_aux_frame() {
1733 + init_tracing();
1734 + let ipc = IpcNamespace::new().unwrap();
1735 + let handshake_address = ipc.handshake_endpoint();
1736 + let engine_id = b"engine-multimodal-aux".to_vec();
1737 + let tensor_data = (0..64).map(|value| value as f32).collect::<Vec<_>>();
1738 + let expected_bytes =
1739 + tensor_data.iter().flat_map(|value| value.to_ne_bytes()).collect::<Vec<_>>();
1740 + let mut request = sample_multimodal_request();
1741 + request.mm_features.as_mut().unwrap()[0]
1742 + .data
1743 + .as_mut()
1744 + .unwrap()
1745 + .get_mut("pixel_values")
1746 + .unwrap()
1747 + .data = Some(MmKwargValue::Tensor(
1748 + WireTensor::from_f32(vec![64], tensor_data).unwrap(),
1749 + ));
1750 +
1751 + let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1752 + handshake_address.clone(),
1753 + engine_id.clone(),
1754 + move |dealer, push| {
1755 + Box::pin(async move {
1756 + let add = recv_engine_message(dealer).await;
1757 + assert_eq!(add.len(), 3);
1758 + assert_eq!(add[0].as_ref(), &[0x00]);
1759 + let request: EngineCoreRequest = rmp_serde::from_slice(&add[1]).unwrap();
1760 + let MmKwargValue::Tensor(tensor) =
1761 + request.mm_features.as_ref().unwrap()[0].data.as_ref().unwrap()["pixel_values"]
1762 + .data
1763 + .as_ref()
1764 + .unwrap()
1765 + else {
1766 + panic!("expected tensor");
1767 + };
1768 + assert_eq!(tensor.data, WireArrayData::AuxIndex(1));
1769 + assert_eq!(add[2].as_ref(), expected_bytes);
1770 +
1771 + send_outputs(
1772 + push,
1773 + RequestBatchOutputs {
1774 + outputs: vec![request_output(
1775 + "req-mm",
1776 + vec![],
1777 + Some(EngineCoreFinishReason::Length),
1778 + )],
1779 + finished_requests: Some(BTreeSet::from(["req-mm".to_string()])),
1780 + ..Default::default()
1781 + }
1782 + .into(),
1783 + )
1784 + .await;
1785 + })
1786 + },
1787 + );
1788 +
1789 + let client = connect_client_with_ipc(
1790 + handshake_test_config(
1791 + handshake_address,
1792 + 1,
1793 + "test-model",
1794 + Duration::from_secs(2),
1795 + 0,
1796 + None,
1797 + ),
1798 + &ipc,
1799 + )
1800 + .await;
1801 +
1802 + let outputs = client.call(request).await.unwrap().collect::<Vec<_>>().await;
1803 + assert_eq!(outputs.len(), 1);
1804 + assert!(outputs[0].is_ok());
1805 +
1806 + let _ = shutdown_tx.send(());
1807 + engine_task.await.unwrap();
1808 + client.shutdown().await.unwrap();
1809 +}
1810 +
1811 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1812 async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() {
1813 init_tracing();
rust/src/engine-core-client/src/transport.rs
+7 -7
@@ -512,14 +512,14 @@ pub async fn send_message(
512 input_send: &mut RouterSendHalf,
513 engine_id: &EngineId,
514 request_type: Bytes,
515 - payload: Vec<u8>,
515 + payload: Bytes,
516 + aux_frames: Vec<Bytes>,
517 ) -> Result<()> {
517 - let message = ZmqMessage::try_from(vec![
518 - engine_id.to_frame(),
519 - request_type,
520 - Bytes::from(payload),
521 - ])
522 - .expect("router messages must contain identity and payload");
518 + let mut frames = Vec::with_capacity(3 + aux_frames.len());
519 + frames.extend([engine_id.to_frame(), request_type, payload]);
520 + frames.extend(aux_frames);
521 + let message =
522 + ZmqMessage::try_from(frames).expect("router messages must contain identity and payload");
523
524 trace!(
525 ?engine_id,