Rust Frontend 多模态张量零拷贝优化
执行摘要
本 PR 消除了 Rust 前端在处理多模态请求时的两处不必要字节克隆:一是在 lower_text_request 中通过 Option::take() 移动 mm_features,二是在 WireArrayData::RawView 序列化时使用借字节的 MsgpackExtRef 替代 Value::Ext(…, clone)。变更保持线格式完全不变,不引入新依赖,对 engine 侧行为无影响。
功能与动机
多模态请求携带的 tensor 缓冲区(如图像 pixel_values)可能极大。PR body 指出此前 Rust 前端对每个多模态请求有两处不必要的分配和拷贝:
lower_text_request 克隆了 request.mm_features(一个包含大字节 buffer 的 Vec<MmFeatureSpec>)给 GenerateRequest;
WireArrayData::RawView 序列化时克隆了整个字节缓冲区用于构造 Value::Ext。
这些多余的拷贝增加了 per-request 的 CPU 负载和临时内存压力,对于大图像请求尤为明显。
实现拆解
-
rust/src/text/src/lower.rs: 移动 mm_features 所有权
- 函数签名改为 mut request: TextRequest;
- mm_features: request.mm_features.take() 替代 mm_features: request.mm_features.clone();
- 迁移后 text_request.mm_features 为 None,与 Python 响应解码路径一致;
- 补充单元测试 lower_text_request_moves_multimodal_features_to_generate_request 验证转移正确性。
-
rust/src/engine-core-client/src/protocol/tensor.rs: 零拷贝 MessagePack 扩展序列化
- 引入 MsgpackExtRef<'a> 结构体,通过 _ExtStruct 标记让 rmp-serde 将其编码为 MessagePack 扩展类型;
- 引入 ByteSlice<'a> 作为 &[u8] 的 newtype,其 Serialize 实现调用 serialize_bytes,确保 rmp-serde 使用正确的字节编码路径(而非序列化);
- 修改 WireArrayData::RawView 分支:从 Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes.clone()) 改为 MsgpackExtRef((CUSTOM_TYPE_RAW_VIEW, ByteSlice(bytes))),消除克隆;
- 补充单元测试 raw_view_serializes_as_msgpack_ext 验证序列化结果与旧路径完全一致。
rust/src/engine-core-client/src/protocol/tensor.rs
核心序列化路径:新增 MsgpackExtRef 和 ByteSlice 实现零拷贝 MessagePack 扩展编码,修改 WireArrayData::RawView 序列化避免克隆。
/// MessagePack extension struct that serializes without copying bytes.
///
/// `rmp-serde`'s `_ExtStruct` path expects the second tuple element to use
/// `serialize_bytes()` to produce a bin/str value inside the ext. By wrapping
/// `&[u8]` in `ByteSlice` (which delegates to `serialize_bytes`), we
/// avoid both a `serde_bytes` dependency and cloning the entire byte buffer.
#[derive(Serialize)]
#[serde(rename = "_ExtStruct")]
struct MsgpackExtRef<'a>((i8, ByteSlice<'a>));
/// Newtype over `&[u8]` to force `serde`'s bytes serialization path.
struct ByteSlice<'a>(&'a [u8]);
impl Serialize for ByteSlice<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
// `serialize_bytes` is what `rmp-serde`'s `ExtStruct` expects for the
// binary payload; a plain `&[u8]` would serialize as a sequence.
serializer.serialize_bytes(self.0)
}
}
// … inside the `Serialize for WireArrayData` impl:
impl Serialize for WireArrayData {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::AuxIndex(index) => serializer.serialize_u64(*index as u64),
Self::RawView(bytes) => {
// Before: `Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes.clone()).serialize(serializer)`
// Now: borrow `bytes` without cloning.
MsgpackExtRef((CUSTOM_TYPE_RAW_VIEW, ByteSlice(bytes))).serialize(serializer)
}
}
}
}
rust/src/text/src/lower.rs
多模态特征所有权转移:将 mm_features 的克隆改为 take(),避免大缓冲区拷贝,并调整函数签名为 mut request。
/// Convert a high-level [`TextRequest`] into one lower-level
/// [`GenerateRequest`] ready for the `llm` crate.
pub fn lower_text_request(
mut request: TextRequest, // was `request: TextRequest`
prompt_token_ids: Vec<u32>,
sampling_hints: SamplingHints,
sampling_limits: SamplingLimits,
tokenizer: &dyn Tokenizer,
) -> Result<PreparedTextRequest> {
let prompt_len = prompt_token_ids.len() as u32;
validate_prompt_token_ids(&prompt_token_ids, &sampling_limits)?;
let generate_request = GenerateRequest {
request_id: request.request_id.clone(),
prompt_token_ids,
// Before: `mm_features: request.mm_features.clone()` caused an extra
// allocation of the potentially-large multimodal tensor bytes.
// After: move the feature data into the engine request so the retained
// `text_request` no longer holds it — matching Python's response path.
mm_features: request.mm_features.take(),
sampling_params: lower_sampling_params(/* … */)?,
// … other fields unchanged …
arrival_time: None,
trace_headers: None,
};
Ok(PreparedTextRequest {
text_request: request, // `mm_features` is now `None` here
generate_request,
})
}
评论区精华
- BugenZhao 询问
ByteSlice newtype 是否必要。
- reidliu41 解释:若没有它,
&[u8] 会走 Serde 的切片序列化路径(序列化),而不是 serialize_bytes,导致 _ExtStruct 无法正确编码。使用 newtype 可在不引入 serde_bytes 依赖的前提下强制字节编码。
- BugenZhao 随后批准 PR。
风险与影响
风险:极低。线格式通过单元测试验证保证完全一致;不改变 engine 行为;不引入新的依赖。唯一需要关注的是 ByteSlice 的借字节生命周期:必须确保 bytes 引用在序列化期间有效,但当前用法中 bytes 来自 WireArrayData::RawView 的引用,其生命周期与 WireArrayData 实例一致,安全。
影响:直接影响 Rust 前端处理多模态请求时的性能,对大 buffer 请求(如图像)减少两次分配和拷贝。对 Python 前端和 engine 侧无影响。
关联脉络
- 关联 PR#47787 同样修改了
lower.rs 等 Rust 前端文件,属于同一 Rust 前端请求路径优化系列。
- 本 PR 与近期
[Rust Frontend] Stamp arrival_time at the frontend entry(#47787)一同表明团队正在持续打磨 Rust 前端的请求处理流水线,减少非必要开销。
参与讨论