Prhub

#33921 [Kimi K3] Preprocess CPU-transport images on the vision owner

原始 PR 作者 mickqian 合并时间 2026-08-09 16:13 文件变更 6 提交数 35 评论 3 代码增减 +470 / -82

执行摘要

K3 图片预处理延迟至 vision owner,高负载 TTFT -79%

Kimi-K3 在 CPU 多模态传输下,原先在 tokenizer worker GPU 上完成全部图像预处理,再把膨胀后的 FP32 patch 张量拷回 CPU、广播/序列化到每个 scheduler GPU 并做 H2D,之后才选出 vision owner。PR body 明确指出:DP helper 在调用本地 feature loader 之前就已确定 owner,因此预处理与 H2D 都只需要发生在该 owner 上。这样既把跨机传输载荷从处理后 FP32 张量降为原始 uint8 像素,也消除了非 owner rank 上的冗余预处理与 H2D 拷贝。

值得精读。这是多模态特征传输中“载荷最小化 + 延迟物化到消费方”的完整案例:决策门控(transport + dtype + 字节启发式)、数据契约(uint8 feature + deferred config 键)、消费端单点物化与调度白名单四者缺一不可。建议阅读顺序:processor 的 _should_defer_gpu_preprocessing_build_deferred_output → 模型侧 materialize_item_features 的 deferred 分支 → mm_schedule.py 的白名单一行。合并后建议补充:非 EPD 启动路径回归测试、混合 batch 的显式优雅处理(当前直接 ValueError)。

讨论亮点

PR 没有 review 评论,但 issue 中有两条值得注意的讨论:1)yhyang201 的回归报告:非 EPD 单节点服务在 warmup 阶段因 models/kimi_k3.py 消费者读取 first_config['backend'] 抛 KeyError 崩溃,而生产者 prepare_deferred 只写入 image_mean、image_std、transparent_bg_config,属于 producer/consumer 数据契约不一致;最终 head 的消费者已移除 backend 分支选择,统一走 _gpu_preprocess_images,读取键与生产者完全对齐,因此该具体错误在最终形态下应已消除,但提交历史无法确认修复时机,也没有针对非 EPD 启动路径的独立回归用例。2)mickqian 的 draft 说明:在 GPU 资源不可用(GB300/B300 均 0 可用)的情况下,microbenchmark 不足以为 serving 性能背书,因此保留 draft 等待严格真实权重 A/B;PR body 最终补齐了该证据。

实现拆解

  1. 新增公共图像处理模块 python/sglang/srt/multimodal/kimi_k3_image_processing.py:把原先内联在 processor 中的 _chessboard_background_fill_transparent_bg 收敛为 fill_transparent_bg,并新增 to_chw_uint8(PIL/张量 → uint8 CHW,支持指定 device)、normalization_tensors(mean/std → scale/bias 预计算)与契约常量 DEFERRED_PREPROCESSING_KEY,保证 eager 与 deferred 两端共用同一套像素转换与背景合成实现。
  2. Processor 决策层(multimodal/processors/kimi_k3.py):KimiK3GPUProcessorWrapper.prepare_deferred 只产出 token 展开结果与 NaViT resize_config,不执行 GPU 预处理;KimiK3ImageProcessor._should_defer_gpu_preprocessing 用四个条件收敛延迟范围(传输方式为 cpu、CUDA 可用、全部输入为 PIL 或 uint8 张量、原始载荷字节数 ≤ 处理后 FP32 张量字节数);_build_deferred_output 把每张图包装为 feature 为 uint8 CPU 张量的 MultimodalDataItem,在 model_specific_data 中写入 DEFERRED_PREPROCESSING_KEY(含 image_mean、image_std、transparent_bg_config、resize_config),并在 process_mm_data_async 中提前返回。
  3. 模型消费端(models/kimi_k3.py):get_image_feature 内嵌的 materialize_item_features 在 DP owner 的 loader 中检测 deferred 标记;命中后调用 K2.5 的 _gpu_preprocess_images,以 to_chw_uint8(device=device) 完成唯一一次 H2D、fill_transparent_bg 完成背景合成、normalization_tensors 生成归一化系数;IPC consumer count 从 server_args.tp_size 改为 get_parallel().tp_size 直接取值。同一 batch 若混有 deferred 与非 deferred item 会抛 ValueError(当前为硬失败策略)。
  4. 调度配套(managers/mm_schedule.py):_can_skip_pre_embed_feature_move 白名单加入 KimiK3ForConditionalGeneration,使 scheduler 在 embedding 之前跳过 _move_items_to_device,保证 uint8 feature 不被每个 rank 提前搬上 GPU,owner-only H2D 语义才真正成立。
  5. 测试与验证配套:test/registered/unit/models/test_kimi_k25.py 新增 6 个用例(cpu transport 延迟、字节启发式边界、非 uint8 不延迟、空 batch 不延迟、float 张量 eager 兼容、cuda_ipc/fabric 保持 eager);test/registered/unit/models/test_kimi_k3_vision.py 新增 owner-only 预处理与调度器跳过移动两个用例,并同步更新 topology mock 与 NaN-safe 断言。无新增配置项或部署配套。
文件 模块 状态 重要度
python/sglang/srt/multimodal/processors/kimi_k3.py 预处理决策 modified 8.74
python/sglang/srt/multimodal/kimi_k3_image_processing.py 图像处理 added 8.28
python/sglang/srt/models/kimi_k3.py 模型消费端 modified 7.21
python/sglang/srt/managers/mm_schedule.py 调度器 modified 4.18
test/registered/unit/models/test_kimi_k25.py 单元测试 modified 7.24
test/registered/unit/models/test_kimi_k3_vision.py 单元测试 modified 6.85

关键符号

_should_defer_gpu_preprocessing _build_deferred_output prepare_deferred to_chw_uint8 fill_transparent_bg normalization_tensors materialize_item_features get_image_feature _can_skip_pre_embed_feature_move

关键源码片段

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

延迟决策与 deferred 输出构造的核心入口:`_should_defer_gpu_preprocessing` 定义何时延迟,`_build_deferred_output` 定义 uint8 feature + deferred config 的数据契约,`prepare_deferred` 提供 token 展开与 resize 配置。

def _should_defer_gpu_preprocessing(self, images) -> bool:
    # 只有 CPU 特征传输 + CUDA 环境下,且全部输入为 PIL 图像或 uint8 张量时,
    # 才考虑延迟预处理;float 张量、预计算 embedding 等路径保持 eager 行为。
    if (
        not images
        or self.mm_feature_transport != "cpu"
        or not is_cuda()
        or not all(
            isinstance(image, Image.Image)
            or (isinstance(image, torch.Tensor) and image.dtype == torch.uint8)
            for image in images
        )
    ):
        return False
​
    raw_bytes = 0
    processed_bytes = 0
    patch_size = self._processor._patch_size
    for image in images:
        width, height = _get_image_dimensions(image)
        # 用与 eager 路径相同的 NaViT resize 配置,估算处理后 FP32 patch 张量字节数;
        # 只有延迟真正能减小跨机传输载荷时,才值得走 deferred 路径。
        resize_config = navit_resize_config(
            width,
            height,
            patch_size,
            self._processor._merge_kernel_size,
            self._processor._in_patch_limit,
            self._processor._patch_limit_on_one_side,
            self._processor._fixed_output_tokens,
        )
        if isinstance(image, torch.Tensor):
            # 2D 或单通道按 3 通道计,否则按张量首维通道数计
            channels = (
                3 if image.dim() == 2 or image.shape[0] == 1 else image.shape[0]
            )
        else:
            # PIL:含 alpha 或 transparency 信息键按 4 通道计,其余按 3 通道计
            channels = (
                4
                if image.mode != "RGB"
                and ("A" in image.getbands() or "transparency" in image.info)
                else 3
            )
        raw_bytes += channels * width * height
        padded_width = resize_config["new_width"] + resize_config["pad_width"]
        padded_height = resize_config["new_height"] + resize_config["pad_height"]
        processed_bytes += 3 * padded_width * padded_height * torch.float32.itemsize
​
    # 只在原始载荷不比处理后张量更大时延迟,避免超大原图(如 4K)反而传得更慢
    return raw_bytes <= processed_bytes
python/sglang/srt/multimodal/kimi_k3_image_processing.py core-logic

新增公共图像处理模块,承载 DEFERRED_PREPROCESSING_KEY 契约,统一 eager/deferred 两端的像素转换、背景合成与归一化系数计算。

DEFERRED_PREPROCESSING_KEY = "kimi_k3_deferred_preprocessing"
​
​
def to_chw_uint8(
    image: Union[torch.Tensor, Image.Image],
    device: torch.device | str | None = None,
) -> torch.Tensor:
    # 只有 uint8 原始像素适合跨 CPU transport 延迟传输;已是张量则要求 uint8,
    # PIL 图像在这里统一转成 CHW uint8,灰度图扩展为 3 通道。
    if isinstance(image, Image.Image):
        # RGB 图像即使带 stray transparency 信息键也绝不提升为 RGBA,
        # 以对齐 checkpoint 的 fill_transparent_bg_with() 语义
        has_alpha = image.mode != "RGB" and (
            "A" in image.getbands() or "transparency" in image.info
        )
        array = np.array(image.convert("RGBA" if has_alpha else "RGB"), copy=True)
        image = torch.from_numpy(array).permute(2, 0, 1)
​
    if image.dtype != torch.uint8:
        raise ValueError(
            f"Kimi-K3 preprocessing expects raw uint8 pixels, got {image.dtype}"
        )
    if image.dim() == 2:
        image = image.unsqueeze(0)
    if image.shape[0] == 1:
        image = image.repeat(3, 1, 1)
    if device is not None:
        image = image.to(device) # 延迟到 owner rank 上的唯一一次 H2D
    return image
​
​
def normalization_tensors(
    image_mean: list[float],
    image_std: list[float],
    device: torch.device | str,
) -> tuple[torch.Tensor, torch.Tensor]:
    # 把 mean 与 std 预计算为 scale 与 bias,供 _gpu_preprocess_images 直接做
    # 线性归一化,结果与 eager 路径的逐像素 (x/255 - mean) / std 完全一致
    scale = torch.tensor(
        [1.0 / (255.0 * std) for std in image_std],
        device=device,
        dtype=torch.float32,
    ).view(1, 3, 1, 1)
    bias = torch.tensor(
        [-mean / std for mean, std in zip(image_mean, image_std)],
        device=device,
        dtype=torch.float32,
    ).view(1, 3, 1, 1)
    return scale, bias
python/sglang/srt/models/kimi_k3.py data-contract

模型消费端:materialize_item_features 在 DP owner 上执行延迟预处理,复用 K2.5 的 _gpu_preprocess_images,是 deferred 数据契约的最终落地处;同时调整 IPC consumer count 取值来源。

def materialize_item_features(image_indices: List[int]) -> torch.Tensor:
    """只物化分配给本 vision-DP rank 的图片特征。"""
    ipc_consumer_count = max(get_parallel().tp_size, 1)
    device_index = device.index
    if device.type == "cuda" and device_index is None:
        device_index = torch.cuda.current_device()
​
    selected_items = []
    for image_index in image_indices:
        item = items[image_index]
        if device.type == "cuda":
            # CUDA-IPC 代理仍按原逻辑在分配确定后重建,跨边界只传一次
            item.reconstruct(device_index, ipc_consumer_count=ipc_consumer_count)
        selected_items.append(item)
​
    # 延迟预处理分支:feature 是跨 CPU transport 传过来的 uint8 原始像素,
    # resize、归一化、patchify 全部推迟到本 owner rank 上执行。
    deferred = [
        item.model_specific_data.get(DEFERRED_PREPROCESSING_KEY)
        for item in selected_items
    ]
    if any(config is not None for config in deferred):
        # 同一 batch 内不允许混用延迟与已预处理 feature,否则契约无法自洽
        if not all(config is not None for config in deferred):
            raise ValueError(
                "Kimi-K3 cannot mix deferred and preprocessed image features"
            )
        # 复用 K2.5 的 GPU 预处理管线,由 deferred 配置驱动归一化与背景合成
        from sglang.srt.multimodal.processors.kimi_k25 import _gpu_preprocess_images
​
        first_config = deferred[0]
        image_scale, image_bias = normalization_tensors(
            first_config["image_mean"], first_config["image_std"], device
        )
        pixel_values, _ = _gpu_preprocess_images(
            [item.feature for item in selected_items], # uint8 CPU 张量
            [config["resize_config"] for config in deferred], # NaViT resize 配置
            image_scale,
            image_bias,
            self.vision_tower.patch_size,
            to_chw=lambda image: to_chw_uint8(image, device=device), # 唯一一次 H2D
            post_resize=lambda x: fill_transparent_bg(
                x, first_config["transparent_bg_config"]
            ),
        )
        return pixel_values.to(dtype=target_dtype)
​
    # 非延迟分支:直接对已预处理 feature 做统一物化(与原逻辑一致)
    features = []
    for item in selected_items:
        if not isinstance(item.feature, torch.Tensor):
            raise TypeError(
                "Kimi-K3 image feature must be a torch.Tensor, "
                f"got {type(item.feature)}"
            )
        features.append(item.feature)
    return materialize_multimodal_features(
        features, device=device, dtype=target_dtype
    )

评论区精华

KeyError: 'backend' 导致非 EPD 服务路径崩溃 正确性

yhyang201 报告:常规单节点 8×B300 非 EPD 服务在 warmup dummy run 时,models/kimi_k3.py 的 materialize_item_features 读取 first_config['backend'] 抛 KeyError;消费者需要 backend 键区分 gpu/cpu 分支,而生产者 prepare_deferred 只写入 image_mean、image_std、transparent_bg_config,属于 producer/consumer 契约不一致。

结论:最终 head 的消费者已移除 backend 分支选择,deferred 分支统一走 _gpu_preprocess_images,读取键与生产者输出对齐;据此推断该回归已在迭代中解决,但提交历史未标明修复时机,也没有针对非 EPD 启动路径的独立回归用例,建议合并后复核该路径。 · 已解决(基于最终 head 推断)

draft 状态与严格真实权重 A/B 证据要求 question

mickqian 在 issue 中说明:在 GPU 资源不可用(GB300/B300 均 0 可用)期间,microbenchmark 有效但不足以支撑 serving 性能声明,因此保留 draft 等待严格真实权重 Kimi-K3 服务端 A/B。

结论:PR body 最终附带了真实权重 A/B 数据(unlimited 吞吐 +248.3%、中位 TTFT -78.7%、BF16 逐位一致),满足作者自己设定的证据门槛。 · 已解决

风险与影响

  1. 非 EPD 回归风险:yhyang201 报告的 KeyError: 'backend' 虽在最终 head 上不复现(deferred 分支不再访问 backend 键),但无独立回归测试锁定该路径,后续若有人重新引入 backend 分支选择,风险仍在。
  2. 混合 batch 硬失败:materialize_item_features 对 deferred 与 eager 混批直接抛 ValueError;延迟判定是 per-request 的,同一 forward batch 内不同请求可能一个被延迟、一个不延迟(如 float 张量请求),该路径会中断服务,缺少优雅降级。
  3. 调度行为变更:_can_skip_pre_embed_feature_move 白名单新增 K3 后,所有 K3 请求在 embedding 前不再被 scheduler 搬到 GPU,依赖模型内部 materialize;use_data_parallel=False 等非 DP 拓扑下行为未充分覆盖,一旦回归可能表现为显存或性能异常而非显式报错。
  4. IPC consumer count 取值来源从 server_args.tp_size 改为 get_parallel().tp_size,在 EPD 等异构拓扑下语义可能不同,测试 mock 已同步但线上拓扑未验证。
  5. 字节大小启发式在边界尺寸可能翻转:raw_bytes <= processed_bytes 的估算会随分辨率、通道数与 in_patch_limit 变化,不同请求可能走不同路径,性能表现不稳定(不会 crash)。
  6. 低负载 P90 回归:rate 2 时 P90 TTFT +20%,作者自述为噪声,但说明延迟判定在低并发下收益不稳。

用户侧:Kimi-K3 + cpu 传输(结合 #34662 使单节点默认回退 CPU 后成为常用路径)的用户收益显著——unlimited 并发吞吐提升约 2.5 倍、中位 TTFT 降约 4.7 倍,4×1536×1024 多图场景微基准提速 12.76 倍。系统侧:跨 processor、model、scheduler 三层引入新的延迟物化数据契约(DEFERRED_PREPROCESSING_KEY),CUDA IPC 与 CUDA VMM 传输路径不受影响(保持 eager);影响面限定在 Kimi-K3 与 cpu transport 组合,对其他模型无行为变化。团队侧:确立了“载荷最小化 + owner 延迟物化”的多模态传输模式,为同族模型提供了可复制的设计范式,测试基线新增 8 个以上用例。

非 EPD 路径回归报告 混合 batch 直接抛 ValueError 调度跳过 pre-embed 特征移动 deferred 数据契约变更 IPC consumer count 取值来源变化 低负载 P90 TTFT 抖动

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论