Prhub

#34163 fix(vlm): preserve Kimi-K3 GPU JPEG accuracy

原始 PR 作者 mickqian 合并时间 2026-08-10 09:42 文件变更 8 提交数 3 评论 0 代码增减 +258 / -9

执行摘要

Kimi-K3 GPU JPEG 解码改走 nvImageCodec fancy 上采样,精度与 PIL 对齐

PR body 明确给出了根因:torchvision.io.decode_jpeg(..., device='cuda') 创建 nvJPEG 时使用默认 flags,对常见 4:2:0 JPEG 采用 nearest-neighbor chroma upsampling,而 PIL 和 Kimi 参考处理器使用 interpolated chroma reconstruction,精度损失被隔离在 JPEG 色度重建环节而非 K3 归一化。作者用 1000 张 OCRBench 真实图片做了 paired 验证:GPU 与 PIL 各 6 张互为 flip,'The six flips in each direction cancel exactly, so the GPU path has no systematic OCRBench loss relative to PIL',并给出量化证据:Mean raw-pixel MAE vs PIL 从 0.476081 降到 0.000034,zero prompt-token-count mismatches。

值得精读。核心看点有三个:一是用第三方 nvImageCodec 替换 torchvision 默认 nvJPEG 行为的思路——通过 fancy_upsampling 配置解决 4:2:0 色度重建精度问题的根因定位非常清晰;二是 decoder 池的容量上限设计(2 实例 + LIFO + 创建锁)在并发与 HBM 之间取平衡;三是测试用 fake nvidia.nvimgcodec 模块在纯 CPU 环境验证 GPU 解码逻辑,避免 CI 依赖 GPU,是很好的 mock 模式。若你的团队也在做视觉预处理精度对齐,这份 PR 的验证方法论(paired per-sample score matrix + raw-pixel MAE 基准)可以直接复用。

讨论亮点

该 PR 没有 review 评论(comments_count=0,review_comments_count=0),合并人即作者本人,属于自审合入。不过 PR body 和三次 commit 记录了三个关键设计取舍:

  • 精度与速度兼得:作者提到曾原型实现 'an exact Pillow fixed-point bicubic GPU kernel',可将 resize 残余误差压到数值噪声,但比当前 antialiased PyTorch resize 慢 11.4% 且 paired 精度无收益,'so it is intentionally not included'——这是典型的'精度够用即可、不为完美主义牺牲吞吐'的决策。
  • 显存上限设计:第二笔 commit perf(vlm): bound nvJPEG decoder pool memory 把 decoder 池固定在 2 实例,PR body 说明 'The two-decoder pool retains roughly 30-50 MiB HBM per process/device pool and does not scale with the I/O worker count',避免 GPU 显存随 16 个 I/O worker 线性增长。
  • EXIF 语义约束:第三笔 commit fix(vlm): preserve JPEG EXIF semantics 引入 apply_exif_orientation=False,PR body 强调 'preserves K3's original EXIF/prompt-coordinate semantics'——若解码器自动应用 EXIF 旋转会改变图像宽高,进而破坏基于原始 prompt 坐标的 grid 语义。

实现拆解

实现分五步展开:

  1. 新增高保真 GPU JPEG 解码模块 python/sglang/srt/utils/nvjpeg_decoder.py(全新文件,+86 行):核心是 _NvJpegDecoderPool 类,在 __init__ 内部延迟导入 nvidia.nvimgcodec(未安装时由调用方 fallback,不阻塞启动);_DECODER_OPTIONS = ':num_cuda_streams=1 :fancy_upsampling=1' 开启插值色度重建;DecodeParams 指定 sample_format=P_RGB(平面 RGB,经 DLPack 后为 CHW uint8)与 apply_exif_orientation=False(保留原始 EXIF 语义,避免旋转改变尺寸破坏 prompt 坐标)。_acquire() 用 LIFO 队列 + 创建锁把每设备 decoder 实例数封顶在 _DECODER_POOL_SIZE = 2,注释说明每个 decoder 占 15-25 MiB 设备侧 scratch,2 个足以重叠解码且 HBM 占用不与 I/O 线程数(K3 默认 16)线性膨胀。decode() 绑定 torch.cuda.current_stream(),解码与 DLPack 导出在同一 stream 完成,零拷贝返回 tensor,finally 中归还 decoder 保证池容量。

  2. 扩展公共加载入口 python/sglang/srt/utils/common.py(+28/-6):新增类型 GPUImageDecodeMode = Union[bool, Literal["nvjpeg_fancy"]]is_jpeg_with_cuda_load_image/load_imagegpu_image_decode 参数改为该三态类型;_load_image"nvjpeg_fancy" 分支延迟导入 decode_jpeg_with_fancy_upsampling 并直接返回 GPU tensor,异常时走新增的 _warn_fancy_jpeg_fallback@lru_cache(maxsize=16) 高频告警去重)后落到 PIL。原有 torchvision nvJPEG 路径(True)与其他模型完全保持不动。

  3. 接入两条消费路径python/sglang/srt/multimodal/processors/kimi_k3.pyKimiK3ImageProcessor.gpu_image_decodeTrue 改为 "nvjpeg_fancy"(加注释说明 K3 精度对 4:2:0 色度上采样敏感);python/sglang/srt/disaggregation/encode_server.pyMMEncoder._load_single_itemself.use_image_processor_gpu and self.model_type == "kimi_k3" 时把 "nvjpeg_fancy" 传给 load_image,否则保持 False,使 GPU 编码器分离(EPD)模式与常规 serving 走同一高保真路径。

  4. 镜像配套docker/kimi_k3/kimi_k3_cu12.Dockerfilekimi_k3_cu13.Dockerfile 各自新增 ARG NVIMGCODEC_VERSION="0.9.0.20" 并执行 pip install "nvidia-nvimgcodec-cu12[all]==..." / cu13[all],注释说明其用途是 high-fidelity GPU JPEG decode 与 zero-copy DLPack handoff,并清理 pip 缓存控制镜像体积。

  5. 测试配套test/registered/unit/multimodal/test_base_processor_image_decode.py 新增 3 个测试:test_high_fidelity_gpu_jpeg_decoder_is_selected(断言 load_image(data, gpu_image_decode="nvjpeg_fancy") 精确调用 fancy 解码器)、test_high_fidelity_gpu_jpeg_decoder_falls_back_to_pil(ImportError 时像素与 PIL 逐位一致)、test_high_fidelity_decoder_uses_fancy_planar_rgb_and_reuses_pool(用 fake nvidia.nvimgcodec 模块验证 Decoder 只创建一次、fancy_upsampling=1P_RGBapply_exif_orientation=False 与 pool 复用);test/registered/unit/disaggregation/test_kimi_k3_encoder_mode.py 新增参数化测试 test_kimi_k3_epd_selects_matching_jpeg_decode_mode,覆盖 use_image_processor_gpu 为 True/False 时 EPD 分别传 "nvjpeg_fancy"/False。三次 commit 的演进(fix: preserve Kimi K3 GPU JPEG accuracyperf: bound nvJPEG decoder pool memoryfix: preserve JPEG EXIF semantics)体现了作者先解决精度、再控制显存、最后补齐 EXIF 语义的顺序。

文件 模块 状态 重要度
python/sglang/srt/utils/nvjpeg_decoder.py 图像解码 added 8.53
python/sglang/srt/utils/common.py 公共工具 modified 7.22
python/sglang/srt/multimodal/processors/kimi_k3.py K3 预处理 modified 5.13
python/sglang/srt/disaggregation/encode_server.py 编码服务 modified 5.4
test/registered/unit/multimodal/test_base_processor_image_decode.py 解码测试 modified 6.92
test/registered/unit/disaggregation/test_kimi_k3_encoder_mode.py 编码测试 modified 5.34
docker/kimi_k3/kimi_k3_cu12.Dockerfile 部署镜像 modified 3.13
docker/kimi_k3/kimi_k3_cu13.Dockerfile 部署镜像 modified 3.13

关键符号

decode_jpeg_with_fancy_upsampling _NvJpegDecoderPool.decode _NvJpegDecoderPool._acquire _get_decoder_pool _load_image _warn_fancy_jpeg_fallback MMEncoder._load_single_item

关键源码片段

python/sglang/srt/utils/common.py core-logic

公共图片加载入口,将 gpu_image_decode 从 bool 扩展为三态并接入 fancy 分支与 PIL fallback,是所有模型共用 gateway。

# gpu_image_decode 从纯 bool 扩展为三态:
# True 走 torchvision nvJPEG(nearest 上采样),False 走 PIL,
# "nvjpeg_fancy" 走新增的高保真 nvImageCodec 路径。
GPUImageDecodeMode = Union[bool, Literal["nvjpeg_fancy"]]
​
​
@lru_cache(maxsize=16)
def _warn_fancy_jpeg_fallback(error: str) -> None:
    # 高频告警去重:fallback 信息只打一次,避免多线程解码风暴刷屏。
    logger.warning(
        "High-fidelity GPU JPEG decode is unavailable; falling back to PIL. "
        "Install the Kimi-K3 serving image or NVIDIA nvImageCodec. Error: %s",
        error,
    )
​
​
def _load_image(
    image_bytes: bytes = b"",
    image_file: str = "",
    gpu_image_decode: GPUImageDecodeMode = True,
) -> Union[torch.Tensor, Image.Image]:
    """
    Try to decode JPEG with nvJPEG on GPU and return a torch device tensor,
    otherwise fallback to decode with PIL on CPU and return a PIL Image.
    Keep the fallback path since nvJPEG may fail on some JPEG images that
    are not strictly compliant with the standard, while PIL is more tolerant.
    """
    if image_file != "":
        image_bytes = get_image_bytes(image_file)
    if is_jpeg_with_cuda(image_bytes, gpu_image_decode):
        try:
            if gpu_image_decode == "nvjpeg_fancy":
                # 延迟导入:未安装 nvimgcodec 时只在这里抛 ImportError,
                # 由下方 except 统一转入 PIL fallback。
                from sglang.srt.utils.nvjpeg_decoder import (
                    decode_jpeg_with_fancy_upsampling,
                )
​
                return decode_jpeg_with_fancy_upsampling(image_bytes)
            # 原有路径保持不动,其它模型继续使用 torchvision CUDA 解码。
            encoded_image = torch.frombuffer(image_bytes, dtype=torch.uint8)
            image_tensor = decode_jpeg(encoded_image, device="cuda")
            return image_tensor
        except Exception as e:
            if gpu_image_decode == "nvjpeg_fancy":
                _warn_fancy_jpeg_fallback(f"{type(e).__name__}: {e}")
            else:
                logger.warning(
                    "Failed to decode JPEG on GPU, falling back to CPU. Error: %s",
                    e,
                )
    return Image.open(BytesIO(image_bytes))

评论区精华

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

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

风险与影响

  1. 新运行时依赖nvidia-nvimgcodec 只在 Kimi-K3 两个 Dockerfile 中安装,普通环境缺失时靠延迟导入 + _warn_fancy_jpeg_fallback 落到 PIL。fallback 只打 warning 不报错,用户可能不知情地退回 PIL 路径,精度对齐静默失效。建议在文档或启动日志中更醒目地提示。
  2. decoder 池的异常状态复用_NvJpegDecoderPool.decodefinally 中无条件归还 decoder,若某次解码因数据损坏抛异常,同一 decoder 对象可能被后续请求继续复用,其内部状态是否安全未在代码中显式验证(通过 'decoder 内部状态损坏' 场景未见检测逻辑)。
  3. DLPack 与 CUDA stream 绑定:返回的 tensor 与 torch.cuda.current_stream(device_id) 绑定,若下游在不同 stream 消费且无同步,存在数据竞争风险;当前 K3 预处理在同一 stream 内使用,风险可控,但函数签名未把 stream 作为参数暴露,未来复用需小心。
  4. EPD 模式硬编码模型判断encode_server.pyself.model_type == "kimi_k3" 是字符串硬编码,后续若模型名变化或其它模型要复用 fancy 解码,需要同步修改此条件。
  5. 并发等待:16 个 I/O 线程共享 2 个 decoder,池满时 self._decoders.get() 阻塞,理论上有等待开销;实测解码吞吐 1815 img/s 比旧路径还快 2.44%,说明当前负载下无退化。

影响范围集中在 Kimi-K3 视觉输入链路,对其它模型零影响(gpu_image_decode=True 的 torchvision 路径完全保留)。对用户:K3 的 GPU 预处理输出与 PIL 参考实现位级对齐(OCRBench raw-pixel MAE 0.000034,paired 确定性跑分 0.895 与 PIL 完全一致),消除 OCRBench 等场景下由色度上采样引入的系统性精度损失;解码吞吐约 18.6x PIL,端到端服务器跑分无回归。对系统:常规 serving 与 GPU 编码器分离(EPD)两条路径统一走 fancy nvJPEG;每进程每设备新增 30-50 MiB HBM 占用(decoder 池);两个 Kimi-K3 镜像(CUDA 12/13)需重建发布才能启用新路径。对团队:新增了一个可复用的高保真 JPEG 解码模块(sglang/srt/utils/nvjpeg_decoder.py),后续其它对色度精度敏感的视觉模型可直接通过 gpu_image_decode="nvjpeg_fancy" 复用。

新增运行时依赖 精度敏感路径 并发解码池复用 EPD 模型硬编码

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论