PyTorch 2.9 里 torch.compile 为什么首个请求更慢?4 组 GPU 实验讲透冷启动、重编译与止损方案

很多人第一次把 torch.compile 接到推理服务里,都会遇到一个非常反直觉的现象:

  • 本地 benchmark 看起来很先进,官方也一直在推。
    • 但线上第一个请求一打进来,延迟不是变快,而是突然卡住。
    • 更糟的是,batch size 一变,p99 又抖一下,像是服务“随机抽风”。
      这不是你的 GPU 坏了,也不一定是代码写错了。多数时候,罪魁祸首是三件事:首次编译、shape 变化触发重编译、以及你没有区分冷启动延迟和稳态吞吐

我这次在一台 RTX 3090 24GBPython 3.12.3PyTorch 2.9.1+cu128 的机器上,做了 4 组最小实验,专门回答下面几个工程里最容易踩坑的问题:

  1. torch.compile 为什么会让第一个请求更慢?
    1. 为什么只改了 batch size,延迟就突然抖一下?
    1. dynamic=True 到底值不值得开?
    1. 如果服务对首请求 SLA 很敏感,怎么把冷启动伤害降下来?
      如果你现在正准备把模型从离线脚本搬到在线推理、A/B 实验或接口服务,这篇文章会比“平均吞吐提升 xx%”更有用。

1. 为什么这个问题值得专门写一篇?

torch.compile 的价值不在争论里,而在工程里。真正让团队付出成本的,不是“它快不快”这么抽象的问题,而是下面这些更具体的场景:

  • 你的接口服务只有低频流量,但每个请求都要求秒级返回,结果首个请求被编译卡住。
    • 你的 batch size 或 sequence length 会波动,结果某个新 shape 第一次进来时,延迟突然暴涨。
    • 你在 notebook 里测的是 steady-state,到了线上却被冷启动和重编译打穿 p95/p99。
    • 你为了追求极致性能一把梭 torch.compile(model),最后只拿到很小的稳态收益,却引入了更差的服务体验。
      对算法工程师和推理平台同学来说,这个问题有明显的付费价值:少踩一次线上延迟抖动的坑,往往比学会一个新 API 更值钱。

2. 问题背景:网上常见说法哪里不够?

我调研了 PyTorch 2.9 官方文档、官方教程和社区 issue,发现网上常见说法大多只讲对了一半。

2.1 “torch.compile 会加速模型”没有错,但它默认说的是稳态表现

PyTorch 官方文档把 torch.compilemode 分成 defaultreduce-overheadmax-autotune 等几类,本质上都是在编译成本、运行时开销和内存占用之间取平衡。问题在于,很多二手文章只会截图“更快”,却不强调一个前提:编译本身也要花时间

如果你的服务是长时间、固定 shape 的高吞吐推理,编译成本可以摊薄;但如果你的服务请求稀疏、模型不大、输入 shape 经常变,那么你真正感受到的,可能不是“更快”,而是“第一次更慢”。

2.2 “只要开 dynamic=True 就好了”也不准确

PyTorch 2.9 的动态 shape 文档明确说明:batch size、sequence length 波动,是使用动态 shape 的典型场景;同时官方也提醒,动态 shape 并不是无代价的万能开关。

社区 issue 里也能看到两个长期存在的现实:

  • 有人抱怨 torch.compile 后第一轮/第一 epoch 明显更慢。
    • 也有人在复杂模型上开启 dynamic=True 后,碰到编译很慢甚至卡住的问题。
      所以真正的问题不是“要不要开”,而是:你的输入到底是不是经常变化?变化幅度多大?模型有没有复杂控制流?你能不能接受更高的首轮编译成本,换更平滑的后续请求?

2.3 很多人没把“冷启动慢”和“重编译抖动”分开看

这两个问题很像,但不是一回事:

  • 冷启动慢:第一次执行 compiled graph,本来就要付出编译成本。

    • 重编译抖动:后续遇到新的 shape、guard 失败或图结构变化,再次触发编译。
      如果不把它们分开,你就会陷入一种错误排障路径:
  • 以为 torch.compile 整体没收益;

    • 或者以为 GPU、驱动、容器出了问题;
    • 再或者看到平均吞吐没问题,就忽略了线上首请求和 p99 已经被打穿。

3. 最小实验或复现环境

3.1 实验环境

  • GPU:NVIDIA GeForce RTX 3090 24GB
    • Driver:590.44.01
    • Python:3.12.3
    • PyTorch:2.9.1+cu128
    • CUDA runtime(torch 内置):12.8
    • 模式:全部是单卡推理实验,关闭梯度,重点观察首轮延迟、steady-state 平均延迟、shape 变化后的抖动

3.2 实验 A:固定 shape 下,eager 和 compile 到底差在哪?

先用一个足够简单、但又不是玩具级别的 MLP block 做基准:

import time
import torch
from torch import nn

torch.manual_seed(0)
torch.set_float32_matmul_precision("high")

device = "cuda"

class Block(nn.Module):
    def __init__(self):
            super().__init__()
                    self.net = nn.Sequential(
                                nn.Linear(4096, 8192),
                                            nn.GELU(),
                                                        nn.Linear(8192, 4096),
                                                                    nn.LayerNorm(4096),
                                                                            )
    def forward(self, x):
            return self.net(x)

def sync():
    torch.cuda.synchronize()

def time_once(fn, x):
    sync()
        t0 = time.perf_counter()
            with torch.no_grad():
                    fn(x)
                        sync()
                            return time.perf_counter() - t0

def time_avg(fn, x, n=30):
    sync()
        t0 = time.perf_counter()
            with torch.no_grad():
                    for _ in range(n):
                                fn(x)
                                    sync()
                                        return (time.perf_counter() - t0) / n
x64 = torch.randn(64, 4096, device=device)
model = Block().to(device).eval()
compiled = torch.compile(Block().to(device).eval())

print("EAGER first", time_once(model, x64))
print("EAGER avg30", time_avg(model, x64))
print("COMPILED first", time_once(compiled, x64))
print("COMPILED avg30", time_avg(compiled, x64))

3.3 实验 B:batch size 变化时,会不会触发重编译?

接着只改输入 batch size,不改模型:

compiled_default = torch.compile(Block().to(device).eval())

for bs in [64, 80, 96, 64, 80, 96]:
    x = torch.randn(bs, 4096, device=device)
        print(bs, time_once(compiled_default, x))
        ```
同时打开官方推荐的日志开关:

```bash
TORCH_LOGS=recompiles python bench.py

3.4 实验 C:如果输入 shape 天然会变,dynamic=True 能不能缓解?

compiled_dynamic = torch.compile(Block().to(device).eval(), dynamic=True)

for bs in [64, 80, 96, 64, 80, 96]:
    x = torch.randn(bs, 4096, device=device)
        print(bs, time_once(compiled_dynamic, x))
        ```
### 3.5 实验 D:如果首请求 SLA 特别敏感,regional compilation 值不值?

最后我又做了一组更接近工程实践的测试:造一个由 12 个重复 MLP block 组成的模型,然后比较两种策略:

- 整个模型一次性 `torch.compile`
- - 只把重复的 hot block 做 regional compilation
注意,这里我用的是 `reduce-overhead`,并在每轮调用前显式执行:

```python
torch.compiler.cudagraph_mark_step_begin()

原因很简单:reduce-overhead 会更积极利用 CUDAGraph。在我的本地实验里,如果把多个 compiled block 串起来反复调用,不做 step 边界标记,确实容易碰到输出复用相关的报错或 warning。这一点线上服务尤其容易忽视。

4. 实验过程与关键现象

4.1 结果一:小模型上,steady-state 提升极小,但首轮会明显变慢

实验 A 的实测结果如下:

指标eagercompiled
首次调用0.147312 s1.198868 s
后续平均(30 次)0.000383 s0.000374 s

这里最值得注意的不是 steady-state,而是两件事:

  1. compiled 首轮慢了约 8.1 倍
    1. steady-state 只快了约 2.3%
      这意味着:如果你的服务是“小模型 + 首请求敏感 + 请求不够密”,那你拿到的可能不是收益,而是一笔额外账单。

4.2 结果二:默认模式会先按第一个 shape 专门化,第一次遇到新 shape 可能抖一下

实验 B 的结果:

输入 batch延迟
640.000458 s
80(第一次见)0.765096 s
96(第一次见)0.001461 s
64(再次出现)0.000501 s
80(再次出现)0.000773 s
96(再次出现)0.000772 s

配合 TORCH_LOGS=recompiles 日志,可以直接看到原因:

Recompiling function forward ...
triggered by the following guard failure(s):
- tensor 'x' size mismatch at index 0. expected 64, actual 80
- ```
这个现象很关键:

- 默认路径往往先根据第一次见到的 shape 做专门化。
- - 第一次碰到 `80` 这个新 batch,会发生 guard failure,然后重编译。
- - 编译器后续可能自动泛化,所以 `96` 不一定再来一次重型抖动。
但对线上服务来说,**你不需要每次都抖,只要在流量高峰时抖一次,p99 就已经难看了。**

### 4.3 结果三:dynamic=True 能把“第一次 shape 变化的暴击”提前处理掉

实验 C 的结果:

| 输入 batch | dynamic=True 延迟 |
|---|---:|
| 64(首次) | 0.251052 s |
| 80 | 0.000875 s |
| 96 | 0.000829 s |
| 后续重复 | 约 0.0005 ~ 0.0008 s |

这说明 `dynamic=True` 的价值不是“让首轮免费”,而是:

- 它仍然要编译;
- - 但它更愿意一开始就接受动态 shape;
- - 因此比“默认模式先专门化、之后再补一次重编译”更平滑。
从我的结果看:

- `dynamic=True` 首轮 0.251 秒,明显高于 eager;
- - 但它比默认 compiled 首轮 1.198 秒小得多;
- - 更重要的是,batch 从 64 切到 80、96 时没有再出现 0.7 秒级的突刺。
如果你的推理服务 batch size 或 sequence length 天然波动,这通常是更像工程答案的配置。

### 4.4 结果四:regional compilation 对首请求敏感服务很有价值

实验 D 的结果如下:

| 策略 | 首次调用 | 后续平均(20 次) |
|---|---:|---:|
| 整模型 compile | 0.732479 s | 0.000806 s |
| regional compilation | 0.059367 s | 0.000819 s |

这组结果给我的结论非常明确:

- **首轮延迟下降约 91.9%**。
- - **steady-state 几乎持平**。
为什么会这样?因为对“结构高度重复”的模型来说,把每个重复 block 独立编译,本质上是在减少“一次性编完整个大图”的成本。对首请求敏感的在线服务,这个收益非常直接。

## 5. 深入分析:真正的问题在哪里?

### 5.1 你看到的“变慢”,很多时候不是算子执行慢,而是编译账单被你摊在了错误的位置

`torch.compile` 的思路,是先把 Python + eager 执行路径整理成更适合后端优化的图,再交给后端做代码生成、调度、缓存等工作。

所以线上第一次调用时,服务在做的并不只是推理,还包括:

- 捕获图;
- - 做 shape/guard 相关决策;
- - 生成和加载编译产物;
- - 在某些模式下准备 CUDAGraph 相关路径。
如果你把“第一次请求”的时间和“后续第 1000 次请求”的时间混在一起比较,结论大概率会失真。

### 5.2 真正打穿线上延迟的,经常不是 steady-state,而是 guard failure 后的重编译

官方 recompilation 文档已经写得很直白:**重编译会显著增加 compile time**。从我的本地实验看,这个说法完全符合现实。

对服务来说,最危险的不是平均值,而是“流量进来时刚好碰到一个新 shape”。这就是为什么有些团队离线 benchmark 很漂亮,线上却觉得“不稳定”:

- 模型本身没慢;
- - 后端内核也没崩;
- - 但 guard 失败触发的重编译,把某个请求瞬间放大了三个数量级。
### 5.3 dynamic=True 有价值,但不是银弹

官方动态 shape 文档建议:如果你本来就知道某一维会变,可以考虑 `dynamic=True`,或者更显式地用 `torch._dynamo.mark_dynamic(tensor, dim)`。

这套思路对**推理 batch size、sequence length 有弹性**的业务很有用,但我不建议把它理解成“永远开启更稳”。原因有两个:

1. 动态 shape 会让编译器保守一些,某些模型的最优内核选择未必和完全静态时一致。
2. 2. 社区 issue 也表明,在更复杂的模型和控制流上,`dynamic=True` 仍然可能带来很长的编译时间,甚至卡住。
所以正确姿势不是迷信一个参数,而是先问自己:**线上到底是“shape 很稳定,只是首轮慢”,还是“shape 天然不稳定,重编译比冷启动更致命”?**

### 5.4 `reduce-overhead` 不是白送,它靠的是更激进的运行时机制

官方 API 文档里写得很清楚,`reduce-overhead` 通过 CUDA graphs 等机制减少 Python 开销,更适合小 batch 场景。但这类模式通常也意味着:

- 更依赖输入和执行边界的稳定性;
- - 可能引入更高的内存占用;
- - 在服务循环里,需要你更清楚“每一步”的边界。
这也是为什么我在 regional compilation 实验里,明确加了 `torch.compiler.cudagraph_mark_step_begin()`。如果你线上碰到过“输出被后续运行覆盖”这类报错,不要只怀疑框架 bug,先检查自己是不是在 CUDAGraph 语义下把 step 边界省掉了。

## 6. 可落地解决方案

下面这套清单,是我认为最适合工程落地的止损顺序。

### 方案一:先把 benchmark 拆成“首轮”和“稳态”两张表

不要再只报一个平均值。至少拆成:

- 首次调用 latency
- - 热身后平均 latency
- - 新 shape 第一次出现时的 latency
- - p95/p99 latency
如果不这样拆,你根本判断不出是冷启动问题,还是 shape 重编译问题。

### 方案二:输入 shape 稳定,就优先固定 shape,不要过早引入动态

如果你的服务本来就是固定分桶、固定 batch、固定 seq_len,那么最便宜的解法往往不是 `dynamic=True`,而是:

- 统一 batch bucket
- - 对 seq_len 做 padding/截断分桶
- - 避免请求侧把 shape 打散
因为**让编译器稳定地命中缓存,通常比事后补救重编译更便宜。**

### 方案三:shape 会波动,就主动用 dynamic=True 或 mark_dynamic

如果你的业务天生有弹性输入,可以优先试:

```python
compiled_model = torch.compile(model, dynamic=True)

如果你非常明确知道哪一维会变,可以进一步试更显式的写法:

torch._dynamo.mark_dynamic(x, 0)

这样做的好处是,把“第一次新 shape 的重编译”尽量提前吸收掉,让后续流量更平滑。

方案四:首请求 SLA 很紧,就优先考虑 regional compilation

如果模型里有很多重复 block,而且你最在意的是首请求延迟,那么值得优先验证:

  • 不要整模型一次性 compile
    • 只编译重复出现的 hot submodule
    • 把一次大编译拆成多个小编译
      尤其在在线服务里,这经常比盲目追求整图 compile 更实际。

方案五:把日志开关接进排障流程,而不是出了问题才临时查

推荐至少记住这几个:

TORCH_LOGS=recompiles
TORCH_LOGS=guards
TORCH_LOGS=dynamic

它们能帮你回答三个最关键的问题:

  • 为什么重新编译了?
    • 是哪个 guard 没过?
    • 哪个维度其实应该被视作动态?

方案六:用了 reduce-overhead,就对 CUDAGraph 语义保持敬畏

如果你为了小 batch 延迟启用了 reduce-overhead,又把模型塞进循环式服务里,建议把下面这个动作纳入调用边界:

torch.compiler.cudagraph_mark_step_begin()

它不是每个模型都必须,但一旦你遇到 CUDAGraph 输出复用、step 边界不清晰、重复调用后报错的问题,这通常是第一批应该检查的点。

7. 适用场景与不适用场景

适用场景

这篇文章里的结论,最适合下面几类任务:

  • 在线推理服务,首请求和 p99 很重要
    • batch size / sequence length 会波动
    • 希望把 eager 平滑迁到 torch.compile
    • 需要向团队解释“为什么离线吞吐不错,线上却觉得更慢”

不适用场景

如果你是下面这些情况,这篇文章的策略要谨慎套用:

  • 长时间离线训练,只关心整轮吞吐,不关心首轮延迟
    • 模型包含非常复杂的控制流,dynamic=True 可能带来更难预测的编译成本
    • 你已经做了成熟的静态 shape 分桶,且 steady-state 才是唯一 KPI
    • 你还没有分清是 I/O、tokenizer、前后处理,还是模型本身导致的慢
      换句话说,不要把所有“服务慢”都归咎给 torch.compile,但也不要只看平均吞吐就宣布它已经成功上线。

8. 总结

如果只让我给一份最短结论清单,我会写成下面 6 条:

  1. torch.compile 可能让首个请求明显变慢,这不等于它没有价值,而是你把编译成本算进了首轮。
    1. 真正容易打穿线上 p99 的,经常不是 steady-state,而是新 shape 触发的重编译。
    1. dynamic=True 适合 batch/seq_len 天然波动的服务,它不能消灭编译成本,但能减少 shape 切换时的突刺。
    1. 如果首请求 SLA 特别紧,regional compilation 往往比整模型一把梭更稳。
    1. TORCH_LOGS=recompiles/guards/dynamic 是排障必备,不要等线上告警了才临时打开。
    1. 开了 reduce-overhead 后,要理解 CUDAGraph 的运行边界,必要时显式调用 torch.compiler.cudagraph_mark_step_begin()
      我的建议是:先把你的服务按“固定 shape / 动态 shape / 首请求 SLA 是否敏感”分型,再决定是固定 bucket、开 dynamic=True,还是改成 regional compilation。 这比盲目讨论“compile 能不能提速”更接近真实工程。

9. 参考与延伸阅读

  1. PyTorch 2.9 Release Blog
  2. https://pytorch.org/blog/pytorch-2-9/
    1. torch.compile API 文档(PyTorch 2.9)
  3. https://docs.pytorch.org/docs/2.9/generated/torch.compile.html
    1. Recompilation 文档(PyTorch 2.9)
  4. https://docs.pytorch.org/docs/2.9/compile/programming_model.recompilation.html
    1. Dynamic Shapes 文档(PyTorch 2.9)
  5. https://docs.pytorch.org/docs/2.9/torch.compiler_dynamic_shapes.html
    1. CUDAGraph Trees 文档(PyTorch 2.9)
  6. https://docs.pytorch.org/docs/2.9/torch.compiler_cudagraph_trees.html
    1. Regional Compilation 教程
  7. https://docs.pytorch.org/tutorials/recipes/regional_compilation.html
    1. PyTorch issue #97783:The first epoch is very slow when using torch.compile
  8. https://github.com/pytorch/pytorch/issues/97783
    1. PyTorch issue #118492:torch.compile(dynamic=True) compiles forever
  9. https://github.com/pytorch/pytorch/issues/118492
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐