vLLM 源码完全拆解(十二):piecewise compilation 与 CUDA Graph——消灭 kernel 调度开销的两层机制

11754 字
59 分钟
vLLM 源码完全拆解(十二):piecewise compilation 与 CUDA Graph——消灭 kernel 调度开销的两层机制

引言:本篇在源码地图中的位置#

本系列前十一篇沿着一条请求的生命周期走完了 vLLM 的主数据流:请求入队(第二篇)、调度决策(第三篇)、KV Cache 管理(第四篇)、输入构造(第五篇)、注意力后端与 PagedAttention kernel(第六、七篇)、采样回传(第九篇)、以及多卡协作(第十一篇)。这些文章里反复出现一行代码——GPUModelRunner.execute_model() 中段的 model_output = self.model(...)——但每次都把它当成黑盒,只关心输入怎么准备好、输出怎么收回来。

这一篇打开这个黑盒,但关注的不是模型计算本身,而是一个容易被忽略的问题:这行 Python 代码调用下去之后,CPU 上发生了什么? 一条 Llama-70B 的 decode 前向要执行数百个 CUDA kernel,nn.Module 逐层派发、逐 kernel launch,这些 launch 全部由 Python/C++ 在 CPU 上串行发出。GPU 算得快,CPU 喂得慢,decode 就变成了 CPU 调度开销的重灾区。vLLM V1 引擎对此的答案是一套自研的编译子系统(vllm/compilation/):用 Dynamo 把模型追踪成一张完整计算图,按注意力切成分片子图交给 Inductor 编译,再给每段子图套上 CUDA Graph——这套机制在 V1 里默认开启,是 vLLM 从「一个 Python 推理服务」变成「一个准静态执行引擎」的关键台阶。

本篇拆解四块源码:support_torch_compile 装饰器与 TorchCompileWrapperWithCustomDispatcher(模型如何声明可编译性、如何绕过 Dynamo guard)、VllmBackend 与 split_graph(切图的全部规则)、InductorAdaptor(Inductor 怎么被「驯服」进 vLLM 的缓存体系)、CUDAGraphWrapper 与 CudagraphDispatcher(capture/replay 的调度机制)。上一篇 CUDA Graph 的通用原理在本站已有独立拆解(CUDA Graph 完全拆解),本篇不复述 capture 的底层 API,而是聚焦 vLLM 的工程封装:它怎么决定给哪些 batch size 捕图、怎么在一张图里塞进动态 batch、怎么让注意力这种 CUDA Graph 不友好的算子跳过回放。

背景与问题定义:为什么 eager 模式的 decode 是「调度开销重灾区」#

一次 decode 步的 CPU 账单#

自回归 decode 每生成一个 token,就要完整跑一遍模型前向。以 Llama-3-70B 为例:80 个 Transformer 层,每层至少包含 QKV 投影、注意力、o_proj、Gate/Up 投影、SiLU、Down 投影、两次 RMSNorm,再加上采样前的 lm_head。即使是融合后的 kernel,总数也在 448 个以上(80 层 × 每层 5-6 个 kernel,外加 embedding、norm、head)。

每个 CUDA kernel launch 在 CPU 侧的开销是微秒量级。PyTorch 官方在 CUDA Graphs 的技术文章里给出的典型数字是 5-10 微秒;对现代 GPU 上吞吐型的大 kernel,这个开销可以忽略——GEMM 跑 200 微秒,launch 占 3%,无所谓。但 decode 恰好是反面:batch size 小的时候,每个 token 的 GEMM 只有几百纳秒到几微秒的计算量,此时一个 decode 步的 448 个 kernel 中,绝大多数是短平快的小算子(norm、激活、逐元素加),GPU 几十微秒就算完,然后空等 CPU 发下一个 launch。

这笔账很容易算成「CPU bound」:

tstep≥∑i=1Nkernelmax⁡(tlaunch(i), tgpu(i))t_{\text{step}} \ge \sum_{i=1}^{N_{\text{kernel}}} \max(t_{\text{launch}}^{(i)},\ t_{\text{gpu}}^{(i)})

其中 tlaunch(i)t_{\text{launch}}^{(i)} 是第 ii 个 kernel 的 CPU launch 开销(包括 Python 解释器逐行执行 forward、nn.Module.__call__ 钩子检查、dispatch 到 ATen、再把参数打包进 CUDA launch),tgpu(i)t_{\text{gpu}}^{(i)} 是 GPU 执行时间。串行流水线上,每一步的墙钟时间是两者的较大值之和。当 batch 小、kernel 短时,t_launch 普遍压过 t_gpu——这就是推理社区常说的「launch bound」或「CPU bound decode」。

vLLM 集成 piecewise CUDA graph 的核心 PR(#10058)给出的实测数字能说明问题:benchmark_latency.py 下,V1 引擎不带 piecewise CUDA Graph 时,OPT-125M 一次前向 0.41 秒,开启后 0.21 秒;Llama-3.1-8B 从 1.26 秒降到 1.04 秒。0.2-0.5 秒级别的收益几乎全部来自 CPU 侧——模型代码一行没改。

三段开销,三个对应的武器#

把 eager decode 的 CPU 开销拆细,其实是三段,每段武器不同:

  1. Python 字节码与框架派发开销:nn.Module 逐层调用、hook 遍历、torch.autograd.Function 的记录逻辑。武器是 Dynamo 图捕获——把整个 forward 编译成一张 FX 图,Python 循环消失。
  2. kernel launch 开销:CPU 把 kernel 参数写进 command buffer、提交给 GPU。武器是 CUDA Graph 回放——一次 graph.replay() 提交整段 kernel 序列。
  3. kernel 自身的低效:内存搬运多、融合不足、没为当前 shape 调参。武器是 Inductor 编译——生成融合后的 Triton kernel,必要时 autotune。

eager 提交下 CPU 逐步 launch 与 GPU 执行之间的空洞(PyTorch 官方博客)
eager 提交下 CPU 逐步 launch 与 GPU 执行之间的空洞(PyTorch 官方博客)

上图来自 PyTorch 官方博客对 CUDA Graphs 的介绍:CPU 侧逐个 launch(上)与一次 graph launch(下)相比,GPU 时间线上的空洞明显收敛。vLLM 没有手写 CUDA Graph 逻辑,而是把这个收益通过编译子系统自动带给所有模型。

这三段分别对应 vLLM 编译子系统的三层结构:Dynamo 层(support_torch_compile)、图处理层(VllmBackend + Inductor passes)、静态执行层(CUDAGraphWrapper)。一个容易被混淆的认知是「CUDA Graph 等于 torch.compile」:不是,vLLM 的层级是 torch.compile(Dynamo+Inductor)负责消灭开销 1 和 3,CUDA Graph 负责消灭开销 2,二者是叠加关系而非替代关系——这一层叠法正是本篇的主角。

为什么不能直接 torch.compile(model, mode="reduce-overhead")#

mode="reduce-overhead" 就是 Dynamo + Inductor + CUDA Graph 的官方组合,框架自带,vLLM 为什么不走?根源在 vLLM 的推理模式与该 API 的假设冲突:

  • CUDA Graph 要求 shape 与地址全静态。标准 CUDA Graph 语义下,图里所有 kernel 的 shape、数据指针在 capture 时固定,回放时一字都不能变。而 vLLM 的 token 数每个 step 都可能不同(调度器决定)、注意力元数据(block table、seq lens)每个 step 都要变。
  • 注意力算子是 CUDA Graph 的天敌。PagedAttention 的执行计划依赖 ragged 的变长输入和分页 KV cache 指针,是逐 batch 动态的。强行捕获进图,要么捕获到错误的行为,要么每次 batch 变化就重捕获——后者开销巨大。
  • Dynamo 的 guard 机制会产生运行时检查。每次调用编译后的函数,Dynamo 要重新评估守卫条件(输入 shape 是否变了、参数是否还是原来的对象)。在每秒数百次的 decode step 里,guard 评估本身成了新的 CPU 开销。
  • 普通 torch.compile 允许反复重编译(graph break 之后遇到新 shape 就再编一次),这在长稳态服务里不可接受:一个请求触发重编译,引擎整个卡住,响应延迟出现尖刺。

vLLM 的答案是自管整个编译过程:Dynamo 只用一次,把模型变成一张图;图由 vLLM 自己切、自己编、自己缓存;CUDA Graph 只捕获注意力之外的部分;运行时彻底绕开 Dynamo guard。这套机制的每一个细节都是为了「编译产物可控 + 运行时零意外编译」。

核心思想:把「一次前向」变成「几张可回放的静态图 + 少量动态算子」#

vLLM 编译子系统的一句话总结:用 Dynamo 把模型折成一张图,用注意力算子当「铰链」把图剪开,中间的每一段都是纯张量变换、shape 只随 token 数变化——恰好是 CUDA Graph 的舒适区;注意力算子本身留在图外,每个 step 由 Python 驱动执行。

整个流程分四步:

  1. 追踪:support_torch_compile 装饰器让模型的 forward 被 Dynamo 完整捕获(不允许 graph break),产出一张从 input ids 到 hidden states 的 FX 图。注意 lm_head 与采样不进图。
  2. 切开:VllmBackend.split_graph 在每个 vllm.unified_attention_with_output 自定义算子处下剪子。结果是一串子图:G0G_0(embedding + 第 1 层前半)、G1G_1(第 1 层后半 + 第 2 层前半)……GnG_n(最后一层 + 最终 norm)。每个注意力点成为一个独立的单节点子图。
  3. 编译:每个非注意力子图交给 Inductor,按「动态 shape」编译一份(token 数是符号变量 s0),可选按特定 shape 额外编译专版。
  4. 包图:每个子图外面套一层 CUDAGraphWrapper,运行时第一次见到某 batch descriptor 就捕获,之后回放;注意力子图套的还是这个 wrapper,但按 mode 分派,实际走 eager。

用目录树表达运行时结构:

model.forward (被 Dynamo 改写)
└── split_gm (GraphModule)
├── submod_0 ─→ Inductor 编译 → CUDAGraphWrapper(PIECEWISE) [embedding→attn0 前]
├── submod_1 ─→ 单节点: torch.ops.vllm.unified_attention_with_output
├── submod_2 ─→ Inductor 编译 → CUDAGraphWrapper(PIECEWISE) [attn0 后→attn1 前]
├── submod_3 ─→ 单节点: attention
├── ...
└── submod_2n ─→ Inductor 编译 → CUDAGraphWrapper(PIECEWISE) [最后 attn 后→输出]

图重放后更紧凑的 GPU kernel 时间线(PyTorch 官方博客)
图重放后更紧凑的 GPU kernel 时间线(PyTorch 官方博客)

CUDA Graph 的整体工作流:capture 一次、replay 多次(PyTorch 官方博客)
CUDA Graph 的整体工作流:capture 一次、replay 多次(PyTorch 官方博客)

回放一个 decode step 时,CPU 上只剩:一次 wrapper 链的 Python 调用 + 每段子图一次 graph.replay() + 注意力算子的少量 Python 调用。对比 eager 模式下逐 kernel 的完整派发链,CPU 侧工作量压缩了两个数量级。

这套设计在 vLLM 的演进时间线也很清晰:2024 年 10 月(PR #9058)vLLM 先建立编译控制框架(自定义 dispatcher、Dynamo 一次追踪);同月(PR #9715)把编译等级定型为 0/1/2/3 四级并引入 PIECEWISE 概念;11 月(PR #10058)把 piecewise CUDA graph 正式接入 V1 model runner;此后 V1 引擎默认 level=3 + cudagraph_mode=PIECEWISE,编译子系统从「实验特性」变成了「引擎的地基」。

原理详解(一):CompilationConfig——编译等级、切分算子与 capture 尺寸#

编译等级:0/1/2/3 四级#

CompilationConfig.level 是整个子系统的总开关(vllm/config/compilation.py,V0.10.1 位于 vllm/config/__init__.py 附近的 CompilationLevel)。PR #9058 定义、PR #9715 定型的四级语义:

level名称DynamoInductor自定义算子运行时 CUDA Graph
0NO_COMPILATION不追踪无启用走旧路径
1DYNAMO_AS_IS每次都追无启用无
2DYNAMO_ONCE只追一次无启用无(但有自定义 dispatcher)
3PIECEWISE只追一次按子图编译禁用(交给 Inductor 融合)按 piecewise/full 模式捕获

V1 引擎在模型不是 enforce_eager 时默认 level=3,此时又默认 cudagraph_mode=PIECEWISE。level 1 和 2 的区别只在「Dynamo 结果是否复用」:level 1 每次调用都重走 Dynamo(像 aot_eager 那样,调试用);level 2 只允许一次追踪,运行时通过自定义 dispatcher 直接跳到编译好的字节码(下面会讲 dispatch_to_code 怎么做到)。真正生产用的是 3。

为什么 level 3 要禁用自定义算子?因为 Inductor 只认识 ATen 算子和带 fake impl 的自定义算子;vLLM 的大量手写 CUDA kernel(torch.ops._C.*)如果不注册成规范的自定义算子,Dynamo/Inductor 看不见内部结构,只能整块当黑盒——黑盒太多,融合就没戏。所以 PIECEWISE 模式下 vLLM 关闭 custom ops(custom_ops 默认从「全开」变成「全关」),让 Inductor 看到 ATen 层面的图,把 RMSNorm、激活、逐元素乘这些小算子重新融合成 Triton kernel——代价是失去手写 kernel 的极致优化,收益是整体图优化。这个取舍在 vLLM 代码里被反复权衡(custom_ops 文档字符串写明:「By default, all custom ops are enabled when running without Inductor and disabled when running with Inductor」)。

切分算子:splitting_ops 与「铰链」#

splitting_ops 决定图在哪里被剪开。V1 的默认值由 set_splitting_ops_for_v1() 设置:

# vllm/config/compilation.py (V0.10.1)
_attention_ops: ClassVar[list[str]] = [
"vllm.unified_attention",
"vllm.unified_attention_with_output",
"vllm.mamba_mixer2",
]

只有注意力(和 Mamba 混合模型的 mixer 算子)被列为默认切点。为什么是它们?两个共性:一是执行计划动态——PagedAttention 每步的执行依赖 batch 的变长结构,shape 语义无法静态化;二是输出 shape 可静态描述——注意力的输出形状就是 query 的形状,这一性质让 Dynamo 可以在 fake-tensor 追踪时把注意力当黑盒「穿透」,整个模型仍然产出一张完整图,不需要 graph break。fake impl 一行:

vllm/attention/layer.py
def unified_attention_with_output_fake(query, key, value, output, layer_name, output_scale=None):
return
direct_register_custom_op(
op_name="unified_attention_with_output",
op_func=unified_attention_with_output,
mutates_args=["output"], # 声明它原地写 output
fake_impl=unified_attention_with_output_fake,
dispatch_key=current_platform.dispatch_key,
)

注意这里的工程巧思:注意力计算本来是「返回一个新 tensor」,但 vLLM 把它改写成带 out 参数的 in-place 语义(mutates_args=["output"])。为什么要多此一举?后面 CUDA Graph 捕获时,每个子图的输出 buffer 必须是地址固定的静态缓冲区。如果注意力返回新 tensor,那么注意力前后的两段子图之间就得传输一个变地址的中间量,CUDA Graph 的静态缓冲区约定就被破坏了。改成 out 风格后,注意力把结果写进一个调用方预先分配好的固定 buffer,前后子图各自回放,数据只在这个约定 buffer 里流转。

splitting_ops 为空列表时会退化成什么?代码里写得直白:PIECEWISE 编译等级 + 空 splitting_ops + PIECEWISE 图模式时,引擎会自动把 cudagraph_mode 升成 FULL——因为没有任何切点,图就是一张,piecewise 模式名不副实。这是常见的用户陷阱:显式传了 --compilation-config '{"splitting_ops": []}',行为却和预期不同。

CUDAGraphMode:一张图还是几段图#

V1 的 CUDAGraphMode 枚举比常见的「开/关」丰富:

class CUDAGraphMode(enum.Enum):
NONE = 0
PIECEWISE = 1
FULL = 2
FULL_DECODE_ONLY = (FULL, NONE)
FULL_AND_PIECEWISE = (FULL, PIECEWISE)

后两个复合值把 decode 和「混合 prefill-decode」两种 batch 分开路由:decode_mode() 取元组第一个元素给 decode batch,mixed_mode() 取第二个给普通 batch。这套双路由解决一个真实问题:decode batch 的 shape 每步基本不变(每请求 1 token),full graph 稳定可用;而 prefill/混批的 token 数 千差万别,full graph 命中率低,piecewise 更合适。FULL_AND_PIECEWISE(decode 用 FULL、混批用 PIECEWISE)是官方注释里「对多数模型性能最好的模式」。

capture 尺寸:为什么默认不是所有 batch size#

CUDA Graph 按 (batch size) 一图一档,vLLM 不可能给所有 token 数都捕获。_set_cudagraph_sizes() 给出默认档位:

# VllmConfig._set_cudagraph_sizes (核心逻辑)
possible_sizes = [1, 2, 4] + [8 * i for i in range(1, 1025)]
# 找到 >= max_num_seqs 的最小值作为 capture 上限
larger_sizes = [x for x in possible_sizes if x >= max_num_seqs]
max_batchsize_to_capture = larger_sizes[0]
batch_size_capture_list = [s for s in possible_sizes if s <= max_batchsize_to_capture]

默认语义:1、2、4 三个小档位单独捕获(decode 最常见的小 batch),之后按 8 为步长递增到覆盖 max_num_seqs。若 max_num_seqs=256,捕获档位是 [256, 248, ..., 16, 8, 4, 2, 1](降序),约 35 档。

为什么小 batch 用细粒度、大 batch 用粗粒度?padding 浪费。batch size 33 来了,向上取整到 40,padding 了 7 个 token(21% 浪费);batch 250 来了,取整到 256,只 padding 6 个(2.4% 浪费)。小 batch 恰恰是 decode 延迟敏感的场景,padding 比例最不能忍,所以 1/2/4 单独建档,8 以上统一以 8 为步——8/16/24/32…每档之间 padding 从不超 7 个 token,而 7 个 token 在 8 档大 batch 下的占比可以忽略。

运行时用一张反查表把任意 batch size 映射到档位:

# vllm/config/__init__.py (VllmConfig.pad_for_cudagraph)
def pad_for_cudagraph(self, batch_size: int) -> int:
# batch_size 超过 max_capture_size 会 IndexError,
# 调用方需要先用 cudagraph_batch_sizes[-1] 判断,
# 超出上限的 batch 走 eager
return self.compilation_config.bs_to_padded_graph_size[batch_size]

bs_to_padded_graph_size 是一张预计算的定长数组: index 是真实 batch size, value 是该 batch 应 pad 到哪个档位。capture_model() 的 execute_model 路径里先判断 num_scheduled_tokens <= self.cudagraph_batch_sizes[-1](即最大捕获档位),低于上限才查表 padding,否则走 eager 并把 token 数 pad 到 TP 的倍数(为了序列并行的 all-reduce 对齐)。

影响编译产物的所有因子都要进 hash#

编译产物能跨进程复用的前提是「产物只依赖可枚举的输入」。VllmBackend.__call__ 里的 cache dir 推导列出了四大因子:

# vllm/compilation/backends.py (VllmBackend.__call__ 节选)
factors = []
factors.append(envs.compute_hash()) # 0. 环境变量(如 VLLM_PP_LAYER_PARTITION)
factors.append(vllm_config.compute_hash()) # 1. 全部 vLLM 配置
factors.append(code_hash) # 2. 被 Dynamo 追踪到的所有源文件内容
factors.append(compiler_hash) # 3. Inductor/系统状态
cache_dir = os.path.join(envs.VLLM_CACHE_ROOT, "torch_compile_cache",
hashlib.md5(str(factors).encode()).hexdigest()[:10])

第 2 项的做法很讲究:vLLM 不只 hash 顶层模型文件,而是通过劫持 InliningInstructionTranslator.inline_call 把 Dynamo 内联过的每一个函数的源文件都记进 traced_files,再把文件内容全部读出来做 hash。为什么这么细?因为 Inductor 编译的产物融合了所有内联函数的代码,任何一个被内联的文件变了,产物都可能不同。漏掉一个,缓存就可能在代码更新后仍然命中旧的二进制——这是典型的「缓存失效域不完整」bug,vLLM 用把追踪面完整记录下来的方式把它堵住。

原理详解(二):VllmBackend 的切图过程——Dynamo 一次追踪与 splitting ops#

模型侧的接入点:support_torch_compile 装饰器#

编译的起点不在 runner,而在模型类上的一行装饰器:

vllm/model_executor/models/llama.py
@support_torch_compile
class LlamaModel(nn.Module):
def forward(
self,
input_ids: torch.Tensor, # 一维 token id 流
positions: torch.Tensor,
intermediate_tensors: Optional[IntermediateTensors] = None,
inputs_embeds: Optional[torch.Tensor] = None,
) -> torch.Tensor:
...

support_torch_compile(vllm/compilation/decorators.py)做三件事。第一,把 TorchCompileWrapperWithCustomDispatcher 动态插进类的 MRO,让每个被装饰的模型实例获得编译能力。第二,从 forward 的类型注解推断哪些参数的哪些维度是动态的:注解为 torch.Tensor 或 IntermediateTensors 的参数,第 0 维默认标记为动态(token 数)。第三,重写 __call__,在首次调用时用 torch._dynamo.mark_dynamic 显式标注这些维度,然后触发一次也是唯一一次 Dynamo 编译。

动态维推断有个容易踩的细节:如果某个参数某次运行时是 None(比如非多模态模型的 inputs_embeds),那么它的整个生命周期都必须保持 None。注释原文写着「if an argument is None, it should always be passed as None during the lifetime of the model, otherwise, it cannot be captured as a single computation graph」——Dynamo 对「有时是张量有时是 None」的参数会产生两个分支,图分裂,自定义 dispatcher 直接失效。

自定义 dispatcher:绕过 Dynamo guard 的全部原理#

TorchCompileWrapperWithCustomDispatcher(vllm/compilation/wrapper.py)是理解「运行时为什么快」的钥匙。常规 torch.compile 的调用链是:调用 → 评估 guards → 查缓存 → 命中则执行编译代码,未命中则重新编译。guard 评估本身每次调用要耗费数十微秒,而且「未命中就重编译」对服务是灾难。

vLLM 的做法是抢在 guard 之前把编译产物截下来:

vllm/compilation/wrapper.py
class TorchCompileWrapperWithCustomDispatcher:
def __init__(self, compiled_callable=None, compilation_level=0):
...
self.original_code_object = self.__class__.forward.__code__
self.compiled_codes: list[CodeType] = []
torch._dynamo.convert_frame.register_bytecode_hook(self.bytecode_hook)
...
def bytecode_hook(self, old_code: CodeType, new_code: CodeType):
# Hook to save the compiled bytecode for direct execution.
if old_code is not self.original_code_object:
return
...
self.compiled_codes.append(new_code)
@contextmanager
def dispatch_to_code(self, index: int):
self.__class__.forward.__code__ = self.compiled_codes[index]
yield
self.__class__.forward.__code__ = self.original_code_object

bytecode_hook 是 Dynamo 编译完成时的回调——Dynamo 把 Python 字节码重写成优化后的字节码,再把新 code object 装回执行栈。vLLM 在这个钩子里把新 code object 存进 compiled_codes 列表。之后每次调用 forward,走的是装饰器重写的 __call__:

# vllm/compilation/decorators.py (节选)
# 捕获完成后直接切换 code object,不再走 Dynamo guard
with self.dispatch_to_code(0):
model_output = self.forward(*args, **kwargs)
return model_output

dispatch_to_code 是一个极其暴力的技巧:直接把类上 forward 函数的 __code__ 换成 Dynamo 产出的字节码,调用完再换回来。它成立的前提是 Dynamo 的一个保证——编译前后的 code object 具有完全相同的参数、cell 变量与自由变量布局。源码注释里给了 PyTorch 开发者论坛的讨论链接来佐证这个保证。这样每次 decode step 完全跳过 guard 评估,Python 调用栈里只剩「切换 code → 执行编译后字节码 → 切换回来」三步。

为什么 vLLM 能这么大胆?因为「只追一次」的前提由 vLLM 自己保证:输入结构固定(id、position、静态 buffer)、动态维固定为 token 数、注意力是黑盒算子。Dynamo 的 guard 所要防御的「输入结构变化」,在 vLLM 的调用约定下根本不会发生。guard 保护的东西被调用约定消灭了,guard 本身自然可以删掉。

split_graph 的切图算法#

Dynamo 产出图后,VllmBackend.__call__ 调用 split_graph 按 splitting_ops 切分:

vllm/compilation/backends.py
def split_graph(graph: fx.GraphModule, ops: list[str]):
subgraph_id = 0
node_to_subgraph_id = {}
split_op_graphs = []
for node in graph.graph.nodes:
if node.op in ("output", "placeholder"):
continue
if node.op == "call_function" and str(node.target) in ops:
# 命中切分算子: 结束当前子图, 该算子独占一个新子图,
# 然后再开一个新子图
subgraph_id += 1
node_to_subgraph_id[node] = subgraph_id
split_op_graphs.append(subgraph_id)
subgraph_id += 1
else:
node_to_subgraph_id[node] = subgraph_id
split_gm = torch.fx.passes.split_module.split_module(
graph, None, lambda node: node_to_subgraph_id[node],
keep_original_order=True)
...

算法是单遍扫描:给每个节点编号其所属子图;遇到切分算子就先 +1(算子自己独占一个子图)、再 +1(切分算子后面的节点进入更新后的子图)。最关键的一行是 keep_original_order=True——注释强调「否则 pytorch 可能重排节点,图里有 mutation 时语义会变」。因为 vLLM 的图里有大量 in-place 写(KV cache 写入、out 参数),fx 的默认节点排序优化会破坏张量的写读次序,这个 flag 是用 bug 换来的经验。

切完之后,80 层的 Llama 会得到 161 个子模块:81 个编译子图(embedding + 79 个层间段 + 最终段,中间段的 Inductor 代码完全相同会命中同一份缓存)、80 个注意力单节点子图。官方设计文档记录的实际数字是 17 份不同的 Inductor handle——首段、中间段共享、尾段各一份,因为 Inductor 按「图内容」缓存而不是按「模块编号」缓存。

PiecewiseCompileInterpreter:边解释边编译#

把切好的图交回 Dynamo 之前,PiecewiseCompileInterpreter 先用 fake tensor 把图「空跑」一遍:

vllm/compilation/backends.py
class PiecewiseCompileInterpreter(torch.fx.Interpreter):
def run(self, *args):
fake_args = [
self.fake_mode.from_tensor(t) if isinstance(t, torch.Tensor) else t
for t in args
]
with self.fake_mode, enable_python_dispatcher():
return super().run(*fake_args)
def call_module(self, target, args, kwargs):
output = super().call_module(target, args, kwargs)
if target in self.compile_submod_names:
index = self.compile_submod_names.index(target)
submod = self.fetch_attr(target)
sym_shape_indices = [
i for i, x in enumerate(args) if isinstance(x, torch.SymInt)
]
# 告诉 Inductor 为「动态 shape」编译一份
compiled_graph_for_dynamic_shape = ...compiler_manager.compile(
submod, args, ..., runtime_shape=None)
# 为这个子图包上 PiecewiseBackend + CUDAGraphWrapper
piecewise_backend = PiecewiseBackend(
submod, self.vllm_config, index, ...)
static_graph_wrapper_class = resolve_obj_by_qualname(
current_platform.get_static_graph_wrapper_cls())
self.module.__dict__[target] = static_graph_wrapper_class(
runnable=piecewise_backend, ...)
...

fake mode 让整张图在不分配一行真实显存、不执行任何真实 kernel 的情况下走完,同时记录每个中间张量的符号 shape(SymInt)。sym_shape_indices 记下哪些位置的 shape 是符号——这就是后续 PiecewiseBackend 在运行时判断 batch size 的依据(runtime_shape = args[self.sym_shape_indices[0]])。

这里还藏着一个内存管理细节:所有被包了 CUDA Graph 的子图共享同一个 graph_pool(current_platform.get_global_graph_pool())。CUDA Graph 的显存池机制允许多个图共享中间 buffer——81 个子图如果各自持池,显存占用会爆掉;共享一个池,后捕获的图会复用先捕获的图释放出来的空间。源码注释特意提醒只在用多流时才需要重新审视这里——单流是共享全局池的前提,将来若引入多流执行,这里要重新设计。

原理详解(三):Inductor 在中间做了什么——融合、消除 no-op 与自定义 pass#

InductorAdaptor 的「驯化」工作#

vLLM 对 Inductor 的使用远不是调一次 compile_fx 那么简单。InductorAdaptor.compile(vllm/compilation/compiler_interface.py)里塞满了 monkey patch,这层适配要解决三个只有 vLLM 才会遇到的问题。

问题一:Inductor 的缓存默认「不信任」vLLM 的调用方式。 标准 torch.compile 流程里,Inductor 的编译发生在 Dynamo 的 tracing context 之内,Dynamo 会提供 shape environment(每个符号变量的取值范围、guard 条件)。而 vLLM 的 piecewise 编译在 Dynamo 完成之后才运行,此时没有 shape env,Inductor 的代码缓存查找会直接失败。vLLM 的对策是注入一个 AlwaysHitShapeEnv——它的全部方法都是空实现(evaluate_guards_expression 恒返回 True、get_pruned_guards 返回空列表),注释坦言这些方法「obtained by trial-and-error until it works」。工程上这样做是安全的:vLLM 自己管理 shape 一致性,Inductor 缓存键里的 guard 检查是多余的防御。

问题二:需要拿到编译产物的 hash 与文件路径来做二级缓存。 Inductor 内部会为编译产物计算 compiled_fx_graph_hash,vLLM 把这个函数整个劫持掉,在它返回时顺手记下 hash;再劫持 compile_fx_inner 从编译后的 callable 里挖出生成的 Python 文件路径。这一对(hash_str, file_path)被当成 handle 存进 vLLM 自己的 CompilerManager.cache,下次启动时先查 vLLM 的缓存文件,命中就直接从 Inductor 的 FxGraphCache 里 load,跳过整条编译流水线。

问题三:高阶算子与 Dynamo 之外的图默认禁止缓存。 Inductor 有一个 _check_can_cache 守卫:不在 Dynamo tracing context 下的图、含高阶算子的图都会拒绝缓存。vLLM 两种都占了,于是直接 patch 成恒返回 None(即「可以缓存」)。源码注释点明了理由:对 vLLM 而言这两种情况都想缓存,见 pytorch/pytorch codecache.py 的相关代码。

这层适配是「依赖内部 API」的典型样本——vLLM 的注释也承认这些 patch 与 PyTorch 版本强耦合(代码里可见 2.5/2.6/2.8 三个版本的分支处理),PyTorch 一动内部接口就断。2025 年 PyTorch 2.8 提供 standalone_compile 之后,vLLM 加了 InductorStandaloneAdaptor(用环境变量 VLLM_USE_STANDALONE_COMPILE 开启)来逐步替换这套劫持,官方注释「we’re going to replace this all with standalone_compile sometime」正是这种迁移的注脚。

set_inductor_config:动态 shape 与静态 shape 的两种编译策略#

vllm/compilation/compiler_interface.py
def set_inductor_config(config, runtime_shape):
if isinstance(runtime_shape, int):
# for a specific batchsize, tuning triton kernel parameters
# can be beneficial
config["max_autotune"] = True
config["coordinate_descent_tuning"] = True

同一个子图在 vLLM 里最多编译两套:runtime_shape=None 的动态 shape 版(token 数是符号变量)与按 compile_sizes 里每个具体尺寸编的静态版。只有静态版才开 max_autotune 与 coordinate_descent_tuning——autotune 要对每个候选 config 实测吞吐,动态 shape 下 Triton kernel 的 grid 依赖 batch,实测结果对其他 batch 不成立,白测。静态 shape 下实测出的最优 config 才可靠。

官方设计文档给出的 autotune 实例能说明这类收益:一个 8 x 2048, 2048 x 3072 的矩阵乘,Inductor 列出十余个候选 Triton config,最优者 0.0130 毫秒,比默认走 cuBLAS 的 0.0160 毫秒快约 19%。对小 batch 的小 GEMM,这类微调乘上 kernel 数量,就是「编译几分钟换 decode 提速」的来源。autotune 极费时间(文档原话「seconds to minutes」,取决于模型与 batch size),所以 vLLM 只对 compile_sizes 指定的少数档位开启,且结果进缓存。

vLLM 的自定义 Inductor pass#

PostGradPassManager 把一列自定义 pass 挂进 Inductor 的 post_grad_custom_post_pass 钩子,在 Inductor 完成自己的简化后、codegen 之前运行。默认链上有四类:

NoOpEliminationPass——删掉冗余的 reshape/slice。这个 pass 的存在理由非常具体:它是 RMSNorm-FP8 量化融合的前置条件。apply_fp8_linear 在 2D 情况下会多插一个 reshape,fused_add_rms_norm 后紧跟的 slice_scatter 在满宽时是纯 no-op,这些噪声节点让 pattern matcher 认不出「rms_norm → quant」的固定模式。NoOpEliminationPass 处理三类情形:reshape 链折叠成对 base tensor 的一次 reshape、产生原 shape 的 reshape 删除、产生原 shape 的 slice 删除。判等逻辑里专门处理了三个等价情形:两个维度都是整数值、reshape 维是 -1(推断维)、两个维是同一个 SymInt——最后一条在动态 shape 编译下必不可少,因为「batch 维」在图里是符号 s0,直接 == 比较恒为假。

FusionPass——用 Inductor 的 PatternMatcherPass 把「RMSNorm + FP8 静态量化」识别出来,替换成 vLLM 手写的融合算子 rms_norm_static_fp8_quant / fused_add_rms_norm_static_fp8_quant。这类融合的收益是双重的:norm 之后本来要写一次激活结果再到显存、量化时再读一次,融合后中间量留在寄存器/共享内存,省一次读写循环还少一个 kernel。pass_config 里 enable_noop 与 enable_fusion 是联动的——fusion 依赖 noop 先清场,代码里如果 fusion 开而 noop 关,会打警告说融合「might not work」。

AtFusionPass / AllReduceFusionPass / AsyncTPPass——注意力+量化融合、flashinfer 通信融合与异步张量并行,属于分布式/量化场景的可选 pass,默认关闭。

ActivationQuantFusionPass——激活量化的融合变体。

这一层是 vLLM「用 Inductor 又不完全受制于 Inductor」的体现:Inductor 的通用融合规则不认识 vLLM 的量化算子,vLLM 就把自己的领域知识写成 pattern 挂进去,让通用编译器产出领域特化的 kernel。

fix_functionalization:让 in-place 语义在图里活下来#

还有一块必须提到的拼图是 fix_functionalization.py。PyTorch 2.6+ 默认启用 auto-functionalization V2,它会把 auto_functionalized 高阶算子的调用重写成「复制输入 → 非原地计算 → 复制回输出」的函数式形式——这个默认行为与 vLLM 的 out 参数风格自定义算子严重冲突:KV cache 的 in-place 写、注意力 out 参数的写回,在函数化之后全变成低效的显存搬运,vLLM 论坛 RFC #14703 专门记录了这个问题。vLLM 在 CompilationConfig.__post_init__ 里对 torch 2.6+ 显式设置 enable_auto_functionalized_v2 = False,并保留 fix_functionalization pass 处理 V1 语义下的图改写。这是一个很好的例子:框架级的「正确默认」,放到推理服务的上下文里就变成了性能杀手——训练框架希望图是纯函数式的(便于求导),推理框架希望图是尽量少搬运的(in-place 是显存带宽的朋友),同一个 PyTorch 无法用一组默认值同时伺候两者。

原理详解(四):CUDA Graph 在 vLLM 的捕获与回放——静态缓冲区、桶形批量与 dispatcher#

两层封装:为什么是 PiecewiseBackend 套着 CUDAGraphWrapper#

编译完成后,每个非注意力子图的实际可执行体是一个三层洋葱:

CUDAGraphWrapper (capture/replay 调度, 平台无关)
└── PiecewiseBackend (静态 shape 编译分派: 动态版 vs 各档位静态版)
└── Inductor 编译产物 (Triton kernel 序列, 一般 shape 或具体 shape)

PiecewiseBackend 管「这个 batch size 该跑哪份编译产物」。它的两个关键数据结构:

vllm/compilation/cuda_piecewise_backend.py
class PiecewiseBackend:
def __init__(self, graph, vllm_config, piecewise_compile_index,
total_piecewise_compiles, sym_shape_indices,
compiled_graph_for_general_shape, vllm_backend):
...
# Inductor 按 compile_sizes 额外编译的静态 shape 档位
self.compile_sizes: set[int] = set(self.compilation_config.compile_sizes)
self.concrete_size_entries: dict[int, ConcreteSizeEntry] = {}
self.compiled_graph_for_general_shape = compiled_graph_for_general_shape
self.sym_shape_indices = sym_shape_indices
def __call__(self, *args) -> Any:
if not self.first_run_finished:
# 第一次运行: 只跑「动态 shape」编译产物
self.first_run_finished = True
self.check_for_ending_compilation()
return self.compiled_graph_for_general_shape(*args)
runtime_shape = args[self.sym_shape_indices[0]]
if runtime_shape not in self.concrete_size_entries:
# 没为这个 size 编译过静态版, 继续用动态版
return self.compiled_graph_for_general_shape(*args)
entry = self.concrete_size_entries[runtime_shape]
if not entry.compiled:
# 首次命中某个静态档位: 按这个 shape 现场编译(有缓存)
entry.compiled = True
self.to_be_compiled_sizes.remove(runtime_shape)
entry.runnable = self.vllm_backend.compiler_manager.compile(
self.graph, args, ..., runtime_shape=runtime_shape)
...
return entry.runnable(*args)

注意首次调用的语义:运行时遇到新档位不会立刻编译静态版,而是先保证首跑走动态版。因为编译发生在「服务已就绪」的路径上,vLLM 要确保任何代码路径都不会在请求处理中途引入长阻塞——动态版兜底,静态版后台逐步填充。to_be_compiled_sizes 清空的时机被用来判断「整个模型的编译是否全部结束」(check_for_ending_compilation 只在最后一个子图时触发收尾)。

CUDAGraphWrapper 则管「什么时候捕获、什么时候回放」。它是平台无关的抽象:构造函数里 resolve_obj_by_qualname(current_platform.get_static_graph_wrapper_cls()) 拿到平台专属实现,NVIDIA 卡是真 CUDA Graph,ROCm 卡是对应的 HIP Graph 机制,接口统一。

BatchDescriptor 与 forward context:回放 key 的设计#

CUDA Graph 回放要求「完全相同的输入形状」。vLLM 需要 scheme 回答:两个 batch 什么情况下可以共用一张已捕获的图?答案就是 BatchDescriptor(vllm/forward_context.py):

class BatchDescriptor(NamedTuple):
num_tokens: int
uniform_decode: bool = False
# False 也可用于一个均匀 decode batch, 让它复用支持非均匀 batch 的图
@property
def non_uniform(self) -> "BatchDescriptor":
return BatchDescriptor(self.num_tokens, uniform_decode=False)

只有两个字段。num_tokens 是 pad 之后的 token 总数(决定所有算子的 shape),uniform_decode 标记「是否所有请求都是等长 decode」(决定注意力后端走哪条 kernel 路径)。注释里专门强调「We should keep the num of items as minimal as possible」——key 越少,能共用的场景越多:一个均匀 decode batch 如果在档位表里查不到对应的 uniform 图,可以用 non_uniform 退化成非均匀 key 去匹配一张更「通用」的图,宁可走一条稍慢的 kernel 路径也不放弃回放机会。

每 step 的回放决策由 CudagraphDispatcher.dispatch 做出:

# vllm/v1/cudagraph_dispatcher.py (节选)
def dispatch(self, batch_descriptor):
if not self.keys_initialized:
return CUDAGraphMode.NONE, None
# 1. 精确匹配 FULL 档位
if batch_descriptor in self.cudagraph_keys[CUDAGraphMode.FULL]:
return CUDAGraphMode.FULL, batch_descriptor
# 2. 退化成非均匀 key 再试 FULL
non_uniform_key = batch_descriptor.non_uniform
if non_uniform_key in self.cudagraph_keys[CUDAGraphMode.FULL]:
return CUDAGraphMode.FULL, non_uniform_key
# 3. 非均匀 key 再试 PIECEWISE(最通用)
if non_uniform_key in self.cudagraph_keys[CUDAGraphMode.PIECEWISE]:
return CUDAGraphMode.PIECEWISE, non_uniform_key
# 4. 实在不行, eager
return CUDAGraphMode.NONE, None

dispatcher 持有两套 key 集合(FULL 与 PIECEWISE 各一组)。选中的 mode 和 key 通过 set_forward_context 写进 forward context,所有 CUDAGraphWrapper 在被调用时从 context 里无条件信任这个决策——源码注释「blindly trust them」——包装器自己不做判断,只对比「context 里的 mode 与包装器自己的 mode 是否一致」,一致就捕获/回放,不一致就直通下层 runnable。这让 FULL 与 PIECEWISE 两类 wrapper 可以共存于同一条调用链 wrapper 只在 decode batch 时被激活,PIECEWISE wrapper 只在混批时被激活。

运行时包装:copy inputs 到静态缓冲区#

CUDA Graph 回放的硬约束是输入地址固定——回放时 kernel 从 capture 时记录的指针读数。vLLM 的 token 流每个 step 都在变(调度器写进持久化 buffer),所以捕获前必须把输入拷进静态缓冲区。这份拷贝逻辑在 VllmBackend.__call__ 的尾部:

# vllm/compilation/backends.py (节选)
# 只对 shape 是符号变量的输入(即 token 流)做拷贝;
# 权重和静态 buffer 的 shape 是具体的, 地址固定, 无需拷贝
self.sym_tensor_indices = [
i for i, x in enumerate(fake_args)
if isinstance(x, torch._subclasses.fake_tensor.FakeTensor)
and any(is_symbolic(d) for d in x.size())
]
self.input_buffers = [example_inputs[x].clone() for x in self.sym_tensor_indices]
def copy_and_call(*args):
list_args = list(args)
for i, index in enumerate(self.sym_tensor_indices):
runtime_tensor = list_args[index]
runtime_shape = runtime_tensor.shape[0]
static_tensor = self.input_buffers[i][:runtime_shape]
static_tensor.copy_(runtime_tensor)
list_args[index] = static_tensor
return self.split_gm(*list_args)

compiler managed cudagraph input buffers 的注释说明了假设:「we assume the first run with symbolic shapes has the maximum size among all the tensors」——静态缓冲区按最大 shape 分配一次,之后每个 step 把真实输入 copy_ 到前缀位置。这个拷贝不是白给的:一个 token 流的 input_ids + positions 拷贝是两次小 memcpy,比起省下的数百次 launch,依然是大赚。

注意这段代码只在 cudagraph_copy_inputs=True 时存在。注释还注明「if the caller can guarantee that the same input buffers are always used, it can set this to False」——V1 runner 使用自己的持久化 buffer(self.input_ids、self.positions,整个生命周期地址固定),所以主路径不需要这个编译侧拷贝。这个 flag 是给「buffer 地址无法保证」的调用路径(如某些测试或非 V1 入口)准备的兜底。

捕获一次的成本与 weak ref 技巧#

CUDAGraphWrapper.__call__ 的捕获分支有几个细节值得逐行看:

# vllm/compilation/cuda_graph.py (节选)
with ExitStack() as stack:
if self.cudagraph_options.gc_disable:
# piecewise 模式下一次 forward 要捕获几十个子图,
# 每次都跑 gc 会把捕获拖得非常慢,
# 因此只对第一个子图跑 gc, 其余全部禁掉
stack.enter_context(patch("gc.collect", lambda: None))
stack.enter_context(patch("torch.cuda.empty_cache", lambda: None))
with torch.cuda.graph(cudagraph, pool=self.graph_pool):
output = self.runnable(*args, **kwargs)
if self.cudagraph_options.weak_ref_output:
# 转 weak ref 后原 output 立即释放以省显存;
# 只有最后一个子图能这么做,
# 因为中间子图的输出还会被其他图引用
output = weak_ref_tensors(output)
entry.output = weak_ref_tensors(output)

三个观察:其一,gc 抑制的粒度按「first graph / rest graphs」区分(CUDAGraphOptions 里 gc_disable=not is_first_graph),原因写在注释里——一次 forward 捕获几十个子图,逐个 gc 不可接受。其二,捕获时产出的 output 用 weak_ref_tensors 转成弱引用,让原始 tensor 立即归还显存池;注释专门解释为什么只有最后一个子图可以做这件事——中间子图的输出还要被下一个子图引用,提前释放会把正在使用的数据卖掉。其三,回放路径上 debug 分支会校验输入地址与捕获时一致(VLLM_LOGGING_LEVEL=DEBUG 才启用),这是把「CUDA Graph 的地址不变式」变成可观测断言的调试手段。

还有一个容易被误解的设计:wrap 器本身不持有静态缓冲区、不拷贝输入。「Note: CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay. We assume implementing them is done outside of the wrapper.」拷贝策略留在编译侧的 copy_and_call 或 runner 的持久 buffer 里,wrapper 只做「捕获/回放/校验」——这让 wrapper 对任意动态 shape 策略保持正交(源码用的词是「staying orthogonal to compilation logic」)。

capture_model:runner 侧的编排#

wrapper 是「被动」的——被调到才捕获。主动的编排发生在 GPUModelRunner.capture_model():

# vllm/v1/worker/gpu_model_runner.py (V0.10.1, 节选)
def capture_model(self) -> None:
if self.compilation_config.cudagraph_mode == CUDAGraphMode.NONE:
logger.warning("Skipping CUDA graph capture...")
return
...
start_free_gpu_memory = torch.cuda.mem_get_info()[0]
with freeze_gc(), graph_capture(device=self.device):
cudagraph_mode = self.compilation_config.cudagraph_mode
if cudagraph_mode.mixed_mode() != CUDAGraphMode.NONE:
# 混合 prefill-decode 批: 按 capture 档位从大到小逐个捕获
compilation_cases = list(reversed(self.cudagraph_batch_sizes))
self._capture_cudagraphs(
compilation_cases,
cudagraph_runtime_mode=cudagraph_mode.mixed_mode(),
uniform_decode=False)
# decode 例行批单独按 FULL 捕获(如果启用了分离路由)
if cudagraph_mode.decode_mode() == CUDAGraphMode.FULL and ...
...
set_cudagraph_capturing_enabled(False)
...
cuda_graph_size = start_free_gpu_memory - end_free_gpu_memory
# This usually takes 5~20 seconds.
logger.info("Graph capturing finished in %.0f secs, took %.2f GiB",
elapsed_time, cuda_graph_size / (1 << 30))

四个值得注意的点。

从大到小捕获。compilation_cases = list(reversed(self.cudagraph_batch_sizes)) 一行加注释「Capture the large shapes first so that the smaller shapes can reuse the memory pool allocated for the large shapes」。CUDA Graph 的共享显存池只在「后捕获的图比先捕获的小」时才能完全复用——小图的中间 buffer 是大图 buffer 的前缀视角。反过来先小后大,每个大图都得新开空间,池化收益归零。这也是 CUDA Graph 工程应用的经典手法。

预热先行。_capture_cudagraphs 里对每个档位先跑 cudagraph_num_of_warmups 次 dummy run(V1 默认为 1 次)再真正捕获。预热有两个作用:触发 Triton JIT 编译、让 CUDA context 的懒初始化全部完成——否则这些一次性开销会被录进图里。预热用的 cudagraph_runtime_mode=CUDAGraphMode.NONE,即「跑热但不录」。

dummy run 的输入也要仿真。捕获用的是 _dummy_run(num_tokens, ...),它构造假的 input_ids、positions 与注意力元数据。这里有个注释明确指出的细节:「When setting max_query_len = 1, we switch to and capture the optimized routine of FA2 for pure decode」——decode 批的 dummy 输入故意把 query 长度设为 1,让 FA2 在捕获时就走 FlashDecode 路径,CUDA Graph 里固化下来的就是这条优化路径而不是通用 prefill 路径。

捕获完成即上锁。set_cudagraph_capturing_enabled(False) 关掉全局捕获开关,此后任何意外的捕获尝试都会被检测并报错(而非静默走 eager)。捕获总耗时与占用显存都被记录——源码注释「This usually takes 5~20 seconds」——这笔一次性成本就是 V1 引擎启动比 eager 慢几十秒的主要来源。

执行路径串讲:从 GPUModelRunner.forward 到一次 decode 步的完整流程#

把前面四块的机制拼成一条完整的时间线。设服务已启动完毕(编译、捕获都完成),现在调度器送来一批 32 个 decode 请求的 SchedulerOutput,假设 32 在捕获档位表里且 FULL_DECODE_ONLY 模式启用:

[1] execute_model(scheduler_output)
_update_states(): 把新增/结束的请求同步进 InputBatch
_prepare_inputs(): 构造 token 流、positions、block table、
slot mapping、attn_metadata
[2] num_scheduled_tokens = 32 <= max_capture_size
→ num_input_tokens = vllm_config.pad_for_cudagraph(32) # 查表, 32 已是档位
[3] batch_descriptor = BatchDescriptor(num_tokens=32, uniform_decode=True)
cudagraph_runtime_mode, batch_descriptor =
cudagraph_dispatcher.dispatch(batch_descriptor)
→ 命中 FULL key → (FULL, (32, uniform))
[4] with set_forward_context(attn_metadata, ..., cudagraph_runtime_mode=FULL,
batch_descriptor=(32, uniform)):
self.model(input_ids=self.input_ids[:32], ...)
[5] model.forward.__code__ 已被换成编译版字节码(dispatch_to_code)
→ split_gm 执行:
submod_0: CUDAGraphWrapper(FULL) → context mode=FULL 匹配
→ 已捕获 → graph.replay() # 静态地址读输入
submod_1: attention op → 直接执行(在图外)
→ 读 forward_context.attn_metadata
submod_2: CUDAGraphWrapper(FULL) → replay()
... 重复 ...
submod_2n: 最后一段 replay, 输出 hidden_states
[6] hidden_states[logits_indices] → compute_logits → sampler → 输出

两个模式差异可以顺带看清。若这批是「prefill 800 token + 5 个 decode 请求」的混批:prepare_inputs 之后 token 数不是档位精确命中;dispatcher 退化到 non_uniform key,mode 落在 PIECEWISE(或 FULL_DECODE_ONLY 模式下落 NONE);跑的是「段间 replay + 注意力 eager」的组合,注意力在每个层间点读最新的 attn_metadata。若 batch token 数超过最大捕获档位(比如 prefill 4096),pad_for_cudagraph 的调用条件不满足,整个前向走 eager,只有 Inductor 编译产物还在工作——这就是「无 CUDA Graph 但仍有编译加速」的混合状态。

还要注意一条跨卡交互:dispatch 决策在 DP(数据并行)>1 时需要跨 rank 协调。所有 DP rank 必须对「本 step 用哪个 mode、pad 到多少」达成一致(源码里 coordinate_batch_across_dp 做 all-reduce 后可能重新 dispatch),否则 rank 之间有的回放有的 eager,集体通信就死锁了。CUDA Graph 的「全员一致」约束在分布式的每层里都会再现——TP 组里 all-reduce 的参与方、EP 组里 dispatch/combine 的参与方,都对齐到同一个 batch descriptor 上。

工程细节与常见坑:capture 大小的显存代价、动态 shape 失效场景与调试方法#

显存账:CUDA Graph 的池化与每档成本#

CUDA Graph 的主要显存开销有两块:每张图录制的 kernel 参数与中间 buffer。中间 buffer 大头靠 graph pool 共享摊薄,但每个 batch 档位仍要一份专属的输入/输出静态区。V0.10.1 的 capture_model 会打印一笔账:

Graph capturing finished in 12 secs, took 2.17 GiB

档位多、模型层深时,这 2 GiB 往往就是几十上百兆 × 档位数的累积。两个常见的调优动作因此而来:一是砍档位——--compilation-config '{"cudagraph_capture_sizes": [1, 2, 4, 8]}' 只保留最常用的几档,牺牲命中率换显存;二是 FULL_DECODE_ONLY——decode 档位用整图、prefill 混批完全走带编译的 eager,只为解码一侧付捕获成本(CompilationConfig.cudagraph_mode 的 docstring 明说这个模式适合 P/D 分离架构里的 decode 实例,prefill 地位不重要,省下的显存可以换成 KV cache)。

另一个隐性成本是捕获期间的临时峰值:dummy run 本身会触发中间张量分配,与模型加载后的常驻显存叠加,显存水位在捕获阶段最容易 OOM。vLLM 用「先捕获最大档位」减轻这个问题(最大档位一次性吃掉峰值,小档位复用空间),但启动脚本的 显存余量仍要为最大档位的 dummy run 留出。

哪些代码写在 CUDA Graph 下会出事#

TorchCompileWrapperWithCustomDispatcher.bytecode_hook 有一段狠代码:

vllm/compilation/wrapper.py
if self.vllm_config.compilation_config.cudagraph_mode != \
CUDAGraphMode.NONE and "update" in new_code.co_names:
import depyf
src = depyf.decompile(new_code)
msg = "Assigning / modifying buffers of nn.Module during forward pass
is not allowed when using cudagraph inside the compiler because
it will cause silent errors. ..."
raise RuntimeError(msg)

编译后的字节码里只要出现名为 update 的调用(buffer 改写),直接抛错并反编译出源码方便定位。为什么这么凶?CUDA Graph 回放期间 CPU 不参与执行,如果图里录了「写某个 buffer」的操作而 buffer 地址在两次回放之间被 PyTorch allocator 复用/移动,错误的结果会被静默接受——不是崩溃,是算错。这个检查把「张量地址不变」从约定升级成了可检测的失败。

同文件里 use_custom_dispatcher 与 guard 的互斥、support_torch_compile 对 None 参数的终生一致性要求,前面已提。还有三个实际使用中常见的坑:

  1. graph break 让自定义 dispatcher 静默失效。模型 forward 里有 Dynamo 无法追踪的语句(打印日志、断言张量值、依赖 .item() 的分支)会让 Dynamo graph break,产出多段编译代码。level 3 的 dispatcher 只在「第一段」上工作,后续段落直接走 Dynamo 默认派发,guard 开销回归——性能不报错地劣化。排查方法是把 VLLM_LOGGING_LEVEL=DEBUG 打开,数 Dynamo 的重编译次数(源码: Dynamo bytecode transform time 只应出现一次)。
  2. cache 命中失败的典型来源。cache dir hash 包含「被追踪文件的内容」,装了 torch 版本不同、改过模型代码、甚至改了会被内联的工具函数,hash 就变,整个编译+捕获流程重来。VLLM_DISABLE_COMPILE_CACHE=1 可以关缓存排查,但注意它同时关掉了 Inductor 的 FX graph cache(源码里 VLLM_DISABLE_COMPILE_CACHE 是「总开关」)。
  3. 编译时间预算。capture_model 的 5-20 秒加上 Dynamo+Inductor 编译的分钟级成本,让「频繁重启的服务」必须依赖编译缓存目录。官方文档建议部署时把 ~/.cache/vllm/torch_compile_cache 目录直接拷到新机器复用——hash 因子设计保证了只要配置与代码不变,缓存命中。

调试与观测: 两个官方入口#

depyf 图导出。VllmBackend.__call__ 里写入 computation_graph.py 的代码改自 depyf 项目(源码注释给了行级链接),把切分后的 fx 图完整落盘。加上 --debug-dump-path 或读 ~/.cache/vllm/torch_compile_cache/<hash>/rank_0_<dp>/,能看到两份关键产物:computation_graph.py(切分后的图,直观看到子图边界与注意力单节点)与 transformed_code.py(Dynamo 改写的 Python 字节码反编译)。官方文档的读图方法:先看子模数数量是否符合层数预期,再确认每个 submod 是被 CUDAGraphWrapper 包住还是直通。

compilation_counter。vllm/compilation/counter.py 的全局计数器暴露了编译过程的每一步: num_graphs_seen(Dynamo 产图数,应=1)、num_piecewise_graphs_seen(切图数,应≈2×层数+1)、num_inductor_compiles(Inductor 编译次数,应≈子图内容种类数)、num_cudagraph_captured(捕获图数,应≈档位数×子图数)。测试文件里大量用这些计数器做断言——比起 benchmark,计数器更能精确定位「哪一层的图没被编译」「哪个图没被捕获」。

小结#

vLLM 的编译子系统把「Python 服务」改造成「准静态执行引擎」,机制可以压成一张四步流水线:

  1. 声明可编译:support_torch_compile 装饰器重写 __call__,首次调用标记动态维并触发 Dynamo;bytecode_hook 截获编译产物,dispatch_to_code 直接切换 code object,从此跳过 guard。
  2. 切图:VllmBackend.split_graph 以 vllm.unified_attention_with_output 为切点把整图拆成一串纯张量变换子图;注意力因为有 out 参数与 fake impl,既能被 Dynamo 当黑盒穿透,又能在运行时以固定地址接在两段子图之间。
  3. 编译:每个子图交给 Inductor 按动态 shape 编一份、按 compile_sizes 编静态版;自定义 pass 把 RMSNorm+FP8 量化融合、no-op 清理挂进 post-grad 流水线;AlwaysHitShapeEnv 与一系列 monkey patch 把 Inductor 的缓存强制打开。
  4. 静态执行:CUDAGraphWrapper 按 forward context 里的 BatchDescriptor 分派捕获或回放;CudagraphDispatcher 在 FULL/PIECEWISE/NONE 之间选路;模型 runner 用持久化 buffer 与降序捕获顺序把地址静态性与显存成本都管好。

这套设计里最值得带走的两个判断:其一是编译开销与运行时收益之间靠「一次性」连接——Dynamo 只追一次、Inductor 只编一次、每档只捕获一次,此后所有请求零编译;为此 vLLM 把「输入结构固定」当成调用约定,而不是让 Dynamo 尝试处理动态性。其二是在图里保留黑盒也是一种能力——注意力算子不进 CUDA Graph 不是妥协,而是让「每步都在变的注意力执行计划」与「每步都不变的张量变换」各走各的最优路径;分界线的位置(切分算子)是这套机制里最重要的单一设计决策。

在 V1 引擎的默认配置下,这套机制对用户完全透明:不传任何 compilation 参数,得到的就是 level 3 + piecewise CUDA graph。真正需要理解它的是三类场景——排查 decode 延迟异常(是否意外 graph break、是否 cache miss)、为小模型榨取极限延迟(上 FULL/full-decode-only)、以及为显存紧张的环境砍档位。这三类场景在源码里各有一条明确的路径,本文把它们与上游机制串成了一张完整地图。

下一篇将转向 vLLM 源码里另一个支撑「生产可用」的重镇:多模态输入从 scheduler 到 model runner 的完整处理链路——V1 引擎如何用 placeholder range 把图像视频的 embedding 嵌入 token 流,以及它与本书前面讲过的连续批处理、KV Cache 管理如何配合。

参考资料#

  1. vLLM 官方设计文档:torch.compile integration
  2. PyTorch 官方博客:Accelerating PyTorch with CUDA Graphs
  3. vllm-project/vllm:vllm/compilation/backends.py(V0.10.1)
  4. vllm-project/vllm:vllm/compilation/cuda_graph.py(V0.10.1)
  5. vllm-project/vllm:vllm/compilation/compiler_interface.py(V0.10.1)
  6. vllm-project/vllm:vllm/compilation/wrapper.py(V0.10.1)
  7. vllm-project/vllm:vllm/v1/cudagraph_dispatcher.py(V0.10.1)
  8. vLLM PR #9058:torch.compile integration with compilation control
  9. vLLM PR #9715:rework compile control with piecewise cudagraph
  10. vLLM PR #10058:Integrate Piecewise CUDA graphs
  11. vllm-project/vllm:vllm/attention/layer.py(V0.10.1)
  12. 本站 CUDA Graph 完全拆解:从逐次 Kernel Launch 到可重放推理执行图
  13. 本站 vLLM 源码完全拆解(五):GPUModelRunner 如何把动态请求拼成一次 GPU 前向
  14. 本站 vLLM 源码完全拆解(六):Attention Backend 如何统一 FlashAttention 与 FlashInfer

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

vLLM 源码完全拆解(十二):piecewise compilation 与 CUDA Graph——消灭 kernel 调度开销的两层机制
https://pinghaoyang.com.cn/aigc/posts/vllm-torch-compile-cudagraph/
作者
平昊阳
发布于
2026-10-09
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
212
分类
26
标签
291
总字数
2,407,536
运行时长
0 天
最后活动
0 天前

文章目录