Prhub

#29656 feat: make mm_inputs msgpack-native

原始 PR 作者 ishandhanani 合并时间 2026-08-21 05:30 文件变更 9 提交数 9 评论 30 代码增减 +691 / -115

执行摘要

mm_inputs 改 msgpack 原生序列化,剔除 PickleWrapper

issue #29465 将「让 mm_inputs 完全兼容 msgpack」列为 P0 任务:TokenizedGenerateReqInput 和 TokenizedEmbeddingReqInput 的 mm_inputs 字段仍是 Optional[PickleWrapper],携带 pickled 的 MultimodalProcessorOutput,其中包含 torch.Tensor、np.ndarray、CudaIpcTensorTransportProxy 以及任意结构的 model_specific_data。PR body 明确指出:'This change sends the structure and unsupported leaf values through msgpack directly, which removes duplicate serialization and reduces scheduler IPC latency.' 目标是彻底去除该字段的 pickle 包埋,为 IPC 全面 msgpack 化扫清障碍。

值得精读。这是 SGLang IPC 全面 msgpack 化的重要节点,展示了 msgspec.Struct 与动态字段兼容、稳定 Ext 协议设计、长度前缀 buffer 布局的性能取舍。重点学习 _pack_buffer_ext 的序列化布局和 __setattr__ 的动态字段路由设计;同时关注 wire ID 稳定性对后续演进的影响。

讨论亮点
  • alexnails 指出动态字段丢失的 BLOCKER:qwen2_audio.py / midashenglm.py 直接给 item 赋值,迁移后会触发 AttributeError 或静默降级。作者通过 __setattr__ 将未声明字段路由进 model_specific_data 并补测试。
  • alexnails 指出 offsets tuple/list 混叠:msgpack 无 tuple 类型,typed decoder 会把 [(0,5)] 变成 [[0,5]],导致 flatten_nested_list 展开后无法解包。作者将 offsets 注解改为 List[Tuple[int, int]],利用 typed decoder 恢复 tuple 对。
  • alexnails 指出 weakref.finalize 失败:dict=True 不提供弱引用,PD encode-disagg 会崩溃并泄漏 GPU embedding 槽。作者增加 weakref=True 并添加回归测试。
  • alexnails 指出 hash 溢出:md5/sha256 的 128/256 位 int 或 uuid4().int 会让 msgpack 抛 OverflowError。作者归一化为低 64 位,保留 pad 计算语义。
  • 围绕 DLPack 的设计讨论:作者解释 DLPack capsule 携带进程内指针,不满足跨进程 wire format,因此保留 SHM/CUDA IPC 代理作为数据面,raw tensor Ext 仅作为拷贝回退。
  • 性能讨论:嵌套 Ext 递归编码 8 MiB NumPy 耗时 8.19 ms,改用长度前缀 buffer ext 后降至 0.630 ms(约 13 倍)。
  • merrymercy 要求把工具函数拆出 io_struct.py 并禁止 import alias,最终迁入 utils/msgpack_utils.py

实现拆解

  1. 数据结构基础改造python/sglang/srt/managers/schedule_batch.pyMultimodalDataItemMultimodalProcessorOutput@dataclass 改为 msgspec.Structkw_only=True, dict=True, array_like=True,并在 review 后补 weakref=True);定义 MultimodalDataValue: TypeAlias = object 作为异构字段的统一边界;__post_init__hash 归一化为低 64 位,__setattr__ 把未声明属性自动写入 model_specific_data,从而保留旧代码中 item.audio_feature_lens = ... 这类动态赋值,避免 qwen2_audio 等处理器崩溃。
  2. 请求类型与序列化接线python/sglang/srt/managers/io_struct.pymm_inputs 字段类型从 Optional[PickleWrapper] 改为 Optional[MultimodalProcessorOutput],并从 wrap_pickle_fields / unwrap_pickle_fields 删除对应行;enc_hook / dec_hook / ext_hook 迁出到独立 util 模块。
  3. 新增 Ext 编解码核心:新建 python/sglang/srt/utils/msgpack_utils.py,定义稳定 wire ID(array=1、torch tensor=2、np array=3、SHM=4、CUDA IPC=5);普通 leaf 用 _pack_ext 递归 msgpack 编码;大 buffer 用 _pack_buffer_ext[4 字节元数据长度][元数据][原始字节] 布局,避免嵌套 Ext 造成额外拷贝;_to_msgpack_state / _from_msgpack_state 显式还原 tupletorch.Size、dtype、device。_encode_shm_pointer_mm_data 委托 __getstate___encode_cuda_ipc_tensor_proxy 只序列化 proxy_statesync_data_meta 白名单。
  4. 模型与处理器适配evs_module.py 删除 EVSDataItem / VideoEVSDataItem 子类,把 thw_gridspre_chunked_input_ids 放入 model_specific_data,断言改为 item.is_video() and key in item.model_specific_datanano_nemotron_vl.py 改用 item.set(...) 写入;transformers_auto.py 保留 token_type_ids 为 tensor,避免违反类型注解导致 typed decoder 崩溃。
  5. 测试与契约保障test/registered/unit/managers/test_io_struct.py 新增 TestTokenizedReqInputMsgpack,覆盖 round-trip、动态字段、hash 归一化、weakref、CUDA IPC 状态、未知 Ext 容错等;test/registered/unit/multimodal/test_evs.py 增加 EVS 数据落位断言。验证结果:test_io_struct.py 45 passed,test_msgpack_ipc_roundtrip.py 7 passed。
文件 模块 状态 重要度
python/sglang/srt/utils/msgpack_utils.py 序列化 added 9.08
python/sglang/srt/managers/schedule_batch.py 批处理结构 modified 8.3
python/sglang/srt/managers/io_struct.py 请求结构 modified 7.75
test/registered/unit/managers/test_io_struct.py 单元测试 modified 7.52
python/sglang/srt/multimodal/evs/evs_module.py EVS 模块 modified 7.07
python/sglang/srt/models/nano_nemotron_vl.py 模型适配 modified 5.82
python/sglang/srt/multimodal/processors/transformers_auto.py 处理器 modified 4.69
test/registered/unit/multimodal/test_evs.py EVS 测试 modified 4.75

关键符号

_pack_ext _unpack_ext _pack_buffer_ext _unpack_buffer_ext _to_msgpack_state _from_msgpack_state _restore_torch_tensor MultimodalDataItem.__post_init__ MultimodalDataItem.__getattr__ MultimodalDataItem.__setattr__ MultimodalProcessorOutput.set TokenizedGenerateReqInput.wrap_pickle_fields pad_input_ids

关键源码片段

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

新增核心序列化模块,定义稳定 Ext wire ID 与 buffer 布局,是本次变更的技术底座

# 稳定的 wire ID,修改需同步 golden-wire 测试
_MSGPACK_EXT_ARRAY = 1
_MSGPACK_EXT_TORCH_TENSOR = 2
_MSGPACK_EXT_NP_ARRAY = 3
_MSGPACK_EXT_SHM_POINTER_MM_DATA = 4
_MSGPACK_EXT_CUDA_IPC_TENSOR_PROXY = 5
# 长度前缀元数据 head:4 字节大端长度
_MSGPACK_BUFFER_METADATA_SIZE = struct.Struct('>I')
​
​
def _pack_ext(code: int, obj: object) -> msgspec.msgpack.Ext:
    # 递归编码 object,内部 unsupported leaf 再走 enc_hook
    return msgspec.msgpack.Ext(code, msgspec.msgpack.encode(obj, enc_hook=enc_hook))
​
​
def _unpack_ext(data: memoryview) -> object:
    # Ext payload 仍是 msgpack,递归解码恢复类型
    return msgspec.msgpack.decode(data, ext_hook=ext_hook)
​
​
def _pack_buffer_ext(code: int, metadata: object, raw_data: memoryview) -> msgspec.msgpack.Ext:
    # 布局:[metadata 长度 (4B)][metadata msgpack][raw bytes]
    # 避免把大块数据再包一层 msgpack,显著降低拷贝开销
    metadata_bytes = msgspec.msgpack.encode(metadata)
    payload = bytearray(_MSGPACK_BUFFER_METADATA_SIZE.pack(len(metadata_bytes)))
    payload.extend(metadata_bytes)
    payload.extend(raw_data)
    return msgspec.msgpack.Ext(code, payload)
​
​
def _unpack_buffer_ext(data: memoryview) -> tuple[object, memoryview]:
    if len(data) < _MSGPACK_BUFFER_METADATA_SIZE.size:
        raise msgspec.DecodeError('MessagePack buffer extension is missing metadata')
    (metadata_size,) = _MSGPACK_BUFFER_METADATA_SIZE.unpack_from(data)
    raw_data_offset = _MSGPACK_BUFFER_METADATA_SIZE.size + metadata_size
    if raw_data_offset > len(data):
        raise msgspec.DecodeError('MessagePack buffer extension has invalid metadata')
    metadata = msgspec.msgpack.decode(data[_MSGPACK_BUFFER_METADATA_SIZE.size:raw_data_offset])
    return metadata, data[raw_data_offset:]
​
​
def _to_msgpack_state(obj: object) -> object:
    # 显式把 tuple / torch.Size / dtype / device 转成带标记 dict,保留运行时类型
    if isinstance(obj, torch.dtype):
        return {'__torch_dtype__': _torch_dtype_name(obj)}
    if isinstance(obj, torch.device):
        return {'__torch_device__': str(obj)}
    if isinstance(obj, np.dtype):
        return {'__np_dtype__': obj.str}
    if isinstance(obj, torch.Size):
        return {'__torch_size__': list(obj)}
    if isinstance(obj, tuple):
        return {'__tuple__': [_to_msgpack_state(v) for v in obj]}
    if isinstance(obj, dict):
        return {k: _to_msgpack_state(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_to_msgpack_state(v) for v in obj]
    return obj
python/sglang/srt/managers/schedule_batch.py core-logic

多模态数据结构从 dataclass 改为 msgspec.Struct,是本次变更的核心数据结构,包含动态字段路由与 hash 归一化

# Msgpack 原生容器 + Ext 解码的 tensor/transport leaf。
# 注意:泛型 tuple 会按 msgpack 语义解码为 list,因此 offsets 显式标注为 tuple 对。
MultimodalDataValue: TypeAlias = object
​
​
class MultimodalDataItem(msgspec.Struct, kw_only=True, dict=True, array_like=True):
    """单个多模态输入(一张图、一段视频或一段音频)的预处理产物。"""
​
    modality: Modality
    hash: Optional[int] = None
    pad_value: Optional[int] = None
    offsets: Optional[List[Tuple[int, int]]] = None
    format: MultimodalInputFormat = MultimodalInputFormat.NORMAL
    feature: Optional[MultimodalDataValue] = None
    precomputed_embeddings: Optional[MultimodalDataValue] = None
    # 处理器自定义字段:tensor/array/ 标量 / 传输代理统一走 object 边界
    model_specific_data: Dict[str, MultimodalDataValue] = msgspec.field(default_factory=dict)
​
    def __post_init__(self) -> None:
        # 归一化到低 64 位,避免 msgpack 拒绝超范围 int(如 md5/uuid4)
        if self.hash is not None:
            msgspec.Struct.__setattr__(self, 'hash', self.hash & _MM_HASH_MASK)
​
    def __getattr__(self, name: str) -> MultimodalDataValue:
        # 兼容 dataclass 时代通过属性访问 model_specific_data 的写法
        if name in self.model_specific_data:
            return self.model_specific_data[name]
        raise AttributeError(f'{type(self).__name__} object has no attribute {name}')
​
    def __setattr__(self, name: str, value: MultimodalDataValue) -> None:
        # 未声明字段自动落入 model_specific_data,保证跨进程不丢动态赋值
        if name in self.__struct_fields__:
            if name == 'hash' and isinstance(value, int) and not (0 <= value <= _MM_HASH_MASK):
                value &= _MM_HASH_MASK
            msgspec.Struct.__setattr__(self, name, value)
        else:
            self.model_specific_data[name] = value
​
    def set(self, key: str, value: MultimodalDataValue) -> None:
        self.model_specific_data[key] = value
test/registered/unit/managers/test_io_struct.py test-coverage

新增 mm_inputs msgpack round-trip 测试套件,覆盖动态字段、hash 归一化、weakref、CUDA IPC 状态等回归场景

class TestTokenizedReqInputMsgpack(unittest.TestCase):
    def _make_mm_inputs(self, device='cpu'):
        # 覆盖典型 VLM 预处理产物:tensor、np.ndarray、np 标量、tuple、list
        return MultimodalProcessorOutput(
            mm_items=[
                MultimodalDataItem(
                    modality=Modality.IMAGE,
                    offsets=[(0, 1)],
                    format=MultimodalInputFormat.NORMAL,
                    feature=torch.tensor([[1.0, 2.0]], dtype=torch.float32, device=device),
                    model_specific_data={
                        'image_grid_thw': torch.tensor([[1, 1, 2]], dtype=torch.int64, device=device),
                        'patch_counts': np.array([2], dtype=np.int32),
                        'names': ['image0'],
                        'count': np.int64(2),
                        'enabled': np.bool_(True),
                        'size': (336, 336),
                    },
                )
            ],
            input_ids=[1, 2],
            padded_input_ids=[10, 10],
            im_token_id=10,
            mrope_positions=torch.tensor([[0, 1]], dtype=torch.int64, device=device),
            token_type_ids=torch.tensor([0, 0], dtype=torch.int64, device=device),
        )
​
    def _round_trip(self, req):
        req.wrap_pickle_fields() # 兼容旧 pickled 字段,mm_inputs 已不在其中
        decoded = msgpack_decode(msgpack_encode(req))
        decoded.unwrap_pickle_fields()
        return decoded
​
    def test_generate_mm_inputs_round_trip_without_pickle_wrapper(self):
        decoded = self._round_trip(
            TokenizedGenerateReqInput(
                input_text='', input_ids=array('q', [1, 2]), input_embeds=None,
                mm_inputs=self._make_mm_inputs(), token_type_ids=[0, 0],
                sampling_params=SamplingParams(), return_logprob=False,
                logprob_start_len=0, top_logprobs_num=0, token_ids_logprob=None,
                stream=False,
            )
        )
        self.assertIsInstance(decoded.mm_inputs, MultimodalProcessorOutput)
        item = decoded.mm_inputs.mm_items[0]
        self.assertEqual(item.modality, Modality.IMAGE)
        self.assertEqual(item.offsets, [(0, 1)]) # typed decoder 恢复 tuple 对
        self.assertTrue(torch.equal(item.feature, torch.tensor([[1.0, 2.0]], device='cpu')))
        # 动态字段经 __setattr__ 留在 model_specific_data
        self.assertTrue(torch.equal(
            item.model_specific_data['image_grid_thw'],
            torch.tensor([[1, 1, 2]], dtype=torch.int64, device='cpu'),
        ))

评论区精华

tuple/torch.Size 序列化后类型丢失 正确性

gemini-code-assist 指出 _to_msgpack_state 将 tuple 和 torch.Size 转成 list,但 _from_msgpack_state 未恢复原类型,可能导致下游 TypeError。

结论:作者在 7238d0ed 修复,在 state 中标记 __tuple__ / __torch_size__ 并显式重建,同时补充 CUDA IPC 回归。 · 已解决

动态字段赋值丢失导致处理器崩溃 正确性

alexnails 列出 qwen2_audio.py 和 midashenglm.py 直接给 item 赋属性,迁移后会被丢弃,引发 AttributeError 或静默降级。

结论:作者用 __setattr__ 将未声明字段路由进 model_specific_data,并添加直接赋值 round-trip 测试。 · 已解决

offsets 的 tuple/list 混叠导致批量解码错误 正确性

msgpack 无 tuple 类型,typed decoder 把 [(0,5)] 变成 [[0,5]],flatten_nested_list 会递归展开导致无法解包。

结论:将 offsets 注解为 List[Tuple[int, int]],typed decoder 自动恢复 tuple 对,round-trip 测试断言 [(0, 1)]。 · 已解决

MultimodalProcessorOutput 缺少 weakref 支持 正确性

encode_receiver.py 用 weakref.finalize 监听 mm_inputs,dict=True 不提供 weakref,导致 TypeError 并泄漏 GPU embedding 槽。

结论:在 Struct 上加 weakref=True,并新增弱引用回归测试。 · 已解决

hash 超 64 位导致 msgpack OverflowError 正确性

调用方可能传 md5/sha256(128/256 位)或 uuid4().int,pickle 能承载但 msgpack 拒绝,tokenizer_manager 只捕获 TypeError/ValueError。

结论:在构造和赋值时归一化为低 64 位,保留模 2^30 pad 语义,避免改变缓存键。 · 已解决

嵌套 Ext 编码性能与 DLPack 取舍 性能

alexnails 质疑 nested Ext 影响性能;作者实测 8 MiB NumPy 从 8.19 ms 降到 0.630 ms(长度前缀 buffer 布局,约 13 倍)。DLPack 因携带进程内指针不适合跨进程 wire format。

结论:采用 [4-byte metadata length][metadata][raw bytes] 的 buffer ext;保留 DLPack 为不可行方案。 · 已解决

transformers_auto 违反 token_type_ids 类型注解 正确性

sherlockwu 指出 transformers_auto.py 给 token_type_ids 赋了 list,typed decoder 会把它路由到 dec_hook(torch.Tensor,...) 导致崩溃。

结论:去掉 .tolist() 保留 tensor,由 tokenizer manager 在 decode 后转 list;补 tensor round-trip 断言。 · 已解决

工具函数应拆出 io_struct.py 设计

merrymercy 表示 io_struct.py 中 Ext 工具函数过大,应移走;同时不要用 import alias。

结论:提交 c8883b1716 将 hooks 迁到 sglang.srt.utils.msgpack_utils,并让请求类型检查跳过导入类。 · 已解决

风险与影响

  • wire 协议稳定性:Ext ID(1-5)是稳定契约,改动需同步 golden-wire 测试;跨版本滚升需考虑旧 pickle 请求与新版调度器的兼容。
  • 动态字段依赖 __setattr__ 路由:若处理器绕过 Struct 机制(如直接写 __dict__)或使用 msgspec.Struct.__setattr__,字段仍可能丢失,需持续排查各处理器。
  • CUDA IPC 生命周期:解码后的代理需正确初始化 _consumer_acknowledged 等状态,否则可能影响引用计数与资源释放。
  • 性能兜底:raw tensor Ext 是拷贝回退路径,大 payload 下仍高于描述符方案;未来若 profiling 显示瓶颈,需引入 auxiliary frames 或阈值切换。
  • 回归风险:核心 IPC 路径变更,涉及多模态请求从 tokenizer 到 scheduler 的全链路,CI 中多模态 e2e 测试必须全绿。
  • 对用户:多模态请求 IPC 延迟降低,pickle 不再用于 mm_inputs,消除了 pickle 带来的安全与兼容性风险。
  • 对系统:调度器接收到的 mm_inputs 是类型化的 msgpack 结构,去除了重复序列化;为后续全面去除 PickleWrapper、翻转默认 msgpack 传输铺路。
  • 对团队:建立了可复用的 Ext 编解码模式,evs_module 等代码去子类化,统一了多模态数据契约,后续 IPC 迁移可复用相同模式。
  • 影响范围:涉及 io_struct.py、schedule_batch.py、evs 模块、transformers_auto 处理器等 9 个文件,属于调度与多模态核心链路。
核心 IPC 路径变更 wire 协议稳定性依赖 Ext ID 动态字段依赖 __setattr__ 路由 pickle 兼容性(旧数据) CUDA IPC 生命周期状态

关联 Issue

#29465 Fully migrate IPC to msgpack: eliminate remaining PickleWrapper workarounds and flip default

完整报告

参与讨论