Prhub

#39329 Add external multimodal processors to the Rust frontend

原始 PR 作者 merrymercy 合并时间 2026-09-16 06:38 文件变更 20 提交数 2 评论 3 代码增减 +1027 / -197

执行摘要

Rust 前端新增外部多模态处理器扩展点

PR body 明确指出:"Processor-specific fields and encoded results need a typed extension boundary so additional model packages can integrate without adding their schemas to the shared frontend." 目标是 "Allow model packages to supply a Rust multimodal processor and Python extension while reusing the frontend's request handling, worker pool, and scheduler integration"。即外部模型包(如未来新增的多模态家族)不需要把 schema 写进共享前端,而是通过一个类型化扩展边界接入现有 Rust worker 池与调度器。

值得精读,尤其是扩展点设计:MmProcessor trait 抽象、ProcessorExtensions 的不透明字段透传、validate-before-park 的防御式结果校验、以及 BF16 以 u16 位模式零拷贝过界的做法。关注点建议放在:外部处理器接入文档是否补齐、multimodal_* 字段行为变更的兼容性说明、以及 GPU 实测落地前的风险接受度。

讨论亮点

该 PR 没有任何 review 评论(review_comments 为空),issue 评论仅包含 CI 指令(/tag-and-rerun-ci、/rerun-group rust-server)。设计取舍主要来自 PR body 自述而非评审交锋:作者明确承认 "GPU model execution was not run locally",以 CPU 验证覆盖处理器分派、请求字段、结果校验、媒体预算、内置结果包装与启动拓扑;同时强调 "Unit tests cover preservation of prefetched allocations and the existing zero-copy Python wrapping contract",说明零拷贝与预取分配保序是本次改动刻意守护的契约。

实现拆解

  1. 请求字段扩展(rust/sglang-server/src/message/request.rs):新增 ProcessorExtensions(内部为 BTreeMap<String, rmpv::Value>),通过 #[serde(flatten)] 捕获 GenerateBody 中所有 multimodal_* 前缀字段;split_extension_columns 按 batch 形状做标量广播或按位置拆分,空列表整体广播,长度不匹配返回 400。这样共享前端无需感知具体处理器 schema,字段以 MessagePack 值树原样保留到 MmWorkItem / MmData。
  2. 处理器抽象(rust/sglang-server/src/multi_modality/worker.rs):新增 MmProcessor trait(Send + Sync,签名 process(work, tokenizer) -> MmProcessOutput),内置 Qwen-VL 管道封装为 QwenMmProcessor;MmContext 从持有 family 改为持有 Arc<dyn MmProcessor>,并新增 with_processor 用于外部注入。统一入口 process() 在 park 前对外部结果调用 ExternalMmEncodedEntry::validate()(feature shape 乘积等于数据长度、token offsets 落在展开的 input_ids 范围内),再对所有结果统一应用 caller hashes 覆写。rust/sglang-server/src/utils/runtime.rs 抽出 spawn_mm_pool 并新增 start_mm_workers_with_processor 入口。
  3. 结果边界类型化(rust/sglang-server/src/lib.rs、multi_modality/result_store.rs、rust/sglang-mm/src/pipeline.rs):MmEncodedResult 新增 external_items / external_token_ids 字段;ExternalMmItemResult 以 MmFeatureArray 枚举暴露 F32 / I64 / BF16(BF16 以裸 u16 位模式零拷贝呈现,由 feature_is_bf16 指示);from_entry 按 MmEncodedEntry::Qwen / External 分派。result_store.rs 新增 MmModality、MmTokenIds、ExternalMmItem 等类型及 validate();pipeline.rs 为 TensorData 增加 Bf16 变体与 len / is_empty。
  4. Python 侧可覆写钩子(python/sglang/srt/rust_server/server.py、config.py、scheduler.py):RustServer.launch 拆出 _load_extension、_start_multimodal、_partition_cores、_server_core_budget、_wrap_mm_result、_build_mm_spec 等类方法/静态方法,模型包可继承 RustServer 替换扩展加载和 MM worker 启动;_build_server_args 新增 extension 参数;scheduler.py 暴露 get_rust_server_class 让包级替换生效;drain 改用 _multimodal_enabled 标记而非 mm_spec is not None。
  5. 测试配套:新增 test/registered/unit/multimodal/rust/shared/test_rust_server_extension.py 验证模型包替换扩展类后 launch 仍能正确构建参数、启动 worker 并记录实例状态;worker.rs 用 ExternalProcessor 桩覆盖外部结果入库与畸形结果拒绝;payload.rs 覆盖跨 image/video/audio 的共享字节预算、顺序与 prefetched allocation 指针保留、扩展字段拒绝路径。
文件 模块 状态 重要度
rust/sglang-server/src/multi_modality/worker.rs 多模态池 modified 8.63
rust/sglang-server/src/message/request.rs 请求解析 modified 8.66
rust/sglang-server/src/lib.rs 边界接口 modified 8.64
rust/sglang-server/src/multi_modality/payload.rs 媒体解析 modified 8.3
python/sglang/srt/rust_server/server.py 启动钩子 modified 8.16
test/registered/unit/multimodal/rust/shared/test_rust_server_extension.py 扩展测试 added 6.75

关键符号

MmProcessor::process QwenMmProcessor::process MmContext::with_processor MmContext::new process apply_caller_hashes ProcessorExtensions::deserialize split_extension_columns resolve_media_work resolve_media_work_with_budget collect_media ExternalMmEncodedEntry::validate MmEncodedResult::from_entry RustServer._load_extension RustServer._start_multimodal RustServer._wrap_mm_result Runtime::start_mm_workers_with_processor Runtime::spawn_mm_pool

关键源码片段

rust/sglang-server/src/multi_modality/worker.rs core-logic

核心改动:引入 `MmProcessor` trait,内置 Qwen 管道封装为 `QwenMmProcessor`,`MmContext` 改为持有 `Arc<dyn MmProcessor>`;统一 `process()` 对外部结果做 park 前校验并应用 caller hashes。

/// 多模态处理器抽象:内置与外部实现共用固定 Rust worker 池。
/// 实现必须满足 `Send + Sync`,且不得保留 request-scoped 的 Python 对象。
pub trait MmProcessor: Send + Sync {
    fn process(
        &self,
        work: MmWorkItem,
        tokenizer: Option<&dyn TextTokenizer>,
    ) -> Result<MmProcessOutput, String>;
}
​
/// 内置 Qwen-VL 处理器:继续走 `sglang-mm` 的 family pipeline。
struct QwenMmProcessor {
    family: Box<dyn sglang_mm::pipeline::MmFamilyProcessor>,
    feature_shm: bool,
}
​
impl QwenMmProcessor {
    fn new(spec: MmSpec) -> Result<Self, String> {
        Ok(Self {
            family: sglang_mm::registry::build_pipeline(spec.pipeline)?,
            feature_shm: spec.feature_shm,
        })
    }
}
​
impl MmProcessor for QwenMmProcessor {
    fn process(
        &self,
        work: MmWorkItem,
        tokenizer: Option<&dyn TextTokenizer>,
    ) -> Result<MmProcessOutput, String> {
        // 解析请求媒体字段并交给 `sglang-mm` 驱动;Qwen 家族处理路径与重构前一致
        let input = super::payload::to_mm_input(work)?;
        let output = sglang_mm::driver::process(self.family.as_ref(), input, |text| {
            let tokenizer = tokenizer.ok_or_else(|| {
                "skip_tokenizer_init is set: multimodal text prompts require input_ids".to_string()
            })?;
            tokenizer.encode(text).map_err(|error| error.to_string())
        })?;
        let drain = sglang_mm::qwen_vl::pack_output(output)?;
        // 按 `feature_shm` 决定原地 park 还是走 POSIX 共享内存跨 TP 广播
        let features = if self.feature_shm {
            park_features_in_shm(&drain.features, &drain.grids)
        } else {
            FeatureStore::Inline(drain.features)
        };
        Ok(MmProcessOutput {
            input_ids: drain.input_ids,
            result: MmEncodedEntry::Qwen(QwenMmEncodedEntry {
                features,
                grids: drain.grids,
                hashes: drain.hashes,
                offsets: drain.offsets,
                mrope: drain.mrope,
                mrope_delta: drain.mrope_delta,
            }),
        })
    }
}
​
/// 单请求处理入口:先交给 `MmProcessor`,再统一做校验与 hashes 覆写。
fn process(ctx: &MmContext, rid: &Rid, mut work: MmWorkItem) -> Result<Vec<i32>, String> {
    let caller_hashes = std::mem::take(&mut work.mm_hashes);
    let mut output = ctx.processor.process(work, ctx.tokenizer.as_deref())?;
    match &mut output.result {
        MmEncodedEntry::Qwen(entry) => {
            // caller hashes 覆写计算值,保证外部 router 的键与 prefix cache 对齐
            apply_caller_hashes(entry.hashes.iter_mut(), &caller_hashes);
        }
        MmEncodedEntry::External(entry) => {
            // 外部结果在 park 之前校验,避免把损坏数据送进调度器
            entry.validate(output.input_ids.len())?;
            apply_caller_hashes(
                entry.items.iter_mut().map(|item| &mut item.hash),
                &caller_hashes,
            );
        }
    }
    ctx.results.park(rid.as_str().to_owned(), output.result);
    Ok(output.input_ids)
}
rust/sglang-server/src/message/request.rs data-contract

请求层数据契约:`ProcessorExtensions` 以 `#[serde(flatten)]` 捕获 `multimodal_*` 字段并用 `rmpv::Value` 保留,`split_extension_columns` 负责 batch fan-out;这是外部处理器获得自有 schema 的入口。

/// 顶层属于所选多模态处理器的字段命名空间。
/// 其余 `GenerateBody` 未知字段保持 Python 的"接受但忽略"行为。
const PROCESSOR_EXTENSION_PREFIX: &str = "multimodal_";
​
/// 模型自有的请求字段。共享服务端保留并随 batch fan-out 其 MessagePack
/// 值表示;由所选处理器反序列化成自己的具体 schema。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(transparent)]
pub struct ProcessorExtensions(BTreeMap<String, rmpv::Value>);
​
impl ProcessorExtensions {
    /// 直接把模型无关的值树反序列化进所选处理器的 schema。
    /// 这里不编解码 MessagePack 字节。
    pub fn deserialize<T: DeserializeOwned>(self) -> Result<T, String> {
        let fields = self
            .0
            .into_iter()
            .map(|(name, value)| (rmpv::Value::from(name), value))
            .collect();
        rmpv::ext::from_value(rmpv::Value::Map(fields))
            .map_err(|error| format!("invalid processor extensions: {error}"))
    }
​
    pub(crate) fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}
​
/// 按 batch 形状拆分扩展列:标量值广播到每个请求,数组值按位置分配。
fn split_extension_columns(
    fields: ProcessorExtensions,
    n: usize,
    is_batch: bool,
) -> Result<Vec<ProcessorExtensions>, Error> {
    let mut requests = vec![ProcessorExtensions::default(); n];
    for (name, value) in fields.0 {
        if !name.starts_with(PROCESSOR_EXTENSION_PREFIX) || value.is_nil() {
            continue;
        }
        if !is_batch {
            requests[0].0.insert(name, value);
            continue;
        }
        let rmpv::Value::Array(values) = value else {
            return Err(Error::Validation(format!(
                "{name} must be a list for batch processing"
            )));
        };
        if values.is_empty() {
            // 空数组广播为空列表,避免 batch 中个别项缺字段
            for request in &mut requests {
                request.0.insert(name.clone(), rmpv::Value::Array(Vec::new()));
            }
            continue;
        }
        if values.len() != n {
            return Err(Error::Validation(format!(
                "{name} list length {} does not match batch size {n}",
                values.len()
            )));
        }
        for (request, value) in requests.iter_mut().zip(values) {
            request.0.insert(name.clone(), value);
        }
    }
    Ok(requests)
}

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 核心路径重构:worker.rs 的 process() 对所有多模态请求(含内置 Qwen-VL)都从直接调用 sglang_mm::driver 改为经 Arc<dyn MmProcessor> 分发,引入动态分派与 trait 对象开销,并改变错误传播路径;任何 trait 实现缺陷都会影响既有 Qwen 路径。
  2. 缺少 GPU 实测:PR body 明确 "GPU model execution was not run locally",外部结果校验(validate() 的 shape/offset 检查)与 BF16 通道仅经单元测试,真实模型包接入后可能出现校验过于严格而拒绝合法结果。
  3. BF16 零拷贝契约:MmFeatureArray::Bf16 以裸 u16 位模式暴露,Python 侧必须依据 feature_is_bf16 做 reinterpret;若消费者误当作真实值使用会产生静默数值错误。
  4. 请求语义变化:multimodal_* 字段原先被当作未知字段忽略,现在会被保留并传给处理器;若处理器不支持这些字段,to_mm_input 直接拒绝请求,属于行为变更,客户端可能从"静默忽略"变成 400。
  5. 公开 API 固化:lib.rs 中大量类型由私有转为 pub(MmEncodedResult、Server、RequestBatch 等),_server 扩展模块成为对外契约,后续字段/签名调整会破坏已接入模型包。

对模型开发者:新增了一条标准化的外部多模态处理器接入路径(Rust trait + Python 钩子 + multimodal_* 透传字段),未来新模型家族无需修改共享前端。对内置 Qwen-VL 用户:请求处理行为保持兼容,但代码路径经过重构,需要 GPU 回归确认编码结果与调度交互无变化。对团队:rust-server 扩展点成为多模态家族接入的标准边界,scheduler.py 的 get_rust_server_class 使包级替换成为受支持模式。整体影响面集中在 Rust 前端与 Python 启动接线,调度器核心逻辑未改动。

核心路径变更 未做 GPU 实测 零拷贝 BF16 契约 新公开扩展 API

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论