CUDA Graphs 深度解析
CUDA Graphs 深度解析
本文档深入探讨 CUDA Graphs 的核心机制与实践应用,旨在理清其预先录制操作序列的本质,并结合实际的大模型推理框架(如 vLLM)展示其如何显著降低 CPU 调度开销,提升 GPU 执行效率。
原文:https://github.com/ForceInjection/AI-fundermentals/blob/main/09_inference_system/vllm/module_analysis/vllm_cuda_graph_deep_dive.md
1 vLLM 中的 CUDA Graphs 实践
以开源大语言模型推理框架 vLLM 为例,探讨其在自回归解码阶段如何通过引入 CUDA Graphs 技术,有效减少 CPU 内核启动(Kernel Launch)开销,同时通过池化策略减少 CUDA Graphs 本身的显存占用,从而逼近 GPU 硬件的性能极限。
在 vLLM 的实际运行中,大模型的推理过程主要分为预填充(Prefill)和解码(Decode)两个阶段。在解码阶段,系统采用逐词生成(Token-by-Token)的自回归方式。虽然大模型推理整体受限于内存带宽,但在具体的解码微观执行层面,由于每次计算耗时极短,导致 Kernel Launch 的延迟开销(Latency Overhead)占比过高。因为每次前向传播需要调用的计算核心(Kernel)数量极多(例如层归一化、注意力机制、矩阵乘法等),如果采用传统的急切(Eager)模式,CPU 频繁发射 Kernel Launch 指令的耗时(通常在微秒级)会逐渐累积,甚至超过 GPU 实际执行计算的时间,导致 GPU 大量时间空转等待 CPU 指令。
为了解决这一性能瓶颈,vLLM 引入了 CUDA Graphs。其核心优化策略与实现路径如下:
• 固定批处理大小池化(Batch Size Bucketing):由于 CUDA Graphs 要求每次图启动时的输入张量形状和内存地址保持固定,如果为每一个可能的批处理大小都录制一张图,会导致极大的显存浪费。因此,vLLM 采取了优化策略,预先定义了一组固定的批处理大小(Batch Size)桶(例如 1, 2, 4, 8, 16 等)。当实际请求的批处理大小不在这些固定值中时,系统会向上对齐(Pad to the nearest bucket),从而大幅减少了需要维护的 CUDA Graph 数量,降低了显存开销。 • 预先录制计算图(Graph Capture):在模型加载和预热阶段,vLLM 会针对上述每一个固定的批处理大小桶,模拟执行一次完整的前向传播,并使用 CUDA Graphs 的应用程序接口(API)录制下整个执行过程。这个过程会生成对应的操作序列清单和内存地址映射表。 • 运行时极速发射(Zero-overhead Launch):在真实的解码阶段,vLLM 会根据当前调度到的请求数量,选择最匹配的预录制 CUDA Graph,只需一次 CPU 调用即可将成百上千个 Kernel 指令一次性提交给 GPU,彻底消除了 Kernel Launch 的开销。 • 退回机制开关( --enforce-eager参数):虽然 CUDA Graphs 能极大提升性能,但预先录制的多张计算图会锁定并占用部分额外的 GPU 显存。在显存极度受限(如刚好卡在 OOM 边缘)或需要进行底层断点调试时,vLLM 提供了--enforce-eager启动参数。开启后,系统将强制退回传统的急切模式执行前向传播,这也会同时禁用torch.compile集成,彻底关闭 CUDA Graphs。这牺牲了部分延迟性能,但释放了被锁定的显存空间,换取了更多的容量。
通过上述机制设计,vLLM 成功将解码阶段的 CPU 调度耗时降至极低水平,极大地提升了系统整体的推理吞吐量。为了理解 vLLM 是如何做到这一点的,我们需要深入 GPU 底层,看看 CUDA Graphs 到底改变了什么。
2 CUDA Graphs 核心机制:操作录制与地址固化
通过通俗的比喻与深入的底层原理解析,详细说明 CUDA Graphs 究竟在显存中“录制”了哪些关键信息,以及它与传统 Eager 模式在执行链路上的本质区别。
对于初学者常有的误解,简单直接的回答是:CUDA Graphs 并不是预先加载 Kernel 代码,而是预先录制“操作指令清单”和“内存地址簿”。
Kernel 的二进制代码本来就常驻在显存里,不存在“提前加载”这个说法。CUDA Graphs 解决的是** CPU 如何高效指挥 GPU 干活**的效率问题。为了彻底理清这一概念,我们做一个直观的比喻。
2.1 比喻:建筑工人(GPU)与工头(CPU)
通过建筑工地的比喻,形象地展示在有无 CUDA Graphs 两种场景下,CPU 调度开销是如何成为系统瓶颈以及如何被消除的。
• GPU(建筑工人):力气大,会砌砖(计算)、会和泥(数据传输)。砌砖的“手艺”(Kernel 代码)已经牢记在他脑子里了(一直存储在显存中)。 • CPU(工头):负责拿对讲机喊话指挥。
2.1.1 没有 CUDA Graphs 时(Eager 模式)
传统 Eager 模式下的调度流程如下。工头喊一句,工人干一下:
工头喊:“张三,去左边墙角,拿第 1 块砖,用‘标准砌法’砌起来!” (CPU 发射 Kernel Launch 指令)
工人干完,发呆等下一句。
工头喊:“张三,去中间,拿第 2 块砖,用‘标准砌法’砌起来!”
工人干完,继续发呆。
瓶颈分析:工头的嗓子(CPU 调度开销)很累,工人大部分时间在等工头的下一句指令,而不是在高效干活。这就是典型的 Kernel Launch Overhead(内核启动开销)。
2.1.2 有 CUDA Graphs 时(Graph 模式)
引入 CUDA Graphs 后的调度流程发生改变。工头拿了一张纸写了个《施工流程表》,贴在了工地的白板上,并对工人说了一句:
工头喊:“张三,照着白板上第 3 号流程表干一遍!” (Graph Launch)
工人一看白板,上面写着:
1. 去左边墙角,拿第 1 块砖,砌起来。 (旁白:这块砖必须永远放在墙角那个位置,如果挪动了位置,流程表就失效了——这对应了 CUDA Graph 对显存地址的强依赖) 2. 去中间,拿第 2 块砖,砌起来。 3. ...
工人就闷头连续干完了所有活,中途不需要再听工头频繁啰嗦。
2.2 关键细节:这到底“录制”了什么?录在哪?
在 GPU 硬件层面录制的并不是 Kernel 的代码(手艺),而是以下两份静态快照:
| 1. 操作序列清单 | [Linear_kernel, SiLU_kernel, Mul_kernel] | |
| 2. 参数地址簿 | Linear_kernel 的输入数据永远在地址 0x7f00 |
2.3 解惑:它到底省了什么?
既然 Kernel 代码本来就在显存里,为什么录制一下就会变快?它省的不是显存读取时间,而是 CPU 与 GPU 之间的沟通和验证时间。
| CPU 行为 | ||
| GPU 行为 | ||
| 中间态 |
3 总结核心机制
CUDA Graph 的本质是:把“CPU 重复发送指挥口令”的动作,替换为“GPU 自己读取贴在显存里的小纸条”。
• Kernel 代码:一直都在显存里(手艺在脑子里)。 • 操作顺序:录制进了显存的纸条里。 • 数据地址:固化在了显存的纸条里(这也是显存占用增加的来源,因为这张纸条会锁定一部分显存不让操作系统乱动)。
所以,您原本以为的“预先加载 Kernel”,其实只是省掉了 CPU 喊话 的过程。
局限性提示:这种高效的前提是 “纸条不能改” 。一旦输入数据的形状(Batch Size)或显存地址发生变化,原有的图就会失效,必须重新录制。这也是为什么 vLLM 需要做 Padding 补齐和 Batch Size Bucketing 分桶 的原因。