Prhub

#35349 [VLM] Size the multimodal preprocessing pool by where preprocessing runs

原始 PR 作者 mickqian 合并时间 2026-08-27 16:32 文件变更 9 提交数 13 评论 4 代码增减 +451 / -82

执行摘要

VLM 预处理池按路径动态调 worker 数

Preprocessing an image costs real CPU time on the tokenizer's event loop, so with a single worker a model's request throughput is capped at 1 / preprocess_time no matter how much GPU is idle. That cap is why PaddleOCR-VL saturated at 6.7 req/s on an H200 with GPU headroom to spare — and the failure is silent. The first version defaulted every processor to 2 workers, but a Blackwell check with heavy preprocessing overturned that default; the default is now path-aware instead of unconditional.

建议精读。该 PR 是“用真实测量驱动默认值决策”的典范:同一份代码在 H200 与 GB300 上得出相反的并发结论,最终以路径区分取代无条件默认。同时暴露并修复了 deepcopy 时机、闭包方法绑定、AST 审计等并发细节陷阱,对维护多模态推理栈的工程师有很高的借鉴价值。值得重点关注 base_processor.py 的路径决策函数与 executor.py 的懒克隆设计。

讨论亮点

PR 没有外部 review 评论,作者在关联 Issue 的评论中记录了三个关键发现与修复:

  • “Two follow-ups pushed after review of what 'every processor benefits' actually requires. … Two — Ernie4.5-VL and MiDashengLM — hardcoded processor = self._processor … Both now resolve the injected clone.” 即并发默认开启后暴露 process_mm_data override 绕过 clone 的隐患,已修复。
  • “CI caught a real one, now fixed in 721adcd. test_mm_process_config.py::test_parallel_workers_require_processor_support asked for two workers and asserted it got one — which only held because the base class did not support concurrency.” 测试改为显式 patch 掉 supports_mm_processor_concurrency
  • “The CPU suite found something better than a stale assertion — a real latent bug this PR would have activated. … _resolve_processor — which every pooled process_mm_data call goes through — did not: its clone branch reached straight for processor.tokenizer.” 现在通过 _tokenizer_of 统一。

实现拆解

实现分五步推进:

  1. 路径感知的 worker 数解析:在 python/sglang/srt/multimodal/processors/base_processor.py 中新增 _preprocessing_competes_with_the_scheduler() 判断预处理是否在调度器服务设备(GPU fast image processor)上运行,新增 _resolve_auto_mm_processor_worker_num() 根据路径返回 2(CPU)或 1(GPU);auto_mm_processor_worker_num 类属性默认改为 None(跟随路径),supports_mm_processor_concurrency 默认改为 True(默认开启并发)。用户显式传入 --mm-processor-worker-num 时优先于路径决策。此改动影响所有多模态模型:Qwen-VL、PaddleOCR-VL 等声明 auto_mm_processor_worker_num = 2 的 GPU 路径模型被上限至 1 个 worker,CPU 路径仍尊重声明值。
  2. 修复 worker 池 clone 时机与解析方式python/sglang/srt/multimodal/processors/executor.pyMultimodalProcessorExecutor.__init__ 改为接收 resolve_processor 可调用对象,不再在 super().__init__() 内部提前 deepcopy;每次 worker 首次使用时才复制 self._resolve_processor(),保证克隆的是子类 __init__ 定制完成后的 processor。同时 base_processor.py 新增 _tokenizer_of() 统一构造与 clone 路径的 tokenizer 解析,避免 InternVL 这类以裸 tokenizer 作为 _processor 的处理器在 clone 时解析不一致。
  3. 修复 Sarashina2Vision 的 kwarg 过滤器绑定python/sglang/srt/multimodal/processors/sarashina2_vision.py 将原先闭合原始 image_processor 的 patched_preprocess 改为 _install_preprocess_kwarg_filter(),用 types.MethodType 绑定到实例,使 copy.deepcopy 重新绑定 __self__ 到每个 worker 自己的 clone,避免 worker 线程调用未打补丁的 _preprocessTypeError: unexpected keyword argument 'do_pad'
  4. 修复绕过注入 clone 的处理器并声明不参与池的处理器ernie45_vl.pymidashenglm.pyprocess_mm_data override 改为接收 processor=None 并通过 _resolve_processor(processor) 解析注入的 clone;test_processor_async_call_sites.py 用 AST 审计所有 process_mm_data override,并显式列出 11 个不经过 process_and_combine_mm_data 的处理器(inkling、llava、whisper 等),强制新增处理器必须决定是否参与池。
  5. 测试配套test_mm_process_config.py 新增 CPU 路径 2 worker、GPU 路径 1 worker、显式参数优先、GPU 上限覆盖模型声明等测试,并 pin 住 server_args 的关键属性避免 MagicMock 全真值误入 CPU 分支;新增 test_processor_clone_isolation.py 验证 kwarg filter 在 deepcopy 后仍绑定到自身 clone;更新 test_processor_async_call_sites.pytest_paddleocr_vl_serving_defaults.py 适配新默认。整体共 62 个单元测试通过。
文件 模块 状态 重要度
python/sglang/srt/multimodal/processors/base_processor.py 处理器基类 modified 8.1
python/sglang/srt/multimodal/processors/sarashina2_vision.py 处理器适配 modified 8.02
python/sglang/srt/multimodal/processors/executor.py 预处理池 modified 6.76
test/registered/unit/managers/test_mm_process_config.py 配置测试 modified 7.58
test/registered/unit/multimodal/test_processor_clone_isolation.py 克隆隔离 added 7.53
test/registered/unit/multimodal/test_processor_async_call_sites.py 调用点测试 modified 7.4
python/sglang/srt/multimodal/processors/ernie45_vl.py VLM 处理器 modified 5.67
python/sglang/srt/multimodal/processors/midashenglm.py VLM 处理器 modified 5.67
test/registered/unit/models/test_paddleocr_vl_serving_defaults.py 服务默认值 modified 6.59

关键符号

_tokenizer_of _preprocessing_competes_with_the_scheduler _resolve_auto_mm_processor_worker_num _install_preprocess_kwarg_filter MultimodalProcessorExecutor.__init__ MultimodalProcessorExecutor._run _process_mm_data_overrides

关键源码片段

python/sglang/srt/multimodal/processors/base_processor.py core-logic

核心变更文件:新增 `_tokenizer_of`、`_preprocessing_competes_with_the_scheduler`、`_resolve_auto_mm_processor_worker_num`,将默认 worker 数改为路径感知,默认开启并发支持,并统一 tokenizer 解析。

def _tokenizer_of(processor):
    """返回 HF processor 背后真正的 tokenizer。    部分处理器(如 InternVL)直接以 tokenizer 作为 `_processor`,
    而不是一个包装了 tokenizer 的 processor。所有解析 tokenizer 的路径
    (构造、worker clone)都必须经过这里,保证 clone 与原始对象解析一致。
    """
    return processor.tokenizer if hasattr(processor, "tokenizer") else processor
​
​
class BaseMultimodalProcessor(ABC):
    # None 让 worker 数跟随预处理实际运行位置;
    # 模型若自己测量过最优值,可在此赋值一个数字。
    auto_mm_processor_worker_num = None
    auto_mm_io_worker_num = 4
​
    # 处理器只有在预处理非线程安全时才显式退出并发。
    # worker 池给每个线程单独 deepcopy 一份 HF processor 并注入,
    # 且唯一运行入口 `process_and_combine_mm_data` 解析注入的 clone,
    # 因此隔离性不依赖子类实现。
    supports_mm_processor_concurrency = True
​
    def __init__(self, hf_config, server_args, _processor, transport_mode, *args, **kwargs):
        ...
        # 统一走 _tokenizer_of,避免 clone 与 __init__ 解析结果分叉
        self._tokenizer = _tokenizer_of(self._processor)
        ...
        # 路径感知 worker 数:CPU 预处理 2 个,GPU 预处理 1 个;
        # 用户显式参数优先于路径决策。
        self.mm_processor_worker_num = (
            1 if skip_mm_pool
            else requested_mm_processor_worker_num
            or self._resolve_auto_mm_processor_worker_num()
        )
        ...
python/sglang/srt/multimodal/processors/sarashina2_vision.py core-logic

修复 Sarashina2Vision 的 kwarg 过滤器绑定:从闭包原始对象改为 `types.MethodType` 绑定实例,使 `copy.deepcopy` 能重新绑定到 worker 自己的 clone,避免并发下 `TypeError`。

import types# Sarashina2Vision 的 remote-code `_preprocess` 只接受窄 kwarg 集合,
# 而 transformers 的 `preprocess` 会转发完整集合,多余的参数必须被丢弃。
_PREPROCESS_PARAMS = frozenset({
    "do_resize",
    "resample",
    "do_rescale",
    "rescale_factor",
    "do_normalize",
    "image_mean",
    "image_std",
    "do_convert_rgb",
    "data_format",
    "input_data_format",
})
​
​
def _install_preprocess_kwarg_filter(image_processor) -> None:
    """丢弃 Sarashina2Vision 的 `_preprocess` 无法接受的 kwargs。    使用 `types.MethodType` 而不是闭包 `image_processor`,这样预处理 worker 的
    `copy.deepcopy` 会把 `__self__` 重新绑定到它自己的 clone,
    避免所有线程都路由回同一个共享对象。
    """
    unfiltered_preprocess = type(image_processor)._preprocess
​
    def _preprocess(self, *args, **kwargs):
        return unfiltered_preprocess(
            self,
            *args,
            **{k: v for k, v in kwargs.items() if k in _PREPROCESS_PARAMS},
        )
​
    image_processor._preprocess = types.MethodType(_preprocess, image_processor)
python/sglang/srt/multimodal/processors/executor.py core-logic

worker 池 clone 时机修复:改为通过 callable 在首次使用时 deepcopy,避免克隆到 `super().__init__()` 期间的半成品 processor,并保证克隆成本只发生在 worker 首次使用时。

class MultimodalProcessorExecutor:
    """在隔离的、线程本地的 processor clone 上运行处理器调用。"""
​
    def __init__(self, resolve_processor: Callable[[], Any], max_workers: int):
        # 按需解析而不是在构造时捕获:子类在 super().__init__() 返回后
        # 还会继续定制 `_processor`,此刻克隆会漏掉那些修改。
        self._resolve_processor = resolve_processor
        # 探针一次:若处理器不可克隆,启动时就回退到同步处理,
        # 而不是在 worker 线程内才失败。
        copy.deepcopy(resolve_processor())
        self._executor = concurrent.futures.ThreadPoolExecutor(
            max_workers=max_workers,
            thread_name_prefix="sglang-mm-processor",
        )
        ...
​
    def _run(self, function, *args, **kwargs) -> T:
        processor = self._worker_state.processor
        if processor is None:
            # 每次只克隆一个:克隆会读取共享 processor,
            # worker 路径也会读取 token 计数辅助函数,因此需要加锁。
            with self._clone_lock:
                processor = copy.deepcopy(self._resolve_processor())
            self._worker_state.processor = processor
        return function(*args, processor=processor, **kwargs)

评论区精华

process_mm_data 覆写绕过注入 clone 正确性

作者在 Issue 评论中指出:并发默认开启后,审计仅覆盖 `process_and_combine_mm_data`(无子类 override),但 `process_mm_data` 有五个处理器 override,其中 Ernie4.5-VL 和 MiDashengLM 硬编码 `processor = self._processor`,导致多 worker 线程共享一个 HF processor,破坏 per-thread deepcopy 隔离。

结论:两个处理器均改为接受 `processor=None` 参数并通过 `self._resolve_processor(processor)` 解析注入的 clone;新增 AST 审计测试防止未来回归。 · 已解决

默认翻转导致旧测试断言失效 测试

CI 报 `test_mm_process_config.py::test_parallel_workers_require_processor_support` 失败:请求 2 个 worker 却断言只得到 1 个,原因是旧断言依赖 `supports_mm_processor_concurrency = False` 的类默认;翻转默认后断言变成 `2 != 1`,并引发 NPU/GPU 测试快失败级联。

结论:测试改为显式 `patch.object(BaseMultimodalProcessor, "supports_mm_processor_concurrency", False)` 以保持原守卫语义,并新增 `test_default_is_concurrent` 验证新默认。 · 已解决

clone 的 tokenizer 解析与 __init__ 不一致 正确性

CPU 套件发现一个潜在 bug:`BaseMultimodalProcessor.__init__` 早已处理 InternVL 等以裸 tokenizer 作为 `_processor` 的情况,但 `_resolve_processor` 的 clone 分支直接访问 `processor.tokenizer`,导致 pooled `process_mm_data` 调用时解析崩溃;该分叉由 #31438 引入但此前不可达。

结论:新增 `_tokenizer_of(processor)` 统一构造与 clone 路径的 tokenizer 解析,并补充 `test_clone_resolves_tokenizer_like_init` 测试。 · 已解决

风险与影响

  1. 默认行为变更supports_mm_processor_concurrencyFalse 翻转为 True,所有多模态处理器默认进入并发池,若某个处理器存在未覆盖的共享状态或非线程安全代码,可能在 worker 线程中触发竞态或崩溃。deepcopy 隔离降低了风险,但不为零。
  2. GPU 路径性能回退:GB300 上全页图像场景,第二个 worker 使吞吐下降 56%(9.30 → 4.02 req/s),TTFT 从 590 ms 升至 5755 ms;虽然默认已按路径收敛到 1 worker,但用户若显式设置 --mm-processor-worker-num=2 且未意识到路径差异,仍可能复现回退。
  3. clone 成本MultimodalProcessorExecutor._run 每次新 worker 首次使用时 deepcopy 整个 HF processor(可能包含大权重),虽只发生一次,但探针 deepcopy 在 __init__ 阶段同步执行,可能增加启动时间。
  4. 测试脆弱性test_mm_process_config.py_make_processor 依赖 MagicMock 并需手动 pin server_args.disable_fast_image_processor 等属性,若未来新增读取的属性未 pin,测试可能静默走错分支。
  5. 未声明处理器dots_note_omni.py 等 11 个处理器显式声明不参与池,但如果后续维护者新增处理器时未更新 _NO_WORKER_POOL_ROUTE 集合,AST 审计测试会失败,这是有意的强制措施,但也可能造成 CI 噪声。

影响范围覆盖所有使用多模态预处理的服务端进程。对 CPU 预处理路径(pil 后端、无 fast image processor 的模型、音频模型)吞吐提升显著(H200 +36%,GB300 +24%);对 GPU 快速图像处理器路径,默认保持 1 worker,避免 Blackwell 上全页文档类重预处理的吞吐坍缩。行为变更明确:Qwen-VL 与 PaddleOCR-VL 在默认后端下从声明 2 worker 降为 1 worker,但实测数据表明这是正确取舍。同时修复了 Sarashina2Vision 在多 worker 下必然崩溃的隐藏 bug,以及 Ernie4.5-VL、MiDashengLM 的 clone 绕过问题。团队需在发布说明中强调默认并发开启,并建议重预处理负载在 CPU 路径下显式验证。

默认行为变更 GPU 路径回退风险 并发线程安全依赖 测试用 MagicMock 脆弱性

关联 Issue

#35342 [VLM] Route every multimodal processor through the worker pool's call site

完整报告

参与讨论