CUDA Graphs 深度解析

本文档深入探讨 CUDA Graphs 的核心机制与实践应用,旨在理清其预先录制操作序列的本质,并结合实际的大模型推理框架(如 vLLM)展示其如何显著降低 CPU 调度开销,提升 GPU 执行效率。

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 块砖,砌起来。

工人就闷头连续干完了所有活,中途不需要再听工头频繁啰嗦。

2.2 关键细节:这到底“录制”了什么?录在哪?

在 GPU 硬件层面录制的并不是 Kernel 的代码(手艺),而是以下两份静态快照

GPU 录制的内容 具体作用 存储位置
1. 操作序列清单 一张列表,记录了要依次执行哪些 Kernel。例如:[Linear_kernel, SiLU_kernel, Mul_kernel] 显存中的一小块固定缓冲区
2. 参数地址簿 一张地址映射表。例如:Linear_kernel 的输入数据永远在地址 0x7f00 同上

2.3 解惑:它到底省了什么?

既然 Kernel 代码本来就在显存里,为什么录制一下就会变快?它省的不是显存读取时间,而是 CPU 与 GPU 之间的沟通和验证时间

执行阶段对比 传统 Eager 模式下的行为 CUDA Graphs 模式下的行为
CPU 行为 针对每一个 Kernel,CPU 都要构造命令包,通过 PCIe 总线发给 GPU。 CPU 只发一条命令:“执行第 3 号图”。
GPU 行为 GPU 收到一个命令,做一次合法性检查(地址对不对?参数安全吗?),然后干活。 GPU 从显存读出固化好的列表省去逐个检查步骤,直接闷头跑流程。
中间态 Kernel 之间 GPU 会短暂停顿,等待 CPU 下一道指令。 Kernel 之间无缝衔接,GPU 流水线完全排满。

2.4 进阶探讨:固定显存地址与动态内容的矛盾

在 CUDA Graphs 的设计中,录制(锁定)的仅仅是显存容器的物理地址,而非容器内装载的数据内容。这一特性是解决大模型推理中动态数据变化的核心所在。具体到 vLLM 的解码阶段,Input Token 与 KV Cache 的处理机制如下:

  • Input Token 地址映射:在录制 CUDA Graph 时,vLLM 会在显存中开辟一块大小固定的缓冲区(例如地址 0x7f00)。在真实的自回归解码中,每生成一个新 Token ,CPU 都会在调用 Graph Launch 之前,通过异步拷贝操作(如 cudaMemcpyAsync),把这个最新 Token 的张量值覆盖写入到该固定的内存块中。此时 Kernel 每次访问同一地址,但读取到的是被 CPU 悄悄替换过的最新数据。
  • KV Cache 地址映射:由于 KV Cache 随着生成过程不断动态增长,如果频繁更换新地址会导致图失效。vLLM 结合 PagedAttention 机制,将 KV Cache 池的基地址在初始化时完全固定并录制进计算图。同时,决定 KV 存储具体“货架”(Block)的 block_tables(块映射表)等元数据也被分配了固定的显存地址缓冲区。每次 Launch Graph 前,CPU 会把当前 Token 需要的最新索引拷贝到该缓冲区。Kernel 启动后,先从固定元数据缓冲区读取索引,再前往固定的 KV Cache 池基地址存取数据。

为了更直观地理解,我们可以继续沿用前文的建筑工地比喻:

  • Input Token 的替换:就像是工地上的“ 1 号物料箱”(对应固定地址 0x7f00)。工头(CPU)每次开工前,都会把今天新烧好的砖头(最新 Token )放进 1 号箱。工人(GPU)不需要关心砖头的来源,只负责“死记硬背”去 1 号箱拿砖头进行施工。
  • KV Cache 的寻址:就像是工地上的“巨型仓库”(对应预分配且基地址固定的 KV Cache 池)。工头还会把一张写着“今天去仓库第 5 号货架拿货”的字条(对应 block_tables 索引),永远放在“ 2 号指令盒”(对应固定的元数据缓冲区)里。工人每次先去固定的指令盒读取字条,随后再去固定的仓库货架进行物料存取操作。

3 总结核心机制

CUDA Graph 的本质是:把“CPU 重复发送指挥口令”的动作,替换为“GPU 自己读取贴在显存里的小纸条”。

  • Kernel 代码:一直都在显存里(手艺在脑子里)。
  • 操作顺序:录制进了显存的纸条里。
  • 数据地址:固化在了显存的纸条里(这也是显存占用增加的来源,因为这张纸条会锁定一部分显存不让操作系统乱动)。

所以,您原本以为的“预先加载 Kernel”,其实只是省掉了 CPU 喊话 的过程。

局限性提示:这种高效的前提是 “纸条不能改” 。一旦输入数据的形状(Batch Size)或显存地址发生变化,原有的图就会失效,必须重新录制。这也是为什么 vLLM 需要做 Padding 补齐Batch Size Bucketing 分桶 的原因。