NVIDIA Dynamo Shadow Engine Recovery:秒级恢复 LLM 推理服务
当 LLM 引擎进程发生故障时,标准的恢复方式是冷启动——需要从存储加载权重到 HBM、编译内核,并捕获 NVIDIA CUDA 图。对于大模型而言,初始化往往需要数分钟,期间剩余的工作节点必须接管全部被转移的流量。
Shadow engine recovery 是 NVIDIA Dynamo 中的一个预览功能,它把大部分恢复工作移出服务路径。它在活跃引擎所在的同一组 GPU 上维持一个已完成初始化的 shadow engine,保持空闲待命。GPU Memory Service(GMS)在两个引擎之间共享已有的权重,而不会在 HBM 中额外创建一份副本。一旦活跃进程失败,shadow 进程便可在数秒内接管服务,重新初始化则在后台完成,完全不影响在线服务。
我们在一个由两个工作节点组成的 GLM-5.2 部署中,主动终止其中一个节点来测量效果。不开启 shadow engine recovery 时,剩余节点在长达 283 秒的冷启动期间独自承担全部请求,故障期间 TTFT 上升、单用户解码速率下降。开启后,第二个节点在 7.3 秒内就恢复了服务,速度提升近 39 倍,将对服务质量的冲击降到最低。
为什么 LLM 推理恢复很慢:两个核心问题
生产环境中的 LLM 引擎经常会遇到可恢复的软件故障,包括进程崩溃、可恢复的 CUDA 错误,以及瞬时的集合通信失败。这些情况下,硬件、驱动和节点本身都是健康的;只有持有损坏状态的进程丢失,新的引擎通常可以在同一组 GPU 上启动。
那为什么这个全新的引擎无法跳过初始化过程?主要有两个障碍:
- 权重与引擎进程绑定。GPU 内存与引擎的 CUDA context 相关联,而 CUDA context 本身又绑定在引擎进程上。进程退出后,驱动会释放所有资源,包括已经驻留在 GPU 内存中的权重。因此,新启动的引擎进程必须重新执行完整的权重加载流程。
- 部分初始化状态无法迁移。NCCL 和
torch.distributed的通信器绑定到特定的运行进程,而 CUDA graph 则固定于捕获时的虚拟地址。这些状态无法从旧引擎继承,每次重启都必须重建。
Shadow engine recovery 针对这两个问题分别给出了优化方案:把权重的生命周期与引擎进程解耦,并提前完成无法迁移的初始化。
Shadow engine recovery 的工作原理
Shadow engine recovery 结合了持久化 GPU 内存、预热好的备用引擎以及 worker 级的协调机制,从而在无需冷启动的情况下完成恢复。
GPU Memory Service:为 LLM 推理提供持久化 GPU 内存
GPU Memory Service(GMS)独立于引擎进程来管理特定的内存区域(如权重)。它由一个与引擎分离的进程持有这些区域,因此即使引擎不断重启,权重依然能保留在内存中。这样,同一块 GPU 上启动的新引擎可以直接挂载到已有内存上。
GMS 是一个 per-GPU 的 sidecar,代表推理引擎持有物理 GPU 内存。它平时基本处于休眠状态,也没有自己的 CUDA context;它的职责是分配物理页面、对外提供这些页面的句柄,并仲裁任意时刻哪些引擎可以读写。引擎连接后导入句柄,并在自己的 CUDA context 中把对应的物理页面映射到虚拟地址。该映射只在启动时执行一次,此后所有访问都不再经过 GMS。
该功能基于 CUDA 虚拟内存管理 API 构建。通过该 API,物理 GPU 内存及其关联的虚拟地址可以拥有独立的生命周期。由于物理分配采用引用计数,只要仍有进程持有映射关系,物理内存就不会释放。两个引擎映射同一份权重张量时,访问的是同一块物理字节,只是各自使用其上下文内的本地虚拟地址。读取权重的内核只需解引用一个普通指针,指向的正是权重原本会占用的那片 HBM,因此 GMS 支持的读取开销并不高于引擎自行分配的读取开销。
这种架构带来两个好处。第一,权重在引擎故障后依然保留。当内核移除故障引擎的 CUDA 上下文时,GMS 的引用会确保物理页面继续驻留,新的引擎可以立即将其映射起来。第二,权重可在并发的引擎之间共享,因此同一 GPU 上的副引擎在权重方面几乎没有额外的内存开销。
将 GMS 集成到推理框架中只需很小的改动。vLLM、SGLang 和 NVIDIA TensorRT-LLM 都是通过自定义的 torch.cuda.CUDAPluggableAllocator 绑定到权重内存池来完成集成的。在引擎内部,权重仍然是普通的 torch.Tensor。启用 GMS 只需在启动时打开一个开关。
GMS 并不仅限于权重。当前的预览版本尚不支持将 GMS 用于 KV 缓存,但相关功能正在积极开发中。目标是让晋升后的 shadow 直接映射被替换引擎的缓存,而不是在流量到来时重新构建。
影子引擎:已预初始化的备用进程,权重几乎零开销
影子引擎是一个完整初始化好的引擎进程,平时保持空闲,与活跃引擎共存于同一组 GPU 上。之所以可行,是因为采用了权重共享:如果不这样做,第二个引擎就需要再保留一份完整的权重,导致可用于处理请求的显存大幅减少。
影子引擎的启动流程与活跃引擎完全一致。它会在每张 GPU 上连接本地 GMS 并导入权重映射,建立通信器(用于 worker 之间 KV 传输的 NCCL 和 NIXL),捕获 CUDA graph,并完成必要的预热。启动结束后,它其实已经具备服务能力,但它不去服务,而是进入“待命”状态:释放可物化的内存部分,然后阻塞等待被启用。
影子引擎在待命前已经预计算好的内容:
- CUDA 上下文、已捕获的 graph 以及通信器。 这些组件无法在进程间继承,因此必须在影子引擎被激活时就直接就绪。
- 权重映射。 GMS 句柄已经预先导入,唤醒影子引擎时只需把它们重新映射到初始化阶段就建立好的虚拟地址即可。
它延后处理的部分:
- KV cache 的物化。 KV cache 是引擎持有的一大块可回收内存。影子引擎在待命期间只预留地址空间而不分配物理内存,等到被提升时再完成物化。
因此,处于待命状态的影子引擎只保留 CUDA 上下文、已捕获的 graph、通信器和权重映射——既不保存额外的权重副本,也没有 KV cache。这个占用足够小,使得影子引擎可以和活跃引擎并存于同一组设备上,从而在几秒内完成恢复。
Worker:单一的部署单元
下图展示了这些组件如何在每个 worker 中组合,并在共享 router 后面进行水平扩展。
这些基础组件被整合到一个 Pod 中。Worker 包含两个引擎容器、一个用于协调 GPU 内存访问的 GMS sidecar,以及一把用于选举活跃引擎的共享锁。
稳态下,一个引擎持有锁并保持唤醒状态,连接 GMS,持有物化的 KV cache,并向前端路由器注册。另一个引擎则完成初始化并连接 GMS,但处于休眠状态,不持有 KV cache,等待获取锁。
纵深恢复
接下来的章节将梳理恢复流程,并解释保障其可靠性的同步与内存管理机制。
流程
Worker 需要经历四个阶段才能回到稳态。
- T₀ 稳态。引擎 A 持有锁并保持唤醒,已向路由器注册。引擎 B 处于休眠状态,阻塞等待锁。
- T₁ 故障。引擎 A 的进程退出——无论是直接崩溃,还是存活探针检测到挂起后将其杀死。进程被回收时,内核随之释放其持有的锁。在影子引擎完成注册之前,Worker 会短暂处于不可路由状态。
- T₂ 切换。 引擎 B 获取锁、唤醒、通过 GMS 重新映射权重、物化其 KV 缓存,并向路由器重新注册。引擎 A 的容器由编排器重启。
- T₃ 重启完成。 引擎 A 完成初始化,进入影子状态。系统回到稳态,两者的角色已互换。
影子模式的优势在于,它进入 T₂ 时已经完成初始化。关键路径上只剩获取锁、重新映射权重以及物化 KV 缓存。
同步机制
工作进程需要满足两点:互斥,确保同一时刻只有一个引擎处于唤醒状态;以及可靠的锁释放,确保在活跃引擎故障时由备用引擎接管。共享文件上的 POSIX flock 提供了这两层保障。当活跃进程因关机、段错误或 SIGKILL 而退出时,内核会回收其文件描述符,影子引擎便能获取锁并开始提供服务。
因此,每个引擎的启动路径就是一段简短的 leader election:
await engine.initialize() # 加载权重、torch.compile、自动调优、捕获 CUDA graph ... # 让引擎进入休眠,等待锁 await engine.sleep() lock = FlockFailoverLock(lock_path) await lock.acquire(engine_id=engine.id) # 等待锁被释放后唤醒 await engine.wake()
若引擎死锁但进程仍在,则由 Kubernetes 的存活探针兜底,最终发送 SIGKILL,触发同样的内核级锁释放。
内存核算
在同一块 GPU 上容纳两个引擎进程而不耗尽 HBM,需要在整个生命周期内精细核算内存。
- 权重。 由 GMS 一次性分配,工作进程中的每个引擎以只读方式映射,永远不会重复占用。
- KV 缓存。目前仅由活跃引擎持有:引擎唤醒时加载,引擎销毁时释放,释放出的资源可供影子引擎接管。
- 缓冲与计算图。NCCL 缓冲区、CUDA 上下文以及已捕获的计算图。每个引擎即使处于休眠状态也会持续持有,这构成了停放中的影子引擎的全部常驻开销。
基准测试结果:影子引擎恢复 vs. GLM-5.2 上的冷启动
为了量化收益,我们在一个两 worker 集群中,对引擎故障后的影子引擎恢复和冷启动进行了对比。
测试配置
我们在 NVIDIA B200 节点上运行两个 worker 服务 GLM-5.2(量化为 NVFP4):每个节点一个 worker,TP=8,最大上下文 200K,KV 缓存采用 FP8。一个前端以轮询方式将请求分发到这两个 worker。负载为合成负载:每个请求 32,000 个输入 token 和 1,000 个输出 token,请求到达速率为 0.7 请求/秒。
两组实验运行的引擎构建与配置完全相同,唯一的区别在于影子引擎。基线组关闭影子引擎,被杀死的 worker 直接冷启动。影子引擎恢复配置中,每个 worker pod 内置一个预初始化的影子引擎,可在活跃引擎故障时接管。
我们仅在负载达到稳态工作点后才注入故障:对其中一个 worker 发送 SIGKILL,然后观察 600 秒。集群中只有两个 worker,因此当其中一个失效后,存活的 worker 必须独自承接全部请求,直到对端恢复。
测试结果
| 指标 | 基线:冷启动 | Shadow engine recovery |
|---|---|---|
| 第二个 worker 重新服务的耗时 | 283 s | 7.3 s |
| 故障后 TTFT p50 | 23,815 ms | 1,311 ms |
| 故障后 decode rate p50 | 12 tok/s/user | 46 tok/s/user |
| 首 token 超过 5 秒的请求数 | 201 / 399 | 1 / 398 |
| 低于 20 tok/s/user 的请求数 | 226 / 399 | 0 / 398 |
相比 283 秒的冷启动基线,影子引擎恢复仅需 7.3 秒(1.7 秒检测故障,5.6 秒提升影子)。故障发生后的 TTFT 和 decode 速率也因此显著改善,并大幅避免了 SLA 违规。
当前范围与后续计划
在验证了 Kubernetes 上推理工作负载的快速恢复可行之后,我们正在努力稳定该实现,并扩大对更多类型工作负载的支持。影子引擎恢复将在未来几个月内逐步推出。
当前的预览版本存在若干限制和部署要求。
- 影子引擎恢复可应对常见的引擎进程故障,但不覆盖硬件、节点或多节点故障,这些场景仍依赖标准的重新调度机制。
- 它依赖 Kubernetes 的 Dynamic Resource Allocation(DRA),因此集群需要 Kubernetes 1.34 或更高版本,并启用 DRA 以及安装 NVIDIA GPU DRA 驱动。
- Dynamo Snapshot 可与恢复功能组合使用,以在服务过程中初始化影子时尽量减少资源争抢。
- 由于被提升的影子初始时 KV cache 为空,切片后的 TTFT 会出现轻微的上升。跨提升过程保留缓存(包括 prefix-cache 索引及缓存内存本身),是当前正在推进的工作。
- vLLM 是目前主要支持的推理后端。
如需试用影子引擎恢复,请先参考 Kubernetes 快速入门 搭建一个可运行的部署,然后按照影子引擎恢复的部署流程操作,并以 vLLM failover 示例 作为完整清单参考。访问 ai-dynamo/dynamo 仓库 提问、提交问题或参与贡献。