Prhub

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

原始 PR 作者 reidliu41 合并时间 2026-07-29 20:21 文件变更 11 提交数 5 评论 0 代码增减 +530 / -159

执行摘要

Rust 前端多模态张量改走 ZMQ 辅助帧消除大拷贝

现有 Rust 前端的 EngineCoreRequest::Add 消息将多模态张量转为自有原始字节,复制到大的 msgpack 负载,再复制到 ZMQ 发送缓冲区。对于大图、多图和视频请求需要多次全尺寸内存拷贝和连续 msgpack 分配。虽然 wire 层已支持 AuxIndex,但 Rust 出站路径未产生相应辅助帧。本 PR 完成该路径以消除冗余拷贝,显著降低延迟和内存占用。

该 PR 值得精读,特别是 PodVec 零拷贝设计和分层提取模式(extract_aux_frames 递归),可作为 Rust 下高性能数据传输的参考。关注 Bytes::from_owner 的使用和阈值可配置的实践。建议在部署时测试默认阈值是否适合典型多媒体配置。

讨论亮点

BugenZhao 在批准前添加了两个 commit:代码风格重构和将阈值改为环境变量配置。这体现了设计上对可配置性的考量,避免硬编码阈值,允许用户根据实际场景调整。无其他实质讨论。

实现拆解

  1. 引入零拷贝张量表示:在 protocol/tensor.rs 中新增 PodVec<T> 包装器和 bytes_from_pod_vec 函数,利用 Bytes::from_owner 将已分配的 typed 缓冲区直接转为零拷贝字节缓冲,避免 pod_collect_to_vec 的中间复制。所有 from_* 构造器改为接受 Vec<T> 所有权而非引用,通过 from_raw_bytes 统一后台。
  2. 张量提取与辅助帧生成:在 protocol/multimodal.rs 中为 MmFeatureSpecMmFieldElemMmKwargValue 添加 extract_aux_frames 递归方法,遍历多模态字段(包括 is_embed),将字节大小超过阈值(默认 256 字节)的张量替换为 WireArrayData::AuxIndex,并将原始 Bytes 追加到辅助帧向量中。
  3. 发送路径修改:在 client/imp.rs 中新增 send_request_to_engine 方法,先调用 extract_aux_frames 提取大型张量,再将主负载和辅助帧向量传入新的 send_encoded_to_engine 方法,使用 ZMQ multipart 发送(主帧 + 每个辅助帧)。旧 send_to_engine 保留用于非请求消息。
  4. 阈值可配置:支持通过环境变量 VLLM_MSGPACK_ZERO_COPY_THRESHOLD 自定义阈值,默认 256 字节,在 ClientInner 构造时读取存储。
  5. 测试覆盖:在 tests/client.rsprotocol/request.rs 中添加端到端和单元测试,验证大张量被移出主负载并作为辅助帧发送,且小张量保持内联。
文件 模块 状态 重要度
rust/src/engine-core-client/src/protocol/tensor.rs 核心协议 modified 8.96
rust/src/chat/src/multimodal/tensor.rs 多模态处理 modified 8.63
rust/src/engine-core-client/src/client/imp.rs 客户端核心 modified 8.12
rust/src/engine-core-client/src/protocol/request.rs 请求协议 modified 7.88
rust/src/engine-core-client/src/protocol/multimodal.rs 多模态协议 modified 7.43
rust/src/engine-core-client/src/tests/client.rs 客户端测试 modified 6.9

关键符号

bytes_from_pod_vec WireNdArray::from_f32 WireNdArray::from_f16 WireNdArray::from_bf16 WireNdArray::from_i64 WireNdArray::from_u32 WireNdArray::from_raw_bytes KwargValue::tensor KwargValue::from_f32_tensor MmFeatureSpec::extract_aux_frames MmFieldElem::extract_aux_frames MmKwargValue::extract_aux_frames WireTensor::extract_aux_frame EngineCoreRequest::extract_aux_frames ClientInner::send_request_to_engine ClientInner::send_encoded_to_engine msgpack_zero_copy_threshold client_sends_large_multimodal_tensor_as_aux_frame

关键源码片段

rust/src/engine-core-client/src/protocol/tensor.rs core-logic

core logic: 新增 PodVec 包装器和 bytes_from_pod_vec 实现零拷贝张量字节转换,重构所有 from_* 构造器以直接获取所有权并调用 from_raw_bytes,是消除副本的核心基础。

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM projectuse bytemuck::{Pod, cast_slice};
use bytes::Bytes;/// Wrapper that lets a `Vec<T>` be used as `AsRef<[u8]>` via zero-copy cast.
/// 利用 bytemuck 的 `cast_slice` 将 typed 数组直接转为字节切片,无需复制。
struct PodVec<T: Pod>(Vec<T>);impl<T: Pod> AsRef<[u8]> for PodVec<T> {
    fn as_ref(&self) -> &[u8] {
        cast_slice(&self.0)
    }
}/// Convert a typed `Vec<T>` into `Bytes` without copying the underlying buffer.
/// 所有权由 `PodVec` 包裹,`Bytes::from_owner` 负责内存管理,发送完成后释放。
fn bytes_from_pod_vec<T>(data: Vec<T>) -> Bytes
where
    T: Pod + Send + 'static,
{
    Bytes::from_owner(PodVec(data))
}// Example: from_f32 now takes ownership and uses bytes_from_pod_vec
impl WireNdArray {
    /// Build a float32 tensor backed by native-endian raw-view bytes.
    /// Takes ownership of the backing buffer without copying its data.
    pub fn from_f32(shape: Vec<usize>, data: Vec<f32>) -> Result<Self, String> {
        validate_element_count(&shape, data.len())?;
        Ok(Self::from_raw_bytes(
            "float32",
            shape,
            bytes_from_pod_vec(data),
        ))
    }
    // 类似地,from_f16, from_bf16, from_i64, from_u32 均改为相同模式。
}
rust/src/engine-core-client/src/client/imp.rs core-logic

core logic: 新增 msgpack_zero_copy_threshold 配置和 send_request_to_engine 方法,分离普通消息与请求消息的发送路径;实现 multipart ZMQ 发送。

// 阈值配置:环境变量 VLLM_MSGPACK_ZERO_COPY_THRESHOLD,默认 256 字节
const MSGPACK_ZERO_COPY_THRESHOLD_ENV: &str = "VLLM_MSGPACK_ZERO_COPY_THRESHOLD";
const DEFAULT_MSGPACK_ZERO_COPY_THRESHOLD: usize = 256;fn msgpack_zero_copy_threshold() -> usize {
    std::env::var(MSGPACK_ZERO_COPY_THRESHOLD_ENV)
        .ok()
        .and_then(|value| value.parse().ok())
        .unwrap_or(DEFAULT_MSGPACK_ZERO_COPY_THRESHOLD)
}impl ClientInner {
    /// 发送 Add 请求,将大张量移出主负载作为辅助帧。
    pub async fn send_request_to_engine(
        &self,
        engine_id: &EngineId,
        mut payload: EngineCoreRequest,
    ) -> Result<()> {
        // 1. 提取超过阈值的张量 buffers,替换为 AuxIndex
        let aux_frames = payload.extract_aux_frames(self.msgpack_zero_copy_threshold);
        // 2. 对剩下的请求编码(小张量保持内联)
        let payload = Bytes::from(encode_msgpack(&payload)?);
        // 3. 发送 multipart:主帧 + 辅助帧
        self.send_encoded_to_engine(engine_id, EngineCoreRequestType::Add, payload, aux_frames)
            .await
    }    /// 底层 multipart 发送:ZMQ 分帧发送主负载和辅助帧。
    async fn send_encoded_to_engine(
        &self,
        engine_id: &EngineId,
        request_type: EngineCoreRequestType,
        payload: Bytes,
        aux_frames: Vec<Bytes>,
    ) -> Result<()> {
        let mut input_send = self.input_send.clone();
        let engine_id = engine_id.clone();
        self.handle.spawn(async move {
            transport::send_multipart_message(
                &mut input_send,
                &engine_id,
                request_type.to_frame(),
                payload,
                aux_frames,
            )
            .await
        }).await.map_err(|_| Error::ClientClosed)??;
        Ok(())
    }
}
rust/src/engine-core-client/src/protocol/request.rs core-logic

core logic: 新增 extract_aux_frames 方法,遍历 mm_features 递归提取大张量;同时添加单元测试验证提取逻辑正确。

impl EngineCoreRequest {
    /// Extract large request tensors into ordered auxiliary frames.
    /// 遍历 mm_features,将超过阈值的张量移出主负载,返回辅助帧向量。
    pub(crate) fn extract_aux_frames(&mut self, threshold: usize) -> Vec<Bytes> {
        let mut aux_frames = Vec::new();
        if let Some(features) = &mut self.mm_features {
            for feature in features {
                feature.extract_aux_frames(&mut aux_frames, threshold);
            }
        }
        aux_frames
    }
}#[cfg(test)]
mod tests {
    #[test]
    fn engine_core_request_extracts_large_nested_tensors_in_wire_order() {
        let inline = vec![1_u8; AUX_FRAME_THRESHOLD - 1]; // 小于阈值,保持内联
        let first_aux = vec![2_u8; AUX_FRAME_THRESHOLD]; // 等于阈值,移出
        let second_aux = vec![3_u8; AUX_FRAME_THRESHOLD + 1]; // 大于阈值,移出
        let first_aux_ptr = first_aux.as_ptr();
        let second_aux_ptr = second_aux.as_ptr();
        let mut request = EngineCoreRequest {
            mm_features: Some(vec![MmFeatureSpec {
                data: Some(BTreeMap::from([
                    ("inline".to_string(), MmFieldElem {
                        data: Some(MmKwargValue::Tensor(WireTensor::from_raw(
                            "uint8", vec![inline.len()], inline,
                        ))),
                        field: MmField::Batched(MmBatchedField { keep_on_cpu: false }),
                    }),
                    ("nested".to_string(), MmFieldElem {
                        data: Some(MmKwargValue::List(vec![
                            MmKwargValue::Int(7),
                            MmKwargValue::Tensor(WireTensor::from_raw(
                                "uint8", vec![first_aux.len()], first_aux,
                            )),
                        ])),
                        field: MmField::Batched(MmBatchedField { keep_on_cpu: false }),
                    }),
                ])),
                modality: "image".to_string(),
                identifier: "id".to_string(),
                mm_position: PlaceholderRange { /* ... */ },
                mm_hash: None,
            }]),
            ..EngineCoreRequest::default()
        };        let aux_frames = request.extract_aux_frames(AUX_FRAME_THRESHOLD);        assert_eq!(aux_frames.len(), 2);
        // 验证辅助帧与原缓冲区地址一致(零拷贝)
        assert_eq!(aux_frames[0].as_ptr(), first_aux_ptr);
        assert_eq!(aux_frames[1].as_ptr(), second_aux_ptr);
        // 验证内联张量仍是 RawView 而非 AuxIndex
        let inline_tensor = /* 获取 inline tensor */;
        assert!(matches!(inline_tensor.data, WireArrayData::RawView(_)));
        // 验证提取后的张量变为 AuxIndex(1) 和 AuxIndex(2)
        let nested_tensor = /* 获取 nested 中 tensor */;
        assert_eq!(nested_tensor.data, WireArrayData::AuxIndex(1));
        let is_embed = /* 获取 is_embed tensor */;
        assert_eq!(is_embed.data, WireArrayData::AuxIndex(2));
    }
}

评论区精华

阈值可配置设计 设计

BugenZhao 在批准前添加了一个 commit 将阈值改为从环境变量加载,而非硬编码。

结论:接受环境变量配置 VLLM_MSGPACK_ZERO_COPY_THRESHOLD,默认 256 字节,与 Python 端保持一致。 · 已解决

代码风格 refactoring style

BugenZhao 添加了一个 commit 进行 minor style refactoring。

结论:被合并,无实质逻辑影响。 · 已解决

风险与影响

兼容性风险:如果接收端(Python 引擎)不能正确处理 AuxIndex 或 multipart ZMQ 消息,可能导致解码失败。但 PR 保持了现有 wire 编码规范(AuxIndex 已在 Python 端支持),且阈值与 Python 默认一致(256 字节),风险可控。
性能风险:multipart 发送增加帧开销(每辅助帧多一个 ZMQ 消息部分),但相对于消除的复制成本可忽略。
安全风险:使用 Bytes::from_owner 通过 PodVec 确保 T: Pod + Send + 'static,缓冲区所有权管理正确,未引入安全问题。
回归风险:主要影响 Rust 前端多模态请求路径,非多模态请求仍使用原 send_to_engine 路径,无影响。

影响范围:所有通过 Rust 前端发送多模态请求的用户(图像、视频),特别是大文件或多张图片。延迟减少约 60%,内存占用降低 33%-50%。对非多模态请求无影响。由于增加了环境变量阈值配置,提供灵活性。团队可通过调整 VLLM_MSGPACK_ZERO_COPY_THRESHOLD 优化。

核心路径变更 依赖 bytes 零拷贝 兼容性依赖 Python 端 AuxIndex 支持

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论