
注:建议在开始学习本专栏前先完成对 推理优化工程师快速入门指南 专栏中所有内容的学习。
从 eager graph 到分层 IR
当你在 PyTorch 中写下 model(x) 时,Dynamo------PyTorch 的编译框架------拦截到的并不是一套算子调用的流水账,而是一个可以程序化重组的计算图。理解这条从捕获到生成的路径,关键不在于记住每一步的 API 名称,而在于看清每一层 IR 究竟在优化什么------以及它为此舍弃了什么。
FX Graph:从执行流到数据流
Dynamo 捕获的产物是一个 FX Graph (torch.fx.Graph)。它把 eager 模式下 Python 解释器的调用栈,重写为一张有向无环图(DAG)。每个节点(Node)对应一个算子调用,args 和 kwargs 给出输入引用,users 追踪下游依赖。
python
import torch
class TinyModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(8, 8)
self.act = torch.nn.GELU()
def forward(self, x):
h = self.linear(x)
return self.act(h)
model = TinyModel()
fx_model = torch.compile(model, backend="eager") # 用 eager backend 观察
# 实际捕获发生在首次调用时
_ = fx_model(torch.randn(4, 8))
# 直接创建 FX Graph 以观察结构(dynamo 内部同样基于 fx.Tracer)
from torch.fx import symbolic_trace
symbolic_traced = symbolic_trace(model)
print(symbolic_traced.graph)
输出会显示:
graph():
%x : [num_users=1] = placeholder[target=x]
%linear_weight : [num_users=1] = get_attr[target=linear.weight]
%linear_bias : [num_users=1] = get_attr[target=linear.bias]
%linear : [num_users=1] = call_function[target=torch._C._nn.linear](args = (%x, %linear_weight, %linear_bias), kwargs = {})
%gelu : [num_users=1] = call_function[target=torch.nn.functional.gelu](args = (%linear,), kwargs = {})
return gelu
两条细节值得注意。第一,linear 节点的 target 不是 self.linear 这个 Module,而是 torch._C._nn.linear(ATen 层的函数)------DAG 中的节点已经显式展开了权重与偏置的读取路径。第二,Python 的控制流(if、for)在此阶段不会 被展开为图节点;Dynamo 通过为每个分支条件生成 guard 来记录控制流的走向,在运行时根据 guard 的命中情况选择对应的编译路径。FX Graph 的核心价值在于结构可遍历 :每个节点都是一个独立的 IR 单元,方便后续 pass 使用 graph.erase_node() 删除、graph.inserting_before() 插入。
ATen Decomposition:消除算子组合的歧义
FX Graph 里的节点粒度仍然太粗。torch._C._nn.linear 是一个融合算子,它在 GPU 上可能直接调 cuBLAS,也可以被拆成 addmm 的前半部分。ATen Decomposition 把算子拆解成更基础、更稳定的数学原语,让后端看到统一的算子集合。
python
from torch.fx.experimental.proxy_tensor import make_fx
from torch._decomp import get_decompositions
# 使用 ATen 分解表
aten_decomps = get_decompositions([
torch.ops.aten.linear,
torch.ops.aten.gelu,
torch.ops.aten.layernorm,
])
decomp_traced = make_fx(model, decomposition_table=aten_decomps)(torch.randn(4, 8))
print(decomp_traced.graph)
分解后,linear 会展开为 aten.t+aten.mm+aten.add(或 aten.addmm),gelu 展开为 aten.erf 组合。注意 :分解不是无条件进行的。上述分解表仅用于演示,让我们观察算子的拆解过程;实际 Inductor 的分解表在 torch._inductor.decomposition 中定义,并受配置控制。每个后端维护一张自己的 decomposition 表,决定哪些算子保持融合形态、哪些需要拆开------因为某些融合形态在下游可以映射到硬件原语(如 Tensor Core 的 mma 指令),拆开反而损失性能。
Fusion Group:决定算子的物理边界
分解之后,图变得更细碎,但可优化的空间也更大。Fusion Group 阶段的目的是把多个细粒度算子重新打包成若干个 cluster(融合组),每个 cluster 在未来会被编译为一个独立的 kernel。分组遵循两条约束:依赖关系 (group 内节点必须是连通的子图)和 代价模型(融合后的 kernel 预估执行时间必须小于分开执行的总时间)。
以 BERT 中的 LayerNorm + GELU + Add(残差连接)为例:
python
import torch
import torch.nn as nn
# 模拟一个典型融合候选
class Block(nn.Module):
def __init__(self, dim):
super().__init__()
self.norm = nn.LayerNorm(dim)
self.linear = nn.Linear(dim, dim * 4)
self.act = nn.GELU()
def forward(self, x, residual):
h = self.norm(x)
h = self.linear(h)
h = self.act(h)
return h + residual # 残差 Add 可以被融合进上一个 kernel 的 epilogue
compiled = torch.compile(Block(64), mode="reduce-overhead")
# 触发编译
_ = compiled(torch.randn(16, 64), torch.randn(16, 64))
Inductor 的 scheduler 会分析 linear → gelu → add 的依赖、别名和 backend 能力。点算子通常可形成一个 fusion group;矩阵乘是否与 epilogue 合并则取决于所选 GEMM backend、布局和版本,可能表现为模板 epilogue,也可能仍是外部 GEMM 加独立融合 kernel。融合的价值是减少启动和中间张量的全局写回,但实际驻留位置仍由 tile、寄存器压力与 shared-memory 预算决定。
Backend Lowering:IR 的最后一站
Fusion Group 之后的 IR 已经与具体硬件高度相关。Backend Lowering 是定义「IR 到代码」映射的边界。对 Inductor 而言,SchedulerNode 会被进一步降级为 Triton 的 tl.load/tl.store 操作;对 TensorRT 而言,则转为 ONNX 层或 TensorRT 的 ITensor 图。
这里有一个关键概念:lowering 不是翻译,而是第二次重构 。Triton 代码中的循环结构(tl.range)和向量化宽度(tl.constexpr)不是从 FX Graph 一一对应来的,而是 scheduler 根据硬件属性(SM 数量、向量宽度、shared memory 大小)重新规划的。下面的代码是 Inductor 生成 Triton kernel 的简化示意------注意它省略了 BLOCK_K 的定义及部分 stride 参数,实际代码由调度器根据目标 GPU 的架构参数自动生成:
python
# Inductor 生成的 Triton kernel(简化示意;BLOCK_K 与 stride 由调度器计算并填充)
@triton.jit
def fused_linear_gelu_kernel(
X, W, B, Y,
M, K,
stride_xm, stride_xk,
stride_wk, stride_wn,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr,
stride_ym, stride_yn,
BLOCK_K: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
offs_k = tl.arange(0, BLOCK_K)
# 加载 X 和 W 的分块
x_ptrs = X + offs_m[:, None] * stride_xm + offs_k[None, :] * stride_xk
w_ptrs = W + offs_k[:, None] * stride_wk + offs_n[None, :] * stride_wn
x = tl.load(x_ptrs)
w = tl.load(w_ptrs)
acc = tl.dot(x, w) # 映射到 Tensor Core
# epilogue:加 bias -> GELU -> 写回
bias = tl.load(B + offs_n)
acc = acc + bias[None, :]
acc = (acc * 0.5) * (1 + tl.math.erf(acc * 0.70710678))
tl.store(Y + offs_m[:, None] * stride_ym + offs_n[None, :] * stride_yn, acc)
Triton kernel 每行关键代码都对应一个调度决策:BLOCK_M/BLOCK_N 是 tile 尺寸,tl.dot 表达块矩阵乘,tl.math.erf 参与 GELU 近似。这些决策需要在 lowering 阶段结合目标 GPU 的 SM 数、寄存器文件容量与分配粒度、shared memory、warp 数和 Tensor Core 支持共同确定。 具体资源值从目标设备属性、编译产物和 occupancy 工具读取。
Runtime Dispatch:编译产物的执行契约
上述所有阶段完成了静态工作,但最终代码必须被嵌入到 eager 模式的动态执行中。Runtime Dispatch 负责三件事:输入形状匹配 (shape guard)、编译缓存查找 (cache lookup)、异步执行(CUDA graph 或直接 launch)。
python
from torch._inductor.cache import CacheKey, PyCodeCache
# 内部逻辑大致如下(语义示意,非真实源码)
def dispatch(compiled_graph, inputs):
# 1. 提取 shape/stride/dtype 签名
signature = CacheKey.from_tensors(inputs)
# 2. 在编译缓存中查找
if signature in compiled_graph.cache:
return compiled_graph.cache[signature](inputs)
# 3. 未命中 -> 重新编译 -> 更新缓存
new_code = compiled_graph.compile(signature)
compiled_graph.cache[signature] = new_code
return new_code(inputs)
CacheKey 的核心是 shape guard :每个动态维度(DYNAMIC 或 STATIC)在编译时被标记,只有 shape 的 guard 表达式成立时才复用缓存。例如 x.size(0) 被标记为 DYNAMIC 时,所有 batch size 都会命中间接指针(X.guard = lambda s: s[0] > 0),避免重复编译。
至此,一条完整的路径已经清晰:eager 调用 → Dynamo 捕获(FX Graph)→ ATen Decomposition(原语化)→ Fusion Group(kernel 边界)→ Backend Lowering(硬件代码)→ Runtime Dispatch(缓存与执行) 。这条流水线的每一层都在做同一件事:在保持计算结果等价的前提下,更换信息的表达方式。FX Graph 丢弃 Python 控制流信息(转而通过 guard 记录条件),Decomposition 丢弃算子融合形态,Fusion Group 重新定义算子边界,Lowering 丢弃张量抽象维度------而 Runtime Dispatch 则在每次调用时快速验证这些丢弃的信息是否仍然成立。
这个分层结构将在下一节继续展开:当被 guard 约束的 shape 假设遭到破坏(如动态 batch 越过已编译区域),系统可能回退或触发重编译------图断裂与重编译就此发生。
Shape Polymorphism 与 Guard
上一节我们追踪了一条静态形状的图从捕获到 runtime dispatch 的完整路径。但真实的生产环境远比这复杂:请求的 batch size 随流量波动,解码长度逐 token 增长,page table 随 KV cache 的分配而伸缩------当输入形状本身成为变量时,编译系统面临一个尖锐的问题:编译产物是绑定形状的,而服务负载不是。
Guard 的本质:形状是隐式约定
先看一个容易被忽略的事实:Dynamo 捕获图时,输入张量的形状被烘焙进了图结构 。一次 model(x) 调用如果 x.shape == (32, 512),那么生成的 FX Graph 中,所有依赖形状的分支(如 if x.shape[0] > 16)都已经坍缩为确定值。这意味着你得到的不是一个通用的"函数",而是一个形状特化的实例。
Guard 就是这套机制的防御层。Dynamo 在缓存编译产物时,同时记录一组断言 (guard):x.shape[0] == 32 and x.shape[1] == 512 and x.dtype == torch.float32 and x.device == 'cuda:0'。每次新输入到达,运行时先检查这些断言。全部通过,命中缓存;任一失败,触发 guard miss,抛弃旧图重新编译。
这套设计看似合理,但在动态形状场景下会引发两个连锁问题。
第一个问题是 guard 与 variant 增长。 当模型中有多个分支依赖形状时,Dynamo 需要记录路径成立的条件。以 page table 为例:batch 维、每序列页数以及 seq_len > 8 之类的控制流都会进入 guard 或触发不同图。可达路径组合会增加 variant 数,但增长速度取决于符号泛化、图断裂和缓存策略。工程上直接记录每个 guard failure 的表达式、新 variant 数与命中 trace,再决定是放宽符号范围、移除 Python 分支还是引入 bucket。
第二个问题更隐蔽:guard 检查本身有开销。每个调用需要验证张量形状、dtype、stride、设备及相关全局状态。绝对时间通常很小,但 decode 的单步 host 路径也很短,因此应在 CPU timeline 中单独标记 guard evaluation,并同时报告每次调用的 guard 数、总检查时间和 step wall time 占比。
Symbolic Shape:把形状变成一等公民
解决 guard explosion 的正道不是减少 guard,而是让图本身支持符号形状 。这正是 symbolic shape(符号形状)机制做的事。
Dynamo 的 torch.compile(dynamic=True) 启用的核心能力是:将形状中的维度标记为符号变量(如 s0, s1),而非具体整数。FX Graph 中的节点不再是 (32, 512) 的硬编码,而是表达式 (s0, s1)。代价是图优化阶段无法利用具体数值------例如,卷积的输出形状公式 ( W − K + 2 P ) / S + 1 (W - K + 2P)/S + 1 (W−K+2P)/S+1 无法化简为整数------但换来了巨大的复用空间。
Dim 是 Dynamo 内部表示动态维度的类,在 torch._dynamo.dim 中定义。符号形状下的编译产物签名(示意)大致如下:
python
# Dim 是 Dynamo 内部表示动态维度的类
# compiled_graph.signature 的结构为语义示意,实际 API 以 torch.compile 文档为准
compiled_graph.signature == {
'x': {'shape': (Dim('s0'), Dim('s1')), 'dtype': torch.float32},
'seq_len': {'shape': (), 'dtype': torch.int64},
}
# guard 从具体值变为符号约束:
# s0 > 0, s1 > 0, s0 % 8 == 0, s1 <= 4096
符号形状的 guard 不再是"形状必须等于某值",而是"形状必须满足某组约束 "。s0 % 8 == 0 这样的约束覆盖了所有 8 的倍数,而不是单个整数。这是一个从点匹配到区域匹配的跃迁------正是它,把编译缓存从"一形状一缓存"变成了"一族形状一缓存"。
符号形状的代价也不能忽视:动态形状图的优化质量通常低于静态形状图。算子融合时,编译器无法确认中间张量的大小是否满足融合条件(如某些融合要求通道数大于 4);向量化时,无法确定广播维度是否对齐。在实践中,动态形状的 kernel 性能往往比静态形状低 10-30%,这直接导向了下一个策略。
Padding 换复用:有限维度的暴力美学
如果动态形状的性能损失不可接受,有一种反向策略:把动态形状"伪装"成静态形状 ,代价是浪费计算。这就是 padding(填充) 的核心理念。
做法很直接:限制动态维度的取值范围,将其对齐到固定大小的桶 。例如,服务端 batch size 通常不会超过 64,那么把输入 padding 到 64------不足 64 的请求用 mask 标记无效位置。这样,图结构固定为 (64, 512),编译产物可以充分优化,运行时只需额外维护一个 mask 张量。
这是一个用空间换时间的工程权衡:
| 策略 | 计算浪费 | 编译优化 | 适用场景 |
|---|---|---|---|
| 纯动态形状 | 0% | 中(符号推理限制) | 形状分布稀疏、不可预测 |
| 静态 + padding | 5-40% | 高(完全特化) | 形状分布集中、有明确上限 |
| Bucketing(桶化) | 2-15% | 高(每桶特化) | 形状有自然聚类、允许近似 |
Bucketing 是 padding 的精细化:不把范围 [1, 64] 统一 padding 到 64,而是切成若干个区间------[1, 8], [9, 16], [17, 32], [33, 64]------每个区间对应一个编译实例。请求到达后,先归入所属桶,再 padding 到该桶的上界。假设 batch size 的分布集中在 8 和 16 附近,bucketing 可以把平均计算浪费从平均 padding 的 ~50% 压低到 ~12%,同时保留每个桶内静态形状的优化红利。
注意 bucketing 有一个不可忽视的副作用:桶的数量等于编译缓存实例的数量。每个桶都需要独立的编译、独立的显存占用。设计桶时需要警惕"桶过多导致编译开销反超计算节省"的窘境------经验法则是:桶的数量控制在 4-8 个以内,且相邻桶的边界尽量对齐模型敏感维度(如注意力头数的整倍数)。
编译缓存 Key:从形状到语义
无论走哪条路,最终都需要一个缓存系统来决定"这次请求该用哪个编译产物"。缓存 key 的设计直接决定命中率。
朴素方案:key = 形状元组。(batch=32, seq=512, page_table_len=16)。实现简单但太脆弱------任何细微变化都 miss。
现代方案(PyTorch 2.x 的 torch.compile 实际采用)是多层 key:
- 结构层:图的字节码哈希(捕获的 FX Graph 结构签名),决定"这个模型长什么样"
- 符号约束层 :符号变量的约束集(
s0 > 0, s0 % 8 == 0),决定"哪一族形状可以用这张图" - 特化层 :具体静态形状(仅在
dynamic=False时启用)
前两层命中即可复用;第三层是动态形状触发的兜底。这套方案的巧妙之处在于:它把"形状"从 key 的核心位置降级为约束的实例,从而实现了"一图对应一族形状"的复用。
线上抖动:谁在为编译买单
最后必须直面一个残酷现实:编译发生在服务的关键路径上。
一次 guard miss 触发重编译,在 GPU 机器上可能需要 200 毫秒到数秒(取决于模型复杂度)。对离线批处理这不是问题,但对在线服务,这意味着一批请求的延迟将从 10 毫秒级跳到秒级------这就是线上抖动(jitter)。
抖动的来源除了冷启动,还有形状分布的漂移 。线上流量并不总是符合预设的桶划分------某个时段 batch size 集中在 17-20 之间,而你预设的桶是 [17, 32],看起来没问题------但如果模型对 17 与 32 的 padding 效率差异巨大(如 attention 矩阵是平方增长),实际吞吐可能下降 30% 而不自知。
缓解抖动的手段,按优先级排序:
- Warm-up:服务启动时,预编译所有桶的图,避免线上首次请求触发编译
- 预热请求:用代表性的形状(各桶上界)发一批空请求,强制走完整编译路径
- 本地上限 :监控 guard miss 率,当 miss 率超过阈值(如 5%),强制切换到
dynamic=True模式 - 成本归属:将重编译的时间计入请求延迟,而非隐藏起来------让团队对形状分化的代价有感知
从 Guard 的朴素匹配到 Symbolic Shape 的区域匹配再到 Bucketing 的维度收紧,我们看到的是同一枚硬币的两面:编译优化的深度与形状泛化的广度天然互斥 。而编译缓存 key 的设计,本质上就是在为这个矛盾寻找最优折衷点。理解了这层博弈,下一节我们将进入一个更底层的执行模型------当算子被逐个调度(而非整图运行)时,CUDA Graph 的 capture/replay 机制如何把 GPU 上的 kernel 启动开销压缩到接近零,以及这又将如何反过来约束形状的设计空间。
CUDA Graph 的地址稳定与内存池
形状特化解决了重编译的粒度问题,但把图真正跑起来还有一道更隐蔽的门槛:CUDA Graph 要求所有内核的输入输出地址在 capture 和 replay 之间保持完全一致。这个约束在 eager 模式下根本不存在------PyTorch 的缓存分配器每次调用都可能返回不同指针。当 Dynamo 把一组算子固化成一张图时,地址的漂移会让 replay 指向错误的内存,轻则数据错乱,重则 CUDA illegal memory access。
graph-safe allocator:让分配器"冻结"
问题根源在于 PyTorch 默认的缓存分配器(caching_allocator)是惰性且随机的 :它按需从 GPU 显存池中切块,同一个 torch.empty(1024) 两次调用返回的地址大概率不同。CUDA Graph capture 期间,每个内核的启动会被记录为"用这个指针、这个 stream、这个参数",如果后续 replay 时指针变了,图就失效了。
解决方案是引入 graph-safe allocator 。它在 capture 开始前预先创建一个独立的、专用的内存池,在这个池内做私有分配------所有图的中间张量都从固定池中取,且地址在首次分配后即被"钉死":
python
import torch
from torch.cuda import graph as cuda_graph
# 预热:确定图的峰值内存需求
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
# 图池:整个 capture 期间所有中间张量都从这里分配
graph_pool = torch.cuda.graph_pool_handle()
# 手动捕获
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g, pool=graph_pool):
# 这段代码的每次调用都必须使用相同的输入/输出张量
static_input = torch.randn(1024, device="cuda")
static_output = torch.empty(1024, device="cuda")
for _ in range(10):
static_output = torch.relu(static_input + static_output)
# replay:输入通过拷贝注入,输出从固定地址读出
static_input.copy_(real_input) # 拷贝进静态地址
g.replay() # 所有内核地址不变
result = static_output.clone() # 拷贝出自定义池
这里的 graph_pool 是关键:它告诉分配器,这个池内的所有 empty/randn 调用都返回固定的偏移地址。分配器在 capture 期间只做一次实际切块,之后所有 replay 都复用同一块显存。代价是:池内的内存无法被其他非图操作复用,这解释了为什么 CUDA Graph 通常只用于性能热点路径(如 decode 阶段),而非整个模型的 eager 执行。
NCCL capture:通信也是图的一部分
单卡图很简单,多卡就复杂了。all_reduce 这类 NCCL 集体通信操作天然是异步的------它把请求提交到 NCCL 的专用 stream,然后立即返回。如果只是把 torch.distributed.all_reduce 包进 capture,通信内核不会被记录进图,replay 时通信与计算彻底脱节。
正确的做法是让 NCCL 参与 capture ,这要求通信算子本身支持图模式。PyTorch 通过 torch.cuda.graphs 配合 NCCL 的 ncclCommCapture 接口实现这一点:
python
# 多卡训练中,每个 rank 独立 capture,但共享同一套张量集合
torch.cuda.graphs.make_graphed_callables(
model, # 包含 matmul + all_reduce + layernorm
sample_args,
num_warmup_iters=5, # 预热:让 NCCL 完成连接建立
num_warmup_iters_for_nccl=3 # NCCL 内部的准备步骤也要预热
)
# 在 capture 块内部:
# - matmul、layernorm 走私有池分配
# - NCCL collective 在受支持版本中通过 CUDA stream capture 被记录进图;
# 不存在需要用户调用的通用 ncclCommGraphCapture 公共 API
# - replay 时,NCCL 内核在图中被同步执行
关键点是预热。NCCL 首次调用会触发连接握手、buffer 注册等准备工作,这些不能在 capture 期间发生。预热完成后,NCCL 通信的 launch 参数(communicator 指针、buffer 地址、count)被固化,后续 replay 只是重放这些已经准备好的内核启动。
这里有一个值得注意的权衡:通信捕获解决了多卡同步,但牺牲了拓扑感知 。NCCL 在 eager 模式下可以按当前网络拓扑选择最优算法(ring/ tree/ NVLink),而捕获后算法被固定。因此,图模式通常只优化计算密集的 decode 阶段,而非通信频繁的 prefill 阶段。
batch bucket:把无限形状映射到有限图
CUDA Graph 的地址是静态的,这直接与动态 batch size 冲突。你不能为每一种 batch 大小都单独 capture 一张图------那是 guard explosion 的图版本。合理的做法是 batch bucket:把连续的形状区间映射到同一张图。
bucket = {min_bs: 1, max_bs: 4, 图A}
bucket = {min_bs: 5, max_bs: 8, 图B}
bucket = {min_bs: 9, max_bs: 16, 图C}
每个 bucket 内部,图的输入张量按 max_bs 分配,实际请求的 batch 小于 max_bs 时,通过尾部填充(padding)让形状对齐:
python
class DecodeGraphPool:
def __init__(self, bucket_edges: list[int]):
self.buckets = {}
for i in range(len(bucket_edges) - 1):
max_bs = bucket_edges[i+1]
# 每个 bucket 一张静态形状的图
self.buckets[i] = self._capture_decode_graph(max_bs)
def get_graph(self, batch_size: int):
# O(log n) 找到对应 bucket
for idx, edge in enumerate(self.bucket_edges):
if batch_size <= edge:
return self.buckets[idx]
raise ValueError(f"batch_size {batch_size} exceeds max bucket")
def _capture_decode_graph(self, max_bs: int):
# 关键:中间张量按 max_bs 分配,实际数据只占前 batch_size 行
# 只预分配真正需要静态地址的 workspace;Flash/Paged Attention
# 通常不会物化 [B,H,S,D] scores。workspace_bytes 来自 bucket 配置。
workspace = torch.empty(workspace_bytes, dtype=torch.uint8, device="cuda")
# capture 期间所有算子使用此静态张量
# replay 时,只需从 external 拷贝真实 token 的前 batch_size 行
return cuda_graph.CUDAGraph()
bucket 的边界选择是经验问题:太密 (每个 batch 一个 bucket)浪费显存和捕获时间;太疏 (如 1-64 一个 bucket)导致填充率高,计算浪费可达 30% 以上。实践中,服务端推理常用 [1, 4, 8, 16, 32, 64] 这类 2 的幂序列,兼顾缓存命中与计算效率。
KV 地址间接层:动态 length 的逃生舱
batch 有了 bucket,length 怎么办?KV cache 的大小随序列长度动态伸缩,如果 Graph 内直接引用 KV 指针,一旦重新分配 KV block,所有图立刻失效。这就是为什么需要引入 KV 地址间接层 :图内部不直接引用物理 KV 地址,而是引用一个指针表(pointer table)。
python
# KV cache 的物理存储是动态的,每个 block 地址可变
kv_blocks = torch.empty(num_blocks, block_size, head_dim, device="cuda")
# 图内使用的是一张"地址表":(batch, block_idx) -> block 索引
# 存储的是 block id 或偏移,而非物理地址
page_table = torch.zeros(max_bs, max_blocks_per_seq, dtype=torch.int64)
# 每次 decode 前更新 page_table:
# page_table[b, j] = kv_blocks[b, j] 的 block 索引
# attention 算子通过 page_table 间接索引 KV:先查表获得 block id,
# 再据 id 计算物理偏移并访问
# 这保证了图结构不变,改变的只是 table 的内容
这个设计借鉴了 PagedAttention 的思路:把 KV cache 的管理从"连续内存"重构成"分页内存"。图捕获的是 page_table 这个整数张量的地址 ,而它的内容可以在每次 replay 前自由更新。于是动态 length 变成了"更新 table,而不是重新捕获图"。
这种间接层的代价是额外的访存:attention 每次查询都要先读 page_table 再跳转到真实 KV 地址,多了一次访存延迟。但对比重编译(毫秒级)和显存碎片(不可控),这几十纳秒的代价几乎可忽略。
更新参数节点:图内的"活"权重
最后一个关键设计是参数更新 。decode 阶段是自回归的------每步输出 token 会作为下一步输入。如果图的输入是一个静态占位张量,那每步都要 input.copy_(new_token),这没问题。但权重参数呢?如果模型用了 LoRA 或 prefix tuning,权重可能随请求变化。
CUDA Graph replay 要求捕获操作使用的地址和拓扑保持兼容,地址内容可以更新。但多租户 LoRA 不能简单地每请求把完整 adapter 拷进同一静态地址:这会产生复制成本、并发覆盖和 batch 内多 adapter 冲突。常见设计是预驻留 adapter buffer、通过静态索引选择,或为 adapter/shape 建立 graph bucket;是否需要 recapture 取决于 kernel 参数与指针集合。
python
def replay_with_lora(graph, lora_weights, static_input, real_input, static_output):
# 1. 拷贝输入
static_input.copy_(real_input)
# 2. 更新 LoRA 权重到静态地址(图内部引用这些地址)
for name, param in lora_weights.items():
static_param = static_params[name] # capture 时分配的静态张量
static_param.copy_(param) # 覆盖内容,地址不变
# 3. 重放
graph.replay()
return static_output.clone()
参数初始分配确实要规划,但"一次 copy_"的大小可能是几十到数百 MB,且复制与 replay 的 stream 同步必须正确。该代码是语义伪代码;工程上应比较驻留索引、批内 segmented LoRA、按 adapter capture 和权重复制四种策略,而不是预设复制总是最优。
Decode graph 池的完整装配
综合以上四个组件的设计,一个典型的 Decode 阶段图池结构如下:
#mermaid-svg-r57wPfiMtMg5DCM1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r57wPfiMtMg5DCM1 .error-icon{fill:#552222;}#mermaid-svg-r57wPfiMtMg5DCM1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r57wPfiMtMg5DCM1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r57wPfiMtMg5DCM1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r57wPfiMtMg5DCM1 .marker.cross{stroke:#333333;}#mermaid-svg-r57wPfiMtMg5DCM1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r57wPfiMtMg5DCM1 p{margin:0;}#mermaid-svg-r57wPfiMtMg5DCM1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster-label text{fill:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster-label span{color:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster-label span p{background-color:transparent;}#mermaid-svg-r57wPfiMtMg5DCM1 .label text,#mermaid-svg-r57wPfiMtMg5DCM1 span{fill:#333;color:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 .node rect,#mermaid-svg-r57wPfiMtMg5DCM1 .node circle,#mermaid-svg-r57wPfiMtMg5DCM1 .node ellipse,#mermaid-svg-r57wPfiMtMg5DCM1 .node polygon,#mermaid-svg-r57wPfiMtMg5DCM1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r57wPfiMtMg5DCM1 .rough-node .label text,#mermaid-svg-r57wPfiMtMg5DCM1 .node .label text,#mermaid-svg-r57wPfiMtMg5DCM1 .image-shape .label,#mermaid-svg-r57wPfiMtMg5DCM1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-r57wPfiMtMg5DCM1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r57wPfiMtMg5DCM1 .rough-node .label,#mermaid-svg-r57wPfiMtMg5DCM1 .node .label,#mermaid-svg-r57wPfiMtMg5DCM1 .image-shape .label,#mermaid-svg-r57wPfiMtMg5DCM1 .icon-shape .label{text-align:center;}#mermaid-svg-r57wPfiMtMg5DCM1 .node.clickable{cursor:pointer;}#mermaid-svg-r57wPfiMtMg5DCM1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r57wPfiMtMg5DCM1 .arrowheadPath{fill:#333333;}#mermaid-svg-r57wPfiMtMg5DCM1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r57wPfiMtMg5DCM1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r57wPfiMtMg5DCM1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r57wPfiMtMg5DCM1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r57wPfiMtMg5DCM1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r57wPfiMtMg5DCM1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster text{fill:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 .cluster span{color:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r57wPfiMtMg5DCM1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r57wPfiMtMg5DCM1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-r57wPfiMtMg5DCM1 .icon-shape,#mermaid-svg-r57wPfiMtMg5DCM1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r57wPfiMtMg5DCM1 .icon-shape p,#mermaid-svg-r57wPfiMtMg5DCM1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r57wPfiMtMg5DCM1 .icon-shape .label rect,#mermaid-svg-r57wPfiMtMg5DCM1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r57wPfiMtMg5DCM1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r57wPfiMtMg5DCM1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r57wPfiMtMg5DCM1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} bucket 1-4
bucket 5-8
请求进入
确定 batch_size
匹配 bucket
捕获于 max_bs=4 的静态图
捕获于 max_bs=8 的静态图
更新 page_table 索引 KV
static_input.copy_ 注入真实 token
graph.replay 执行 10 层 decode
静态输出拷贝到动态 buffer
返回新 token 与 KV 状态
整体逻辑是:形状维度通过 bucket 离散化 ,长度维度通过间接层绕过 ,内容维度通过 copy 注入------三者叠加,把无限动态的服务形状映射到一组有限的静态图上。
核心收获:CUDA Graph 的本质是"用地址换取确定性"。它把内存分配从运行时行为变成编译期行为,适合固定形状的 decode 循环;而动态性则通过 bucket、间接层和输入拷贝三种手段分流化解。理解了这套地址稳定策略,你就掌握了服务端 LLM 推理引擎(如 TensorRT-LLM、vLLM)中 decode 热点优化的底层逻辑。下一节我们将把这些技术组合起来,看一个完整的 Graph Dispatch 系统如何决策"什么时候用图,什么时候回退 eager"。
生命周期感知的显存规划
CUDA Graph 的地址稳定只是静态化的第一步。当一张包含数十个算子的图被固化为可重放的对象后,显存规划问题的性质发生了变化:我们不再是"每次调用时分配一块临时 buffer",而是"为整张图的执行预先划分一块固定大小的显存区域"。这就像从"每次出行租一辆车"变成了"为一条固定线路包一辆长期巴士"------车况、路线、时间表都固定了,但你要在出发前决定这辆巴士上每个座位分别给谁用。
interference graph:把"谁和谁不能共用"画出来
显存规划的第一步,是为图中的每个中间张量(activation)计算它的生命周期(liveness interval)。一个张量的生命周期从它的生产者(某个算子的输出)开始,到它的最后一个消费者(某个算子的输入)结束。在这段区间内,这个张量必须占据一块独立的显存。
接下来引入干扰图(interference graph) :图中的每个节点是一个张量,如果两个张量的生命周期有重叠------即它们在执行的某个时刻同时存活------则在它们之间连一条边。这个图直接刻画了"哪些张量绝不能共用同一块显存",而没有边相连的张量则可以安全地复用同一块空间。
举个例子,一个典型的三阶段融合算子链:
conv(x) → relu(y) → pool(z)
假设 x 的生命周期为 [1, 1](只在 conv 执行时存活),y 为 [2, 2],z 为 [3, 3]。那么 x 与 y、y 与 z 的生命周期有重叠,干扰图中存在边;而 x 与 z 无重叠,可复用同一块内存。有干扰边的张量绝不能在显存中复用同一块区域;无干扰边的张量则可以安全地共享空间。
实际优化器的工作流程如下:先对图中的每个张量做 liveness analysis,构建干扰图,然后运行一个图着色算法------用尽可能少的颜色(即显存块)给所有节点着色,保证相邻节点颜色不同。着色完成后,每种颜色对应一块显存,所有同色张量在不同时间复用这块空间。
python
# 以伪代码展示核心逻辑
def assign_memory(graph):
# 1. 计算每个中间张量的 lifetime
# node.order 是节点在拓扑排序中的编号
lifetimes = {}
for node in graph.nodes:
def_time = node.order
last_use = max(user.order for user in node.users)
lifetimes[node] = (def_time, last_use)
# 2. 构建 interference graph
interference = {}
for a in graph.nodes:
for b in graph.nodes:
if a is b: continue
# 生命周期重叠 → 添加干扰边
if not (lifetimes[a][1] < lifetimes[b][0]
or lifetimes[b][1] < lifetimes[a][0]):
interference.setdefault(a, set()).add(b)
# 3. 贪心着色:每个颜色 = 一块显存区域
color_map = greedy_color(interference)
# 4. 每块显存区域的大小 = 该颜色下最大张量的大小
memory_blocks = {}
for node, color in color_map.items():
size = node.shape.numel() * node.dtype.itemsize
memory_blocks[color] = max(memory_blocks.get(color, 0), size)
return color_map, memory_blocks
这个方法的核心思想在于:它把显存规划从"分配-释放"的动态问题变成了"着色-复用"的静态问题。动态分配器的首次分配开销、碎片整理开销、缓存未命中开销全部消失------因为一切在 capture 阶段就已经确定。
workspace 共享:融合算子吃的是同一锅饭
上述 liveness analysis 处理的是中间张量 ------即图中显式的数据流。但实际编译器中还有一类容易被忽略的显存消耗:算子内部的工作空间(workspace)。
以 cuBLAS 的 GEMM 为例,某些算法需要额外的 scratch space 来存放临时数据;Flash Attention 的某些实现需要 workspace 来存放中间统计量;融合后的 Conv-BN 算子可能需要辅助 buffer 来做通道重排。这些 workspace 的特点在于:它们只在一个算子执行的极短时间内被使用,生命周期远短于任何中间张量。
因此,所有算子的 workspace 可以共享同一块显存区域------只要它们不在同一时刻执行。这在实践中几乎总是成立的,因为同一时刻只有一个 kernel 在运行(除非使用多 stream 并发,这是后话)。于是编译器的策略很简单:取所有算子 workspace 需求的最大值,分配一块统一的工作区。
这块共享 workspace 的地址在 capture 时被固定下来,并被传给所有需要它的算子。代价是每个算子可用的 workspace 大小被限制为最大值------如果某个算子需要超过这个上限的 workspace,编译器要么拆分该算子,要么扩大共享区。这就像办公室里的公共会议室:所有部门都能预约使用,但面积只有一个规格,超出规格的需求必须被拆分或改造。
stream fence:当异步变成同步再变回异步
干扰图着色的正确性建立在全序执行 的假设上:张量 A 的最后一个消费者完成后,A 的空间才被释放------这个"完成"是严格按程序顺序理解的。但 CUDA 的执行模型是异步的:cudaStreamSynchronize 之前,kernel 的实际执行进度是不确定的。
这带来一个尖锐的问题:如果张量 A 的生命周期在 stream 1 上结束(最后一个消费者是 stream 1 上的某个 kernel),而张量 B 在 stream 2 上被分配了与 A 相同的地址,可能会出现竞争条件------stream 2 的 kernel 开始写入 B 的地址时,stream 1 上消费 A 的 kernel 可能还没执行完毕。
解决这个问题需要在生命周期边界上插入 stream fence ------一个轻量级的同步原语,强制 stream 1 上与 A 相关的 kernel 在 stream 2 的写入开始前完成。在实践中,这是通过 cudaEventRecord 和 cudaStreamWaitEvent 实现的:
cpp
// stream 1: 消费 A 的最后一个 kernel
cudaEventRecord(a_done, stream1);
// stream 2: 写入与 A 复用的 B 地址
cudaStreamWaitEvent(stream2, a_done, 0);
kernel_writes_B<<<grid, block, 0, stream2>>>();
这实际上是在地址复用的边界上建立一个临时同步点------用一次同步换取了整块显存的复用效率。在异步执行的背景下,"谁在前谁在后"的全局顺序被替换为"谁先完成谁先释放"的偏序关系,而 fence 就是实现这个偏序关系的桥梁。
一个有意思的设计权衡:fence 的粒度越细(即每个生命周期边界都严格同步),同步开销越大;粒度越粗(把多个生命周期放在一个 fence 之后),则显存复用率越低。优秀的编译器(如 TensorRT)会在两者之间做贪心权衡------只有当复用收益超过同步成本时才插入 fence。
峰值与碎片:静态规划的最后一道防线
即使有了干扰图着色和 workspace 共享,显存规划仍需处理两个遗留问题:峰值控制 与碎片抑制。
峰值问题很直接:干扰图着色保证的是"任何时刻存活张量的空间总和不超过总分配量",但着色算法并不保证总分配量接近理论最小值。贪心策略可能产生一个次优的结果------比如两块各 8KB 的显存块本来可以合并,但着色给它们分了两块。解决方法是事后做一次块合并(block coalescing):检查相邻的显存块,如果它们的存活区间互不重叠,就合并为一块更大的区域。
碎片问题的来源则更隐蔽。CUDA Graph 的显存池是预先分配的连续区域,但不同张量的大小差异悬殊------一个 4D 激活可能是 2GB,一个小 workspace 可能只有 4KB。如果不加控制,最终池内会布满无法利用的"空隙"------明明总量够用,但单独一块连续空间装不下一个大的中间张量。
现代编译器普遍采用两种策略抑制碎片:
- 大小分桶:将张量按大小归入若干桶(比如 1KB-64KB、64KB-4MB、4MB 以上),不同桶使用不同的缓存池或分配区域。大池子内部不混入小张量,避免碎片产生。
- 顺序拉直(linearization) :将整张图的显存布局整理为一个线性的 offset 序列------每个张量在池中占据一个固定 offset,按生命周期顺序排列。这样任何时刻的活跃张量必定集中在一个连续区域内,碎片从结构上被消除。
TensorRT-LLM 的 KV cache 池、FasterTransformer 的 activation buffer 池,本质上都是这些策略的工程实例。
调试越界:当"不可能"还是发生了
静态规划的推理过程看起来无懈可击------生命周期分析正确、着色正确、fence 正确------但实践中错误仍会发生。且静态规划的错误是最难调试的一类:不存在"分配失败"的报错,只会出现数据静默损坏、CUDA illegal memory access,甚至程序在完全无关的位置崩溃。
最常见的越界场景是 liveness 分析不准确 ------比如一个算子通过算子库内部持有了张量的引用(a.k.a. 内部别名),分析器没有检测到。此时张量 A 的空间被提前释放并分配给 B,但 A 的第二次使用(在算子库内部)会覆盖 B 的数据。这种 bug 的隐蔽性在于:它不是每次都触发------只有当 B 恰好被分配到 A 的原地址时才会爆发。
因此,调试静态显存规划问题需要一个专门设计的工具链。实践中三种手段最有效:
- canary 填充 :在每块显存区域的边界填充一个固定模式(如
0xABCDEF01),在每次 kernel 执行后检查这些模式是否被破坏。被破坏即说明有越界写入,且可以通过检查哪个位置被改来定位越界的张量。 - 地址染色:为每个张量分配一个独特的字节模式,在每次图重放后 dump 整个显存池,搜索哪些模式的字节被其他模式的写入破坏了。这能把"某种数据损坏"缩小到"特定的张量对之间发生冲突"。
- 顺序偏移日志:为每个 kernel 记录它访问的显存地址范围,与规划的布局对比,检测是否存在"规划外的访问"。
这三种手段本质上是给静态规划加上了动态验证------因为静态分析的盲区只能通过运行时观测来弥补。在工业级部署中,这些调试开关通常在开发阶段开启,在生产环境关闭(因为它们对性能有显著影响)。
至此,我们完成了从捕获、特化、静态化到显存规划的全链路追踪:Dynamo 捕获 FX Graph,Inductor 做分解和融合,CUDA Graph 固化地址,显存规划通过生命周期分析和着色实现空间复用。这条路径的每一步都在优化性能和确定性之间做取舍,而最终产物是一个完全绑定的、可预计算性能的、可无限次回放的执行对象------这正是编译后端的目标形态。
实验:诊断一次重编译风暴
前三节分别拆解了形状特化、CUDA Graph 地址稳定与内存规划,但每一个机制在真实服务中如何相互作用、又在何种条件下暴露出瓶颈,还需要一次可重复的实验来回答。这一节构造一个典型的推理服务负载------混合长度流量------用四种执行模式跑同一组请求,直接测量重编译的发生频率、延迟分布与显存水位。这不是为了证明某个框架更优,而是为了看清「编译器的开销」在服务路径上到底以什么形式出现。
实验设计:四种执行模式
实验环境为单张 A100-80G,模型选用 7B 参数的 Decoder-only 架构,batch size 上限为 64。输入序列长度从 32 到 512 token 均匀采样,输出长度固定为 128 token------这是一个典型的混合长度流量:短请求与长请求交错到达,几乎每个 batch 的形状都不同。
四种执行模式如下:
| 模式 | 捕获方式 | 形状处理 | 代表实现 |
|---|---|---|---|
| Eager | 无捕获 | 每次调用按实际形状执行 | PyTorch eager |
| Compile | Dynamo 捕获 | 符号形状,动态维不做特化 | torch.compile(dynamic=True) |
| Graph Bucket | 按 bucket 捕获多张图 | 形状归入离散区间 | 自定义 bucket 池 + CUDA Graph |
| Static Engine | 单次捕获 | 形状固定为最大尺寸 | TensorRT-LLM 静态 engine |
负载生成器以 Poisson 过程产生请求,平均到达率设为 20 req/s,持续运行 5 分钟。每个请求的输入长度独立采样,这意味着相邻两个 batch 的形状几乎必然不同------这正是触发 guard miss 和重编译的理想条件。
首次编译:把冷启动拆成可观测阶段
从进程启动到第一请求完成的 wall time 不能只记一个总数。四种模式的启动路径不同,统一拆成以下阶段:
| 阶段 | Eager | Compile | Graph Bucket | Static Engine |
|---|---|---|---|---|
| 权重加载与设备初始化 | 有 | 有 | 有 | 有 |
| 图捕获/IR 构建 | 无 | Dynamo/Inductor | 每个 bucket 各自捕获 | builder 构建执行计划 |
| 代码生成与编译 | 无 | 按 graph variant | 按 bucket variant | 按 engine profile |
| 内存池与地址绑定 | 缓存分配器按需建立 | 编译运行时管理 | graph-private pool | 静态 workspace/KV 规划 |
| 首次 kernel/autotune | 算子库可能触发 | 编译 kernel 可能触发 | 每张图分别预热 | builder 或首次执行触发 |
计时点分别放在 process start、weights ready、capture start/end、compile end、warmup end 和 first response。这样才能区分冷启动是被权重 I/O、符号形状推理、代码编译、autotune 还是显存池初始化主导。对于弹性伸缩,真正进入容量模型的是"实例从创建到通过健康检查"的时间;生产系统可用预构建 engine、磁盘编译缓存、warm pool 与分阶段健康检查降低暴露在请求路径上的部分。
P99 延迟:用事件关联识别重编译尖峰
延迟直方图本身无法说明长尾来自重编译。实验同时打开 TORCH_LOGS=guards,dynamic,recompiles,为每个请求记录 guard_check、guard_failure、compile_start/end、graph_replay、allocator stall 与 GPU execution 区间,并用 request id 对齐。随后把请求分成 cache hit、guard miss 后命中已有 variant、同步编译、bucket fallback 和 static replay 五个 cohort,分别计算 P50/P99。
如果高延迟样本与 compile_start/end 区间重合,且移除编译时间后 cohort 回到稳态分布,才可归因于重编译;如果没有重合,则继续检查 allocator、CPU 调度和队列拥塞。Graph Bucket 的潜在优势来自稳定地址与 replay 降低 host launch,以及将编译移出大部分稳态请求路径;它的代价是 padding、更多图实例和各 bucket 的内存池。两端都必须通过同一 timeline 验证。
缓存命中:同时度量覆盖率、条目数与未命中代价
编译缓存由"键空间---命中路径---代价"三部分组成:
| 模式 | 缓存对象 | 命中事件 | 未命中路径 |
|---|---|---|---|
| Eager | 算子库与 allocator 内部缓存 | 不存在整图命中 | 按实际形状执行 |
| Compile | guard 集合与 graph variant | 某个 variant 的 guard 成立 | 泛化、编译新 variant 或回退 |
| Graph Bucket | bucket_id → captured graph |
形状落入已预热 bucket | 捕获新 bucket 或 eager fallback |
| Static Engine | optimization profile/固定 engine | 输入位于 profile 范围 | 拒绝、选择其他 engine 或重建 |
报告中同时给出请求命中率、按 token 加权命中率、缓存条目数、条目显存、guard 检查 CPU 时间和未命中暴露延迟。仅有高命中率并不足以说明策略有效:过宽 bucket 会提高命中却增加 padding,过窄 bucket 会增加图数量与显存。下面的函数计算其中一个关键代价------尾部填充:
python
def compute_padding_overhead(lengths, bucket_edges):
"""计算 bucketing 引入的算力浪费。
假设每个长度 L 落入满足 bucket_edges[i] <= L <= bucket_edges[i+1] 的桶,
则实际计算按桶上界 bucket_edges[i+1] 进行,浪费比例 = (bucket_max / L) ** 2
(假设计算量与序列长度平方成正比)。
"""
total_compute = 0.0
for L in lengths:
# 找到 L 所属的桶上界
bucket_max = next(edge for edge in bucket_edges if L <= edge)
total_compute += (bucket_max**2) / (L**2)
# 若 L=48 归入 64 的 bucket,则浪费 (64/48)^2 = 1.78x 计算
return total_compute / len(lengths)
对整条 trace 计算该比值的均值、P95 和按 token 加权均值,并分别拆开 prefill 的近似二次 attention 项与 decode 的近似线性项。bucket 边界优化由命中收益、padding FLOPs、graph 内存和 fallback 代价共同决定;因此边界不是简单的等宽区间,而可以由长度直方图和代价函数动态规划得到。
内存水位:把静态化成本还原为五本账
显存峰值拆成 W + K V + A + G + F W+KV+A+G+F W+KV+A+G+F:模型权重 W W W、KV Cache、活跃临时张量 A A A、图私有内存池 G G G 和 allocator 碎片/保留量 F F F。四种模式在相同请求 trace 下定期采样 allocated、reserved、graph-private pool 与 KV blocks,并在捕获新 variant 前后做差:
| 模式 | 主要弹性 | 主要静态成本 | 容量风险 |
|---|---|---|---|
| Eager | 临时张量按实际形状申请 | allocator 缓存与碎片 | 瞬时峰值和地址不确定性 |
| Compile | variant 可复用,但 workspace 依实现变化 | 多 variant 代码与 buffer | guard 扩张带来的隐性常驻量 |
| Graph Bucket | bucket 内 replay 稳定 | 多张图的私有池与最大形状 buffer | 图数量挤压 KV 容量 |
| Static Engine | profile 内执行确定 | optimization profile、workspace 与上界预留 | 最大形状规划过于保守 |
将显存压力转化为服务成本时,用 K V a v a i l a b l e = H B M − W − A − G − F − s a f e t y KV_{available}=HBM-W-A-G-F-safety KVavailable=HBM−W−A−G−F−safety 重新计算可容纳 token 数,再通过真实长度分布推导并发,而不是拿总显存直接相除。若多个 bucket 的生命周期不会并发,还可研究共享 pool 或串行 capture;若地址稳定要求阻止共享,则这部分容量就是降低重编译尾延迟所支付的显式成本。
决策表:没有免费的灵活性
最终用约束而非固定数字选择执行模式:
| 维度 | Eager | Compile(动态) | Graph Bucket | Static Engine |
|---|---|---|---|---|
| 冷启动路径 | 最短,仍含权重与库初始化 | 增加捕获/编译 | 增加多 bucket 预热 | 增加 engine 构建或加载 |
| 稳态长尾来源 | launch、allocator、队列 | guard miss、variant 编译 | bucket fallback、padding | profile 越界与排队 |
| 缓存结构 | 无整图缓存 | guard→variant | bucket→graph | profile→engine |
| 计算浪费 | 接近实际形状 | 取决于泛化策略 | bucket padding | 最大形状/profile padding |
| 显存成本 | allocator reserve | variant workspace | 多图私有 pool | 静态 workspace 与上界预留 |
| 形状灵活性 | 最高 | 较高但受 guard 约束 | 离散区间 | 受 optimization profile 约束 |
| 典型落点 | 开发、长尾 fallback | 可泛化的动态区域 | 高频且分布稳定的形状 | 高度固定、需要确定性的路径 |
核心矛盾不是简单的线性取舍,而是四个耦合项:编译/捕获减少 host 开销,却引入冷启动;bucketing 提高 replay 覆盖率,却增加 padding;地址稳定降低抖动,却占用图内存池;更强静态化提高确定性,却缩小合法形状域。最佳系统通常是混合执行器:高频形状走预热图,低频形状走动态编译或 eager fallback,并根据 trace 定期重建 bucket。下一步正是设计这套图断裂与混合执行策略。