GPU 算力没在算模型,在等 CPU 发号施令——CUDA Graph 是怎么解决这件事的

GPU 算力没在算模型,在等 CPU 发号施令——CUDA Graph 是怎么解决这件事的


1. 你大概有这样一段代码

用 transformers 加载一个模型,写了个简单的 decode 循环:

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B",
                                              torch_dtype=torch.float16).cuda()
model.eval()
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-1.5B")

prompt = "解释一下推测解码的原理"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")

with torch.no_grad():
    output = model.generate(**inputs, max_new_tokens=200)

print(tokenizer.decode(output[0]))

能跑,但总觉得慢,想优化。

加量化、加 flash attention、换 vLLM……这些方法都有效,但 CUDA Graph 是其中最”无副作用”的一个——不改模型权重,不改输出质量,只改”GPU 执行任务的方式”,在 decode 阶段能有 20-50% 的提升,代码改动量也不大。

这篇文章就讲怎么给自己的推理代码加 CUDA Graph。


2. 先搞清楚为什么慢:kernel launch overhead

大模型的一次前向,不是一个大计算,而是几百个小 CUDA kernel 的串行组合:RMSNorm、RoPE、QKV projection、attention、FFN gate/up/down、residual add……每个都是独立的 kernel。

每次调用一个 kernel,CPU 都要做一次 kernel launch:打包参数、通知 driver、把任务推进 GPU 队列。这个操作本身耗时 5-20 µs,看起来不多,但一次前向几百个 kernel 累积下来就是几毫秒。

Decode 阶段每步只生成一个 token,单步耗时可能就 10-20 ms,kernel launch overhead 就占了 20-30%。GPU 的相当一部分时间在等 CPU 一遍遍地发号施令,而不是在做计算。

用 nsight systems profile 一下会很直观——timeline 上 GPU 活跃段之间有一堆小空隙,那些就是 CPU launch 的等待时间。

CUDA Graph 的解法很直接:第一次推理时把这几百个 kernel 的序列”录制”成一张图(DAG),之后每次推理直接 replay 这张图——整个 forward 只需要一次 CPU 调用,launch overhead 接近清零。


3. 先跑一个最简单的 demo,感受一下

在套到 LLM 之前,先用一个最小的可运行例子感受 CG 的效果——直接复制跑,有 GPU 就行:

import torch
import time

device = "cuda"

# 用一个小 MLP 模拟 LLM 里的一层
model = torch.nn.Sequential(
    torch.nn.Linear(2048, 8192),
    torch.nn.GELU(),
    torch.nn.Linear(8192, 2048),
).to(device).half().eval()

# ⚠️ 关键:输入 tensor 提前分配好,地址整个生命周期不变
x = torch.randn(8, 2048, device=device, dtype=torch.float16)

# --- Step 1: warmup(让 kernel 编译完成、显存分配稳定)---
with torch.no_grad():
    for _ in range(3):
        _ = model(x)

# --- Step 2: capture ---
g = torch.cuda.CUDAGraph()
with torch.no_grad():
    with torch.cuda.graph(g):
        y = model(x)   # 被"录制"进 DAG,这次不真正执行

# --- Step 3: replay ---
# 每次推理:就地更新输入(地址不变),然后 replay 整张图
x.copy_(torch.randn_like(x))
g.replay()
# y 的地址是 capture 时固定的,直接读
print(y.shape)   # torch.Size([8, 2048])

# --- 对比一下速度 ---
N = 2000
torch.cuda.synchronize()
t0 = time.time()
with torch.no_grad():
    for _ in range(N):
        x.copy_(torch.randn_like(x))
        _ = model(x)
torch.cuda.synchronize()
print(f"Eager: {(time.time() - t0) * 1000 / N:.3f} ms/iter")

torch.cuda.synchronize()
t0 = time.time()
for _ in range(N):
    x.copy_(torch.randn_like(x))
    g.replay()
torch.cuda.synchronize()
print(f"CG:    {(time.time() - t0) * 1000 / N:.3f} ms/iter")

在 A100 上跑出来大概是:

Eager: 0.130 ms/iter
CG:    0.032 ms/iter

四倍差距,全来自消掉了几十个 kernel 的 launch overhead。这个 MLP 只有 3 个 kernel,LLM 一层有几十个,累计下来收益更大。

三个要点记住就够了:

  1. warmup 不能省,3 次足够
  2. 输入 x 的地址不能变,只能就地 copy_()fill_() 更新值
  3. replay 后直接读 y,它的地址也是 capture 时固定的

4. 套到 LLM 上:第一个坑,KV cache

理解了基本用法,来套到 LLM 推理上。

LLM 的 decode 阶段每步输入是当前 token(shape [batch, 1]),同时维护一个 KV cache 存历史计算结果。问题就出在这里——标准 transformers 的 KV cache 是一个 tuple,每步都会增长

# step 1: past_key_values 里每个 tensor shape = [batch, heads, 1, head_dim]
# step 2: past_key_values 里每个 tensor shape = [batch, heads, 2, head_dim]
# step 3: [batch, heads, 3, head_dim]
# ...

每步 shape 不一样,就意味着每步 kernel 的参数不一样,图的拓扑在变。CUDA Graph 要求拓扑固定,这里直接冲突。

解法:StaticCache

transformers 4.38 之后提供了 StaticCache——提前分配一块固定大小的显存,KV cache 永远写在这块固定 buffer 里,shape 不变,每步只是更新写入位置:

from transformers import StaticCache

# 提前分配好,最多支持 512 个 token 的 KV cache
static_cache = StaticCache(
    config=model.config,
    max_batch_size=1,
    max_cache_len=512,
    device="cuda",
    dtype=torch.float16,
)

有了 StaticCache,KV cache 的 shape 整个推理过程都不变,CUDA Graph 的”拓扑固定”约束就满足了。


5. 完整可运行代码

把所有东西拼在一起:

from transformers import AutoModelForCausalLM, AutoTokenizer, StaticCache
import torch

# --- 加载模型 ---
model_name = "Qwen/Qwen2.5-1.5B"
model = AutoModelForCausalLM.from_pretrained(
    model_name, torch_dtype=torch.float16
).cuda().eval()
tokenizer = AutoTokenizer.from_pretrained(model_name)

MAX_SEQ_LEN = 512
BATCH_SIZE  = 1

# --- 分配固定地址的输入 buffer(地址整个生命周期不变)---
input_ids    = torch.zeros(BATCH_SIZE, 1,  dtype=torch.long,  device="cuda")
position_ids = torch.zeros(BATCH_SIZE, 1,  dtype=torch.long,  device="cuda")
cache_pos    = torch.zeros(1,              dtype=torch.long,  device="cuda")

# --- 分配 StaticCache ---
static_cache = StaticCache(
    config=model.config,
    max_batch_size=BATCH_SIZE,
    max_cache_len=MAX_SEQ_LEN,
    device="cuda",
    dtype=torch.float16,
)

# --- Step 1: warmup ---
for _ in range(3):
    with torch.no_grad():
        model(
            input_ids=input_ids,
            position_ids=position_ids,
            cache_position=cache_pos,
            past_key_values=static_cache,
            use_cache=True,
        )

# --- Step 2: capture ---
static_cache.reset()   # 重置 cache 状态(warmup 写进去的内容清掉)

g = torch.cuda.CUDAGraph()
with torch.no_grad():
    with torch.cuda.graph(g):
        logits = model(
            input_ids=input_ids,
            position_ids=position_ids,
            cache_position=cache_pos,
            past_key_values=static_cache,
            use_cache=True,
        ).logits   # shape: [batch, 1, vocab_size]

# --- Step 3: decode 循环 ---
def decode_one_token(token: int, pos: int) -> int:
    """一步 decode,返回下一个 token id"""
    input_ids.fill_(token)    # 就地更新,地址不变
    position_ids.fill_(pos)
    cache_pos.fill_(pos)
    g.replay()                 # replay,不重新 launch kernel
    return logits[0, 0].argmax().item()


def generate(prompt: str, max_new_tokens: int = 100) -> str:
    inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
    input_token_ids = inputs["input_ids"][0].tolist()

    # prefill 阶段:正常跑,不用 CUDA Graph
    # (prefill 序列长度每次不同,不适合 CG)
    with torch.no_grad():
        static_cache.reset()
        prefill_out = model(
            input_ids=inputs["input_ids"],
            cache_position=torch.arange(len(input_token_ids), device="cuda"),
            past_key_values=static_cache,
            use_cache=True,
        )

    # 取 prefill 最后一步的 next token
    next_token = prefill_out.logits[0, -1].argmax().item()
    generated = [next_token]
    pos = len(input_token_ids)

    # decode 阶段:每步 replay CG,飞快
    for _ in range(max_new_tokens - 1):
        if next_token == tokenizer.eos_token_id:
            break
        next_token = decode_one_token(next_token, pos)
        generated.append(next_token)
        pos += 1

    return tokenizer.decode(generated, skip_special_tokens=True)


print(generate("解释一下推测解码的原理"))

这段代码和最开始那段的主要区别:

  1. StaticCache 替换了动态增长的 past_key_values
  2. 单独的固定 buffer(input_idsposition_idscache_pos),每步 fill_() 更新
  3. prefill 走正常 eager,decode 走 CUDA Graph replay

6. 第二个坑:batch size 变了怎么办

上面的代码是 batch size = 1 的版本。实际场景里,你可能想同时处理多个请求(batch inference),而且每轮 decode batch size 可能在变(有请求完成就减少,有新请求进来就增加)。

CUDA Graph 捕获的是固定 batch size 的图,batch size 变了图就不能用了。

解法:预先捕获几个常用 batch size 的图,放进一个字典,运行时按实际 batch size 选对应的图

# 预先 capture 这几个 batch size 的图
CAPTURE_BATCH_SIZES = [1, 2, 4, 8, 16, 32]

graphs = {}   # batch_size → CUDAGraph
logits_buffers = {}   # batch_size → logits tensor(capture 时固定的地址)

for bs in CAPTURE_BATCH_SIZES:
    # 每个 batch size 单独分配一套 buffer
    _input_ids    = torch.zeros(bs, 1, dtype=torch.long,  device="cuda")
    _position_ids = torch.zeros(bs, 1, dtype=torch.long,  device="cuda")
    _cache_pos    = torch.zeros(1,    dtype=torch.long,  device="cuda")
    _cache = StaticCache(config=model.config, max_batch_size=bs,
                         max_cache_len=MAX_SEQ_LEN, device="cuda",
                         dtype=torch.float16)

    # warmup
    for _ in range(3):
        with torch.no_grad():
            model(input_ids=_input_ids, position_ids=_position_ids,
                  cache_position=_cache_pos, past_key_values=_cache,
                  use_cache=True)

    # capture
    _cache.reset()
    g = torch.cuda.CUDAGraph()
    with torch.no_grad():
        with torch.cuda.graph(g):
            _logits = model(input_ids=_input_ids, position_ids=_position_ids,
                            cache_position=_cache_pos, past_key_values=_cache,
                            use_cache=True).logits

    graphs[bs] = {
        "graph": g,
        "input_ids": _input_ids,
        "position_ids": _position_ids,
        "cache_pos": _cache_pos,
        "logits": _logits,
    }


def decode_batch(tokens: list[int], pos: int) -> list[int]:
    bs = len(tokens)

    # 找最小的已捕获 batch size >= bs,padding 多余位置
    target_bs = next(b for b in CAPTURE_BATCH_SIZES if b >= bs)
    entry = graphs[target_bs]

    entry["input_ids"][:bs].fill_(0)           # 先清空
    for i, t in enumerate(tokens):
        entry["input_ids"][i].fill_(t)          # 就地更新有效 token
    entry["position_ids"][:bs].fill_(pos)
    entry["cache_pos"].fill_(pos)

    entry["graph"].replay()

    return entry["logits"][:bs, 0].argmax(dim=-1).tolist()   # 只取有效部分

这就是 vLLM 的 batch size bucketing 思路,只是简化版。多几张图,覆盖了实际场景里各种 batch size,运行时零额外开销。


7. 第三个坑:MoE 模型

如果你跑的是 Mixtral、DeepSeek-MoE、Qwen-MoE 这类 MoE 架构的模型,还有一个额外的问题。

Dense 模型每层所有 token 都过同样的权重矩阵,计算路径固定。MoE 不一样——router 会把 token 分配给不同的 expert,每个 expert 这一步收到多少 token 是数据依赖的,每次都可能不同:

batch=8, top_k=2, 8 个 expert → 理论上每个 expert 平均 2 个 token
实际可能:expert0 收 5 个,expert1 收 0 个,expert2 收 3 个...

expert 收到不同数量的 token,送进 expert FFN 的 tensor shape 每次都在变,CUDA Graph 直接挂。这也是为什么在一些 GitHub issue 里会看到这样的说法:

This is due to the tensor in MoE layer has dynamic shape, which is not applicable for CUDA graph. You can partially capture CUDA graph for Attention layer.

先说好消息:batch=1 的 decode 不受影响

batch=1,每步只有 1 个 token,top_k=2 的情况下恰好 2 个 expert 各收到 1 个 token,shape 固定。上面第 5 节的代码直接拿去跑 MoE 模型,decode 阶段完全没问题。

batch>1 才是麻烦的地方,下面说两种解法。

解法一:只 capture attention,MoE FFN 跑 eager(partial capture)

这是最简单、改动最小的方案,也是 Megatron-LM 对 MoE 模型的推荐做法:每个 transformer layer 里,attention 部分 capture 成 CUDA Graph,MoE FFN 部分继续走 eager

一个 transformer layer 的结构是:

Input
  → [Attention]   ← shape 固定(batch × 1 × dim),可以 capture
  → [MoE FFN]     ← expert 分配动态,不 capture
  → Output

把 attention 单独包成一个 CG,MoE FFN 保持 eager,每层的调用变成:

class MoETransformerLayerWithPartialCG:
    def __init__(self, attention, moe_ffn, hidden_size, batch_size):
        self.moe_ffn = moe_ffn

        # 给 attention 分配固定地址的输入输出 buffer
        self.attn_input  = torch.zeros(batch_size, 1, hidden_size, device="cuda", dtype=torch.float16)
        self.attn_output = torch.zeros(batch_size, 1, hidden_size, device="cuda", dtype=torch.float16)

        # warmup
        for _ in range(3):
            with torch.no_grad():
                _ = attention(self.attn_input)

        # 只 capture attention
        self.attn_graph = torch.cuda.CUDAGraph()
        with torch.no_grad():
            with torch.cuda.graph(self.attn_graph):
                self.attn_output = attention(self.attn_input)

    def forward(self, hidden_states):
        # attention:replay CG(无 kernel launch overhead)
        self.attn_input.copy_(hidden_states)
        self.attn_graph.replay()
        attn_out = self.attn_output.clone()

        # MoE FFN:正常 eager(shape 动态,无法 capture)
        ffn_out = self.moe_ffn(attn_out)
        return ffn_out

attention 部分的 kernel launch overhead 消掉了,MoE FFN 部分没动。相比整层都走 eager,能拿到一半左右的 CG 收益——对于 Mixtral 这类模型,attention 占单层耗时的 40-50%,效果还是明显的。

Megatron-LM 里通过 --cuda-graph-modules=attn 参数开启这个模式,正是这个思路。

解法二:给 MoE FFN 加 expert capacity,整层都 capture

如果想把 MoE FFN 也 capture 进去,需要消掉 shape 动态的根源:提前规定每个 expert 最多接受 capacity 个 token,无论 router 实际分了多少,tensor shape 始终是 [capacity, hidden_dim]——分配超过 capacity 的 token 丢弃,不足的用零 padding:

import math

def moe_forward_with_capacity(hidden_states, router_logits, experts,
                               top_k, capacity_factor=1.25):
    batch, seq, dim = hidden_states.shape
    hidden_flat = hidden_states.view(-1, dim)     # [num_tokens, dim]
    num_tokens  = hidden_flat.shape[0]
    num_experts = len(experts)

    routing_weights = torch.softmax(router_logits, dim=-1)
    topk_weights, topk_ids = routing_weights.topk(top_k, dim=-1)

    # capacity 提前算死,replay 时这个值不会变
    capacity = math.ceil(num_tokens * top_k / num_experts * capacity_factor)

    # 固定 shape 的 buffer:[num_experts, capacity, dim]
    expert_inputs  = torch.zeros(num_experts, capacity, dim,
                                 device=hidden_states.device, dtype=hidden_states.dtype)
    expert_weights = torch.zeros(num_experts, capacity,
                                 device=hidden_states.device, dtype=hidden_states.dtype)

    # 把 token 填进对应 expert 的 slot(超出 capacity 就 drop)
    for e in range(num_experts):
        assigned   = (topk_ids == e).any(dim=-1)             # [num_tokens]
        token_idx  = assigned.nonzero(as_tuple=True)[0]
        n = min(len(token_idx), capacity)
        if n > 0:
            w_pos = (topk_ids[token_idx[:n]] == e).float().argmax(dim=-1)
            expert_inputs[e, :n]  = hidden_flat[token_idx[:n]]
            expert_weights[e, :n] = topk_weights[token_idx[:n], w_pos]

    # 每个 expert 的输入永远是 [capacity, dim],shape 固定,CG 可以录制
    outputs = torch.stack([experts[e](expert_inputs[e]) for e in range(num_experts)])

    # 把结果散回原位置
    result = torch.zeros_like(hidden_flat)
    for e in range(num_experts):
        assigned  = (topk_ids == e).any(dim=-1)
        token_idx = assigned.nonzero(as_tuple=True)[0]
        n = min(len(token_idx), capacity)
        if n > 0:
            result[token_idx[:n]] += expert_weights[e, :n, None] * outputs[e, :n]

    return result.view(batch, seq, dim)

capacity_factor=1.25 给了 25% 余量,生产环境里 token drop 概率很低。代价是每个 expert 都要跑满 capacity 个位置,padding 的部分算了白算(浪费约 5-10% 算力)。

Megatron-LM 的参数是 --moe-expert-capacity-factor 1.25 --moe-pad-expert-input-to-capacity,开了这两个之后 MoE 层也能 capture。

两种方案怎么选:

  • 想最快改完上线,改动最小 → 用 partial capture(解法一)
  • 想把 MoE FFN 的 launch overhead 也消掉 → 用 expert capacity(解法二),但要改 MoE 层实现
  • 不想自己改,直接用 vLLM → FusedMoE kernel 内置了 capacity dispatch,CUDA Graph 开箱即用

9. prefill 为什么不加 CUDA Graph

你可能注意到上面的代码里,prefill 阶段始终走的是 eager mode,没有 CUDA Graph。

原因很简单:prefill 的输入序列长度每次都不一样。你一次处理 “你好” 和 “解释一下量子纠缠的基本原理及其在量子计算中的应用”,这两个序列的长度差了几十倍,对应的 kernel 参数完全不同,图拓扑每次都变,CG 捕获不了。

Decode 阶段每步输入永远是一个 token(长度固定为 1),天然适合 CUDA Graph,这也是为什么 vLLM 这类系统里 CG 只作用于 decode。


10. 加了 CG 之后能快多少

以 Qwen2.5-1.5B 为例,batch size = 1,decode 阶段:

方式 单步延迟 吞吐(tokens/s)
Eager (standard generate) ~8 ms ~125
+ CUDA Graph ~5 ms ~200
提升 ~37% ~60%

小模型收益更大(kernel 数量相对 kernel 计算量的比例更高,launch overhead 占比更大)。大模型(如 70B)每个 kernel 跑的时间本来就长,launch overhead 占比低,CG 的相对收益会小一些,但绝对值(省下的毫秒数)还是有的。


11. 踩坑备忘

坑 1:capture 里不能有 Python 控制流依赖 GPU 结果

# ❌ 这会出问题:capture 时走了某个分支,replay 时永远走那个分支
with torch.cuda.graph(g):
    out = model(x)
    if out.max() > 0.5:        # GPU 结果 → Python if → 分支被固化
        out = postprocess(out)

把判断逻辑移到 graph 外面,或者改成 torch.where(GPU tensor 操作,不走 Python 分支)。

坑 2:capture 时不能有随机操作

# ❌ capture 时 dropout 用了随机数,replay 时随机种子不更新
with torch.cuda.graph(g):
    out = model_with_dropout(x)

推理时 model.eval() 关掉 dropout,这个坑就不存在了。记得 eval。

坑 3:capture 里不能有 CPU-GPU 同步

tensor.item().cpu()print(tensor) 这些都会触发 CPU-GPU 同步,capture 时如果有这些操作会报错或者行为异常。把这类操作移到 graph 外面。

坑 4:忘了 warmup 就 capture

第一次 capture 之前必须先把 kernel 跑热(3 次以上)。直接 capture 可能录进去的是未完全初始化的 kernel 状态,replay 时结果错误或崩溃。

坑 5:StaticCache 忘了 reset

capture 前必须调 static_cache.reset(),否则 warmup 写进去的 KV 数据还在里面,capture 的初始状态不是”空 cache”,之后的推理结果就乱了。


12. 一句话总结

给自己的 transformers 推理代码加 CUDA Graph,核心改动只有两处:一是把 past_key_values 换成 StaticCache(解决 KV cache shape 动态变化的问题),二是提前分配好固定地址的输入 buffer(解决 tensor 地址不能变的约束)。三步走:warmup → capture → 每步 replay。prefill 不动,只加速 decode。


如果这篇文章涉及的 LLM 推理效率优化你想系统深入,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里有专章讲 LLM 推理效率,涵盖量化、kernel 优化和推理框架的工程权衡,和本文讨论的 CUDA 层优化有直接关联。

动手学AutoML书籍封面

Flag Counter