PyTorch 2.9 里 torch.compile 为什么首个请求更慢?4 组 GPU 实验讲透冷启动、重编译与止损方案
PyTorch 2.9 里 torch.compile 为什么首个请求更慢4 组 GPU 实验讲透冷启动、重编译与止损方案很多人第一次把torch.compile接到推理服务里都会遇到一个非常反直觉的现象本地 benchmark 看起来很先进官方也一直在推。但线上第一个请求一打进来延迟不是变快而是突然卡住。更糟的是batch size 一变p99 又抖一下像是服务“随机抽风”。这不是你的 GPU 坏了也不一定是代码写错了。多数时候罪魁祸首是三件事首次编译、shape 变化触发重编译、以及你没有区分冷启动延迟和稳态吞吐。我这次在一台RTX 3090 24GB、Python 3.12.3、PyTorch 2.9.1cu128的机器上做了 4 组最小实验专门回答下面几个工程里最容易踩坑的问题torch.compile为什么会让第一个请求更慢为什么只改了 batch size延迟就突然抖一下dynamicTrue到底值不值得开如果服务对首请求 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.compile的mode分成default、reduce-overhead、max-autotune等几类本质上都是在编译成本、运行时开销和内存占用之间取平衡。问题在于很多二手文章只会截图“更快”却不强调一个前提编译本身也要花时间。如果你的服务是长时间、固定 shape 的高吞吐推理编译成本可以摊薄但如果你的服务请求稀疏、模型不大、输入 shape 经常变那么你真正感受到的可能不是“更快”而是“第一次更慢”。2.2 “只要开 dynamicTrue 就好了”也不准确PyTorch 2.9 的动态 shape 文档明确说明batch size、sequence length 波动是使用动态 shape 的典型场景同时官方也提醒动态 shape 并不是无代价的万能开关。社区 issue 里也能看到两个长期存在的现实有人抱怨torch.compile后第一轮/第一 epoch 明显更慢。也有人在复杂模型上开启dynamicTrue后碰到编译很慢甚至卡住的问题。所以真正的问题不是“要不要开”而是你的输入到底是不是经常变化变化幅度多大模型有没有复杂控制流你能不能接受更高的首轮编译成本换更平滑的后续请求2.3 很多人没把“冷启动慢”和“重编译抖动”分开看这两个问题很像但不是一回事冷启动慢第一次执行 compiled graph本来就要付出编译成本。重编译抖动后续遇到新的 shape、guard 失败或图结构变化再次触发编译。如果不把它们分开你就会陷入一种错误排障路径以为torch.compile整体没收益或者以为 GPU、驱动、容器出了问题再或者看到平均吞吐没问题就忽略了线上首请求和 p99 已经被打穿。3. 最小实验或复现环境3.1 实验环境GPUNVIDIA GeForce RTX 3090 24GBDriver590.44.01Python3.12.3PyTorch2.9.1cu128CUDA runtimetorch 内置12.8模式全部是单卡推理实验关闭梯度重点观察首轮延迟、steady-state 平均延迟、shape 变化后的抖动3.2 实验 A固定 shape 下eager 和 compile 到底差在哪先用一个足够简单、但又不是玩具级别的 MLP block 做基准importtimeimporttorchfromtorchimportnn torch.manual_seed(0)torch.set_float32_matmul_precision(high)devicecudaclassBlock(nn.Module):def__init__(self):super().__init__()self.netnn.Sequential(nn.Linear(4096,8192),nn.GELU(),nn.Linear(8192,4096),nn.LayerNorm(4096),)defforward(self,x):returnself.net(x)defsync():torch.cuda.synchronize()deftime_once(fn,x):sync()t0time.perf_counter()withtorch.no_grad():fn(x)sync()returntime.perf_counter()-t0deftime_avg(fn,x,n30):sync()t0time.perf_counter()withtorch.no_grad():for_inrange(n):fn(x)sync()return(time.perf_counter()-t0)/n x64torch.randn(64,4096,devicedevice)modelBlock().to(device).eval()compiledtorch.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 实验 Bbatch size 变化时会不会触发重编译接着只改输入 batch size不改模型compiled_defaulttorch.compile(Block().to(device).eval())forbsin[64,80,96,64,80,96]:xtorch.randn(bs,4096,devicedevice)print(bs,time_once(compiled_default,x)) 同时打开官方推荐的日志开关 bash TORCH_LOGSrecompiles python bench.py3.4 实验 C如果输入 shape 天然会变dynamicTrue 能不能缓解compiled_dynamictorch.compile(Block().to(device).eval(),dynamicTrue)forbsin[64,80,96,64,80,96]:xtorch.randn(bs,4096,devicedevice)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而是两件事compiled 首轮慢了约 8.1 倍。steady-state 只快了约 2.3%。这意味着如果你的服务是“小模型 首请求敏感 请求不够密”那你拿到的可能不是收益而是一笔额外账单。4.2 结果二默认模式会先按第一个 shape 专门化第一次遇到新 shape 可能抖一下实验 B 的结果输入 batch延迟640.000458 s80第一次见0.765096 s96第一次见0.001461 s64再次出现0.000501 s80再次出现0.000773 s96再次出现0.000772 s配合TORCH_LOGSrecompiles日志可以直接看到原因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 结果三dynamicTrue 能把“第一次 shape 变化的暴击”提前处理掉 实验 C 的结果 | 输入 batch | dynamicTrue 延迟 | |---|---:| | 64首次 | 0.251052 s | | 80 | 0.000875 s | | 96 | 0.000829 s | | 后续重复 | 约 0.0005 ~ 0.0008 s | 这说明 dynamicTrue 的价值不是“让首轮免费”而是 - 它仍然要编译 - - 但它更愿意一开始就接受动态 shape - - 因此比“默认模式先专门化、之后再补一次重编译”更平滑。 从我的结果看 - dynamicTrue 首轮 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 dynamicTrue 有价值但不是银弹 官方动态 shape 文档建议如果你本来就知道某一维会变可以考虑 dynamicTrue或者更显式地用 torch._dynamo.mark_dynamic(tensor, dim)。 这套思路对**推理 batch size、sequence length 有弹性**的业务很有用但我不建议把它理解成“永远开启更稳”。原因有两个 1. 动态 shape 会让编译器保守一些某些模型的最优内核选择未必和完全静态时一致。 2. 2. 社区 issue 也表明在更复杂的模型和控制流上dynamicTrue 仍然可能带来很长的编译时间甚至卡住。 所以正确姿势不是迷信一个参数而是先问自己**线上到底是“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那么最便宜的解法往往不是 dynamicTrue而是 - 统一 batch bucket - - 对 seq_len 做 padding/截断分桶 - - 避免请求侧把 shape 打散 因为**让编译器稳定地命中缓存通常比事后补救重编译更便宜。** ### 方案三shape 会波动就主动用 dynamicTrue 或 mark_dynamic 如果你的业务天生有弹性输入可以优先试 python compiled_model torch.compile(model, dynamicTrue)如果你非常明确知道哪一维会变可以进一步试更显式的写法torch._dynamo.mark_dynamic(x,0)这样做的好处是把“第一次新 shape 的重编译”尽量提前吸收掉让后续流量更平滑。方案四首请求 SLA 很紧就优先考虑 regional compilation如果模型里有很多重复 block而且你最在意的是首请求延迟那么值得优先验证不要整模型一次性 compile只编译重复出现的 hot submodule把一次大编译拆成多个小编译尤其在在线服务里这经常比盲目追求整图 compile 更实际。方案五把日志开关接进排障流程而不是出了问题才临时查推荐至少记住这几个TORCH_LOGSrecompilesTORCH_LOGSguardsTORCH_LOGSdynamic它们能帮你回答三个最关键的问题为什么重新编译了是哪个 guard 没过哪个维度其实应该被视作动态方案六用了 reduce-overhead就对 CUDAGraph 语义保持敬畏如果你为了小 batch 延迟启用了reduce-overhead又把模型塞进循环式服务里建议把下面这个动作纳入调用边界torch.compiler.cudagraph_mark_step_begin()它不是每个模型都必须但一旦你遇到 CUDAGraph 输出复用、step 边界不清晰、重复调用后报错的问题这通常是第一批应该检查的点。7. 适用场景与不适用场景适用场景这篇文章里的结论最适合下面几类任务在线推理服务首请求和 p99 很重要batch size / sequence length 会波动希望把 eager 平滑迁到torch.compile需要向团队解释“为什么离线吞吐不错线上却觉得更慢”不适用场景如果你是下面这些情况这篇文章的策略要谨慎套用长时间离线训练只关心整轮吞吐不关心首轮延迟模型包含非常复杂的控制流dynamicTrue可能带来更难预测的编译成本你已经做了成熟的静态 shape 分桶且 steady-state 才是唯一 KPI你还没有分清是 I/O、tokenizer、前后处理还是模型本身导致的慢换句话说不要把所有“服务慢”都归咎给torch.compile但也不要只看平均吞吐就宣布它已经成功上线。8. 总结如果只让我给一份最短结论清单我会写成下面 6 条torch.compile可能让首个请求明显变慢这不等于它没有价值而是你把编译成本算进了首轮。真正容易打穿线上 p99 的经常不是 steady-state而是新 shape 触发的重编译。dynamicTrue适合 batch/seq_len 天然波动的服务它不能消灭编译成本但能减少 shape 切换时的突刺。如果首请求 SLA 特别紧regional compilation 往往比整模型一把梭更稳。TORCH_LOGSrecompiles/guards/dynamic是排障必备不要等线上告警了才临时打开。开了reduce-overhead后要理解 CUDAGraph 的运行边界必要时显式调用torch.compiler.cudagraph_mark_step_begin()。我的建议是先把你的服务按“固定 shape / 动态 shape / 首请求 SLA 是否敏感”分型再决定是固定 bucket、开dynamicTrue还是改成 regional compilation。这比盲目讨论“compile 能不能提速”更接近真实工程。9. 参考与延伸阅读PyTorch 2.9 Release Bloghttps://pytorch.org/blog/pytorch-2-9/torch.compileAPI 文档PyTorch 2.9https://docs.pytorch.org/docs/2.9/generated/torch.compile.htmlRecompilation 文档PyTorch 2.9https://docs.pytorch.org/docs/2.9/compile/programming_model.recompilation.htmlDynamic Shapes 文档PyTorch 2.9https://docs.pytorch.org/docs/2.9/torch.compiler_dynamic_shapes.htmlCUDAGraph Trees 文档PyTorch 2.9https://docs.pytorch.org/docs/2.9/torch.compiler_cudagraph_trees.htmlRegional Compilation 教程https://docs.pytorch.org/tutorials/recipes/regional_compilation.htmlPyTorch issue #97783The first epoch is very slow when using torch.compilehttps://github.com/pytorch/pytorch/issues/97783PyTorch issue #118492torch.compile(dynamicTrue)compiles foreverhttps://github.com/pytorch/pytorch/issues/118492