Prhub

#54098 [Bugfix] Format kernel-import errors eagerly so warning_once does not retain them

原始 PR 作者 zwang86 合并时间 2026-09-16 04:35 文件变更 3 提交数 7 评论 14 代码增减 +12 / -6

执行摘要

修复 warning_once 缓存异常对象导致的 LLM 实例内存泄漏

Issue #54096 报告:在无 vllm._C 编译内核的平台上,LLM.__init__ 中的 import_kernels 抛出的 ImportError 被 logger.warning_once 永久缓存。PR body 明确指出:warning_once 用 lru_cache 缓存参数,异常对象通过 __traceback__ 引用每个帧及其局部变量;import_kernels 在 LLM.__init__ 内执行,因此在没有编译内核的平台(如 Neuron 等 OOT 平台)上,被钉住的局部变量包含正在构造的 LLM 实例,它永远无法被垃圾回收,其 EngineCore 子进程只能随解释器退出,且加速器插件上的设备一直被泄漏的引擎占用。此外每个异常实例都是独立的 cache key,导致 once 去重失效、每次调用都重复告警。

建议精读。改动本身很小,但揭示了 logger.warning_once 的 lru_cache 缓存参数这一容易被忽视的引用生命周期陷阱:向日志系统传入活对象(异常、traceback)会让去重缓存变成对象引用的永久锚点。PR review 中「只修具体调用点、不动日志层、不加测试」的取舍也很有参考价值——对于小而隐晦的修复,最小化改动面可以降低核心路径回归风险。关注点:该修复未根治日志层语义,后续若遇到类似告警去重 + 大对象组合,应回到 warning_once 的缓存契约上重新评估。

讨论亮点

Review 的核心交锋集中在「修哪里、改多少」:

  • yewentao256 在 review body 中提出:“I think in this case, we should fix the specific bug we have instead of doing updates to logs. I don't think it worth the complexity we introduce here. Something raised in LLM.__init__ is severe”——即不要动日志层,只修具体调用点。作者据此重做 PR,最终仅改动五个调用点。

  • yewentao256 在 tests/test_logger.py 上建议删除回归测试:“We don't need a specific test for this small update”,作者回复 “removed the test”。

  • yewentao256 在 vllm/platforms/interface.py 上建议删除解释性注释:“Comment not needed”,作者回复 “removed the comment”。

  • 最终 yewentao256 给出 APPROVED:“LGTM, thanks for the work!”。

实现拆解

实现按以下步骤推进:

  1. 根因定位:确认 logger.warning_once 的 lru_cache 会永久保存调用参数;直接传入存活异常对象时,repr 惰性格式化在缓存命中后才执行,缓存本身却已持有异常引用,并通过 __traceback__ 级联持有调用栈上的所有对象。

  2. 修改五个 kernel import 调用点(vllm/platforms/interface.py 的 Platform.import_kernels、vllm/platforms/cuda.py 的 CudaPlatformBase.import_kernels、vllm/platforms/cpu.py 的 CpuPlatform.import_kernels 中的 4 处):把 logger.warning_once("...: %r", e) 统一改为 logger.warning_once("...: %s", repr(e))。repr(e) 在调用前立即求值为普通字符串,%s 与旧 %r 对标准 ImportError 渲染结果逐字节一致,但缓存中不再包含任何对象引用;warning_once 的 key 变为稳定的格式化字符串,once 去重也恢复生效。

  3. 按 review 意见收窄改动面:作者最初方案包含 logger.warning_once 本身的行为改造与 vllm/platforms/interface.py 中的解释性注释,以及 tests/test_logger.py 中的回归测试(用 meta-path finder 阻断 vllm._C 导入并用 weakref 断言异常可被 GC)。reviewer yewentao256 认为不值得为这个小改动增加日志层复杂度和专门测试,作者据此移除了测试和注释,最终只保留 5 个调用点的纯格式化变更。

  4. 验证:PR body 给出手动验证脚本——阻断 vllm._C 与 vllm._moe_C_stable_libtorch 导入后调用两次 Platform.import_kernels(),修复前异常在 gc.collect() 后仍存活且告警重复两次,修复后告警只出现一次、异常可被回收;端到端验证在 Neuron 平台上 del llm 后 EngineCore 在 2 秒内关停。

文件 模块 状态 重要度
vllm/platforms/interface.py 平台层 modified 4.54
vllm/platforms/cpu.py 平台层 modified 5.39
vllm/platforms/cuda.py 平台层 modified 4.83

关键符号

Platform.import_kernels CpuPlatform.import_kernels CudaPlatformBase.import_kernels

关键源码片段

vllm/platforms/interface.py core-logic

平台基类 `Platform.import_kernels` 的默认实现,是所有平台 kernel 导入的兜底路径,本 PR 的关键修复点(`%r` 活异常 → `%s` + `repr(e)`)。

@classmethod
def import_kernels(cls) -> None:
    """Import any platform-specific C kernels."""
    try:
        import vllm._C # noqa: F401
    except ImportError as e:
        # 关键修复点:warning_once 基于 lru_cache 永久缓存格式化参数
        # 若把存活异常直接传入,缓存会通过 __traceback__ 钉住整条调用栈
        # (含构造中的 LLM 实例),导致对象无法被 GC、设备无法释放
        # repr(e) 在调用前立即求值为字符串,输出与 %r 逐字节一致
        logger.warning_once("Failed to import from vllm._C: %s", repr(e))
    with contextlib.suppress(ImportError):
        import vllm._moe_C_stable_libtorch # noqa: F401
vllm/platforms/cpu.py core-logic

CPU 平台 `import_kernels` 包含 4 个 kernel import 调用点(`_C` / `_C_AVX512` / `_C_AVX2`),是本 PR 改动量最大的文件,也是无编译内核场景下泄漏与重复告警最典型的触发路径。

@classmethod
def import_kernels(cls) -> None:
    # CPU 场景下库名是 _C_AVX2/_C_AVX512,但模块名仍叫 _C
    # 导入成功时会触发 "dynamic module does not define module export function"
    # 异常,但库实际已加载成功,因此对该消息做过滤后静默忽略
    if Platform.get_cpu_architecture() in (CpuArchEnum.X86,):
        ignored_msg = "dynamic module does not define module export function"
        if torch.cpu._is_avx512_supported():
            if torch.cpu._is_avx512_bf16_supported():
                try:
                    import vllm._C # noqa: F401
                except ImportError as e:
                    # 预先 repr(e),避免 warning_once 的 lru_cache
                    # 长期持有异常对象及其 traceback 引用
                    logger.warning_once(
                        "Failed to import from vllm._C: %s", repr(e)
                    )
            else:
                try:
                    import vllm._C_AVX512 # noqa: F401
                except ImportError as e:
                    if ignored_msg not in e.msg:
                        logger.warning_once(
                            "Failed to import from vllm._C_AVX512: %s", repr(e)
                        )
        else:
            try:
                import vllm._C_AVX2 # noqa: F401
            except ImportError as e:
                if ignored_msg not in e.msg:
                    logger.warning_once(
                        "Failed to import from vllm._C_AVX2: %s", repr(e)
                    )
    else:
        try:
            import vllm._C # noqa: F401
        except ImportError as e:
            logger.warning_once("Failed to import from vllm._C: %s", repr(e))

评论区精华

是否值得为这个小改动新增回归测试 测试

作者最初在 `tests/test_logger.py` 中新增了 `test_import_kernels_does_not_retain_the_import_error`,用 meta-path finder 阻断 `vllm._C` 导入并通过 `weakref` 断言异常可被 GC。reviewer `yewentao256` 建议删除:“We don't need a specific test for this small update”。

结论:按 reviewer 意见移除测试,验证改为 PR body 中的手动脚本。 · 已解决

`warning_once` 是否值得做日志层级修复 设计

reviewer `yewentao256` 在 review body 中强调应先修具体 bug 而不是更新日志行为:"I think in this case, we should fix the specific bug we have instead of doing updates to logs. I don't think it worth the complexity we introduce here. Something raised in `LLM.__init__` is severe"。

结论:作者重做 PR,只改五个调用点,不动 `logger.warning_once` 本身,日志层语义保持不变。 · 已解决

`interface.py` 中的解释性注释是否保留 style

作者在 `vllm/platforms/interface.py` 的修复处加了 3 行解释性注释(说明 `warning_once` 缓存与 traceback 钉住问题),reviewer `yewentao256` 建议删除:“Comment not needed”。

结论:作者回复 “removed the comment”,注释最终移除。 · 已解决

风险与影响

主要风险集中在三方面:

  • 核心初始化路径变更:import_kernels 在 LLM.__init__ 中执行,三个文件覆盖平台基类与 CPU、CUDA 平台实现,是所有平台的共用启动路径。虽然只是格式化方式变化,但若某平台的自定义异常 __repr__ 行为与 %r 不一致,可能改变日志输出。对标准 ImportError 而言 repr(e) 与 %r 逐字节一致,作者已声明并验证。

  • 缺少自动化回归测试:新增的 tests/test_logger.py 回归测试在 review 中被移除,最终变更没有任何测试配套。若后续有人把 %s + repr(e) 改回 %r + 活对象,或新增平台在 import_kernels 中写入同类调用点,泄漏会静默回归而无法被 CI 发现。

  • 结构性问题未根治:logger.warning_once 的 lru_cache 永久缓存语义仍然存在,未来任何其他调用点向它传入存活对象(尤其是异常、生成器等带引用的对象)都会复现同类泄漏。本 PR 只是修了已知的五个调用点。

影响范围明确但集中:

  • 用户侧:CPU-only、Neuron 等无 vllm._C 编译内核平台上,del llm 后引擎可被真正回收,EngineCore 子进程在 2 秒内关停,加速器设备不再被泄漏引擎占用,资源管理与多实例场景受益明显。

  • 系统侧:恢复 warning_once 的一次性告警语义,失败路径不再每次调用都刷告警,减少日志噪音和缓存膨胀。

  • 团队侧:改动仅 12 行、无 API/配置/schema 变更,合入风险低;但由于没有自动化测试,该修复的长期守卫依赖代码审查的警觉性。

核心初始化路径变更 测试被移除缺少回归覆盖 新增调用点易漏改

关联 Issue

#54096 [Bug]: logger.warning_once with a live exception argument leaks the exception's entire traceback (pins the LLM instance on platforms without vllm._C)`

完整报告

参与讨论