Prhub

#49607 [Perf] Hash videos by source bytes

原始 PR 作者 guan404ming 合并时间 2026-07-27 23:57 文件变更 8 提交数 8 评论 17 代码增减 +94 / -21

执行摘要

视频哈希优先使用压缩源字节,大幅减少解码开销

视频请求哈希完整的解码帧缓冲区;图像已经哈希其压缩源。为了减少开销,哈希源字节和解码帧中较小者,加上采样元数据。后续任务:来自元数据的解码选项仍然不在密钥中,就像图像一样。

此 PR 值得精读,尤其适合关注多模态缓存、性能优化以及类型安全设计的读者。实现中的一个亮点是“比较大小选择最小哈希输入”的通用思路,可复用到其他场景。Review 中关于碰撞风险和类型精度的讨论也提供了很好的工程实践参考。建议合并后密切监控视频哈希相关的缓存行为和内存指标。

讨论亮点
  • 设计迁移:哈希逻辑位置:Isotr0py 建议将条件判断放在 hasher.py 而非 parse.py 的 get_item_for_hash 中,以保持简洁。作者采纳并在 hasher 中实现,但保留 get_item_for_hash 以暴露元数据。

  • 正确性:哈希碰撞风险:depthfirst-app[bot] 指出仅哈希源字节而省略 num_frames 等解码参数可能导致碰撞。作者通过 commit 8fec083 在哈希中包含采样元数据来修复。

  • 类型安全:Isotr0py 要求避免使用 Any 类型,作者将列表类型改为精确的联合类型。

  • 递归包装简化:Isotr0py 建议在 _get_video_with_metadata 中递归处理 MediaWithBytes,作者应用此建议减少重复。

  • 元数据传播影响:Isotr0py 询问移除 video_needs_metadata 检查是否会影响不需要元数据的模型。作者澄清元数据仅用于哈希键,不影响预处理流程。

实现拆解

  1. 扩展 MediaWithBytes 数据类:在 base.py 中为 MediaWithBytes 添加 __iter__ 和 __getitem__ 方法,使其能像媒体对象一样解包和索引(例如视频元组)。同时更新注释说明视频也使用此包装。

  2. 修改 VideoMediaIO 返回 MediaWithBytes:在 video.py 中,load_bytes、load_base64 和 load_file 方法现在返回 MediaWithBytes[tuple[npt.NDArray, dict]],将解码后的帧与原始源字节打包在一起。load_base64 在返回前先将 base64 数据编码为字节。

  3. 调整数据解析器:在 parse.py 的 _get_video_with_metadata 中添加对 MediaWithBytes 输入的递归处理,保留包装。修改 _parse_video_data 使用精确联合类型。在 VideoProcessorItems 中新增 get_item_for_hash 方法,当存在元数据时返回 (MediaWithBytes, metadata) 元组,以便哈希器同时访问源字节和采样参数。

  4. 核心哈希逻辑:在 hasher.py 的 serialize_item 方法中新增视频分支。当项目是 MediaWithBytes 且其 media 为 np.ndarray 时,比较 frames.nbytes 与 len(obj.original_bytes)。若源字节更小或相等,则对原始字节哈希;否则对解码帧哈希。同时通过 iter_item_to_bytes 配合元组展开确保元数据被包含。

  5. 测试与配套更新:添加 test_hash_collision_video_num_frames 验证不同帧数产生不同哈希。更新 utils.py 中 fetch_video 的返回类型,以及 inputs.py 和 connector.py 的导入。

文件 模块 状态 重要度
vllm/multimodal/hasher.py 哈希器 modified 6.33
vllm/multimodal/media/video.py 视频 IO modified 7.26
vllm/multimodal/parse.py 数据解析 modified 7.18
vllm/multimodal/media/base.py 基础层 modified 6.64
tests/multimodal/test_hasher.py 测试 modified 5.59

关键符号

VideoMediaIO.load_bytes VideoMediaIO.load_file VideoProcessorItems.get_item_for_hash MediaWithBytes.__iter__ MediaWithBytes.__getitem__ MultiModalHasher.serialize_item

关键源码片段

vllm/multimodal/hasher.py core-logic

核心哈希决策:新增视频分支,根据帧字节与源字节大小选择哈希输入,并确保元数据被包含。

@classmethod
def serialize_item(cls, obj: object) -> Iterable[bytes | memoryview]:
    # 处理其他类型如 tuple, torch.Tensor ...
​
    # 新增视频分支:当对象是 MediaWithBytes 且包裹 numpy 数组时
    if isinstance(obj, MediaWithBytes) and isinstance(obj.media, np.ndarray):
        frames = obj.media # type: np.ndarray
        # 比较解码帧与原始源字节的大小
        if frames.nbytes < len(obj.original_bytes):
            # 解码帧更小:对帧内容进行哈希
            return cls.iter_item_to_bytes("video", frames)
        else:
            # 源字节更小或相等:对原始压缩字节进行哈希
            return cls.iter_item_to_bytes("video", obj.original_bytes)
​
    if isinstance(obj, torch.Tensor):
        # ... 原有逻辑
vllm/multimodal/media/video.py core-logic

修改 `VideoMediaIO` 的 `load_bytes`、`load_file` 和 `load_base64` 方法,使其返回 `MediaWithBytes` 包装,携带原始源字节。

def load_bytes(self, data: bytes) -> MediaWithBytes[tuple[npt.NDArray, dict[str, Any]]]:
    video = self.video_loader.load_bytes(
        data, num_frames=self.num_frames, **self.kwargs
    )
    # 将解码结果与原始源字节打包
    return MediaWithBytes(video, data)
​
def load_file(self, filepath: Path) -> MediaWithBytes[tuple[npt.NDArray, dict[str, Any]]]:
    with filepath.open("rb") as f:
        data = f.read()
    # 调用 load_bytes 并返回 MediaWithBytes
    return self.load_bytes(data)
vllm/multimodal/parse.py core-logic

添加 `get_item_for_hash` 方法为视频项提供元数据,更新 `_get_video_with_metadata` 以递归处理 `MediaWithBytes`,调整列表类型。

class VideoProcessorItems(ProcessorBatchItems[HfVideoItem | None]):
    # ... 其他方法
​
    def get_item_for_hash(self, index: int) -> Any:
        item = self.data[index]
        if isinstance(item, MediaWithBytes) and isinstance(self.metadata, list):
            metadata = self.metadata[index]
            if metadata is not None:
                # 返回 (item, metadata) 元组,让 hasher 同时访问源字节和采样参数
                return item, metadata
        return item
​
def _get_video_with_metadata(
    self, video: VideoItem
) -> tuple[np.ndarray | MediaWithBytes[np.ndarray], dict[str, Any] | None]:
    # 递归处理 MediaWithBytes 包裹
    if isinstance(video, MediaWithBytes):
        new_video, metadata = self._get_video_with_metadata(video.media)
        return MediaWithBytes(new_video, video.original_bytes), metadata
    # 其他情况 ...

评论区精华

哈希逻辑位置:hasher.py vs parse.py 设计

Isotr0py 建议将条件判断移到 hasher.py 以保持 parse.py 简洁。作者同意但保留了 `get_item_for_hash` 以提供元数据。

结论:最终哈希逻辑在 hasher.py 中实现,parse.py 的 `get_item_for_hash` 负责暴露元数据。 · 已解决

哈希碰撞风险:仅哈希源字节可能忽略解码参数 正确性

depthfirst-app[bot] 指出当源字节更小时,仅哈希源字节而省略 num_frames 等参数会导致碰撞,可能返回错误的缓存结果。

结论:作者通过 commit 8fec083 将采样元数据包含在哈希中,确保不同解码参数产生不同哈希。 · 已解决

移除 video_needs_metadata 检查的影响 question

Isotr0py 担心移除 `if not self.video_needs_metadata: metadata = None` 可能导致不需要元数据的模型出现问题。

结论:作者解释元数据仅用于哈希键,不影响预处理流程,因此安全。 · 已解决

使用精确类型替代 Any style

Isotr0py 要求避免使用纯 `Any` 类型,以保持类型安全。

结论:作者将 `new_videos` 列表类型改为精确的联合类型。 · 已解决

递归包装 MediaWithBytes 简化代码 设计

Isotr0py 建议在 `_get_video_with_metadata` 中递归处理 `MediaWithBytes`,以减少重复且更优雅。

结论:作者应用了建议,使代码更简洁。 · 已解决

风险与影响

  • 缓存碰撞风险:若元数据未正确包含,不同解码参数可能导致相同哈希。已通过包含 metadata 字典修复,但需确认所有相关键(num_frames、fps、frames_indices、do_sample_frames)都被传递。当前在 VideoProcessorItems.get_item_for_hash 中返回原始 metadata 列表元素,覆盖了这些字段。
  • 类型复杂性:MediaWithBytes 与联合类型(np.ndarray | MediaWithBytes[np.ndarray])增加静态类型检查难度。已使用精确联合缓解,但仍需关注后续类型擦除。
  • 内存开销:视频路径现在保留 original_bytes(源字节),长视频可能导致额外内存占用。但哈希环节仅在构建缓存键时保留,较短生命周期,影响有限。
  • 回归风险:修改了 _parse_video_data 的元数据传播逻辑(移除 video_needs_metadata 条件),可能影响依赖该字段的模型。测试通过,但属于值得关注的配套变更。
  • 用户影响:使用多模态视频功能的用户将获得更快的哈希计算(特别是短视频),提升缓存命中率和响应速度。长视频场景无退化。正确性增强确保不同采样参数生成不同哈希。
  • 系统影响:视频媒体 I/O 输出类型发生变化,所有调用方需要适配 MediaWithBytes 的返回类型。utils.fetch_video 等公共函数已更新。MediaWithBytes 现在同时用于图像和视频,代码一致性提升。
  • 团队影响:引入新的抽象层(get_item_for_hash 协议),后续其他模态可复用。Review 中积累了关于哈希安全性和类型设计的经验。
缓存碰撞风险(已修复) 类型复杂度 内存开销增加

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论