arXiv'26 | Foundry 把 LLM 冷启动的 CUDA Graph 捕获从 10 分钟压到 3.9 秒
arXiv’26 | Foundry 把 LLM 冷启动的 CUDA Graph 捕获从 10 分钟压到 3.9 秒
原文:Foundry: Template-Based CUDA Graph Context Materialization for Fast LLM Serving Cold Start
1. 前言
你有没有想过,当你重启一个 vLLM 服务实例时,为什么模型权重几秒就加载完了,服务却还要再等上几十秒甚至几分钟才能接请求?
我之前也一直以为冷启动的瓶颈在权重加载。毕竟一个几百 GB 的模型,从磁盘读进显存怎么想都很慢。但现实是:RDMA 传输已经能把 1T 参数模型的权重加载压到 2 秒以内,真正卡脖子的变成了 CUDA Graph 捕获——Qwen3-235B-A22B 光这一步就要 10 分钟。
今天想和大家聊聊 UMich + Berkeley + Duke 的这篇 Foundry(作者里有 Ion Stoica,vLLM 就出自他实验室)。它把 CUDA Graph 捕获这一步做成了”离线存档、在线秒读”,最多减少 99% 的冷启动延迟。
考虑到不少读者(包括曾经的我)对 CUDA Graph 这些底层机制不太熟,本文会花较大篇幅先把基础概念讲清楚——看懂了 preliminary,这篇论文的方法其实非常自然。
2. Preliminary:看懂论文前需要知道的几件事
2.1 GPU kernel 是怎么被执行的?
先从最基础的说起。PyTorch 里一行 torch.matmul(x, w),底层发生的事情是:
- CPU 上的 Python 代码调用 PyTorch 的 C++ 算子
- C++ 算子准备好参数(输入输出的显存地址、矩阵形状等)
- CPU 通过 CUDA runtime 向 GPU 提交(launch)一个 kernel——kernel 就是一段在 GPU 上跑的函数,比如某个特定尺寸的 GEMM(矩阵乘法)
- GPU 收到后执行这个 kernel
关键在第 3 步:每次 launch 都有固定的 CPU 开销,大约 5~10 微秒。这包括 Python/C++ 的调用栈、参数打包、驱动调用等。
对于 prefill 这种大计算量的场景,一个 kernel 在 GPU 上跑几毫秒,launch 的几微秒开销可以忽略。但 decode 阶段完全不一样:每次只生成 1 个 token,计算量很小,每个 kernel 可能只跑几十微秒,而一次 forward 要 launch 几百上千个 kernel。这时 CPU 的 launch 开销就藏不住了——GPU 算完一个 kernel,干等着 CPU 发下一个。
2.2 CUDA Graph:把上千次 launch 合并成一次
CUDA Graph 的思路很直接:既然 decode 每一步执行的 kernel 序列都一样(同样的模型结构、同样的 batch size),那把整个 kernel 序列录下来,之后每次一口气整体提交,不就把几百次 launch 开销变成一次了吗?
如下图,上半部分是逐个 launch 的模式,GPU 的时间线上全是等待 CPU 的红色空隙;下半部分是 CUDA Graph 模式,CPU 只提交一次,GPU 上 kernel 无缝衔接:

用 PyTorch 代码来看,CUDA Graph 的使用分两步——capture(捕获) 和 replay(重放):
import torch
# 准备静态输入 buffer(地址必须固定,原因见 2.3)
static_input = torch.zeros(8, 4096, device="cuda")
# ===== 第一步:capture,跑一遍 forward,录下所有 kernel =====
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
static_output = model(static_input) # 这一遍不是真的执行,是"录制"
# ===== 第二步:replay,之后每次推理直接重放 =====
static_input.copy_(new_data) # 把新数据拷到固定地址
g.replay() # 一次调用,整图提交,几百个 kernel 全跑完
result = static_output.clone()
capture 期间,CUDA runtime 会把 forward 过程中所有 kernel launch(函数、参数、执行依赖)记录成一张有向图——节点是 GPU 操作,边是依赖关系。replay 时整张图作为一个单元提交,CPU 开销只付一次。
效果有多明显?论文在 Qwen3-30B-A3B 上实测(下图),关闭 CUDA Graph 后 decode 的 TPOT(每 token 生成时间)从约 10ms 恶化到 35ms,慢了 3 倍多。这也是为什么 vLLM、SGLang、TensorRT-LLM 全都默认开启 CUDA Graph——它不是可选优化,是性能刚需:

2.3 代价一:静态地址约束
CUDA Graph 的高效有个前提:图里录下的所有东西都是”死”的。capture 时每个 kernel 的参数——包括输入输出的显存地址——被原封不动地记在图里。replay 时不会重新读取你的 Python 变量,而是直接用录制时的那个地址。
这就是上面代码里 static_input 的由来:你不能换一个 tensor 传进去(地址变了,图不认识),只能把新数据拷贝到录制时的固定地址。vLLM 里所有输入输出 buffer 都是预分配的持久 buffer,就是为了满足这个约束。
另外,batch size 也是”死”的——图录的是 batch=8 的 kernel 序列,batch=16 就得另录一张图。所以 vLLM 会为每个可能的 batch size 各捕获一张图(1, 2, 4, 8, …, 512),一共几百张。
看到”几百张图”你可能会担心:这得占多少显存?其实不用担心,一张 CUDA Graph 本质上只是一份”执行计划的元数据”,具体包含两部分:
- 拓扑:节点列表(每个节点是一次 kernel launch 或 memcpy/memset 操作)+ 节点间的依赖边
- 每个节点的参数:kernel 函数句柄、参数 buffer、launch 维度(gridDim/blockDim)
关键在于,参数 buffer 里存的是指针而不是数据本身——权重、KV cache、中间激活这些大块头都不在图里,图只是”指向”它们的地址。而且 vLLM 里不同 batch size 的图共享同一套预分配的输入输出 buffer(反正同一时刻只有一张图在 replay),所以几百张图的额外开销就是几百份元数据。
论文的数据可以做个量化参照:Foundry 把 512 张图序列化到磁盘后,图元数据总共不到 1GB——Qwen3-235B-A22B 的完整档案 2.2GB,其中 1.4GB 是 kernel 二进制,剩下约 0.8GB 才是 512 张图的元数据,平均每张图不到 2MB;用二进制格式解析 512 张图只要不到 100ms。对比几百 GB 的模型权重,这点开销可以忽略。
所以 CUDA Graph 真正贵的从来不是”存”,而是”录”——也就是下面要说的 capture 过程。
2.4 代价二:capture 非常慢,而且没法绕过
问题来了:capture 本质上是完整跑一遍模型 forward(还要先跑几遍 warmup,等 cuBLAS 初始化、Triton autotuning 这些一次性工作做完)。几百个 batch size 就是几百遍 forward。模型越大、并行度越高,这一步越慢。
论文给了一个 vLLM 启动耗时的分解(Qwen3-14B,2×H200):

总共 49.9 秒里,Graph capture 占了 29.4 秒,接近 60%。注意这还是在 torch.compile 已经加载预热缓存(而非从头编译)的情况下。权重加载、KV cache profiling 这些都已经被前人的工作优化到秒级了,唯独 graph capture 没人管——到了 Qwen3-235B-A22B EP8 这种规模,光 capture 就要 650 秒。
为什么这很要命?现代 LLM 服务高度依赖弹性伸缩:流量涨了要扩容新实例,负载特征变了要切换并行策略(比如 EP4 切 EP8)。每次扩容/切换都要重新经历一次冷启动,而冷启动直接拉高用户的 TTFT(首 token 延迟)。分钟级的冷启动意味着弹性基本没法做。
2.5 直觉方案为什么不行:CUDA Graph 不可序列化
看到这里你可能会想:capture 慢,那第一次 capture 完把图存到磁盘,下次启动直接读回来不就行了?
这正是整篇论文要解决的问题:CUDA Graph 没法直接序列化。 因为一张捕获好的图不是自包含的,它和捕获时进程的执行上下文(execution context) 死死绑在一起。具体来说有两个依赖:
依赖一:kernel 参数里嵌着显存地址。 图的每个 kernel 节点存着参数 buffer,里面是捕获时的裸指针,比如”权重在 0x6200“。新进程重启后,CUDA driver 分配的地址完全可能不同,权重实际加载到了 0x8600——图里的指针全成了野指针,replay 直接访存出错。更麻烦的是新版 cuBLAS 会把指针打包进不透明结构体(opaque struct)当成一坨字节数组传参,你连哪几个字节是指针都认不出来,想改都没处下手。
依赖二:kernel 函数是懒加载的。 图的 kernel 节点持有的是kernel 函数句柄(handle)——指向已加载进 CUDA context 的函数代码。而 CUDA 的 kernel 二进制(cubin,打包在 CUmodule 里)默认懒加载:某个 kernel 第一次被调用时,所在模块才会被加载进来。正常启动流程里 warmup 顺带触发了所有加载;但一个跳过 warmup 直接读图的新进程,这些模块根本不在内存里,句柄悬空,replay 必然失败。
一句话总结:图里录的是”地址 0x6200 上跑函数 F”,但新进程里 0x6200 没有数据、F 没被加载。 这就是论文说的 non-portable。
2.6 现有两条路,各有各的坑
路线一:Medusa 的手工 patch。 之前的 SOTA 工作 Medusa 只序列化图的拓扑(节点和边),在线重建时用手写的 per-kernel 规则把每个 kernel 参数里的旧指针改写成新地址;kernel 加载问题则靠”重新执行模型第一层”来触发。如下图 (b),问题很明显:每来一个新 kernel 就要写一条新 patch 规则,cuBLAS 的 opaque struct 没法 patch,而且”执行第一层就能加载所有 kernel”的假设对前几层 dense、后面 MoE 的混合架构直接失效。本质上这是在和无穷无尽的 kernel 生态搏斗,越搏越脆。
路线二:进程级 checkpoint。 NVIDIA 的 cuda-checkpoint 配合 CRIU 可以把整个进程(CPU + GPU 状态)快照下来。但快照是无差别打包:模型权重、KV cache 全在里面,镜像好几个 GB;恢复慢;不支持多卡推理必需的 IPC memory;而且动态切并行策略时需要保留在途请求状态,进程级恢复直接把这些冲掉了。

上图 (a) 就是 Foundry 的思路:与其在线修补上下文,不如离线把上下文一起物化(materialize)下来。
3. Foundry 的核心设计
Foundry 的洞察可以用一句话概括:既然图和上下文绑定,那就让新进程”长得和捕获时一模一样”——地址布局一样、加载的 kernel 一样——图自然就能直接用,一个字节都不用改。
整个系统分 SAVE(离线)和 LOAD(在线)两个阶段,如下图:SAVE 阶段正常跑一遍 warmup + capture,同时通过驱动层拦截把三样东西录进档案——图的元数据、内存布局、kernel 二进制;LOAD 阶段按档案还原环境、重建图,完全跳过 warmup 和 capture:

实现方式是一个 LD_PRELOAD 注入的 CUDA driver 拦截层(下图),应用无感知,不需要改 vLLM 的模型代码:

下面拆解三个关键技术。
3.1 确定性内存布局:让地址”每次都一样”
前面说了,图里嵌的显存指针在新进程里会失效,而逐个改指针的路走不通。Foundry 换了个方向:不改指针,改环境——保证每次启动时所有对象都落在和捕获时完全相同的地址上,指针自然全部有效。
怎么做到?Foundry 拦截 CUDA 的显存分配调用,重定向到 VMM(虚拟内存管理)API。VMM 允许把物理显存映射到指定的虚拟地址上。Foundry 先保留一大段从固定基址开始的虚拟地址空间,然后让每次分配都紧挨着上一次的尾部顺序排布:
固定基址 0x6000
├── 第 1 次分配:模型权重 → 0x6000 起
├── 第 2 次分配:KV cache → 紧跟其后
├── 第 3 次分配:IO buffer → 再紧跟
└── ...
只要分配顺序相同,地址布局就完全确定。而 LLM 推理引擎恰好满足这个条件:权重、KV cache 池这些大对象都是启动时一次性分配、终身不释放的(PyTorch 的 caching allocator 也不会把显存还给驱动),分配序列天然稳定。
有个细节值得一提:LOAD 时跳过了 capture,capture 期间产生的那些临时中间 buffer 的分配也就不会发生——但它们的地址同样嵌在图里。Foundry 的解法是 SAVE 时录下 capture 窗口内的所有分配事件,LOAD 时重放这些分配,把图期望的完整地址空间补齐。这也说明光关掉 ASLR 是不够的,因为 SAVE 和 LOAD 的分配序列本身就有差异。
这套顺序分配还带来一个白送的优化:预分配。SAVE 结束时记下最终 offset(比如 0x6800),LOAD 一开始就把 0x6000~0x6800 整段一次性映射好。之后所有分配请求只需检查范围、推进指针——每次分配从一次驱动调用变成一次纳秒级的指针自增。更重要的是,这保证了图引用的所有地址在图重建开始前就已生效,图重建可以和权重加载、KV cache 初始化异步并行。
3.2 Kernel 二进制的提取与重载
第二个依赖(kernel 懒加载)的解法同样是”离线录、在线放”。
SAVE 阶段,Foundry 拦截 cuModuleLoad / cuLibraryLoad 系列 API,记录三样东西:
- 二进制本体:从内存里的共享库(如
libcublas.so)中抽出实际被加载的模块代码 - 加载方式:当时用的是哪个 API、什么参数(LOAD 时原样重放)
- kernel 目录:
(模块内容哈希, kernel 函数名) → 入口点的映射表
LOAD 阶段开头就把这些二进制批量灌回驱动。图重建遇到 kernel 节点时,直接拿 (hash, name) 查目录拿到有效句柄——不需要执行任何一行模型代码来”触发”加载,torch.compile 的编译和 autotuning 开销也顺带全免了。
这个设计最大的好处是库无关(library-agnostic):不管是 cuBLAS 闭源 kernel、Triton 生成的 kernel、还是 DeepEP 的通信 kernel,在驱动层看到的都是同样的 load API,一视同仁。不需要像 Medusa 那样理解每个库的参数布局。论文还处理了两个工程坑:有些库的设备代码是多段 relocatable 的,要先链接才能加载,Foundry 在 SAVE 时就预链接成 ready-to-load 的 cubin;DeepEP 依赖的 NVSHMEM 需要加载后初始化设备端状态,Foundry 检测到就打个标记,LOAD 时补一次初始化调用。
3.3 模板化重建:几百张图,只建二十几张
地址对了、kernel 有了,接下来是把几百张图重建出来。这里还有最后一个瓶颈:通过 CUDA driver API 逐节点重建一张图要几十毫秒,几百张就是几十秒。更坑的是论文实测发现,即使多线程分别构建独立的图,驱动调用也会互相争锁——线程越多单次调用越慢,总时间几乎不降(NVIDIA 文档声称 graph 对象不做内部同步,实际并不是这么回事)。
Foundry 的解法来自一个关键观察:不同 batch size 捕获的图,拓扑往往一模一样——节点类型、顺序、依赖边都相同,只是每个节点的参数(kernel 参数、gridDim/blockDim)不同。如下图,一张图可以拆成”拓扑骨架 + 参数集”两部分:

Qwen3-14B 在 H100 上捕获 512 张图(batch 1~512),去重后只有 22 种拓扑。而 CUDA driver 提供了 cuGraphExecUpdate API,可以在不重新实例化的前提下原地更新一张已构建图的节点参数——比从头建图快 20 倍。
于是重建策略变成:
- 每种拓扑只用 driver API 建一张模板图(主线程串行建,避免驱动争锁)
- 其余图只准备参数集——这一步不涉及驱动调用,可以多线程线性加速
- 推理时如果请求的 batch size 恰好是模板本尊,直接 replay;否则先
cuGraphExecUpdate打上该 batch size 的参数再 replay(一次性开销,之后重复调用直接跳过)
实测每张图的处理成本:stream capture 要 59.7ms(Qwen3-14B)/ 198.6ms(Qwen3-30B-A3B),模板构建 31.1/69.5ms,而按需参数更新只要 0.98/2.89ms——比 capture 快 32/24 倍:

各模型去重效果也都很稳定,512 张图收敛到 12~25 个模板,96% 以上的图走轻量级参数更新路径:

3.4 跨卡共享:单卡 SAVE,多卡 LOAD
还有一个很实际的问题:MoE 模型动辄 EP8 甚至几十卡部署,难道每个并行配置都要占满整批 GPU 跑一次 SAVE?
Foundry 注意到 DP/TP/EP 这类 SPMD 并行的性质:所有 rank 执行相同的计算流,图拓扑完全一致,差异只在权重分片内容和通信 kernel 的 rank 相关参数(比如传给 collective kernel 的 rank id)。所以完全可以在一张 GPU 上完成 SAVE:给 NCCL/NVSHMEM 这些通信库垫一个 stub 层,capture 时做假通信;LOAD 时再把 stub 句柄换成真实通信 kernel、填上各 rank 自己的参数。
一次单卡离线捕获,服务全体 rank——64 卡集群的离线成本和存储直接降 64 倍。局限是 PP(流水线并行)不适用,因为不同 stage 跑的是不同的模型分段,本来就不共享拓扑;不过 PP 的 inter-stage bubble 在 latency 敏感的 decode 场景本来就不受待见,这个限制实际影响不大。
4. 实验效果
setup:8×H200(外加 8×B200 验证新架构),CUDA 13.1,vLLM v0.11.2。模型覆盖 dense(Qwen3-14B/32B、Llama3-8B、Gemma3-12B)和 MoE(Qwen3-30B-A3B、Qwen3-235B-A22B),并行配置 DP1~8、EP2~8,BF16 和 FP8 都测了。
4.1 冷启动延迟:最多砍 99%
主结果如下图,15 种配置全线 95%~99% 的初始化延迟削减:

几个值得注意的点:
- Qwen3-14B:vLLM with graphs 要 36~48s(DP1~8),Foundry 压到 1.7~1.8s,且几乎不随 rank 数增长——因为上下文恢复和图重建全程不占 GPU 计算,各 rank 独立并行
- Qwen3-235B-A22B EP8:vLLM 要 650s(其中 capture 占大头,约 10 分钟),Foundry 3.9s,99% 削减
- 更妙的是,Foundry 的启动时间甚至和完全不开 CUDA Graph 的 eager 模式差不多快(Qwen3-14B DP8:eager 5.0s vs Foundry 1.7s),但 eager 要付 3 倍 TPOT 的性能代价,Foundry 不用
和 CUDA-checkpoint 的对比(下图)也很清晰:Foundry 1.3~2.3s vs checkpoint 5.7~6.1s,快 3~4 倍。分解来看,checkpoint 恢复的大头是把整个快照的 GPU 状态灌回去(Toggle Back),而 Foundry 只恢复图相关的最小状态:

存储上,Foundry 的档案是 CUDA-checkpoint 镜像的 1/4 到 1/5(Qwen3-14B:1.1GB vs 3.7GB)。而且由于单卡 SAVE + 跨 rank 共享,Qwen3-235B EP8 的档案总共也就 2.2GB——checkpoint 方案在这个场景下甚至跑不了(不支持 EP 需要的 IPC memory):

4.2 吞吐无损
最后确认一下没有白干:重建出来的图和原生捕获的图性能一致吗?下图是 batch 16~512 的 TPOT 曲线,vLLM(实线)和 Foundry(虚线)几乎完全重合,H200 和 B200 都是如此:

这个结果其实是设计使然:Foundry 重建的图在驱动眼里和原生捕获的图没有任何区别——同样的拓扑、同样的 kernel、同样的地址,replay 路径完全一致。
5. 一些思考
这篇论文最打动我的是问题选择。 冷启动优化做权重加载的一大堆(ServerlessLLM、BlitzServe 等等),但把 CUDA Graph capture 单独拎出来当主要矛盾的很少——而 Figure 1 的数据摆在那里:其他环节都被优化到秒级之后,capture 占了启动时间的 60%,Qwen3-235B 上更是 10 分钟级别。找对了没人管但真实存在的瓶颈,方法本身可以很朴素。
“改环境而不是改数据”是个很优雅的思路转换。 Medusa 试图理解并修补图里的每个指针(和 kernel 生态搏斗,注定越来越脆),Foundry 反过来让运行环境向图对齐(确定性布局 + 二进制重载,一劳永逸)。这种思路在系统领域其实反复出现——与其做复杂的翻译层,不如消除差异本身。
局限也值得说: 目前 PP 不支持(拓扑跨 rank 不一致);档案和 CUDA 版本、驱动版本、GPU 架构绑定,环境一变就要重新 SAVE;另外它解决的是”重建已知配置”,如果新实例的 KV cache 大小、batch size 集合和 SAVE 时不同,还是得重新捕获。生产环境里这些配置维度不少,档案管理本身会是个工程活。
顺带一提,CUDA Graph 这套”静态图 + 固定地址”的机制本身也是 LLM 推理系统建模里容易被忽略的一环——我之前读 serving 仿真相关工作时就注意到,很多性能模型只考虑 kernel 计算时间,CUDA Graph 的建模是缺失的,而它对 decode TPOT 的影响实测有 30%~60%。这篇论文算是又一个佐证:推理系统的性能故事里,CPU 侧和驱动侧的开销远比想象中重要。
扯一句题外话:我们组之前折腾 MoE 推理优化(ExpertFlow,DAC’26)时也和这类系统层的坑打过不少交道——显存布局、kernel 加载这些底层细节,往往才是工程落地的真正门槛。如果你对 AutoML、NAS 到 LLM 压缩这条线的系统性内容感兴趣,我们把相关积累整理成了《动手学 AutoML》这本书,从搜索空间设计到 LLM 后训练剪枝实战都有覆盖,感兴趣的朋友可以翻翻。
