执行摘要
- 一句话:测试夹具改优雅关闭,消除 GPU 残留误报
- 推荐动作:值得精读。这是一个罕见的"机制导向"CI 修复案例:作者没有止步于表面修好,而是用定量 A/B 证伪自己的初次结论,最终给出"减少 38% 但不消除"的诚实评估。对维护大型 GPU 测试集群的团队,terminate_and_kill_process_tree 加静态反汇编扫描的方法论可借鉴;同时它揭示了 SIGKILL 关停 CUDA 服务器时 pinned host 内存回收的耗时机制,对理解测试隔离设计有参考价值。
功能与动机
两次 CI 失败(test_self_e2e_perturb_real_kv_unused_cache.py 在 GPU-idle 30s 预检查超时、test_spec_eagle.py 的 TestEagle3Overlap 报 kill_process_tree 60s 未回收)均源于 fixture 用裸 SIGKILL 关停服务器:teardown 在 0.01s 内返回但 52 GiB(单服务器)或 295 GiB(四卡)内存仍被分配,是否在下个类的 30s gate 内回收完全取决于内核回收速度。与 #31746 / #31871 同根因:大 pinned host KV 池的 cudaHostUnregister 与内核回收极慢,实测 243 GB pinned host 池在裸 SIGKILL 后需 25.94 s 才完全干净。PR body 还指出全仓库 502 个杀掉服务器进程树的 teardown 中,只有来自 #31746 / #31871 的两个做了优雅关闭,因此需要系统性推广。
实现拆解
- 提取公共 teardown 工具:在 python/sglang/test/test_utils.py 新增 terminate_and_kill_process_tree(process, terminate_timeout=60, **kill_kwargs),序列为 SIGTERM → 最多等 60s → kill_process_tree(SIGKILL)兜底,并把 wait_timeout 等关键字透传给后者的 kill 等待逻辑。
- 收敛本 PR 触发失败的夹具:python/sglang/test/kv_canary/e2e_base.py 的 CapturedServerE2EBase(CanaryE2EBase 基类)与 python/sglang/test/server_fixtures/spec_eagle_fixture.py 的 SpecEagleServerBase,这两个是 run 30448736325 中分别在 GPU-idle 30s 超时和 SIGKILL 后 60s 未回收的现场。
- 推广到其余单服务器夹具:default_fixture(约 26 个测试模块)、chunked_prefill_test_utils(约 12 个)、streaming_session_fixture、pcg_spec_fixture、ngram_fixture、mmmu_fixture、dsa_mtp_fixture、standalone_fixture、hybrid_attn_backend_fixture、eagle_fixture、vlm_utils、test_deterministic_utils。其中 default_fixture 与 eagle_fixture 原本就传 wait_timeout=60,语义从"SIGKILL 后等 60s"变成"SIGTERM 后再 SIGKILL 并等 60s";kl_mamba 测试的 HiCache L2/L3 原本有内联的 terminate + wait 序列,现在删除重复代码统一走公共函数。
- 修复连锁 import 回归:vlm_utils 不再 import kill_process_tree 后,test/registered/vlm/test_vision_openai_server_a.py(通过 from sglang.test.vlm_utils import * 间接依赖)与 test/registered/xpu/test_gemma_4_e2b.py(直接 from sglang.test.vlm_utils import kill_process_tree)会分别触发 NameError / ImportError;PR 将这两个文件的 teardown 也一并转换为 terminate_and_kill_process_tree。
- 有意保留的例外:disaggregation_fixture 因涉及三个进程加活跃的 mooncake 传输引擎,SIGTERM 行为需单独 PR 验证;ascend/xpu 专用 fixtures 因非 CUDA runner、GPU-idle gate 不适用,未纳入本次修改。
- 验证手段:本地 1×H100 与 4×H100 devbox 跑全部转换后的 fixture(含 TestEagle3Overlap、4×H100 TP4 的 kl_mamba 12 个用例)全部通过;另用静态方法反汇编 522 个受影响测试模块中的 3284 个 setUp/tearDown 方法排查不可解析的 globals,提前捕获了上述两个 import 回归。
关键文件:
python/sglang/test/test_utils.py(模块 测试工具;类别 test;类型 test-utility;符号 terminate_and_kill_process_tree): 新增 terminate_and_kill_process_tree(),是全部转换夹具共用的 teardown 工具:先 SIGTERM 等待最多 60s,再 SIGKILL 兜底,让服务器在用户态释放 CUDA 上下文与 pinned host 内存。
test/registered/radix_cache/unified_radix_tree/test_unified_radix_cache_kl_mamba.py(模块 缓存测试;类别 test;类型 test-coverage): #31871 的延续:三个类(普通、HiCache L2、HiCache L3)teardown 统一收敛到公共函数,删除各自内联的 terminate + wait 重复代码;其中 HiCache L2/L3 正是 pinned host 池回收最慢的场景。
python/sglang/test/server_fixtures/default_fixture.py(模块 默认夹具;类别 test;类型 test-coverage;符号 DefaultServerBase): 影响面最大的公共夹具(约 26 个测试模块继承),teardown 从 kill_process_tree(pid, wait_timeout=60) 升级为带 SIGTERM 前置的版本。
python/sglang/test/kv_canary/e2e_base.py(模块 端到端基座;类别 test;类型 test-coverage;符号 CapturedServerE2EBase, CanaryE2EBase): 本 PR 的两个 CI 失败现场之一(test_self_e2e_perturb_real_kv_unused_cache.py 的 GPU-idle 30s 超时),CanaryE2EBase 所在基类 CapturedServerE2EBase 的 teardown 被转换。
python/sglang/test/server_fixtures/spec_eagle_fixture.py(模块 推测解码;类别 test;类型 test-coverage;符号 SpecEagleServerBase): 另一个失败现场(TestEagle3Overlap 的 kill_process_tree 60s 未回收),SpecEagleServerBase teardown 被转换。
关键符号:terminate_and_kill_process_tree
关键源码片段
python/sglang/test/test_utils.py
新增 terminate_and_kill_process_tree(),是全部转换夹具共用的 teardown 工具:先 SIGTERM 等待最多 60s,再 SIGKILL 兜底,让服务器在用户态释放 CUDA 上下文与 pinned host 内存。
def terminate_and_kill_process_tree(
process,
terminate_timeout: float = 60,
**kill_kwargs,
) -> None:
"""先优雅关闭服务器,再对剩余进程树 SIGKILL 兜底。
直接 kill_process_tree 时,内核需要在进程回收阶段解构 CUDA 上下文、
解钉 host 内存;实测大 pinned 池(243 GB 量级)会让 GPU 残留约 26 秒,
足以让下一个测试类的 GPU-idle 预检查(30s gate)误报失败。
先发 SIGTERM,让服务器在用户态 unregister 这些资源,再走 SIGKILL。
"""
# 第一步:SIGTERM 请求优雅退出,最多等 terminate_timeout 秒
process.terminate()
try:
process.wait(timeout=terminate_timeout)
except subprocess.TimeoutExpired:
pass # 超时不阻塞,直接进入下面的 SIGKILL 兜底
# 第二步:无论是否优雅退出,都确保整棵进程树被清掉
kill_process_tree(process.pid, **kill_kwargs)
test/registered/radix_cache/unified_radix_tree/test_unified_radix_cache_kl_mamba.py
31871 的延续:三个类(普通、HiCache L2、HiCache L3)teardown 统一收敛到公共函数,删除各自内联的 terminate + wait 重复代码;其中 HiCache L2/L3 正是 pinned host 池回收最慢的场景。
@classmethod
def tearDownClass(cls):
# HiCache L2 场景:服务器持有 243 GB 量级的 pinned host KV 池,
# 裸 SIGKILL 时内核回收曾让下一个测试类的 GPU-idle 预检查超时;
# 先 SIGTERM 让用户态 unregister,实测将残留窗口从约 25.94 s
# 缩短到 16.01 s(减少约 38%),仍未完全消除但显著降低误报概率
terminate_and_kill_process_tree(cls.process, wait_timeout=60)
评论区精华
最有价值的讨论是 alisonshao 在 issue 评论中对结论的两次修正:
- 第一次 A/B 只测了 device memory(裸 kill 0.01s 返回、52/295 GiB 未释放;优雅关闭 5.4-6.0s、约 0 残留),作者随后承认该测量没有覆盖真正的失败机制。
- 换上真实 pinned host pool 复测(Llama-3.1-8B、--enable-hierarchical-cache --hicache-ratio 4.0,243 GB pinned host + 67.5 GB device):裸 kill 后完全回收需 25.94 s,优雅关闭后仍需 16.01 s。作者总结:"This is the mechanism. The fix reduces the next class's exposure by ~38% but does not eliminate it."
-
另有一个 follow-up 建议:抽象 test base 如 _PerturbRealKvUnusedCacheBase 仍会被 unittest 从 main 收集,在最后一次真实 teardown 后空跑一次 GPU-idle gate 才 SkipTest,移出测试模块可去掉这个多余检查点。
-
优雅关闭 vs 裸 SIGKILL 的 GPU 内存回收 A/B 验证 (testing): 转换后的 fixture 全部验证通过;但作者随后承认首次测量只覆盖了 device memory,未覆盖真实失败机制。
- 根因校正:pinned host KV 池的回收耗时才是关键 (correctness): 接受作者结论:机制确认,修复是缓解而非根治;总回收时间约 26 s 在繁忙共享 runner 上仍可能超过 30 s gate。
- 抽象 test base 仍被 unittest 收集并空跑 GPU-idle gate (design): 留作 follow-up,不在本 PR 处理。
- 分组测试下的 rerun-test 行为待验证 (question): 未在本 PR 处理,作者标记为待办。
风险与影响
- 风险:
- GPU 残留窗口未完全消除:作者实测优雅关闭后仍需约 16 s 才完全干净,在繁忙共享 runner 上仍可能逼近 30 s gate,只是概率降低。
- SIGTERM 不响应的服务器会让每个 teardown 先等满 terminate_timeout 60s,再叠加 kill 阶段的 wait_timeout,最坏情况每个类多出最多约 120 s,CI 总时长可能上升。
- vlm_utils 不再 re-export kill_process_tree 属于符号级破坏性变更,任何在其外部依赖该符号的测试或工具代码会立即 ImportError(本 PR 修了仓库内两个,仓库外测试可能受影响)。
- PD disaggregation 路径未覆盖:ChunkedTestPDBase 复用 PDDisaggregationServerBase.tearDownClass(),仍为裸 SIGKILL,相关场景的 GPU 残留问题依旧存在。
- 涉及约 100 个测试模块的 teardown 行为变更,虽经静态扫描验证,但个别 fixture 可能有自己的进程管理假设,需要 CI 多轮观察。
- 影响:对 CI 稳定性:直接消除两类失败现场(GPU-idle 30s 超时、SIGKILL 后 60s 未回收),并把约 100 个测试模块的 teardown 从"依赖内核回收"改为"用户态释放优先",降低跨测试类污染(失败的类不再毒化下一个类的 gate)。对成本:每次 teardown 增加数秒,CI 全量耗时上升,但换来的是更稳定的调度。对团队:确立了"先 SIGTERM 再 SIGKILL"的 teardown 标准模式,后续新 fixture 应直接使用公共工具而非各自内联序列。
- 风险标记:GPU 残留窗口未完全消除, SIGTERM 超时可能拖慢 CI, vlm_utils 符号移除引发连锁, disaggregation 路径未覆盖, 涉及约 100 个测试模块
关联脉络
- PR #31746 [Fix] Release hierarchical cache host pool on graceful shutdown: 同根因链条的起点:修复 hicache 主机 KV 池在优雅关闭路径的 unregister;本 PR 的 SIGTERM 前置依赖该用户态释放路径存在。
- PR #31871 [CI] Graceful teardown in kl_mamba hicache tests to release pinned host pool: 首次内联 terminate-then-kill 序列的 PR;本 PR 将其提取为公共函数 terminate_and_kill_process_tree 并推广到全部单服务器夹具。
- PR #31509 per-class GPU-idle gate(PR body 引用): GPU-idle 预检查机制是本 PR 要消除的误报来源;PR body 明确引用该 gate(#31509)作为失败触发条件。
参与讨论