正在获取所在地区…
正在读取天气
音乐
暂未播放
CUDA Graph 的桶与垫:五个推理框架如何把动态解码批塞进静态图
承接:前一篇讲到哪里,这一篇补什么
前端与引擎之间的那道进程边界 把一次请求从 HTTP 入口一路追到 GPU 前向,末尾停在「引擎把一批请求交给模型执行」这一步。执行侧最常被忽略的一层,是解码阶段几乎每个框架都会启用的 CUDA Graph。
CUDA Graph 本身不是新技术。一次反向捕获只记录 kernel 之间的依赖关系,重放时一次性提交,省掉每个 kernel 的 launch 开销;CUDA Graph 完全拆解 已经讲过流捕获、实例化、重放,以及为什么地址、形状和控制流必须稳定。vLLM 的分段编译与整图捕获、SGLang 的分段图与解码整图、TensorRT-LLM 的整图捕获路径、DeepInfer 的捕获与重放,各自在框架文章里也出现过。
但这些文章都只在自己的仓库里讲。把五个框架摆在一起,一个反复出现的问题是:解码批的请求数和 token 数每一步都在变,而一张 CUDA Graph 只对应一个固定的形状,系统到底怎样在两者之间架桥?
答案由三件事组成:
- 桶(bucket):在启动时只捕获有限几个形状;运行时把实际批大小向上取整到最近的桶。
- 垫(padding):补出来的 dummy 行会被送进图内执行,但必须让采样、KV 写入、输出收集等后续环节把它们当作「不存在的行」处理。
- 回退(fallback):某些批根本无法进图(混合 prefill、超出最大桶、注意力后端不支持),必须干净地走 eager 路径。
这篇横向拆解五个框架对这三件事的处理:vLLM、SGLang、TensorRT-LLM、llama.cpp、DeepInfer。重点不在「谁开了 CUDA Graph」,而在桶怎么划、垫什么、谁来承认垫、什么时候不垫。前四个框架都在 Python/C++ 引擎层显式管理这层映射,llama.cpp 则把映射藏进了 ggml 的图缓存机制,这是五个框架里最有辨识度的一个分歧。

图源:PyTorch 官方博客 CUDA Graphs 教程。这张图把「捕获—实例化—重放」串成一条链路:捕获阶段记录 GPU 上实际发生的 kernel 与依赖,实例化把记录编译成可执行图,重放阶段一次提交整段序列。解码阶段每一次 step 都重复同样的 kernel 序列,这正是「为什么值得为解码批建图」的直观依据;而也正是因为要「整段一次提交」,图内每个 kernel 的形状都必须事先固定——这一约束正是后文桶与垫问题的来源。
先立一本账:为什么要垫,垫什么,垫在哪里
从一次解码 step 的真实形状说起
一个连续批处理中的解码 step,其输入形状由三个量决定:
- N:本步被调度的请求数;
- q:每个请求的 query token 数,普通解码为 1,投机解码为 1+k(k 为草稿长度);
- Li:第 i 个请求的上下文长度(已包含 KV 的长度)。
三者合起来决定 KV 缓存访问的规模:
i=1∑NLi这个值就是 attention kernel 实际要读的 KV 行数。除了 Nq(本步 token 总数)之外,它几乎每一步都在变:新请求加入、请求完成退出、每个请求的 Li 自增,都会改变这个和。CUDA Graph 要求捕获时确定的一切张量形状在重放时不变,所以框架必须选一个能覆盖变化、又足够便宜的静态化策略。
最直接的做法是把每一项都放大到桶的边界:
- N 垫到桶大小 B:dummy 请求补足行数;
- q 固定:只给同构解码批捕获图,或者按草稿长度分别捕获;
- Li 取上界:让「总 KV 长度」变成一个可以按桶调度的标量,或者干脆把它留在图外由 kernel 读取。
哪些量必须静态化、哪些可以留在缓冲区里,构成了各框架设计空间的第一条分界线。
桶间隔的账:浪费、图数量与启动时间
桶不是免费资源。设桶列表为 b1<b2<⋯<bm,一个落在 (bi,bi+1] 的实际批 N 会被垫到 bi+1,浪费比例为
w(N)=bi+1bi+1−N最坏浪费出现在区间左端刚过 bi 处,即
wmax(i)=bi+1bi+1−bi−1以 vLLM 的默认桶表为例:[1, 2, 4] 之后以 8 为步长到 256、再以 16 为步长到 512。可以算出:
- 总图数:3+31+17=51 张,其中 31 来自 8∼248 的 8 步长,17 来自 256∼512 的 16 步长(两端都含);
- 在 N=249 时被垫到 256,最坏浪费约 7/256≈2.7%;
- 在 N=5 时被垫到 8,浪费 3/8=37.5%——小批时桶的颗粒度最粗;
- 从 256 到 512 每 16 一张,这一段覆盖了最常见的解码批范围,最坏浪费不超过 15/512≈2.9%。
DeepInfer 的默认表是 [1,2,4] 后接步长 8 直到 max_num_seqs * (1 + nextn)。它的图数由一个乘积决定:普通解码且 max_num_seqs=256 时是 3+31+1=35 张;若开启投机解码(默认 nextn=5),上限变成 256×6=1536,桶表扩展到 8~1528,图数升到约 195 张。这是用更多捕获和更多图池内存,换取在高 token 行数区间仍然保持 8 的细粒度——vLLM 的默认上限 512 在同样负载下只能以 16 为步长。SGLang 的桶表由 cuda_graph_config[decode].bs 给出,先被注意力 TP/CP 对齐过滤,通常桶数更少、颗粒更粗,换来的是每张图都能保证后端可执行。
图数量直接影响启动阶段的三笔开销:
- 捕获时间:每张图都要先 warmup,再在 side stream 上捕获、实例化。DeepInfer 在
capture_model里直接计时并打印All graph capturing finished in ...s,TensorRT-LLM 也把捕获放在引擎初始化阶段完成; - 私有显存池:图内临时张量分配在 graph pool 里,池大小由图内峰值决定。多个形状共用同一个池可以摊薄,DeepInfer 让所有桶共用
graph_memory_pool,并把这个池传给草稿模型(DFlash)的图运行器,日志里会单独打印这部分额外显存; - 编译与捕获的耦合:vLLM 的桶表同时是
torch.compile的编译尺寸表,桶选得越密,编译和捕获的总工作量越大;compile_sizes必须落在桶边界上,否则启动时直接报错。
所以「桶越密越好」并不成立。更准确的经验是:把桶密集区放在负载最常出现的批大小区间。vLLM 在 256 以下用 8 步长、256 以上用 16 步长,就是把低批区间留给延迟敏感场景、把高批区间交给相对浪费可接受的粗颗粒;DeepInfer 用幂级数尾部照顾 1、2、4 三个极小批,避免「单请求也要跑 8 行」的固定浪费。
一次 N=6 的解码 step,五个框架各做什么
把上面抽象成一次具体推演。假设本步 6 个请求、普通解码(每请求 1 个 query token),KV 均已在缓存中,注意力后端支持图化:
| 框架 | 选桶与取整 | 补出来的行 | 图内看到的行数 | 采样看到的行数 |
|---|---|---|---|---|
| vLLM | token 数 6 → 8 | 分页缓冲区的两个 padding 行 | 8 | 6 |
| SGLang | 请求格 6 → 8 | ForwardInputBuffers 末两格 | 8 | 6(num_padding=2 告知后端) |
| TensorRT-LLM | 请求数 6 → 8 | 两条 dummy LlmRequest,尾部 | 8 | 6(回放后切掉尾部) |
| llama.cpp | 无取整 | 无 | 6 | 6 |
| DeepInfer | token 行 6 → 8 | 两条 dummy SequenceMetadata | 8 | 6(num_reqs=6) |
同一件事在五个代码库里对应五个不同的「假行」载体:缓冲区里的两行、请求列表尾部的两个对象、元数据列表尾部的两条记录、以及什么都不补。图内执行的算力都是 8 行,但 KV 写入与采样结果必须只覆盖 6 行——这正是后文横向对照要逐项对齐的差别。
「垫」到底垫了什么
如果把 padding 理解成「往 batch 里塞几个空请求」,会漏掉实际发生的事。框架要垫的是一整组互相一致的张量列:
| 张量 | 在图中被谁读 | 垫错会怎样 |
|---|---|---|
input_ids | embedding 查表 | 查表正常但要读合法 token |
positions | RoPE | 位置偏移错乱,注意力窗口错位 |
block_table / req_pool_indices | KV 读写 | 指向不存在的块,越界读 |
seq_lens | attention 的 mask 与 kernel 分块 | 图内 kernel 按错误的长度算 |
seq_lens_sum | 分块调度、通信量估算 | 分配过大或越界 |
num_padding | 采样、输出收集 | dummy 被当成真实请求采样 |
所以「垫」在源码里通常是列式补足——图内模型看到的是一张 B×q 的完整表,表里最后 B−N 列是 dummy。而采样这一步不同:它的行数往往是 N,或者 B,取决于框架把采样放在图内还是图外、dummy 的 logits 是否被单独切片丢掉。
垫的代价:用算力换一致性
补足到桶意味着图内的注意力、线性层、MoE 门控都对 B 个请求执行。浪费比例的最坏情况,就是桶间隔:
waste≤BnextBnext−N桶越密,浪费越小,但捕获时间、私有显存池和「每次重放选哪张图」的分派开销越大。桶的密度因此是一个典型的三方权衡:
- 浪费:越小越好;
- 启动成本:每张图都要 warmup + 捕获,几秒到几十秒;
- 分派复杂度:图键维度越多,运行时查表越复杂。
五个框架对这三者的取舍,直接体现在默认桶列表的形态上。
vLLM:桶表 + 批量描述符,padding 表在启动前就定下来
vLLM 的 CUDA Graph 支持从最初的「分段编译 + 分段图」演进到了「编译逻辑与图调度解耦」的结构。官方设计页里有一组前后对照图:旧设计把图与编译绑定在 PiecewiseBackend 中、按 batch_size 隐式分派;新设计把图逻辑收进 CUDAGraphWrapper,由 CudagraphDispatcher 根据运行时的 batch descriptor 显式分派。

图源:vLLM 官方文档 CUDA Graphs 设计页。左侧是重构前的形态:图逻辑嵌在 PiecewiseBackend 里,编译产物与图实例一一绑定,批大小一变就要沿着编译后的包装层逐段重查;右侧是重构后的形态:CUDAGraphWrapper 持有图实例,CudagraphDispatcher 维护「批描述符 → 图」的集合,编译后端只提供可分段调用的代码体。对读者最有用的一点是:桶表并不属于编译器,它是运行时分派器的一张表,所以桶大小可以在启动配置里独立调整,而不触发重新编译。
vLLM 的解码批走整图(FULL)或分段图(PIECEWISE),具体用哪一种由编译配置与注意力后端共同决定。分派入口是 vllm/v1/cudagraph_dispatcher.py 里的 CudagraphDispatcher。

图源:vLLM 官方文档 CUDA Graphs 设计页。这张图把执行链拆成调度、模型执行与 Executor 运行时三块:调度器只产出「本步跑哪些请求、各多少 token」,模型执行层负责把批整理成设备张量,运行时按形状选图。这里的桶表与 padding 逻辑全部落在模型执行层,调度器对此一无所知,也不需要知道——这也是为什么改桶大小不会影响调度语义,但会改变端到端延迟。
先把「请求数 → 图大小」做成一张表
1def _compute_bs_to_padded_graph_size(self) -> None:2 max_size = self.compilation_config.max_cudagraph_capture_size3 capture_sizes = self.compilation_config.cudagraph_capture_sizes4 self._bs_to_padded_graph_size = [0] * (max_size + 1)5 for end, start in zip(6 capture_sizes + [max_size + 1],7 [0] + capture_sizes,8 ):9 for bs in range(start, end):10 if bs == start:11 self._bs_to_padded_graph_size[bs] = start12 else:13 self._bs_to_padded_graph_size[bs] = end这张表的含义是:对每个实际批大小 bs,记下「应当重放的最小图大小」。落在某个桶内部的尺寸,一律返回下一个桶的边界;恰好等于桶边界时返回自身。查询时用数组下标直接命中,避免运行时再做二分。
vLLM 默认桶列表的形态写在 CompilationConfig 的注释里:
1[1, 2, 4] + list(range(8, 256, 8)) + list(range(256, max_cudagraph_capture_size + 1, 16))也就是 1、2、4 三个小桶,之后以 8 为步长到 256,再以 16 为步长到上限(默认 512,数据中心 Blackwell 上为 1024)。这个形态同时照顾了两件事:
- 小批(1~4)是延迟敏感的低负载场景,必须精确命中,否则「一个请求也要跑 8 个」的浪费不可接受;
- 大批以 16 为步长,浪费比例被摊薄,捕获张数也被限制在几十张。
桶大小不是批大小,而是 token 数
vLLM 把桶定义在 token 维度而不是请求维度,这一点很容易被忽略。_create_padded_batch_descriptor 先用 _bs_to_padded_graph_size[num_tokens] 求出填充后的 token 数,再根据是否 uniform decode 换算成请求数:
1if uniform_decode and self.cudagraph_mode.has_mode(CUDAGraphMode.FULL):2 num_reqs = min(num_tokens_padded // uniform_decode_query_len, max_num_seqs)3 assert num_tokens_padded % uniform_decode_query_len == 04else:5 uniform_decode = False6 num_reqs = min(num_tokens_padded, max_num_seqs)uniform_decode_query_len = 1 + num_speculative_tokens。所以在投机解码开启时,一个 N 个请求、每个请求 q 个 query token 的批,token 总数是 Nq,桶按 Nq 划分,请求数则从 token 数反推。
这里有一个容易读错的细节:num_tokens 并不是「图内的行数上限」。对整图模式,FULL 图的 batch descriptor 以 (num_tokens, num_reqs, uniform, has_lora) 四元组定位;num_reqs 是图内 attention 与采样等按请求组织的 kernel 看到的行数。二者必须满足
否则 assert num_tokens_padded % uniform_decode_query_len == 0 就会失败。这条约束的含义是:在 uniform 整图里,补齐后的 token 数必须能被每个请求的 query 长度整除。实际批 N 补到桶 B 时,补出来的请求数是 B/q−N,是一个整数;若改用「补 token 数」或「补请求数」中任意一种、而不检查整除关系,图内就找不到合法的请求数——这正是 vLLM 为什么按 token 数取桶、再整除换算请求数的原因。
为什么要在请求数之外再按 token 数组织桶?因为 token 数决定了几乎所有 kernel 的工作量:GEMM 的行数、MoE 门控的路由数、采样 kernel 的行数。若按请求数分桶,同样的请求数在 q=1 和 q=6 时差六倍;按 token 数分桶则天然把投机解码的变长 query 也纳入进来。代价是桶表必须同时满足整除约束,以及下文 compile_sizes 的边界约束。
还有一个隐含前提:uniform_decode 只有在批内每个请求的 query 长度都恰好等于 uniform_decode_query_len 时才为真。混合批(一部分请求在 prefill、一部分在 decode,或部分请求草稿长度不同)会落到非 uniform 分支,num_reqs = min(num_tokens_padded, max_num_seqs)——这时 token 数就是请求数,等价于请求维桶。所以 vLLM 的桶在「纯解码」与「混合」两种语义间切换,前者的图键更紧、能走 FULL,后者只能落到 PIECEWISE 或 eager。
图键上还挂了什么
最终一张图由 BatchDescriptor 定位,字段包括 num_tokens、num_reqs、uniform、has_lora、num_active_loras。LoRA 的多适配器场景下,_get_lora_cases() 会额外把「有多少个 adapter 活跃」纳入捕获组合;分段图模式允许 num_reqs=None,因为分段图的批内 kernel 不看请求数,只按 token 偏移组织;整图模式必须精确给出 num_reqs,注释里写得很清楚:FA3 的 scheduler_metadata 依据请求数计算。
dispatch() 在重放时先按填充后的键查 FULL 集合,未命中再退到「不区分请求数、非 uniform」的键查 PIECEWISE 集合;两者都没有就返回 NONE,交回 eager。

图源:vLLM 官方文档 CUDA Graphs 设计页。这张流程图表达的是 vLLM 的重放调用链:外部调用先到达 CUDAGraphWrapper,包装器按分派器给出的模式(整图 / 分段 / 不图化)决定是整段重放、逐段重放,还是直接走普通执行。把它和上面的 _bs_to_padded_graph_size 表放在一起看:vLLM 的「桶」在运行时的全部作用就是产出这条调用链最左侧的输入键;一旦该键在分派器的集合里查不到,调用会沿着流程图向下透传,而不是抛错。
一个不易察觉的约束
_compute_bs_to_padded_graph_size 末尾会校验 compile_sizes:如果用户指定的编译尺寸会被 padding 改变,直接抛错。理由同样是注意力后端——某些 op 在编译期按 token 数生成调度元数据,一旦图里的 token 数与编译时不一致,元数据的语义就错了。这条检查把「垫」的合法性前置到了启动阶段,而不是等到运行时形状对不上才暴露。
SGLang:请求格 + 对齐,桶里还带 DP 的痕迹
SGLang 的解码图同样按桶划分,但桶的单位、对齐规则和图键都比 vLLM 多一层。
桶是「请求格」,每格含 q 个 token
decode_cuda_graph_runner.py 里的一个桶大小 bs 对应请求槽位数,每个槽位占 captured_req_width 个 token(普通解码为 1,投机解码为草稿长度 + 1)。所以一个桶的 token 数是:
重放时:
1raw_num_token = raw_bs * self.captured_req_width2if self.require_mlp_tp_gather:3 max_batch_size = self._max_dp_batch_size(forward_batch)4 bs = self._pad_to_bucket(max_batch_size, self.capture_bs)5else:6 bs = self._pad_to_bucket(raw_bs, self.capture_bs)7padded_num_tokens = bs * self.captured_req_width注意 _max_dp_batch_size 分支:在注意力 DP 下,不同 rank 上的本地批可能大小不同,但后续的 all-reduce/gather 要求各 rank 的 token 数一致。SGLang 用「DP 全局批」去选桶,让所有 rank 选中同一个桶,从而在图中满足通信形状的一致性。
这段代码还暴露了 SGLang 三个约束的先后顺序:先用对齐规则生成桶表(只有满足 TP/CP 切分的桶才会被捕获),再用二分把实际批取整到桶。顺序不能反过来——如果先按负载取整出任意形状、再去检查能否整除,那么大量批会在运行时才发现没有对应图,退化为 eager;SGLang 宁可牺牲桶密度,也要保证每个桶都可执行。这与 vLLM 的做法相反:vLLM 先按负载密度生成桶表,再通过 compile_sizes 校验把不合法的编译尺寸挡在启动阶段。
捕获桶要先对齐注意力切分
base_cuda_graph_runner.py 中的 get_batch_sizes_to_capture 在生成桶列表时做了两层过滤:
1alignment_width = captured_req_width2if get_exec().overlap.enable_two_batch_overlap:3 alignment_width = 14
5capture_bs = [bs for bs in capture_bs if bs * alignment_width % mul_base == 0]6capture_bs = [bs for bs in capture_bs if bs <= num_max_requests]mul_base 来自注意力的 TP/CP 对齐约束,单位是 token 行数。一个桶的 token 行数必须能被切分整除,否则图内 attention kernel 的序列切分形不成完整的 tile。两批重叠(TBO)开启时,一行 token 会被拆到两个 micro-batch,对齐约束改为按请求生效,所以 alignment_width 退化为 1。
这段代码实际上回答了「为什么桶表不能随便写」:桶不仅是调度器的取整格,还要满足 Attention 后端与通信库的形状约束。捕获时若桶被过滤掉,运行时就可能选不到图而掉入 eager。
补出来的行怎么被承认
SGLang 在重放前构造一个 ForwardBatch 的「视图」,把实际字段与捕获期的静态缓冲区拼在一起:
1return SimpleNamespace(2 batch_size=bs,3 forward_mode=capture_forward_mode,4 actual_forward_mode=forward_batch.forward_mode,5 input_ids=buffers.input_ids[:num_tokens],6 positions=buffers.positions[:num_tokens],7 req_pool_indices=buffers.req_pool_indices[:bs],8 seq_lens=buffers.seq_lens[:bs],9 seq_lens_sum=(... forward_batch.seq_lens_sum + (bs - raw_bs) * seq_len_fill_value),10 seq_lens_cpu=(None if forward_batch.seq_lens_cpu is None else buffers.seq_lens_cpu[:bs]),11 num_padding=bs - raw_bs,12 ...13)关键有两点:
num_padding = bs - raw_bs是一个显式字段,向后端传递「多少行是假的」;seq_lens_sum用seq_len_fill_value补偿,每个 dummy 行计一个固定的seq_len_fill_value,这样图内分块 kernel 与通信量估算得到的总长度和真实执行保持一致。
dummy 槽位的 seq_lens 填什么值,各家选择不同:填 1 会让 attention 只看一个 KV 位置,代价最小;填 0 在某些 kernel 里会触发空循环或除零。seq_len_fill_value 就是把这个常量放到一个可配置的地方。
缓冲区里的 input_ids[:num_tokens] 只复制了真实 token,dummy 部分不刷新。SGLang 的注释直接写道:「Padded tokens aren’t read, so skip zeroing」。这也解释了为什么 dummy 行可以携带任意旧值:只要 num_padding 和 seq_lens 让图内 kernel 不去读它们就行。
两批重叠(TBO)对桶的二次影响
TBO 是一种把解码批拆成两半、让两半的前向在 GPU 上交错执行以重叠通信与计算的技术。它对 CUDA Graph 的影响是:
- 两半共享同一组静态缓冲区,但各自在独立的流上提交,因此捕获时要按流分别建图,图键里必须带
stream_idx; - 每半的 token 行数只占整批的一半,桶对齐的宽度参数在 TBO 下退化为按请求对齐(源码里把
alignment_width设为 1),否则两个半批无法落进同一张图; - 重放后要按真实的非 padding token 数区分有效行,源码里对应
num_token_non_padded缓冲。
这导致 SGLang 的图键不能只写请求数。源码里的 _make_graph_key 由 ShapeKey(size, stream_idx, variant_label, attention_variant) 组成:size 是桶大小(普通模式下取 bs,ragged 投机模式下取 token 数)、stream_idx 区分多流捕获时的流编号、variant_label 区分 LoRA 形态、attention_variant 区分注意力后端形态。只写请求数,就会把「同一桶大小、不同 LoRA / 不同注意力变体」的批错误地塞进同一张图。这类「图的复用边界由后端细节决定」的模式,在下面 TensorRT-LLM 的图键里能看到同样的影子。
TensorRT-LLM:请求维桶 + 尾部 dummy 请求,图外先对好账
TensorRT-LLM 的 C++ 调度器完成一轮 batch 之后,Python 侧的 CudaGraphRunner 才介入。它的 padding 是请求维的:给生成批补若干 dummy 请求,批大小向上取到最近一张图。
dummy 只能挂在尾部
_get_padded_batch 的文档说明非常直接:dummy 行只能放在真实行之后,不能插在前面。原因是有三个消费者硬编码了「生成行从下标 0 开始」:
DecoderInputPreparer.prepare_inputs会跳过 dummy 的 input_ids,并把重叠调度器的 token 按偏移写进input_ids_cuda[num_tokens:...];TorchSampler按raw_logits_cuda[:len(generation_requests)]读取生成 logits;_execute_logit_post_processors以 0 为起点遍历批。
如果 dummy 排在前面,这三处的下标对齐全部错位——真实请求会拿到别人的输出。因此这里的 padding 不是「补几个空位」这样的形状问题,而是输出关联问题。
先按各 rank 的最大批选桶
在注意力 DP 下,TP 组内各 rank 看到的本地批不同,但图必须同时被所有 rank 重放。_get_padded_batch 先用一次固定大小的 all-gather 交换三个值:是否可进图、批大小、采样档位:
1graph_batch_info = self._gather_adp_graph_batch_info(2 batch, can_run_cuda_graph, SampleType.FULL)3all_can_run_cuda_graph = all(rank_info[0] for rank_info in graph_batch_info)4if all_can_run_cuda_graph:5 new_batch_size = max(rank_info[1] for rank_info in graph_batch_info)采样档位也要一起交换,因为它是图键的一部分。如果两个 rank 选了不同的采样档位,就会出现一个 rank 重放图 A、另一个重放图 B,图内的集合通信节点数对不上,卡死。
之后按「运行时草稿长度」做一次向上取整:
1padded_batch_size = self._round_up_batch_size_with_draft_len(2 new_batch_size, runtime_draft_len)草稿长度不同,同一批大小的图内 token 数不同,所以图要按草稿长度分别捕获。这一步的本质是把「桶」从一维批大小扩展成「批大小 × 草稿长度」的二维格子。
两层闸门:能否进图 + 是否在捕获集里
pad_batch 是上下文管理器,返回时把 generation_requests 尾部切掉,恢复原始批。真正决定「进图」的闸门在另一处:
1if not self._capture_allowed:2 return None, None, None3if batch_size not in self.supported_batch_sizes:4 return None, None, None_capture_allowed 只在启动的两遍 warmup 上下文里为真。注释说明:若允许在活的批上临时捕获,warmup 过程会真正写 KV、甚至在旧图指针已固定后改变共享注意力工作区的大小,导致已经捕获的图内部地址失效。这条约束与 vLLM 的「静态分页缓冲区是唯一真相」在设计精神上一致:图的地址必须在启动阶段冻结。
batch_size not in supported_batch_sizes 对应「取整后仍没有图」的情形。注意 TensorRT-LLM 这里用哈希集合而不是区间表:只有恰好等于捕获桶大小的批才能进图,padding 把批向上取到桶,再逐位查集合。这与 vLLM 的「数组下标区间表」是两种等价但实现形态不同的做法。
什么批可以进图
_can_run_cuda_graph_batch 只检查 batch.can_run_cuda_graph 这一个来自调度器的标志,或者编解码混合图特批的路径。也就是说「这个批能不能图化」的判定被放到了调度阶段,由 ScheduledRequests 上的字段明确表达,CudaGraphRunner 只负责执行。这种分层很适合 C++ 调度器:调度器决定「本轮允许什么形态」,Python 侧只按这个决定走图或走 eager。
一个容易忽略的分支是上下文请求的 chunk 大小:不一致时 get_graph_key 直接返回 None。这对应一种常见情形:分块 prefill 的若干请求 chunk 长度不同,没有任何一张图能描述这个形状,于是退回 eager。这个设计与 vLLM 的 PIECEWISE 分段图思路不同:vLLM 用分段图把「形状不兼容的 attention」切成图外 eager 子段,而 TensorRT-LLM 在整图路径上直接放弃整图,把混合 prefill 交给另一条路径。
第二个桶维度:短序列图与长序列图
TensorRT-LLM 的图键里有一个其他框架没有的字段:short_seq_len_mode。它不是按批大小或 token 数划分,而是按「本批最长序列是否低于某个阈值」划分。_get_seq_len_mode 的注释说得很明确:某些稀疏注意力算法在短序列上可以跳过索引器里的 MQA 与 Top-K,以降低计算开销;为了利用这一点,必须为短序列和长序列分别捕获 CUDA Graph。
判断逻辑是:
1max_seq_len = max(total_seq_lens)2if max_seq_len <= self.sparse_config.seq_len_threshold - num_extra_kv_tokens:3 short_seq_len_mode = True4else:5 short_seq_len_mode = False这里 total_seq_lens 的算法还处理了三种特殊情况:被提升为生成行的上下文档使用 context_current_position + 1;重叠调度关闭、dummy 请求、未分配 batch 的请求用 max_beam_num_tokens + num_draft_tokens;其余用 max_beam_num_tokens + num_draft_tokens + 1。这些修正的目的只有一个:让图键里的「短/长」判定与图内实际执行的那一行序列长度一致,避免「按短图捕获、却按长序列重放」。
对 padding 的启示是:当一个批被补到桶之后,图键的其它维度必须按补完后的批重新计算。补齐的 dummy 行会改变 max_seq_len(如果填充行的 seq_len 填得过大),从而把批从短图推到长图。这也是 dummy 行的 seq_len 要尽量小的另一个理由:它不只要少算,还不能改变图的分派类别。
SGLang 的代码里能看到这个「填充值从哪来」的设计:它不是硬编码常量,而是由注意力后端提供:
1self.seq_len_fill_value = (2 self.attn_backend.get_cuda_graph_seq_len_fill_value()3)并在捕获前把整列 seq_lens 填成这个值。为什么交给后端?因为不同注意力后端对「空行」的容忍度不同:FlashAttention 系后端通常接受 1,某些后端要求非零的最小块长度,另一些在分块调度的统计里需要特定值。由后端定义填充值,等于让「假行看到什么」这件事由真正读它的那一方决定——这是比在引擎侧硬编码更不容易出错的安排。DeepInfer 的 dummy 用 seqlen_q=1, seqlen_k=1 并显式跳过槽位 0,是同一思路在单后端场景下的简化版。
llama.cpp:没有桶表,图缓存本身就是桶
llama.cpp 的 graph 级重构之后,CUDA 后端把「桶」藏进了 ggml 的图缓存:没有显式的 capture_sizes 列表,没有 padding dummy 请求,也没有 _pad_to_bucket 这样的函数。它的做法是按 cgraph 的节点身份与内容做缓存与更新,让同一结构、同形状的计算图自动复用同一张 CUDA Graph。
图键是首节点指针
ggml-cuda.cu 中:
1static const void * ggml_cuda_graph_get_key(ggml_cgraph * cgraph) {2 return cgraph->nodes[0];3}CUDA Graph 缓存在 ggml_backend_cuda_context::cuda_graphs 里,以首节点指针为键:
1std::unordered_map<const void *, std::unique_ptr<ggml_cuda_graph>> cuda_graphs;查询时还带一个时间淘汰:
1ggml_cuda_graph * cuda_graph(const void * first_node_ptr) {2 // sweep every 5s, evicting cuda graphs unused for >=10s3 if (time_now - last_graph_eviction_sweep >= 5'000'000) {4 last_graph_eviction_sweep = time_now;5 for (auto it = cuda_graphs.begin(); it != cuda_graphs.end(); ) {6 ...7 }8 }9 auto it = cuda_graphs.find(first_node_ptr);10 if (it == cuda_graphs.end()) {11 it = cuda_graphs.emplace(first_node_ptr, std::make_unique<ggml_cuda_graph>()).first;12 }13 ...14}这一点非常关键:前四类框架的图集合在启动时就固定,服务运行期间只增不减;llama.cpp 的图集合是运行中动态增长、按时间淘汰的。
llama.cpp 的图节点(ggml_tensor 对象)按图结构复用,同一类批(例如「N 个序列、每个 1 个 token 的解码批」)构建出的计算图首节点是同一个对象;换一种批形态,首节点不同,自然落到另一张图。于是「桶」由 llama.cpp 在构建图时事实上划分好——它按 n_seqs、n_tokens 等维度构造不同结构、不同形状的图,每种结构对应一个首节点,也就对应一份 CUDA Graph。
由此可以直接推论:llama.cpp 的 llama-graph 构建函数里那些「按 n_seqs 走不同分支」的代码,在这套语境下就是 vLLM 的 build_graph_batch_sizes 与 SGLang 的 capture_bs 的替代物——只是它没有被单独写成一个桶列表,而是散落在建图分支里。
这与前三个框架的差别是根本性的:
- vLLM/SGLang/TRT-LLM:图是执行层的资源,由调度器、引擎、图运行器共同管理,桶表在 Python 里;
- llama.cpp:图是计算图构建的副产品,图运行器只判断「这张已构建好的图能否图化」,桶隐含在图的构建逻辑里;
- 另外前四者的桶是「形状 = 上限」的离散集合(一个批只能向上落到桶),llama.cpp 的图是「按当前实际形状建图 + 可执行图就地更新」,理论上没有上限桶的概念,代价是新形状要付出一次 warmup/更新开销。

图源:NVIDIA 技术博客 CUDA Graphs。这张图展示了典型的一段计算在提交时形成的有向无环图(DAG):节点是一次 kernel 或拷贝,边是依赖。llama.cpp 的 cudaGraphExecUpdate 依赖的正是「节点拓扑大体不变、只是属性或形状变化」这一前提;若图结构发生增删节点式的改变,更新会失败并退回重新实例化。看这张图时可以对照 llama.cpp 的 node_properties:它逐节点缓存的 node、node_src_data_ptrs、node_src_ne、node_src_nb 就是这 DAG 里每个节点的算子、源指针、形状与步长——比对失败就意味着这张图不能再用来重放当前计算图。
复用判定看节点属性而非形状向量
每张图都缓存了整条 cgraph 的节点属性快照:
1struct node_properties {2 ggml_tensor node;3 void * node_src_data_ptrs[GGML_MAX_SRC];4 int64_t node_src_ne[GGML_MAX_SRC][GGML_MAX_DIMS];5 size_t node_src_nb[GGML_MAX_SRC][GGML_MAX_DIMS];6}7std::vector<node_properties> node_props;每次 ggml_backend_cuda_graph_compute 都会比对 uid 和节点属性:
cgraph->uid非零且与缓存图相同:直接复用,不做比对;- 否则逐节点比较
node结构、源张量数据指针、维度与步长;有任何变化就标记「需要更新」。
「更新」不是重新捕获一张图,而是 cudaGraphExecUpdate 就地改写可执行图:
1static void ggml_cuda_graph_update_executable(cudaGraphExec_t & instance, cudaGraph_t cuda_graph) {2 cudaError_t stat = cudaGraphExecUpdate(instance, cuda_graph, &result_info);3 if (stat == cudaErrorGraphExecUpdateFailure) {4 ... cudaGraphExecDestroy(instance);5 cudaGraphInstantiate(&instance, cuda_graph, nullptr, nullptr, 0);6 }7}如果更新失败(节点增删之类的不兼容变更),回退为重新实例化。这等于用 driver 提供的「就地改写」能力,替代了其他框架里「按桶列表预先捕获多张图」的思路:形状变化时不是选另一张图,而是改这一张图。
为什么不垫
llama.cpp 不做 dummy padding,因为它没有需要取整的理由:图内 kernel 的长度是动态的,attention 的序列切分和 KV 寻址都从张量或内存里读,图的形状由构建时的 n_seqs/n_tokens 决定,而批管理器(llama_batch)在调度时就按实际 token 数组织。变化发生时,要么构造成同一张图(cgraph->uid 相同)被复用,要么构造出结构不同的图、落到新的首节点下缓存。
代价相应也有:
- 每出现一种新的批形态,都要走一次「warmup 两次 + 捕获」或「更新可执行图」,且这些操作发生在服务进程内;
- 图的淘汰依靠时间:
cuda_graph每 5 秒扫一遍,淘汰 10 秒未使用的图,防止服务进程的内存被不断增长的新形态撑满。
启用条件与整图分块
ggml_cuda_graph_set_enabled 在 Volta 之前架构上关闭图。另外,ggml 的整张图在捕获时会被切成固定大小的块:
1// number of ggml nodes per CUDA graph chunk, 0 captures the whole ggml graph as one CUDA graph2static int ggml_cuda_graph_chunk_size() {3 ...4 return env != nullptr ? atoi(env) : 512;5}默认每 512 个节点一张子图,各子图以独立 cudaGraphExec_t 顺序启动。注释给出的原因很实际:把 ggml 图拆成若干 CUDA Graph,GPU 可以在运行第 k 段时同时入队第 k+1 段,从而缩短「前半段计算未完、后半段还没开始提交」的空窗。这与前三个框架里「每一层或每几层一张图」的分段图有相通之处,但切分点在 ggml 节点级别,粒度更细、也更均匀。
DeepInfer:桶表 + 幂级数尾部,padding 是「元数据列」而不是「请求对象」
DeepInfer 的解码图运行器在 deepinfer/worker/cuda_graph_runner.py,形态上与 vLLM 同族:预捕获一组固定形状,运行时查表取整,重放前把本步元数据写进捕获期的静态缓冲区。但两边在几个细节上走出了不同的路。
桶列表的生成
1def build_graph_batch_sizes(max_size: int, interval: int) -> list[int]:2 """Build power-of-two warmup sizes followed by fixed-width intervals."""3 interval = min(interval, max_size)4 batch_sizes: list[int] = []5 size = 16 while size < interval:7 batch_sizes.append(size)8 size *= 29 batch_sizes.extend(range(interval, max_size, interval))10 batch_sizes.append(max_size)11 return batch_sizes默认 DEEPINFER_CUDAGRAPH_BATCH_SIZE_INTERVAL = 8,于是桶列表为 [1, 2, 4] + [8, 16, ..., max_size];若 max_size 不能被 8 整除,末位再补一个 max_size 精确桶。
max_size 是 max_num_seqs * (1 + nextn),而 nextn 来自 DEEPINFER_USE_NEXTN_DECODE(默认 5)。换言之,桶的单位也是 token 行而不是请求行:普通解码下每个请求 1 行,投机解码下每个请求 1+nextn 行。这在形态上与 vLLM 的 token 维桶一致,但 DeepInfer 的幂级数尾部(1、2、4)比 vLLM 更短,8 的固定步长延伸得更长。
捕获时先从最大值切出全部子图,共享一个私有池
1self.padding_seq_metadata_list = [padding_seq_metadata] * self.max_batch_size2input_metadata_source_full = prepare_input_fn(self.padding_seq_metadata_list)3input_metadata_device_full = input_metadata_source_full.recursive_fn(lambda x: x.cuda(non_blocking=True))4output_full = model.allocate_output(self.max_batch_size)5
6for batch_size in reversed(self.batch_sizes):7
8 def _cut(x: torch.Tensor, _bs: int = batch_size) -> torch.Tensor:9 assert x.shape[0] == self.max_batch_size10 return x[:_bs]11
12 input_metadata_source = input_metadata_source_full.recursive_fn(_cut)13 input_metadata_device = input_metadata_device_full.recursive_fn(_cut)14 output = output_full.narrow(batch_size)这段代码做了三件事:
- 一份最大缓冲区覆盖
max_batch_size,每个桶是它的前缀切片。所有桶共享同一块静态内存,捕获时pool=self.graph_memory_pool只取第一张图的池,其余图复用——这是 Graph 私有池的标准做法,保证不同图分配出的临时张量落在同一块可复用区域。 - dummy 元数据只有一条,复制成 Bmax 份。每个 dummy 行的
block_table=[0]、seqlen_q=1、seqlen_k=1,sampling_params(temperature=0.0)。这个 dummy 始终指向第 0 号块,而且注释特意写着mtp_info_write_slot默认落在 padding/dummy 槽位 0,写 KV 的 kernel 会跳过该槽位。 - 输出从最大缓冲上切片,重放后只取前
num_reqs行给采样器。
重放时「把真实列写进最大缓冲区的头部」
1graph_size, cuda_graph_used = self.cuda_graph_runner.select(total_q)2self._prepare_input_tensors(3 seq_metadata_list + self.cuda_graph_runner.padding_seq_metadata_list[: graph_size - total_q],4 out_dict=cuda_graph_used.input_metadata_source.to_dict(),5 metadata_zos=metadata_zos,6)7input_metadata = self._graph_input_metadata(cuda_graph_used, num_reqs)这里 total_q 是本步 token 总数(Nq),graph_size 是桶大小。补足后的元数据列表 = 真实请求 + 前 graph_size - total_q 个 dummy。to_dict() 拿到的是该桶自己的静态缓冲区字典,prepare_input_tensors 就地把这一批请求元数据写进缓冲区。
_graph_input_metadata 只改一个字段:
1input_metadata = cuda_graph_used.input_metadata_device2# 实际的请求个数,不包含 pad 的。要和 sampler inputs 对齐,sampler inputs 不包括 pad 的部分。3input_metadata.num_reqs = num_reqs换句话说,图内看到的是桶大小的完整张量,采样器和后续处理看到的则是 num_reqs 行——一个对象同时承载「图内形状」和「有效行数」两种语义。
padding 是一条元数据记录,不是请求对象
与 TensorRT-LLM 补一条 dummy LlmRequest、SGLang 补 ForwardBatch 视图不同,DeepInfer 的 dummy 是 SequenceMetadata——调度器眼里的形状记录,没有对应的会话状态,不出现在任何队列里。这带来一个后果:padding 完全发生在 worker 侧,调度器看到的仍然是 N 个请求,不需要知道图的存在。这与 DeepInfer 的异步调度设计同构:调度在 CPU 上持续进行,worker 负责把调度结果适配到图;两者之间通过元数据列与缓冲区通信,而不是通过「虚拟请求」。
图内为什么能保持形状一致
图内 kernel 看到的 seqlen_k 对 dummy 行是 1,对真实行是各自的实际长度。如果 attention kernel 按 seqlen_k 逐行分块,dummy 行只贡献一小段无效计算,不会改变 chunk 的数目——chunk 的数目由 num_reqs 与桶大小决定。这解释了为什么 dummy 行可以只填 1:它必须让 kernel 的执行路径稳定,但自身贡献要尽量小。
ZOS:连 padding 元数据的准备也搬进设备
普通图化流程里,每步都要在 CPU 上把本步批(真实请求 + 补齐的 dummy)整理成一组张量,再 cuda(non_blocking=True) 拷到 GPU。批越大、元数据越多,这一步的 H2D 拷贝越不可忽略;而它恰好发生在「上一 kernel 已结束、下一 kernel 尚未提交」的关键路径上。
DeepInfer 的 ZOS(Zero-Overhead Scheduling)把这一步改写为设备侧构造。ModelRunner 创建图运行器时传入:
1self.cuda_graph_runner = CudaGraphRunner(2 max_batch_size=self.scheduler_config.max_num_seqs * (1 + self.nextn),3 input_on_device=envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING,4)input_on_device=True 时,_run_model 不再执行 recursive_copy_,而是直接让图内的静态张量与调度器产出的设备内存列共享存储:
1def _run_model(model, kv_caches, input_metadata_source,2 input_metadata_device, copy_inputs):3 cp_group = PG.context_parallel_group()4 if copy_inputs and cp_group.rank() == 0:5 input_metadata_device.recursive_copy_(input_metadata_source)6 if cp_group.size() > 1:7 input_metadata_device.broadcast()8 return model(kv_caches=kv_caches, input_metadata=input_metadata_device)ZOS 路径上真正承担「把本步批映射到静态缓冲」工作的,是 prepare_decode_zos_inputs 和随它传入的 remap。MetadataZOS 里对 remap 的定义是:
1remap[i] = j if curr_batch[i] matches prev_batch[j], else -1也就是「本步第 i 行是否就是上一步第 j 行」。这个映射由 C++ 侧在调度完成后生成,放在 pinned CPU 内存里,作为 H2D 的源,同时供 CPU 侧判断哪些行是「新行」、哪些行可以沿用上一步的结果。配合同一结构里缓存的 prev_sampled_token_ids、prev_predict_token_ids、prev_num_accepted_tokens,解码输入的若干列(例如上一 token、草稿 token、接受数)不必重新从 Python 对象里逐字段搬运。
对 padding 来说,ZOS 的价值不是「让 dummy 更便宜」,而是让补齐后的整列元数据保持固定布局:dummy 行始终占据尾部固定偏移,真实行的位置由调度器给出,设备侧内核按 remap 决定刷新哪些行,dummy 行的内容可以保持上一次捕获时的值不变。这样每步只需要更新变化的那一小部分,而不是把 B 行全部重写。这也是 CudaGraphData 文档里那句「Source tensors are on CPU, or share device tensor storage under ZOS」的实际含义。
代价是这套机制把「图内输入构造」与「调度器的元数据布局」绑得更紧:prepare_decode_zos_inputs 的签名里带着 max_seqlen_q、block_table_compress_ratio、need_default_seqlens 等图内形状参数,调度器一旦改变这些约定,图侧也要同步修改。对只读源码的人来说,这是 DeepInfer 比 vLLM/SGLang 更难读的一段——它的 padding 语义有一部分落在 C++ 与设备内核里,而不是 Python 的 if padding: 分支里。
主模型图与草稿图共享同一个池
投机解码让图化变成两张图:主模型一张、草稿模型(DFlash)一张。DeepInfer 的做法是先捕获主模型全部桶,再把主模型的 graph_memory_pool 与 graph_stream 传给草稿图运行器:
1self.dflash_cuda_graph_runner.capture(2 dflash_model,3 kv_caches,4 main_input_metadata=main_graph_runner.graphs[max(main_graph_runner.graphs)].input_metadata_device,5 graph_memory_pool=main_graph_runner.graph_memory_pool,6 graph_stream=main_graph_runner.graph_stream,7)并断言主模型图必须先全部捕获完成。日志会打印 DFlash CUDA graph extra memory,用 torch.cuda.mem_get_info() 的前后差值估算草稿图额外占用。共享池的意义是复用同一段时间内的峰值显存,而不是为草稿图再开一块独立池;代价是两张图的生命周期被绑定,销毁时必须一起 destroy,否则会留下悬挂的池引用。
横向对照:五个答案,三个岔路口
岔路口一:桶按请求维还是 token 维
| 框架 | 桶单位 | 默认形态(源码为准) | 图键上的其他维度 |
|---|---|---|---|
| vLLM | token 数 | [1,2,4] + range(8,256,8) + range(256,max,16),上限默认 512 | uniform、LoRA 活跃数、请求数(分段图可为空) |
| SGLang | 请求格 × 每格 token | 配置项 cuda_graph_config[decode].bs,按注意力对齐过滤 | 流编号、LoRA 变体、attention 变体 |
| TensorRT-LLM | 请求数 | 由调度器配置的捕获桶列表 | 草稿长度、短/长序列模式、采样档位、上下文请求数、encoder token 数、LoRA 数据类型 |
| llama.cpp | 隐含(cg 结构) | 无列表,由构建逻辑产生 | 首节点指针 + 全图节点属性 |
| DeepInfer | token 行 | [1,2,4] + [8, 16, ...],默认步长 8 | 无(total_q 单维) |
请求维和 token 维的选择,取决于「图的形状由哪个量决定」。普通解码 q=1 时两者等价;投机解码时,token 维天然容纳变长草稿,请求维则要把草稿长度放进图键,把桶扩成二维格子。vLLM 和 DeepInfer 走前者,TensorRT-LLM 走后者,SGLang 用「请求格 × 格宽」把两者合成一个量。
图键的复杂度则反映了「图内还有多少分支」。vLLM 的 LoRA 活跃数、TRT-LLM 的上下文请求数与 encoder token 数,都是因为图内 kernel 的行为会随这些字段变化;DeepInfer 的解码图只对纯 decode 批捕获,prefill 与混合批不进图,图键因此可以简单到只剩 token 数。
岔路口二:垫的对象是什么
| 框架 | 垫的对象 | 谁来承认「这是假的」 | dummy 行看到的数据 |
|---|---|---|---|
| vLLM | 分页缓冲区中的 padding 行 | 图外采样按 num_reqs 切片;注意力后端读槽位掩码 | 由静态缓冲的末段决定 |
| SGLang | ForwardInputBuffers 中的末段 | num_padding 字段 + seq_lens 填充值 | 旧值不刷新,seq_len_fill_value |
| TensorRT-LLM | 真实 LlmRequest 列表尾部的 dummy | 采样与后处理按 len(generation_requests) 截断 | 正常请求结构,但走跳过分支 |
| llama.cpp | 不垫 | 无需承认(按实际形状建图) | — |
| DeepInfer | 元数据列末尾的 SequenceMetadata | input_metadata.num_reqs 回到真实值;输出只取前 num_reqs | seqlen=1、指向第 0 号块 |
同样是「补假行」,不同框架把「假」的信息放在不同层:
- TensorRT-LLM 把假行做成一等请求,所有下游逻辑必须知道尾部有几条是假的,因此
pad_batch用上下文管理器保证「补了必切回」; - SGLang 把假行做成缓冲区里的一段,用显式
num_padding字段通知后端; - DeepInfer 把假行做成元数据列里的一段,用
num_reqs一个字段回到真实值; - vLLM 中假行所在的区域永远不被采样读取,承认方式是「按有效行切片」;
- llama.cpp 根本不创建假行。
这五个选择对应了五种「有效行语义」的落点:请求列表、缓冲区字段、对象字段、切片边界、不存在。
岔路口三:形状变化时往哪退
| 框架 | 形状变化时的机制 | 没有图时的回退 |
|---|---|---|
| vLLM | 区间表选桶;FULL → PIECEWISE → NONE 三级 | 分段图内可执行 eager,全程 eager |
| SGLang | 二分 _pad_to_bucket;超过最大桶 | can_run_graph 拒绝,走 eager |
| TensorRT-LLM | 桶集合查表;上下文 chunk 不一致返回空键 | maybe_get_cuda_graph 返回空,走 eager |
| llama.cpp | cudaGraphExecUpdate 就地更新;不兼容则重建 | 图上 warmup 未完成或更新失败,走普通提交 |
| DeepInfer | 二分 bisect_left 选桶;超过最大桶抛错 | run_cuda_graph 为假,走 eager(prompt 批恒走 eager) |
前三个框架的回退发生在执行层:引擎在调模型前先决定进图还是 eager,图运行器本身不做额外判断。llama.cpp 的回退发生在图缓存层:每次 compute 都做一次属性比对,没稳定下来就先不图化。DeepInfer 则把「prompt 批不进图」写进了调用路径:run_cuda_graph = not is_prompt and ...,只有纯解码批参与图化。
一个共同的硬约束:图内不能做同步与动态分配
五个框架的代码里都能看到这一点。DeepInfer 在 execute_main_model 和 execute_sample 的 docstring 里都强调「MUST NOT contain any cuda synchronize」,并在调用处用 forbid_cuda_sync() 上下文包裹;TensorRT-LLM 用 _capture_allowed 阻止在活的批上临时捕获;vLLM 用 compile_sizes 校验阻止编译尺寸被 padding 改变;SGLang 用 num_padding 保证图内 kernel 不读假行;llama.cpp 用节点属性比对保证复用图的地址和形状完全不变。
这些看似无关的检查,本质是同一件事:图的静态性必须由形状之外的机制配合才有意义。垫出来的行如果会被同步、被采样、被当作真实请求计数,再精确的桶表也只是把错误掩盖到了别的环节。
岔路口四:采样与输出关联在哪里对齐
同一条「假行不能进采样」的规则,在五个框架里落到了不同的阶段。TensorRT-LLM 把采样行的范围定为 generation_requests,dummy 追加在尾部,回放后切掉;DeepInfer 用 input_metadata.num_reqs 把采样输入的行数改回真实值;SGLang 用 num_padding 让注意力后端与后续处理知道末段是假的;vLLM 由采样器直接读有效行。
差别会在「投机解码」时放大。TensorRT-LLM 的 pad_batch 文档里提到,dummy 的 input_ids 会被 DecoderInputPreparer 跳过,重叠调度器的 token 按 input_ids_cuda[num_tokens:...] 写入。也就是说,dummy 请求必须和真实请求一样有「本步要写的 token 位置」,否则图内的 token 列会被写成杂乱的值。DeepInfer 则给 dummy 一个固定的 last_next_n_token 填充值 nextn_pad_token_id,并让 mtp_info_write_slot 默认落在 padding 槽位 0,使写 KV 的 kernel 直接跳过它。
所以 dummy 的构造不只是形状问题,还包含「它在图内会触发哪些副作用」:写 KV、写 MTP 信息、更新 slot 映射、参与 MoE 路由。一个合格的 dummy 必须让这几件事都落在「安全且可丢弃」的位置上;否则图内看似正确地跑完了,图外却会出现 KV 污染或下一 step 的计数错位。
设计规则:从五个实现里抽出的五条约束
把源码里的选择抽象成约束,可以用它来检查任何一个新框架的图化实现:
- 桶必须是可执行的:桶形状要同时满足注意力切分、TP/CP 对齐、编译尺寸等约束;桶表在启动阶段就应验证,而不是运行时才发现无图可用。
- 取整必须是单调的:实际批越大,选中的桶不能越小;vLLM 用区间表、SGLang/DeepInfer 用二分、TRT-LLM 用集合,但都必须保证这一点,否则重放顺序会与调度顺序冲突。
- 假行必须可识别:图内执行的行数 B 与图外消费的行数 N 必须有明确的边界字段(
num_reqs、num_padding、尾部切回),且所有消费者读的是同一个字段。 - 假行不能有持久副作用:KV 写入、MTP 信息、slot 映射、MoE 路由计数都要对 dummy 行做隔离或跳过;否则下一次 step 的调度账本会漂移。
- 回退必须无状态残留:从图切回 eager 时,静态缓冲区里的旧值不能被当作本步输入;DeepInfer 的
prepare_input_tensors每次重放前重写元数据、SGLang 的fill_from每次重填缓冲,都是在保证这一点。
这五条约束里,前两条是「形状账」,后三条是「语义账」。多数图化 bug 不是出在形状账上——桶表算错很容易在启动时就暴露;语义账才是线上偶发错误的来源:一个 batch 里偶尔出现 dummy 行被采样、偶尔出现 dummy 写了 KV、偶尔在取消/抢占时图缓冲没被重填。看懂这五条,再去读任何一个框架的图运行器,都能快速定位它把哪条约束放在了哪一层。
配置旋钮背后的因果关系
把上面的机制反过来读,就能解释线上常见的几个配置项:
- vLLM 的
cudagraph_capture_sizes不要随意写小桶。桶表是按数组下标查的,任意正整数都可以;但 SGLang/DeepInfer 的二分要求桶有序,TRT-LLM 的集合天然无序。改 vLLM 桶表时,桶越密浪费越小,但compile_sizes若不在桶边界上会启动失败。 - SGLang 用
disable_cuda_graph_padding换取「不多算」。load_batch里的can_run_graph会拒绝无法精确命中的批,图只在 request-free 的空跑与精确形状时使用。适合批大小高度可预测的负载;负载抖动时大量批掉入 eager。 - TensorRT-LLM 的混合 prefill 要避免。
get_graph_key在上下文 chunk 长度不一致时返回None,整段前向放弃图化。分块 prefill 时若多个请求 chunk 长度不同,就失去了图化机会——这是调度侧可以主动优化的一点。 - llama.cpp 的图缓存有淘汰窗口。长时间负载形态稀疏(例如每天大量不同的
n_seqs)时,图缓存会被时间淘汰,反复捕获;固定服务形态下则很快稳定。 - DeepInfer 的主模型图与 DFlash 图共享私有池。
capture_dflash_model把主模型的graph_memory_pool与graph_stream传给草稿图运行器,日志会打印草稿图额外占用的显存。显存紧张时,这一份额外的图池在配置中值得核算。
小结
- CUDA Graph 的静态形状与解码批的动态形状之间,靠桶 + 垫 + 回退三件套架桥;五个框架的差异集中在这三件事的边界划分上。
- vLLM 用区间表把「token 数 → 图大小」做成 O(1) 查表,桶按 token 数划分,uniform 支路再整除换算请求数,图键随 uniform、LoRA 扩展,分派三级退让;SGLang 的桶是「请求格 × 格宽」,捕获前必须通过注意力 TP/CP 对齐过滤,注意力 DP 下用全局批选桶;TensorRT-LLM 在请求维补尾部 dummy,先跨 rank 对齐批大小与采样档,再由集合判定进图,短/长序列是它独有的第二桶维度,
_capture_allowed把「捕获」限制在启动窗口;DeepInfer 在 token 行维预捕获幂级数尾部 + 固定步长,dummy 是元数据列,靠num_reqs回到真实行数,ZOS 再把元数据准备搬到设备侧。 - llama.cpp 是唯一不显式垫的框架:桶隐含在图构建中,按首节点指针缓存,形状变化时用
cudaGraphExecUpdate就地改写,失效则重建,并依靠时间淘汰控制缓存规模。它的代价是新形状要付一次 warmup/更新,收益是不存在「取整浪费」。 - 「垫」从来不是一个独立的形状操作。它要求在图外一切消费有效行数的地方——采样、输出收集、KV 写入、后续 step 的调度——都能正确识别假行。谁能把假行信息放在最短路径上、最少的字段里,谁的实现就最不容易出错。
- 桶的密度是启动成本、显存占用与取整浪费的三方权衡;按默认桶表计算,vLLM 是 51 张图,DeepInfer 在普通解码下 35 张、开启投机解码后约 195 张,SGLang 更少但颗粒更粗。桶密不等于好,把密桶放在负载最常出现的区间才是有效策略。
- 图化不是越多越好:vLLM 的分段图、SGLang 的对齐约束、TRT-LLM 的混合 prefill 放弃、DeepInfer 的 prompt 批不进图,都是「图化边界由工作负载决定」的具体落点。
参考资料
- vLLM 源码:cudagraph_dispatcher.py
- vLLM 源码:config/compilation.py
- SGLang 源码:base_cuda_graph_runner.py
- SGLang 源码:decode_cuda_graph_runner.py
- SGLang 源码:cuda_graph_config.py
- TensorRT-LLM 源码:cuda_graph_runner.py
- llama.cpp 源码:ggml-cuda.cu
- llama.cpp 源码:ggml-cuda/common.cuh
- DeepInfer 源码:deepinfer/worker/cuda_graph_runner.py
- DeepInfer 源码:deepinfer/worker/model_runner.py
- DeepInfer 源码:deepinfer/environ.py
- PyTorch 官方 CUDA Graph 文档
- vLLM 官方文档:CUDA Graphs
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



