Prhub

#39560 [Fix] Release NCCL on scheduler exit and let the ASGI server own shutdown

原始 PR 作者 hnyls2002 合并时间 2026-09-16 04:39 文件变更 5 提交数 9 评论 2 代码增减 +111 / -19

执行摘要

修复关闭路径:NCCL 主动中止,ASGI 服务器接管退出

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 通信资源,并让服务器拥有退出权。

值得精读,尤其关注两个设计决策:一是'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 没有实质性的 review 评论线程(review_comments_count: 0),Issue 评论只有一条 /rerun-test test_deepep_small.py test_lora_moe_tp_logprob_diff.py 指令及机器人回报两条用例通过的结果,设计权衡主要沉淀在代码注释与 commit 演进中。

值得注意的演进信号来自 9 个 commit 的序列:先是逐个提交

实现拆解

整个修复分为四条链路,按依赖顺序拆解如下:

  1. 调度器侧:新增非阻塞的通信组中止。在 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 已经退出的情况很常见,会阻塞等待。

  2. 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 必须放在最后。

  3. 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 请求管理 modified 7.09
python/sglang/srt/distributed/parallel_state.py 分布式 modified 6.53
python/sglang/srt/entrypoints/http_server.py HTTP 服务 modified 6.43
python/sglang/srt/managers/scheduler.py 调度器 modified 5.44
python/sglang/test/test_utils.py 测试工具 modified 4.5

关键符号

set_server_stop_hook abort_distributed_environment sigterm_watchdog run_scheduler_process wait_for_gpu_release

关键源码片段

python/sglang/srt/managers/tokenizer_manager.py core-logic

核心改动所在:新增类级 `_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 core-logic

新增 `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}")

评论区精华

CI 重跑指令与结果 other

PR 没有任何 review 评论线程,唯一的 Issue 评论是作者发出的 `/rerun-test test_deepep_small.py test_lora_moe_tp_logprob_diff.py`,机器人回报:`4-gpu-h100` 上 `test_deepep_small.py` 与 `2-gpu-h100` 上 `test_lora_moe_tp_logprob_diff.py` 均通过。

结论:两条用例均通过,无未解决的 review 疑虑;设计权衡主要通过 9 个 commit 的演进体现,尤其是最后一个 commit 删除 NCCL abort kill switch,改成就地降级策略。 · 已解决

风险与影响

  1. 依赖 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 处理 缺少自动化回归测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论