执行摘要
- 一句话:修复关闭路径:NCCL 主动中止,ASGI 服务器接管退出
- 推荐动作:值得精读,尤其关注两个设计决策:一是'abort vs destroy'的权衡——关闭路径上 peer 已退出是常态,因此选择本地中止而非集体式销毁,并用'未初始化返回、无 API 降级、异常仅告警'的就地降级策略替代开关控制;二是'hook 交接退出权'的模式——不在 asyncio 任务里
sys.exit(0),而是通过 set_server_stop_hook() 让真正的服务器对象(uvicorn Server 或 granian Server)执行 lifespan shutdown,同时先 cancel 自身任务避免 pending 任务告警。这套模式对任何'信号处理 vs 服务器生命周期'耦合的服务都有借鉴价值。建议阅读时对照 commit 历史,可以看到作者从加 kill switch 到删 kill switch 的收敛过程。
功能与动机
PR body 明确列出了两个背景问题:一是 run_scheduler_process 在优雅路径上释放 host buffers 但从不 drop 进程组,干净停止时会打印 destroy_process_group() was not called before program exit;二是 TokenizerManager.sigterm_watchdog 以 sys.exit(0) 收尾,而 SystemExit 是 BaseException,会逃逸任务包装并停止 ASGI 服务器正在等待的事件循环,导致 lifespan shutdown 永远走不到,每次干净停止都在 ERROR 级别打印 CancelledError traceback。此外,超时后 SIGKILL 落在 release 过程中,正是 GPU 显存残留的根源,因此需要让 scheduler 在优雅路径主动释放 NCCL 通信资源,并让服务器拥有退出权。
实现拆解
整个修复分为四条链路,按依赖顺序拆解如下:
-
调度器侧:新增非阻塞的通信组中止。在 python/sglang/srt/distributed/parallel_state.py 新增 abort_distributed_environment():若 torch.distributed.is_initialized() 为假则直接返回;随后通过 getattr(torch.distributed.distributed_c10d, "_abort_process_group", None) 拿到中止函数,老版本 torch 没有该私有 API 时原地降级返回,调用异常则记录 warning 而不上抛。之所以用 abort 而不是已有的 destroy_distributed_environment(),是因为 destroy_process_group 是集体操作,shutdown 路径上 peer 已经退出的情况很常见,会阻塞等待。
-
scheduler 退出流程接线。在 python/sglang/srt/managers/scheduler.py 的 run_scheduler_process 的 finally 块中,先保持原有逻辑(异常路径不释放 GPU 以免 synchronize() 挂死),在 scheduler.gracefully_exit 为真时先调用 scheduler.release_host_resources(),最后调用 abort_distributed_environment()。注释明确说明顺序原因:release 过程可能仍需要可用的通信器,所以 abort 必须放在最后。
-
TokenizerManager 侧:新增停止 hook 并把退出交还服务器。在 python/sglang/srt/managers/tokenizer_manager.py 中,将 _server_stop_hook 声明为类级变量(避免触碰冻结的 __init__),新增 set_server_stop_hook()。sigterm_watchdog 收尾处做了三件事:把硬编码的 15 秒超时提取为 _SCHEDULER_EXIT_TIMEOUT_SECS 常量;等待结束后收集仍在存活的 scheduler PID 并打 warning,点名
关键文件:
python/sglang/srt/managers/tokenizer_manager.py(模块 请求管理;类别 source;类型 core-logic;符号 set_server_stop_hook, sigterm_watchdog): 核心改动所在:新增类级 _server_stop_hook 与 set_server_stop_hook(),重构 sigterm_watchdog 收尾逻辑——提取 15 秒超时常量、点名未退出 scheduler PID、最后把退出权交给 ASGI 服务器而非 sys.exit(0),是'让服务器拥有 shutdown'的直接实现点。
python/sglang/srt/distributed/parallel_state.py(模块 分布式;类别 source;类型 core-logic;符号 abort_distributed_environment): 新增 abort_distributed_environment(),用 torch 私有 API _abort_process_group 本地中止所有通信组,取代会阻塞的集体式 destroy_process_group,是修复 destroy_process_group() was not called before program exit 告警的关键。
python/sglang/srt/entrypoints/http_server.py(模块 HTTP 服务;类别 source;类型 core-logic): 三条服务器路径的接线点:uvicorn 默认路径从 uvicorn.run() 改为 Server + server.run() 以获得可停止的对象,SSL-refresh 与 granian 路径分别挂上对应 hook;多 worker 路径明确不接线。
python/sglang/srt/managers/scheduler.py(模块 调度器;类别 source;类型 dependency-wiring): 把 abort_distributed_environment() 接入 run_scheduler_process 的 finally 块,放在 release_host_resources() 之后——因为释放过程可能仍需要可用的通信器,中止必须最后执行。
python/sglang/test/test_utils.py(模块 测试工具;类别 test;类型 test-coverage): 配套可观测性改进:wait_for_gpu_release 新增实际等待时长上报,超过 1 秒阈值即打印,让慢速 GPU 释放不再静默,与 PR 39545 引入的该函数形成接力。
关键符号:set_server_stop_hook, abort_distributed_environment, sigterm_watchdog, run_scheduler_process, wait_for_gpu_release
关键源码片段
python/sglang/srt/managers/tokenizer_manager.py
核心改动所在:新增类级 _server_stop_hook 与 set_server_stop_hook(),重构 sigterm_watchdog 收尾逻辑——提取 15 秒超时常量、点名未退出 scheduler PID、最后把退出权交给 ASGI 服务器而非 sys.exit(0),是'让服务器拥有 shutdown'的直接实现点。
# class TokenizerManager 内,类级变量:由拥有事件循环的一方设置,
# Engine 与 gRPC 不拥有服务器,保持 None。放在类级是为了避开被冻结的 __init__。
_server_stop_hook: Optional[Callable[[], None]] = None
def set_server_stop_hook(self, hook: Callable[[], None]) -> None:
# ASGI 服务器(uvicorn / granian)通过这个 hook 接管退出,
# 避免在 asyncio 任务里调用 sys.exit(0) 提前杀死事件循环。
self._server_stop_hook = hook
# sigterm_watchdog 收尾部分(本 PR 重构后的形态)
# 先派发 ShutdownReq 让 scheduler 在用户态释放资源并退出,
# 然后等待宽限期结束,最后兜底杀进程树。
self._dispatch_to_scheduler(ShutdownReq())
deadline = time.monotonic() + _SCHEDULER_EXIT_TIMEOUT_SECS
while time.monotonic() < deadline and collect_scheduler_processes():
time.sleep(0.1)
stragglers = [proc.pid for proc in collect_scheduler_processes()]
if stragglers:
# SIGKILL 落在释放过程中,正是 GPU 显存残留的根源。
# 这里先把仍存活的 PID 点名,让慢速释放不再静默。
logger.warning(
f"Schedulers still alive {_SCHEDULER_EXIT_TIMEOUT_SECS}s after "
f"ShutdownReq, killing them before they released: {stragglers}"
)
kill_process_tree(os.getpid(), include_parent=False, wait_timeout=60)
if self._server_stop_hook is not None:
# sys.exit(0) 抛出的 SystemExit 会摧毁事件循环,
# 导致 ASGI 服务器的 lifespan shutdown 永远不执行。
# 事件循环现在还能活着,先取消自己的任务,否则 pending 的
# handle_loop 会被报为 destroyed-while-pending;再把退出交给服务器。
current = asyncio.current_task()
for task in self.asyncio_tasks:
if task is not current:
task.cancel()
self._server_stop_hook()
return
# Engine / gRPC 不拥有服务器,保持直接退出。
sys.exit(0)
python/sglang/srt/distributed/parallel_state.py
新增 abort_distributed_environment(),用 torch 私有 API _abort_process_group 本地中止所有通信组,取代会阻塞的集体式 destroy_process_group,是修复 destroy_process_group() was not called before program exit 告警的关键。
def abort_distributed_environment() -> None:
"""本地释放本 rank 的通信器。
destroy_process_group 是集体操作,peer 已退出时会阻塞等待,
而关闭路径上 peer 已退出是常见情况,所以这里改用 abort。
"""
if not torch.distributed.is_initialized():
return
# _abort_process_group 属于 torch 私有 API,老版本可能不存在;
# 不存在就原地降级返回,而不是退回会阻塞的集体式销毁。
abort = getattr(torch.distributed.distributed_c10d, "_abort_process_group", None)
if abort is None:
return
try:
# 不传参数即中止全部进程组,包括默认的 world group。
abort()
except Exception as e:
# 关闭路径上的失败不应放大为新的异常,记录告警即可。
logger.warning(f"NCCL abort on shutdown failed, {type(e).__name__}: {e}")
评论区精华
本 PR 没有实质性的 review 评论线程(review_comments_count: 0),Issue 评论只有一条 /rerun-test test_deepep_small.py test_lora_moe_tp_logprob_diff.py 指令及机器人回报两条用例通过的结果,设计权衡主要沉淀在代码注释与 commit 演进中。
值得注意的演进信号来自 9 个 commit 的序列:先是逐个提交
- CI 重跑指令与结果 (other): 两条用例均通过,无未解决的 review 疑虑;设计权衡主要通过 9 个 commit 的演进体现,尤其是最后一个 commit 删除 NCCL abort kill switch,改成就地降级策略。
风险与影响
- 风险:
- 依赖 torch 私有 API:
abort_distributed_environment() 使用 torch.distributed.distributed_c10d._abort_process_group,属于带下划线的私有接口,PyTorch 版本升级时签名或语义可能变化。已有 getattr 守卫和异常告警兜底,但若新版 torch 中该函数变为带参数或为 no-op,将退回
- 影响:影响范围覆盖所有单 tokenizer 进程的 in-process 服务器关闭路径——uvicorn 默认、uvicorn SSL-refresh、granian HTTP/2——以及所有 scheduler 进程的优雅退出路径。受益点有三:一是日志噪音大幅下降,干净关闭不再出现 CancelledError ERROR traceback 和 destroy_process_group() was not called 告警;二是 GPU 显存在 SIGTERM 后立即归还,对 CI 复用 GPU、避免下一个测试因显存未释放而 OOM 有直接帮助;三是 straggler PID 点名与 wait_for_gpu_release 时长上报让慢速关闭变得可观测、可定位。不受影响的路径包括 Engine、gRPC(无服务器,保持直接 sys.exit(0))以及 tokenizer_worker_num > 1 的多 worker 路径(子进程 re-import app,不挂 hook)。对团队而言,这是一次低风险、高频收益的生命周期清理,与近期多个 CI teardown 修复(GPU 释放等待、NCCL 端口竞争)形成同一方向的合力。
- 风险标记:依赖 torch 私有 API, 核心关闭路径变更, 任务取消可能中断 in-flight 处理, 缺少自动化回归测试
关联脉络
- PR #39545 [CI] Wait for a killed test server's GPU memory before the next launch: 与本 PR 直接相关:该 PR 在
python/sglang/test/test_utils.py 引入 wait_for_gpu_release,本 PR 在此基础上增加等待时长上报,并依赖同一机制验证 SIGTERM 后显存立即归零的目标。
- PR #39611 [CI][Disaggregation] Fix EADDRINUSE flake in test_disaggregation_dwdp_gpt_oss: 同一'干净关闭、资源回收'主题:修复测试服务器因 NCCL 端口竞争导致的关闭 flake,与本 PR 的 NCCL 主动中止、进程树兜底清理形成互补。
- PR #39534 [Fix] Aggregate all IPC weight update responses: 同属 TokenizerManager 生命周期控制路径:该 PR 修复 manager 的 IPC 响应归并,本 PR 修复 manager 的 SIGTERM 关闭路径,两者都集中在
python/sglang/srt/managers/ 下的进程控制逻辑。
参与讨论