1. 推理 Infra 里的图模式到底在解决什么问题
做推理优化的人迟早会撞上一堵墙:模型结构明明没变,换了个 batch size 或者序列长度,性能就掉一大截。你盯着 profiler 看,kernel 一个接一个,中间夹杂着大量细碎的操作,GPU 利用率像心电图一样忽高忽低。这时候老手会告诉你,别死磕单个 kernel 了,去看图。
图模式(graph mode)这个词在推理 infra 圈子里被反复提起,但它经常被讲得很玄。我自己的理解是:图模式本质上是一种把“逐行执行”变成“先规划、再执行”的调度策略。你告诉框架要算什么,框架先把整个计算过程描述成一张有向无环图,然后在这张图上做全局优化——算子融合、内存复用、并行调度、kernel 选择——最后交给硬件一口气跑完。
这里有个关键概念叫 recipe。你可以把它理解成“图的菜谱”:它不直接是那张图,而是描述了怎么把用户的计算逻辑翻译成图、用哪些规则去优化、最终生成什么样的执行计划。recipe 是图模式的配置层,决定了图长什么样、怎么被优化、怎么落到硬件上。而 exe_mode 则是执行模式的选择开关,它决定了这次推理是走 eager 逐算子执行,还是走 graph 整图执行,或者是两者的混合。
为什么这件事值得单独拿出来讲?因为推理 infra 的性能瓶颈,很多时候不在算力本身,而在调度开销和内存搬运。逐算子执行时,每个算子都要经历一次 host 到 device 的调度往返,kernel launch 的开销累积起来非常可观。图模式把这些开销摊薄到整张图上,同时给了编译器做全局优化的空间。实测下来,在中小模型、动态 shape 不极端的场景里,图模式能带来百分之二三十的端到端提升,有些算子密集的模型甚至更高。
这篇文章适合谁看?如果你正在做推理服务部署,被延迟和吞吐折腾过;如果你在写自定义算子,想知道它怎么被图模式接纳;如果你只是好奇 recipe 和 exe_mode 这些词到底指什么,那这篇内容应该能给你一个从概念到落地的完整视角。我会尽量用从业者之间聊天的口吻,把图模式从 recipe 到硬件执行这条链路拆开讲清楚。
2. 图模式的核心设计思路与方案选型
2.1 为什么是图,而不是继续优化单算子
先说一个我踩过的坑。早期做推理优化,我的思路是逐个算子调优:这个 matmul 换更好的 tiling,那个 layernorm 手写融合。单看每个 kernel 的耗时都在下降,但端到端延迟就是不动。后来用 trace 工具一看,问题出在算子之间的空隙——每个 kernel 执行完,GPU 要等 host 下发下一个指令,这个等待时间加起来比 kernel 本身还长。
这就是图模式要解决的核心矛盾:单算子最优不等于全局最优。逐算子执行时,调度器是“近视”的,它只能看到当前这个算子,看不到后面还有什么。而图模式把整个计算过程摊开,调度器能看到全局依赖关系,于是可以做几件逐算子做不到的事。
第一是算子融合。相邻的 element-wise 操作可以合并成一个 kernel,减少 kernel launch 次数和中间结果的读写。第二是内存规划。图模式下编译器能算出每个中间张量的生命周期,把可以复用的内存块提前分配好,避免频繁的 malloc/free。第三是并行调度。没有依赖关系的分支可以并行执行,充分利用硬件的多流能力。
那为什么不是所有场景都上图模式?因为图模式有它的代价。构建图本身有开销,动态 shape 会导致图反复重编译,调试也比逐算子困难得多。所以方案选型的核心,是判断你的场景更适合哪种模式。
2.2 recipe 的角色:图模式的配置抽象
recipe 这个词用得很妙,它确实像菜谱。菜谱不直接是菜,它告诉你放什么料、什么顺序、火候怎么控制。recipe 在图模式里承担的是同样的角色:它是一组规则的集合,描述了从计算逻辑到执行图的转换方式。
具体来说,recipe 通常包含这几类信息。图构建规则:哪些操作应该被捕获进图,哪些应该留在图外。比如有些控制流或者动态逻辑,强行入图反而会带来问题。优化 pass 配置:开启哪些图优化,算子融合的激进程度,内存复用的策略。硬件映射规则:图上的算子怎么落到具体硬件,用哪个 kernel 实现,精度怎么处理。
我见过不少团队把 recipe 当成一个黑盒配置,改个参数就完事。但真正理解 recipe 的结构后,你会发现它是调优的抓手。比如你的模型里有大量小算子,那就在 recipe 里把融合阈值调低,让更多算子有机会合并。如果你的场景对精度敏感,那就在 recipe 里指定某些算子必须用高精度实现,不参与融合。
recipe 的另一个价值是可复现性。同一个模型配同一份 recipe,在不同机器上应该得到一致的执行图。这对线上服务很重要,你不想因为换了台机器,性能表现就完全变了。
2.3 exe_mode:执行模式的选择逻辑
exe_mode 是更上层的开关,它决定这次推理走哪条路。常见的取值有几种:纯 eager 模式,纯 graph 模式,以及混合模式。
纯 eager 模式就是逐算子执行,灵活、调试方便,适合开发阶段和动态性极强的场景。纯 graph 模式把整个前向过程捕获成图,一次性执行,适合稳定、追求极致性能的线上服务。混合模式则是折中,把图中稳定的部分固化,动态的部分保持 eager。
选 exe_mode 的时候,我一般看三个维度。动态性:如果输入 shape 变化频繁,纯 graph 会反复重编译,这时候混合模式或者带 shape 缓存的 graph 模式更合适。性能敏感度:延迟和吞吐要求越高,越应该往 graph 靠。调试需求:开发期用 eager,上线前切 graph,这是比较稳妥的节奏。
有个经验是,不要一上来就追求全图。先把热点区域入图,观察收益和稳定性,再逐步扩大范围。全图模式虽然理论收益最大,但一旦出问题,排查成本也最高。
3. 从 recipe 到硬件执行的关键细节
3.1 图捕获阶段:哪些操作该入图
图捕获是整个链路的第一步,也是最容易出问题的一步。捕获的本质是记录计算过程,把算子调用和它们之间的数据依赖关系记下来。但并不是所有操作都适合入图。
适合入图的操作有几个特征:计算密集、shape 相对稳定、没有复杂的 host 侧副作用。比如卷积、矩阵乘、归一化、激活函数这些,都是图模式的常客。不适合入图的则包括:依赖 Python 控制流的逻辑、需要读取 host 内存的操作、动态创建张量的行为。
我在实际项目里遇到过一个问题:模型里有个根据输入长度动态选择分支的逻辑,强行入图后,图捕获阶段只记录了当时走的那条分支,换个输入长度结果就错了。后来把这段逻辑留在图外,用 exe_mode 的混合模式处理,问题才解决。
捕获阶段还有个细节是张量标记。你需要明确告诉框架,哪些张量是图的输入,哪些是输出,哪些是中间状态。输入输出的边界定义清楚了,图才能被正确切分和优化。中间状态的标记则影响内存规划,标记得越准确,内存复用效率越高。
提示:图捕获时尽量用静态 shape 或者有限的 shape 集合。如果 shape 完全动态,考虑在 recipe 里配置 shape 缓存策略,避免每次新 shape 都触发重编译。
3.2 图优化阶段:recipe 里的 pass 怎么配
图捕获完成后,就进入优化阶段。这个阶段 recipe 的作用最明显,它决定了哪些优化 pass 被启用、以什么顺序执行。
常见的图优化 pass 包括:常量折叠,把编译期能算出来的值提前算好;死代码消除,去掉对输出没有贡献的节点;算子融合,把多个小算子合并成一个大算子;布局转换消除,减少不必要的 transpose;内存复用,给生命周期不重叠的张量分配同一块内存。
配置这些 pass 的时候,顺序很重要。一般先做常量折叠和死代码消除,把图简化;再做算子融合,因为融合后的图结构变了,后面的内存规划要基于新结构来做;最后做内存复用和布局优化。
融合的激进程度是个需要权衡的参数。融合得太保守,收益有限;融合得太激进,可能生成超大 kernel,反而导致寄存器溢出或者 occupancy 下降。我的经验是,先从默认配置开始,用 profiler 看融合后的 kernel 耗时和 occupancy,再逐步调整。
这里补充一个参数计算的例子。假设你有两个连续的 element-wise 算子,各自读写一次张量,张量大小是 N。不融合时,内存访问量是 4N(两次读两次写)。融合后,只需要读一次写一次,内存访问量降到 2N。如果这两个算子都是 memory-bound 的,融合带来的理论收益就是接近一倍的带宽节省。这个计算能帮你判断融合是否值得。
3.3 硬件映射阶段:图怎么落到具体设备
优化完的图还是抽象的,需要映射到具体硬件才能执行。这个阶段的核心工作是算子选择和kernel 生成。
算子选择是指,图上的一个逻辑算子,可能有多种硬件实现。比如一个矩阵乘,可以用不同的 tiling 策略、不同的指令集、不同的精度。recipe 里可以指定选择策略,比如优先选延迟低的,还是优先选吞吐高的。
kernel 生成则是把选定的实现真正编译成硬件能执行的代码。这一步可能涉及即时编译,也可能查预编译的 kernel 库。即时编译灵活但首次执行慢,预编译快但覆盖的场景有限。实际系统里通常是两者结合,常见 shape 走预编译,特殊 shape 走即时编译。
硬件映射阶段还有个容易被忽略的点是精度处理。图优化可能会改变计算顺序,而浮点运算不满足结合律,顺序变了结果可能有微小差异。对精度敏感的场景,需要在 recipe 里锁定某些算子的计算顺序,或者指定用更高精度累加。
4. 完整实操流程与关键环节实现
4.1 环境准备与基础配置
动手之前先把环境理清楚。你需要一个支持图模式的推理框架,以及配套的 profiler 工具。框架方面,主流的选择都提供了图模式接口,具体用哪个看你的技术栈。profiler 是必须的,没有它你无法判断图模式到底带来了什么变化。
配置上,我建议先建立一个 baseline。用 eager 模式跑一遍你的模型,记录端到端延迟、各算子耗时、GPU 利用率。这个 baseline 是后面所有对比的参照。没有 baseline 就上图模式,你根本不知道收益来自哪里。
基础配置里还要确认几件事:框架版本是否支持你需要的图优化 pass,硬件驱动是否满足要求,是否有已知的兼容性问题。这些看起来是琐事,但版本不匹配导致的诡异问题,我见过太多次了。
4.2 编写第一份 recipe
recipe 的写法因框架而异,但结构大同小异。下面是一个概念性的示例,展示 recipe 通常包含哪些部分。
recipe = { "capture": { "include_ops": ["conv", "matmul", "layernorm", "gelu"], "exclude_ops": ["dynamic_branch", "host_sync"], "input_shapes": [(1, 128), (8, 128), (32, 128)], }, "optimize": { "constant_folding": True, "dead_code_elimination": True, "fusion": { "enabled": True, "max_fused_ops": 4, "min_fused_ops": 2, }, "memory_reuse": True, }, "codegen": { "precision": "fp16", "accumulate_precision": "fp32", "kernel_selection": "latency_first", }, }这份 recipe 的意思是:捕获阶段只把指定的计算类算子入图,动态分支和 host 同步操作排除在外,同时声明了几组可能的输入 shape。优化阶段开启常量折叠和死代码消除,融合最多四个算子、最少两个,开启内存复用。代码生成阶段用 fp16 计算、fp32 累加,kernel 选择优先考虑延迟。
写第一份 recipe 的原则是保守起步。融合阈值不要设得太激进,精度策略先用默认,shape 集合先覆盖你线上最常见的几种。跑通之后再逐步调优。
4.3 执行模式切换与验证
recipe 准备好后,通过 exe_mode 切换到图模式执行。切换后第一件事不是看性能,而是验证正确性。图模式改变了执行顺序和精度处理,结果可能有细微差异。
验证方法是拿一组固定输入,分别用 eager 和 graph 模式跑,对比输出。差异应该在可接受的数值误差范围内。如果差异过大,先检查 recipe 里的精度配置,再检查是否有算子被错误融合。
正确性验证通过后,再看性能。对比 baseline 的端到端延迟和吞吐,同时用 profiler 看图模式下的 kernel 数量、GPU 利用率、内存占用。正常情况下,kernel 数量应该减少,GPU 利用率应该更平稳,内存占用可能下降。
如果性能没有提升甚至下降,排查方向有几个:图捕获是否覆盖了热点区域,融合是否过于保守,shape 是否频繁变化导致重编译。这些在下一节的问题排查里会详细展开。
4.4 逐步扩大图覆盖范围
第一版 recipe 跑通后,不要急着上线。先分析 profiler,看哪些部分还在图外执行,这些部分是不是热点。如果是,考虑把它们也纳入图捕获。
扩大覆盖范围时,每次只改一个变量。比如这次只增加一类入图算子,下次只调整融合阈值。这样出问题时容易定位。我见过有人一次性改一堆配置,结果性能下降,根本不知道是哪个改动导致的。
覆盖范围扩到一定程度后,会遇到收益递减。这时候要判断,继续扩大是否值得。有些动态性极强的部分,强行入图的收益可能还不如保持 eager。接受一个不完美的图,比追求全图但引入一堆问题要务实。
5. 常见问题与排查技巧实录
5.1 图模式性能不升反降
这是最常见的问题。图模式理论上应该更快,但实际跑下来可能更慢。原因通常有几个。
重编译开销。如果输入 shape 变化频繁,每次新 shape 都触发图重编译,编译时间可能超过执行时间。排查方法是看 profiler 里编译耗时占比。解决办法是在 recipe 里配置 shape 缓存,或者限制 shape 集合。
融合过度。融合生成的超大 kernel 可能导致寄存器溢出,occupancy 下降,反而变慢。排查方法是看融合后 kernel 的 occupancy 和寄存器使用。解决办法是调低融合阈值。
图捕获不完整。热点区域没被捕获进图,图模式只优化了非热点部分,整体收益被稀释。排查方法是看 profiler 里图内和图外执行的耗时占比。
5.2 精度对不上
图模式改变计算顺序后,浮点结果可能有差异。如果差异在合理范围内,可以接受。如果差异过大,需要排查。
先检查 recipe 里的精度配置。计算精度和累加精度是否设置正确,有没有算子被强制降精度。再检查融合是否把不该融合的算子合并了,比如把需要高精度累加的操作融合进了低精度 kernel。
还有一个隐蔽的原因是布局转换。图优化可能插入或消除 transpose,如果处理不当,可能导致数据被错误解读。排查方法是逐层对比 eager 和 graph 的中间输出。
5.3 动态 shape 场景下的图管理
动态 shape 是图模式的天敌。每次 shape 变化都可能触发重编译,性能波动很大。
应对策略有几个。shape 分桶:把连续的 shape 范围划分成几个桶,每个桶用一张图,减少重编译次数。动态 shape 支持:部分框架支持在图上表达动态维度,不需要为每个 shape 单独编译。混合模式:把 shape 敏感的部分留在 eager,其余入图。
我自己的经验是,shape 分桶最实用。根据线上实际的 shape 分布,选几个代表值建图,覆盖绝大多数请求。极端 shape 走 eager 兜底,虽然慢一点,但不会拖累整体。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 性能不升反降 | 重编译开销大 | 看编译耗时占比 | 配置 shape 缓存或分桶 |
| 性能不升反降 | 融合过度 | 看 kernel occupancy | 调低融合阈值 |
| 性能不升反降 | 图捕获不完整 | 看图内外耗时占比 | 扩大捕获范围 |
| 精度差异过大 | 精度配置错误 | 检查 recipe 精度项 | 修正计算/累加精度 |
| 精度差异过大 | 错误融合 | 逐层对比中间输出 | 排除敏感算子 |
| 动态 shape 性能波动 | 频繁重编译 | 统计 shape 分布 | shape 分桶或混合模式 |
| 首次执行特别慢 | 即时编译 | 看首次编译耗时 | 预编译常见 shape |
提示:排查图模式问题时,永远先确认 baseline 是准确的。我遇到过好几次,问题其实出在 baseline 测量方式不对,跟图模式本身没关系。
5.5 几个容易忽略的实操心得
第一个心得是保留 eager 回退路径。图模式再稳定,也可能遇到没覆盖的场景。线上服务一定要有回退机制,图模式出问题时能切回 eager,保证服务不中断。
第二个心得是监控编译事件。图重编译是性能波动的常见来源,把编译事件纳入监控,能提前发现 shape 分布变化或者 recipe 配置问题。
第三个心得是recipe 要版本化。recipe 改了,性能表现可能完全不一样。把 recipe 和模型版本一起管理,出问题时能快速定位是哪次改动引入的。
第四个心得是不要迷信全图。我见过团队花大力气追求全图模式,结果收益还不如把热点区域优化好。图模式是手段不是目的,端到端性能才是目标。
6. 图模式后续可以怎么扩展
图模式跑通之后,还有不少可以深挖的方向。一个是跨模型复用。如果多个模型共享部分结构,可以把这部分抽成公共子图,编译一次多处复用。另一个是与量化结合。图模式下的量化感知优化,能在图层面做更全局的精度和性能权衡。
还有个方向是自动 recipe 调优。recipe 的参数空间不小,手工调优费时费力。用搜索或者学习的方法自动找较优配置,是个有意思的工程问题。我试过用简单的网格搜索,在有限参数上已经能省不少事。
最后分享一个小技巧:图模式调优时,把每次实验的 recipe、性能数据、profiler 截图都存下来。图模式的性能表现和很多因素相关,没有系统记录,你很快会忘记哪次改动带来了什么变化。这个习惯看起来笨,但长期看能省大量重复排查的时间。