Prhub

#35318 [Perf] PaddleOCR-VL: overlap page preprocessing, pack the ViT, enable prefill CUDA graph

原始 PR 作者 mickqian 合并时间 2026-08-19 08:20 文件变更 13 提交数 6 评论 4 代码增减 +1280 / -267

执行摘要

打包视觉塔 + 并发预处理 + CUDA 图,OCR 吞吐近 2 倍

PR body 直接给出量化目标:“PaddleOCR-VL served a 1080p page in 235 ms and saturated at 6.7 req/s on an H200 while the GPU sat idle”,且 profiling 显示瓶颈几乎不在模型自身计算。四个根因:QwenVLImageProcessor 的并发 worker 只授予显式 model_type 白名单而 paddleocr_vl 不在其中(87 ms/页预处理把吞吐封顶在约 11 req/s);通用 multimodal 规则禁掉 breakable CUDA graph,连纯文本 prefill 都失去加速;ViT 每图一次设备端 max() 建 rope 表、一次 host sync 切片 encoder 输出、一次位置插值重算(LFU 缓存写了但从未被调用)、一次完整 projector;quant_config 被接受后静默丢弃,--quantization 对 LM 和视觉塔都是 no-op。

值得精读。设计层面有四个可复用点:宿主端边界避免 device-to-host 同步、LFU 位置编码缓存、kernel==stride 时 conv 到 matmul 的算子降级、多模态架构在 CUDA graph allowlist 上的回归。流程层面,作者对 worker 数和 vLLM 对比数字的两次自我纠偏,以及把“逐位精确”与“GEMM 容差”分开断言的测试哲学,是高质量性能工程的范本。建议后续在引入低精度或新后端时重点回归数值一致性。

讨论亮点

PR 没有外部 reviewer 评论,但作者本人留下了三处高价值的技术自我纠偏:

  1. worker 数从 4 修正为 2。评论原文:“Corrected the worker count from 4 to 2 ... two won every shape while four was consistently worse:1080p pages 6.72 → 9.55 req/s at two,8.92 at four;360p pages with 512-token outputs 22.38 → 25.20 at two,25.06 at four。Past two workers,spreading request arrivals fragments the GPU prefill batches faster than the extra CPU overlap pays for itself。”

  2. 主动推翻与 vLLM 的对比结论。评论原文:“That figure does not hold up... it came from a run of the worker=4 configuration(this PR later corrected to 2)with only n=3 per arm。Re-measured with the configuration that actually shipped... 4 independent server instances × 3 reps = n=12 per arm... difference −0.009 (−0.1%),Welch t = −0.05,95% CI [−0.386, +0.367] → straddles zero。”

  3. 提交 cd06180 修正过严的测试容差:投影 GEMM 批量化改变求和顺序,末位差异是 BLAS 依赖的(Apple silicon 相等、x86 不等)。把断言拆成“2x2 合并纯数据搬运必须逐位相等”与“投影允许 1e-12 容差”,并论证 permutation 错误会移动约 1 个量级、容差依然能抓住它。

实现拆解

步骤 1:图像预处理并发化 — python/sglang/srt/multimodal/processors/paddleocr_vlm.py

PaddleOCRVLImageProcessor 继承自 QwenVLImageProcessor,后者只在显式 model 白名单上授予并发;PR 在子类上声明 supports_mm_processor_concurrency=True、auto_mm_processor_worker_num=2、auto_mm_io_worker_num=16。初版提交是 4 个 worker,作者在合并前用 H200 32 路并发对三种负载做扫描,发现 4 在每个 shape 上都劣于 2,最终以提交 90e24b2 修正为 2 并用单测钉死。文档页预处理约 87 ms,单 worker 时吞吐上限约 11 req/s,这是纯 CPU 重叠优化,预处理结果不变。

步骤 2:视觉塔打包化 — python/sglang/srt/models/paddleocr_vl.py(+229/−265)

这是核心重构,围绕“边界留在宿主端”的设计契约展开:

  • 新增 build_packed_2d_position_ids:由宿主端 grid_thws 直接生成整批 [total_patches, 2] 二维位置 id,max_grid_size 也在宿主端推导,避免设备端 max() 触发同步;
  • 新增 merge_patch_neighbourhoods:把 2x2 邻域合并抽成独立步骤,逐图一次 strided copy 写进预分配 buffer;
  • Projector.forward 重写:pre_norm 整批 → merge → 两个投影各跑一次,N 图 batch 从 4N 个 kernel 降到 N 次拷贝 + 3 个 kernel;
  • interpolate_pos_encoding 签名简化并返回 contiguous() 张量;此前写了但从未被调用的 fetch_position_embedding_lfu_cache 真正接入 forward,按 (h, w) 缓存最多 20 个网格(约 12 MiB);
  • patch_embedding 显式走 Conv2dLayer 的 unfold+matmul 路径(kernel == stride、零 padding),避免每 forward 都发起 cuDNN conv;
  • 删除 numpy、einops 依赖,模块 docstring 写明打包布局契约。

步骤 3:prefill CUDA graph 白名单与契约修复 — model_config.py、ernie4.py

multimodal_breakable_cuda_graph_supported_model_archs 加入 PaddleOCRVLForConditionalGeneration。带图 batch 在 replay 时被拒绝并退回 eager,因此只利好文本/混合流量(2704 token 单流 TTFT 16.1 → 11.5 ms)。同时给 Ernie4Model 新增标准 get_input_embeddings,替换调用侧 hasattr + types.MethodType 动态补丁;quant_config 也改为真正透传,结束 --quantization 静默 no-op。

步骤 4:测试与文档

  • test/registered/unit/models/test_paddleocr_vl_vision.py:fp64 下把打包结果钉在逐图参考实现上,覆盖 2x2 合并 permutation 精确性、batch 不变性、位置编码缓存与未缓存插值一致性、LFU 驱逐、conv 与 matmul 路径、打包二维位置 id;
  • test/registered/unit/models/test_paddleocr_vl_serving_defaults.py:守卫并发开关、worker 数、基类保守性、CUDA graph allowlist——全是“refactor 会静默丢掉”的配置;
  • test/registered/vlm/test_paddleocr_vl_server.py:单图 OCR + 四种不同尺寸并发请求,验证打包前向不跨图串数据;
  • 文档:cookbook 页(0.9B/1.5/1.6 共享同一 config.json,一个 recipe 服务三个版本)、supported-models 行、docs 侧栏与 Unlimited-OCR.mdx 交叉引用。
文件 模块 状态 重要度
python/sglang/srt/models/paddleocr_vl.py 视觉塔 modified 9.05
test/registered/unit/models/test_paddleocr_vl_vision.py 单元测试 added 8.26
test/registered/unit/models/test_paddleocr_vl_serving_defaults.py 默认守卫 added 7.53
test/registered/vlm/test_paddleocr_vl_server.py 端到端测试 added 7.22
python/sglang/srt/multimodal/processors/paddleocr_vlm.py 图像预处理 modified 6.16
python/sglang/srt/configs/model_config.py 模型配置 modified 5.19
python/sglang/srt/models/ernie4.py 语言主干 modified 5.43
docs/src/snippets/configs/PaddlePaddle/paddleocr-vl.jsx 部署脚本 added 6.72
docs/cookbook/autoregressive/Baidu/PaddleOCR-VL.mdx 文档 added 5.74
docs/docs/supported-models/multimodal_language_models.mdx 支持列表 modified 3.05

关键符号

build_packed_2d_position_ids merge_patch_neighbourhoods interpolate_pos_encoding fetch_position_embedding_lfu_cache Projector.forward SiglipVisionEmbeddings.forward encode_image get_input_embeddings

关键源码片段

test/registered/unit/models/test_paddleocr_vl_vision.py test-coverage

CPU 单测把打包视觉数学钉在 fp64 逐图参考实现上,覆盖 2x2 合并 permutation 精确性、batch 不变性、位置编码 LFU 缓存、conv 与 matmul 路径等;并明确区分“纯数据搬运必须逐位相等”与“GEMM 批量化允许末位容差”两类断言。

# ============ test/registered/unit/models/test_paddleocr_vl_vision.py ============
# 测试把“必须逐位一致”和“允许末位差异”分开断言:
# merge 是纯数据搬运,必须 bit-exact;投影则不然——把 N 个逐图 GEMM 合成
# 一个会改变分块与求和顺序,末位会有差异(fp64 实测最大约 3e-14 相对误差,
# 且依赖 BLAS 实现:Apple silicon 上相等,x86 上不等)。permutation 错误
# 会把数值移动约 1 个量级,1e-12 容差依然能一锤定音地抓住它。
_GEMM_REORDER_RTOL = 1e-12
_GEMM_REORDER_ATOL = 1e-12
​
​
def test_projector_merge_permutation_is_exact():
    """2x2 重排只搬数据不做运算,因此必须逐位相等。"""
    torch.manual_seed(1)
    projector = _build_projector()
    packed = _packed_features()
    normed = projector.pre_norm(packed)
​
    actual = merge_patch_neighbourhoods(normed, GRIDS, projector.merge_kernel_size)
​
    # 逐图做 rearrange 的结果拼起来作为参照
    m1, m2 = projector.merge_kernel_size
    expected = torch.cat(
        [
            rearrange(
                normed[offset : offset + t * h * w],
                "(t h p1 w p2) d -> (t h w) (p1 p2 d)",
                t=t,
                h=h // m1,
                p1=m1,
                w=w // m2,
                p2=m2,
            )
            for offset, (t, h, w) in zip(_grid_offsets(), GRIDS)
        ],
        dim=0,
    )
​
    assert actual.shape == expected.shape
    # 纯数据搬运,用 torch.equal 做逐位比较
    assert torch.equal(actual, expected)
​
​
def test_projector_packed_merge_matches_per_image_reference():
    """打包后的 batch 投影必须等于逐图投影的拼接(允许 GEMM 重排容差)。"""
    torch.manual_seed(1)
    projector = _build_projector()
    packed = _packed_features()
​
    actual = projector(packed, GRIDS)
    expected = _reference_projector_output(projector, packed)
​
    assert actual.shape == expected.shape
    assert actual.shape[0] == sum(t * h * w for t, h, w in GRIDS) // 4
    assert actual.shape[1] == _TextConfig.hidden_size
    torch.testing.assert_close(
        actual, expected, rtol=_GEMM_REORDER_RTOL, atol=_GEMM_REORDER_ATOL
    )
python/sglang/srt/multimodal/processors/paddleocr_vlm.py core-logic

让文档页预处理跨 worker 并发:在处理器类上声明 supports_mm_processor_concurrency、2 个 worker 与 16 个 IO 线程,并附上 H200 实测的取舍说明。这是吞吐翻倍的另一个关键杠杆。

# ============ python/sglang/srt/multimodal/processors/paddleocr_vlm.py ============
class PaddleOCRVLImageProcessor(QwenVLImageProcessor):
    models = [PaddleOCRVLForConditionalGeneration]
​
    # 文档页是远比聊天图片更重的预处理单元:resize + normalize + patchify
    # 一整页高清扫描要花几十毫秒,单 worker 会让请求吞吐封顶在
    # 1 / preprocess_time,GPU 再闲也上不去。这里跨 worker 做重叠,
    # 预处理本身的逻辑不变。
    #
    # 选 2 而不是更多:H200 上 32 路并发实测,2 worker 在每个 shape 上都
    # 同时胜过 1 和 4(1080p 页 6.72 -> 9.55 req/s,4 worker 只有 8.92;
    # 360p 页 512 token 输出 22.38 -> 25.20,4 worker 25.06)。超过 2 后,
    # 请求到达被打散对 GPU prefill 批次的碎片化影响,超过了额外重叠能
    # 换来的收益。
    auto_mm_processor_worker_num = 2
    auto_mm_io_worker_num = 16
    supports_mm_processor_concurrency = True

评论区精华

并发预处理 worker 数从 4 修正为 2 性能

作者在 H200 上以 32 路并发对三种负载形状做扫描:1080p 页 128 token 输出(2 路 9.55 vs 4 路 8.92 req/s)、360p 页 512 token 输出(2 路 25.20 vs 4 路 25.06)、Qwen2.5-VL 360p(2 路 12.60 vs 4 路 12.25)。结论是超过 2 个 worker 后,请求到达被打散对 GPU prefill 批次的碎片化影响超过了额外 CPU 重叠的收益——与 qwen_vl 处理器文档化的取舍一致。

结论:worker 数定为 2(提交 90e24b2),并用 test_worker_count_stays_at_the_measured_optimum 单测钉死防回退。 · 已解决

回收与 vLLM 饱和吞吐对比的结论 性能

PR body 最初报告饱和吞吐落后 vLLM 0.27.1 约 4%;作者事后指出该数字来自 worker=4(后被修正的配置)且 n=3/arm,统计功效不足。重测采用实际发布的 worker=2 配置、4 个独立实例 × 3 次 = n=12:SGLang 12.406 ± 0.587 vs vLLM 12.415 ± 0.203 req/s,Welch t = −0.05,95% CI [−0.386, +0.367] 跨零。单流 TTFT 则稳定领先约 10%(119.88 vs 133.19 ms,Welch t = −34.3)。

结论:PR 不再宣称落后 vLLM,饱和吞吐修正为持平;强调实例间放置效应需要 n≥12 才能平均掉。 · 已解决

投影测试 bit-exact 断言过严,拆分为 merge 精确 + GEMM 容差 测试

把 N 个逐图 GEMM 合成一个会改变分块与求和顺序,末位有差异且 BLAS 依赖(Apple silicon 相等、x86 CI 上报 5.8e-16 abs / 2.8e-14 rel)。作者选择拆分断言而非整体放宽:2x2 合并是纯数据搬运,必须 torch.equal 逐位一致;投影允许 1e-12 容差,并论证 permutation 错误会移动约 1 个量级,容差依然能抓住它。

结论:提交 cd06180 落地:新增 test_projector_merge_permutation_is_exact,并重写 test_projector_packed_merge_matches_per_image_reference 的容差语义。 · 已解决

风险与影响

  1. 数值末位差异:打包 GEMM 与逐图 GEMM 求和顺序不同,结果末位有 BLAS 实现依赖的差异。贪婪解码下 10/10 逐字节一致,但引入采样、低精度(FP8/INT8)或新后端后差异可能放大;现有测试只覆盖 fp64。
  2. 位置编码 LFU 缓存:命中条件是 (h, w) 精确匹配;文档尺寸高频变化时会反复驱逐重算。缓存是模块级 dict,多实例并发安全性未被测试覆盖。
  3. 全局 allowlist 变更:multimodal_breakable_cuda_graph_supported_model_archs 是全局列表,放行后带图 batch 在 replay 时被拒并回 eager,行为切换成本未被计入基准;该列表本身是“refactor 静默丢配置”的高危点,靠新增单测守护。
  4. 默认行为变更:并发预处理默认开启(2 worker + 16 IO 线程),低核数容器可能 CPU 争抢;基类保持 1 worker 保守值,只有 PaddleOCR-VL 显式开启。
  5. 量化配置透传:--quantization 从静默 no-op 变为真正生效,此前被掩盖的配置错误可能新暴露为加载失败(预期内修复,需回归观察)。
  6. 契约变更:ernie4.py 新增 get_input_embeddings 属公共接口变化,若存在依赖旧 hasattr 探测的调用方,行为可能改变(当前无证据)。

用户侧:PaddleOCR-VL 0.9B/1.5/1.6(共享同一 config.json)单流 TTFT 约 2 倍、32 路并发吞吐约 1.84 倍;纯文本预填因 CUDA graph 提速 40%;与 vLLM 0.27.1 对比,饱和吞吐从“落后 4%”修正为持平(−0.1%,95% CI 跨零),单流 TTFT 领先约 10%。系统侧:paddleocr_vl.py 核心路径重构(+229/−265),vision tower 从逐图处理变为整批打包,建立“宿主端 grid 边界 + 零 D2H 同步”的可复用模式;同时修复了处理器并发、CUDA graph、quant 透传三类此前被静默丢弃的配置,处理器与配置默认行为对全部 PaddleOCR-VL 请求生效。团队侧:新增“打包算法对逐图参考的 fp64 钉死”与“serving allowlist 守卫”两类测试范式,对后续 refactor 有保护价值;基准方法学(双臂交替、n≥12、事后回收错误结论)值得推广。

核心视觉塔路径重写 数值末位 BLAS 依赖差异 全局 CUDA graph 白名单变更 并发预处理默认开启 量化配置透传暴露旧问题

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论