Prhub

#54346 [Bugfix][Multimodal] Release Qwen2.5-VL and Qwen3-VL RoPE caches with the model

原始 PR 作者 waizuichougou 合并时间 2026-08-30 11:10 文件变更 2 提交数 4 评论 11 代码增减 +20 / -7

执行摘要

Qwen2.5/3-VL RoPE 缓存改实例级,修复 shutdown 显存驻留

PR body 明确目标是修复“deterministic GPU memory retention when Qwen2.5-VL or Qwen3-VL is shut down in-process after serving a multimodal request”。根因有二:Qwen2_5_VisionTransformer.get_rope_by_thw 是实例方法却用类级 functools.lru_cache 装饰,self 进入缓存键使缓存包装器强引用每个处理过请求的 vision tower;Qwen3_VisionTransformer.rot_pos_ids 用类级静态 lru_cache,缓存张量存活到解释器退出。PR body 还说明其与 #54162(top-level model 持有)、#54246(layer-bound KV cache)是互补修复,共同覆盖 in-process shutdown 场景的多类内存泄漏源。

值得精读,建议按以下视角阅读:

  1. 重点学习“实例方法被类级 lru_cache 装饰导致缓存以 self 为键强引用实例”这一隐蔽生命周期陷阱的定位方法(PR body 中展示了 AI 辅助引用链分析的完整路径)。
  2. 关注“bounded per-instance LRUCache 替代类级缓存”的通用范式:用容量上限防止无限增长,用实例属性保证随所有者释放,是 Python 缓存生命周期管理的标准解法,可迁移到其他模型的同类问题。
  3. 参考 review 中“宁删不烂”的测试取舍:低价值测试(手动装配对象导致无法捕捉真实回归)不如删除,避免虚假的安全感。

阅读顺序建议先看 qwen3_vl.py 的 rot_pos_ids 改动理解模式,再看 qwen2_5_vl.py 的 get_rope_by_thw 确认落地细节。

讨论亮点

审查的核心交锋集中在测试价值上:

  • DarkLight1337 指出新增的弱引用测试无实际价值:“These tests are not useful as you manually set the attribute anyway. If the upstream modification changes, the test would still pass”。因为测试用手动 object.__new__ 并直接赋值 _rope_by_thw_cache,即使未来实现把缓存改回类级或改名,测试依然通过,属于“假回归保护”。
  • 作者回应称已改为正常构造视觉模型,确保缓存初始化变化时测试会失败。
  • DarkLight1337 进一步裁定:“I think this is too complicated tbh. Let's just drop the tests”,作者照做并删除全部测试。
  • 另外 DarkLight1337 在评论中要求“Let's fix this for Qwen3-VL too”,直接扩大了修复覆盖范围,成为本 PR 包含两个模型的来源。
  • 作者还在评论中说明已补齐 DCO sign-off 并请 reviewer 重新启用 auto-merge,属于首次贡献的流程性沟通。

实现拆解

  1. 修复范围确定:作者最初只提交 Qwen2.5-VL 的修复,reviewer DarkLight1337 在 issue 评论中提出“Let's fix this for Qwen3-VL too”,第 2 个 commit 补上 qwen3_vl.py 的对称改动。
  2. Qwen2.5-VL 改造(vllm/model_executor/models/qwen2_5_vl.py):删除 from functools import lru_cache,改为 from functools import partial,新增 from vllm.utils.cache import LRUCache;在 Qwen2_5_VisionTransformer.__init__ 中新增 self._rope_by_thw_cache = LRUCache(capacity=1024);get_rope_by_thw 去掉 @lru_cache(maxsize=1024) 装饰器,改为显式缓存键 (t, h, w) 的命中判断、计算、写入三步。
  3. Qwen3-VL 改造(vllm/model_executor/models/qwen3_vl.py):同样引入 LRUCache,在 Qwen3_VisionTransformer.__init__ 中新增 self._rot_pos_ids_cache = LRUCache(capacity=1024);rot_pos_ids 从 @staticmethod + @lru_cache(maxsize=1024) 改为实例方法,缓存键 (h, w, spatial_merge_size) 与原静态缓存键一致;调用方 rot_pos_emb 中 self.rot_pos_ids(...) 的调用方式不变,对上层完全透明。
  4. 行为一致性保证:缓存键、1024 容量上限、命中返回同一 tensor 对象(is 判断成立)等语义均与原来相同;差异仅在生命周期归属——从“类级/解释器级”变为“实例级”,多实例场景下各实例首次遇到相同键会各自计算一次,换取模型删除时缓存随之释放。
  5. 测试演进与最终取舍:第 3 个 commit 曾在 tests/models/test_language_model_cache_is_weak.py 新增弱引用回归测试,reviewer 认为通过 object.__new__ 手动装配属性使测试无法捕捉上游初始化回归、且构造过于复杂,第 4 个 commit 删除全部测试;验收依靠 PR body 中的端到端 GPU 实验(A100 双卡、ModelScope 真权重、del + gc.collect() + torch.cuda.empty_cache() 链路)。
文件 模块 状态 重要度
vllm/model_executor/models/qwen3_vl.py 视觉编码器 modified 6.91
vllm/model_executor/models/qwen2_5_vl.py 视觉编码器 modified 6.37

关键符号

get_rope_by_thw rot_pos_ids

关键源码片段

vllm/model_executor/models/qwen3_vl.py core-logic

Qwen3-VL 视觉编码器的核心修复文件:`rot_pos_ids` 从 `@staticmethod + @lru_cache(maxsize=1024)` 改为实例方法,并在 `__init__` 中引入 `self._rot_pos_ids_cache = LRUCache(capacity=1024)`,让缓存张量随模型实例释放。调用方 `rot_pos_emb` 无需改动。

# qwen3_vl.py 中的关键改动:
# - `__init__` 新增实例级缓存,替代原类级静态 `lru_cache`
# - `rot_pos_ids` 由 `@staticmethod` 改为实例方法,缓存随实例释放
​
class Qwen3_VisionTransformer(nn.Module):
​
    def __init__(
        self,
        vision_config: Qwen3VLVisionConfig,
        norm_eps: float = 1e-6,
        quant_config: QuantizationConfig | None = None,
        prefix: str = "",
    ) -> None:
        super().__init__()
        ...
        self.num_grid_per_side = int(self.num_position_embeddings**0.5)
        # 新增实例级 LRU 缓存:把 RoPE 位置张量的生命周期绑定到模型实例,
        # 替代原类级静态 `lru_cache`,容量保持 1024 项不变
        self._rot_pos_ids_cache = LRUCache(capacity=1024)
        ...
​
    def rot_pos_ids(self, h: int, w: int, spatial_merge_size: int) -> torch.Tensor:
        # 缓存键与原静态缓存完全一致,命中行为(返回同一 tensor 对象)保持不变
        cache_key = (h, w, spatial_merge_size)
        if cache_key in self._rot_pos_ids_cache:
            return self._rot_pos_ids_cache[cache_key]
​
        # 生成二维旋转位置索引:先广播出行 / 列索引,
        # 再按 `spatial_merge_size` 分块转置,模拟 mRoPE 的二维网格展开
        hpos_ids = np.broadcast_to(np.arange(h).reshape(h, 1), (h, w))
        h_div = h // spatial_merge_size
        w_div = w // spatial_merge_size
        hpos_ids = hpos_ids.reshape(
            h_div,
            spatial_merge_size,
            w_div,
            spatial_merge_size,
        )
        hpos_ids = hpos_ids.transpose(0, 2, 1, 3)
        hpos_ids = hpos_ids.flatten()
​
        wpos_ids = np.broadcast_to(np.arange(w).reshape(1, w), (h, w))
        wpos_ids = wpos_ids.reshape(
            h_div,
            spatial_merge_size,
            w_div,
            spatial_merge_size,
        )
        wpos_ids = wpos_ids.transpose(0, 2, 1, 3)
        wpos_ids = wpos_ids.flatten()
​
        # 合并为 `[h * w, 2]` 的位置张量并写入实例级缓存
        result = torch.from_numpy(np.stack([hpos_ids, wpos_ids], axis=-1))
        self._rot_pos_ids_cache[cache_key] = result
        return result
​
    def rot_pos_emb(self, grid_thw: list[list[int]]):
        # 调用方维持 `self.rot_pos_ids(...)` 原样不变,
        # 对上层语义透明,无需任何外部适配
        max_grid_size = max(max(h, w) for _, h, w in grid_thw)
        pos_ids = [
            self.rot_pos_ids(h, w, self.spatial_merge_size)
            if t == 1
            else self.rot_pos_ids(h, w, self.spatial_merge_size).repeat(t, 1)
            for t, h, w in grid_thw
        ]
        ...
vllm/model_executor/models/qwen2_5_vl.py core-logic

Qwen2.5-VL 视觉编码器的核心修复文件:`get_rope_by_thw` 移除 `@lru_cache(maxsize=1024)` 装饰器并改用手动缓存逻辑,`__init__` 中新增 `self._rope_by_thw_cache = LRUCache(capacity=1024)`,解决实例方法经类级缓存被强引用的问题。

# qwen2_5_vl.py 中的关键改动:
# - 原 `get_rope_by_thw` 是实例方法但被类级 `@lru_cache(maxsize=1024)` 装饰,
# `self` 进入缓存键导致类缓存强引用每个 vision tower,shutdown 后无法释放
# - 改为显式检查实例级 LRUCache,缓存跟着模型走
​
class Qwen2_5_VisionTransformer(nn.Module):
​
    def __init__(
        self,
        vision_config: Qwen2_5_VLVisionConfig,
        norm_eps: float = 1e-6,
        quant_config: QuantizationConfig | None = None,
        prefix: str = "",
    ) -> None:
        super().__init__()
        ...
        self.fullatt_block_indexes = vision_config.fullatt_block_indexes
        self.spatial_merge_unit = self.spatial_merge_size**2
        # 新增实例级 LRU 缓存,容量与原 `@lru_cache(maxsize=1024)` 一致;
        # 原装饰器使 `self` 进入缓存键,导致类缓存强引用模型实例
        self._rope_by_thw_cache = LRUCache(capacity=1024)
        ...
​
    def get_rope_by_thw(self, t, h, w):
        cache_key = (t, h, w)
        if cache_key in self._rope_by_thw_cache:
            return self._rope_by_thw_cache[cache_key]
​
        window_index_thw, cu_seqlens_window_thw = self.get_window_index_thw(t, h, w)
        cos_thw, sin_thw = self.rotary_pos_emb_thw(t, h, w)
​
        # 按 window 索引重排 `cos_thw` 和 `sin_thw`,并展平为每 token 的 rope 值
        window_index_thw_dev = window_index_thw.to(cos_thw.device, non_blocking=True)
        cos_thw = cos_thw[window_index_thw_dev, :, :]
        cos_thw = cos_thw.flatten(start_dim=0, end_dim=1)
        sin_thw = sin_thw[window_index_thw_dev, :, :]
        sin_thw = sin_thw.flatten(start_dim=0, end_dim=1)
​
        cu_seqlens_thw = torch.repeat_interleave(
            torch.tensor([h * w], dtype=torch.int32), t
        )
​
        # 一次性写入全部中间结果,保证同一 `(t, h, w)` 命中时返回同一批 tensor
        result = (
            cos_thw,
            sin_thw,
            window_index_thw,
            cu_seqlens_window_thw,
            cu_seqlens_thw,
        )
        self._rope_by_thw_cache[cache_key] = result
        return result

评论区精华

弱引用回归测试的有效性质疑 测试

DarkLight1337 在 `tests/models/test_language_model_cache_is_weak.py` 上指出新增测试无价值:测试用 `object.__new__` 手动设置 `_rope_by_thw_cache` 等属性,即使上游实现把缓存改回类级或改名,测试依然通过,属于假回归保护。

结论:作者先回应称已改为正常构造模型,但 reviewer 仍认为复杂度偏高,最终删除全部新增测试。 · 已解决

测试删除的取舍 设计

DarkLight1337 明确表态:“I think this is too complicated tbh. Let's just drop the tests”,认为为验证缓存生命周期而构造完整视觉模型的开销大于收益。

结论:作者按建议移除测试,PR 最终仅保留两个源文件改动,验收依赖 PR body 中的端到端 GPU 测量。 · 已解决

修复范围扩展到 Qwen3-VL question

DarkLight1337 在 issue 评论中要求“Let's fix this for Qwen3-VL too”,将原本只针对 Qwen2.5-VL 的修复扩展到 Qwen3-VL 的类级静态 `lru_cache`。

结论:作者在第 2 个 commit 中补充 `qwen3_vl.py` 的对称改动,`rot_pos_ids` 改为实例方法并绑定实例缓存。 · 已解决

风险与影响

  1. 无自动化测试兜底:最终 PR 只改动两个源文件,没有任何测试文件保留。若未来有人重新引入类级 lru_cache(例如为省事加回装饰器),CI 不会失败,属于纯纪律性回归风险。
  2. LRUCache 线程安全性未验证:原 functools.lru_cache 底层有锁保护;替换为 vllm/utils/cache.py 的 LRUCache 后,__contains__ 与 __setitem__ 是分步操作,若 vision encoder 在多线程中并发调用 get_rope_by_thw / rot_pos_ids,存在 check-then-act 竞态窗口。vLLM 中 encoder 执行路径通常单线程,但此点未在 PR 中讨论。
  3. 多实例重复计算:Qwen3-VL 原来跨实例共享同一 rot_pos_ids 张量,改为实例级缓存后,每个新实例首次遇到相同 (h, w) 组合都要重算一次位置张量;多引擎/多模型实例场景下有少量额外 CPU 计算。
  4. 编译路径未覆盖:get_rope_by_thw 位于可能被 should_torch_compile_mm_encoder 编译的 encoder 路径(见 support_torch_compile 装饰器),实例方法化与显式字典式缓存逻辑在 torch.compile 下的行为没有测试覆盖。
  1. 用户/平台侧:修复的核心场景是 in-process 多次启停(多模型切换、批处理平台、复用 LLMEngine 等)。实测 Qwen2.5-VL-3B 关闭后保留 CUDA 分配从 1,354,216,448 B 降至 10,617,344 B(缩减 99.22%,约 1.251 GiB),vision module 不再存活;Qwen3-VL-2B 关闭后保留 279,577,088 B,vision module 与缓存 tensor 均被回收。对长驻服务无运行时影响。
  2. 系统侧:与关联 PR #54246(layer-bound KV cache 释放)、#54162(top-level model 持有释放)共同构成“in-process shutdown 完整释放 GPU 内存”的系列修复,覆盖三类泄漏源。
  3. 团队侧:改动小、模式清晰、可读性好,但零测试留存意味着未来依赖 reviewer 的代码审查纪律;该模式(类级缓存改实例缓存)可作为后续类似泄漏修复的参考模板。
缺少自动化测试 核心推理路径变更 LRUCache 线程安全性未验证 多实例场景重复计算 torch.compile 路径未覆盖

关联 Issue

#54246 [Bugfix][MRV2] Release layer-bound KV cache memory in shutdown()

完整报告

参与讨论