深夜的“模型餐厅”后厨灯火通明。客人用手机、汽车、摄像头点了一桌满汉全席——那是 PyTorch、TensorFlow 这些高端菜单。而 TVM 这家餐厅有位叫“小灶”的工程师,他的工作不是做菜,而是把每一道大菜翻译成后厨真正能照做的“菜品分解单”:鸡丁怎么切、酱汁怎么调、先过油还是先爆香。这张分解单,就是 RELAY IR。更妙的是,小灶不会拿着分解单直接冲进厨房,而是先花几分钟把工序优化一遍:把能合并的下锅步骤合并,把没人点的装饰菜砍掉,把酱汁提前熬好,把食材按灶台尺寸切好。这个过程,就是 TVM 的图优化。AI 模型在真正进入硬件执行前,先被这双看不见的手“精加工”一遍,于是推理速度更快、内存占用更少——客人吃得更香,餐厅翻台率更高。
掌握 RELAY IR 图优化,你就能在不动硬件的前提下,让模型的推理性能再上一个台阶。这是 AI 编译器工程师的基本功,也是从“会跑模型”走向“会优化模型”的关键一跃。
目录
可视化演示
展示TVM编译器在RELAY IR上的三种核心优化Pass:算子融合、常量折叠与死代码消除、布局变换,含交互切换与Pass深度调节。
本节配有交互动效(进入视口后自动加载,约 3KB)。
算子融合优化Pass 算子融合将相邻算子合并为单个kernel,消除中间张量读写,Conv+BN+ReLU融合后减少2次内存访问 融合策略:injective(元素级)、reduce(规约)、all(全融合),TVM根据算子类型自动选择最优融合级别 fuse: Conv→BN→ReLU ⇒ ConvBNReLU (1 kernel) ; mem_reduce: 2× fewer 融合减少kernel launch开销
RELAY IR 基础:读懂菜品分解单
客人点了一道“宫保鸡丁”,前厅把需求写成了洋洋洒洒的调味描述。但后厨真正需要的是一张分解单:鸡丁切 1.5 厘米见方、花生米提前炸脆、酱汁按糖醋酱油 1:2:3 兑好。RELAY IR 就是 TVM 的这张分解单。小灶接手任何一个深度学习模型,第一步都是把它翻译成 RELAY IR 计算图——一张只关心“数据怎么流”的图纸。
这张图纸上,每一个节点就是一道工序(一个算子),比如卷积、加法、ReLU 激活;每一条有向边就是食材的流向(数据的依赖),上游工序产出的张量喂给下游工序。和普通计算图不同,RELAY IR 还具备函数式语言的表达能力:变量、常量、函数调用、类型标注一应俱全。每个节点都带上了 shape 和 dtype 信息,这让后续优化器不必反复猜测“这一步到底在加工什么形状的食材”。
把模型转成 RELAY IR,通常由前端完成。TFLite、PyTorch、ONNX 这些“高级菜单”经过各自的前端解析器,统一翻译成 RELAY。“宫保鸡丁”的写法千变万化,但分解单的格式只有一个。用数学语言来描述这张图:
分解单越清晰,优化师越有底气。小灶每次拿到 RELAY IR,就像厨师长拿到一张字迹工整、原料标注齐全的分解单:哪道工序吃什么、产出什么、喂给谁,一目了然。接下来,他才好施展真正的优化手艺。
- 节点集合 $V$图中的每个圆圈代表一个算子,比如 conv2d、add、relu。它们是分解单上的每一条工序,$V = \{v_1, v_2, \dots, v_k\}$ 表示这张图一共有 $k$ 道工序。
- 有向边集合 $E$每条边 $(v_i, v_j)$ 表示 $v_i$ 的输出张量作为 $v_j$ 的输入,也就是食材从工序 $i$ 递到工序 $j$。边的方向就是数据流的方向。
- 图 $G$ 封装$G = (V, E)$ 把节点和边打包成一个整体,所有图优化 Pass 都在这个 $G$ 上做文章——增删节点、改写边、整体变形。
Pass 体系架构:后厨管理手册
厨师长不会冲到灶台前乱喊一气。后厨墙上贴着一本《管理手册》:每项任务是一张任务卡,规定“做且只做一件事”;任务卡按顺序排成一列,做完一张打一个勾。TVM 的图优化也是这样组织起来的。优化动作被封装成一个个 Pass——“扫描计算图,把能提前算的常量算掉”是一张卡,“把相邻算子合并”是另一张卡。
在 TVM 中,Pass 是作用于 IR 的一次确定性变换。它的基类是 tvm.transform.Pass,RELAY 层则提供了一系列内置 Pass:FuseOps、FoldConstant、EliminateCommonSubexpr、DeadCodeElimination、ConvertLayout 等。每个 Pass 由 PassInfo 记录名称、所属优化级别和依赖的 Pass。PassContext 是那块记录板,负责保存配置参数、统计信息和调试输出。
多个 Pass 可以按顺序串成一条优化管线,交给 relay.transform.Sequential 执行。比如一个典型管线:先 InferType 补全类型,再 FoldConstant 折叠常量,接着 FuseOps 做融合,最后 DeadCodeElimination 清理垃圾节点。数学上看,管线就是函数的复合:
有了这本“后厨管理手册”,小灶再也不用担心优化动作混乱。每张任务卡只做一件事,做完打个勾;任务卡与任务卡之间的先后,由管线严格把关。后厨的秩序,就是性能的起点。
- 输入模块 $M$$M$ 是待优化的 IRModule,相当于一张待加工的原始分解单。
- 内部先执行 $P_1$管线最先应用 $P_1$,它把 $M$ 变成 $P_1(M)$,比如先做类型推断。
- 逐层复合 $P_2, \dots, P_n$前一个 Pass 的输出作为后一个的输入,层层嵌套,最终得到 $\text{passes}(M)$。这要求 Pass 之间严格有序,后一个 Pass 必须能接受前一个的输出格式。
算子融合:合并工序的大火快炒
做一道小炒黄牛肉,如果“切肉”、“腌肉”、“爆炒”各用一个灶台,牛肉每挪一次窝都要从热锅里盛出来、晾凉、再回锅——香气跑了,时间也耗了。算子融合就是四合一的灵感:把“腌肉 + 爆炒 + 颠勺 + 出锅”合并成一气呵成。在 RELAY IR 里,这意味着把多个算子合并为一个“融合算子”,整条链只访问一次输入、写一次输出。
FuseOps 是 TVM 里负责融合的 Pass。它会先构建计算图的数据流 DAG,用访问函数统计每个中间张量被下游消费的次数;再根据融合规则——通常 elementwise 算子(加、乘、激活)可以和平行结构的算子合并,卷积等重型算子作为组的“锚点”——把匹配的节点圈进同一个融合组。融合后的节点会变成一个复合算子,比如 fused_nn_conv2d_add_relu,后端代码生成时直接为它生成一个完整的内核。
融合最大的收益是减少内存访存。假设一条链上有 $n$ 个串行算子,每个算子都得把中间结果写回内存、再读回来:
融合就像小灶最拿手的小炒黄牛肉:主料辅料同锅,食材在高温中一气呵成,菜品出锅时锅气还在。模型推理的“锅气”,就是少了几次冷冰冰的内存往返。
- 融合前访存 $M_{\text{before}}$$n$ 个算子串联时,中间张量要经历“写回内存 + 再从内存读入”,每个算子产生 $2$ 次访存。写成 $M_{\text{before}} = 2n$。
- 融合后访存 $M_{\text{after}}$合并为单一 kernel 后,中间数据停留在寄存器或缓存里,只有链首读一次输入、链尾写一次输出:$M_{\text{after}} = 2$。
- 加速倍数两者相除,得到 $\text{speedup} = n$。这解释了为什么卷积 + 批归一化 + 激活的“铁三角”融合能让推理明显变快——三次访存变成了从头到尾一次流畅的翻炒。
常量折叠:提前熬好的高汤
老店的招牌菜为什么出餐快?因为高汤凌晨四点就熬好了,酱汁提前三天发酵完毕。客人点单时,后厨只需从冰箱里取出成品。常量折叠干的正是这件事:把编译期就能算出来的子图,提前算出结果,替换成一张“成品标签”。
FoldConstant 遍历计算图,对每个算子检查它的所有输入:如果输入全部是 Constant 节点,且算子本身没有副作用、结果不依赖运行时状态,就当场在编译期执行计算,把结果包装成一个新的常量节点。折叠后的常量会继续向图的下游传播——下游算子的输入突然也全变成常量了,于是它们也被折叠,像多米诺骨牌一样推倒一片。
这也为“算子特殊化”铺了路:当权重在编译期已知,后端可以选用针对该权重特性的优化实现。折叠的触发条件用逻辑谓词表达:
小灶常说,聪明的后厨不把功夫花在客人眼皮底下。高汤提前熬、酱汁提前调,客人点菜时直接端上桌——常量折叠就是那个“提前”的智慧。
- 输入全常量$\forall a \in \text{args}(v)$ 表示遍历算子 $v$ 的所有输入参数,每个参数 $a$ 都满足 $\text{is\_const}(a)$,即都是常量节点。
- 无副作用$\lnot \text{has\_side\_effect}(v)$ 表示算子 $v$ 不依赖外部状态,比如不能是随机数生成或带 I/O 的操作,否则编译期执行会和运行时行为不符。
- 两者同时成立$\iff$ 说明这是充要条件:只要满足上述两点,编译器就可以放心地执行 $v$,并用常量结果替换它。
死代码消除:砍掉不上桌的雕花
米其林餐厅的一道摆盘,厨师会花二十分钟雕一朵萝卜花,但客人根本不吃它,最后直接扔进垃圾桶。这朵花就是“死代码”——计算结果不会被任何输出使用,纯属浪费精力。在小灶的分解单上,这样的工序越多,后厨就越忙乱。
死代码消除(DCE)从图的输出节点出发,反向做可达性分析:从最终输出能追溯到的节点,都是“活”的,必须保留;追不到的节点,就是雕花,可以删除。但删除有一个前提——节点没有副作用。如果一个节点虽然输出没人用,但它会修改全局状态或产生外部效果,编译器必须保守地保留它。RELAY 的 DeadCodeElimination 会清理 Let 绑定中那些无效绑定,让图变得干净。
用集合的眼光看,保留的节点就是输出节点的所有上游祖先:
小灶的厨房有个规矩:不做端不上桌的菜。死代码消除把这条规矩写进了编译器——后厨每一分钟精力,都只花在真会被吃掉的菜上。
- 输出集合 $O$$O$ 是计算图的最终输出节点列表,也就是真正要端上桌的菜。后厨所有劳动,最终只为它们服务。
- 反向可达的活节点从任意输出 $o$ 出发,沿着数据依赖边反向遍历,能碰到的一切节点都加入 $\text{live}$ 集合。它们都是“被需要的工序”。
- 删除差集$V \setminus \text{live}$ 是整张图减去活节点后剩下的部分,这些节点没有任何输出依赖,可以直接从图中移除。
布局变换:按灶台调整切配方式
同样一块牛排,中餐大厨切成条爆炒,西餐大厨整块煎烤。食材本身没变,但切配方式必须服从灶台的脾气。张量在内存里的排布也是这样:NCHW 和 NHWC 是两种最常见的“切配方式”,不同硬件对不同排布有截然不同的偏好。
NCHW 把通道数放在最前面,GPU 上的卷积算子通常喜欢这种排布,因为可以高效利用张量核心;NHWC 是“通道后置”,CPU 上做 SIMD 向量化时访存更连续,很多端侧推理引擎也偏好它。布局变换 Pass 会在必要时插入布局转换节点,把张量从一种排布转到另一种。ConvertLayout 和 AlterOpLayout 是这里的两个主力:前者负责数据重排,后者负责把算子重写成更适合目标布局的实现。
排布方式决定了内存访问的步长。以一张 $N \times C \times H \times W$ 的图为例,两种布局下同一像素的偏移完全不同:
小灶切菜前总要先看一眼灶台:是猛火炒锅还是平底煎锅。布局变换就是那张“按灶台切配”的任务卡——食材还是那些食材,切法对了,下锅效率翻倍。
- NCHW 索引$n$、$c$、$h$、$w$ 分别是批次数、通道号、高度、宽度。NCHW 把 $c$ 放在第二维,因此遍历空间位置时,同通道数据连续排列。
- NHWC 索引NHWC 把 $c$ 放在最后一维,同一像素点的 $C$ 个通道值相邻,这种排布在 CPU 上做逐像素 SIMD 向量化时,可以一次加载多个通道。
- 两种排布对比两个公式唯一的结构区别是乘法的结合顺序,它决定了内存跳跃的步长。布局变换就是在图里插入转置节点,把数据从一种步长模式切到另一种。
Pass 组合调优:排班表的艺术
单张任务卡再漂亮,排班顺序错了也白搭。先熬高汤再砍菜谱,砍的时候才能顺手扔掉过期的酱料;先做布局变换再算子融合,插入的转置节点可能正好挡在融合模式中间。Pass 组合调优,就是小灶每天收工前在白板上反复琢磨的那件事:明天这几张任务卡,到底谁先谁后、谁去谁留?
一些顺序依赖是硬性的:FuseOps 前通常要跑 InferType,否则融合时无法确认数据形状;FoldConstant 产生的孤立常量节点,正好交给紧随其后的 DeadCodeElimination 清理,所以常折一般在前、DCE 在后。而布局变换与融合的先后,则需要根据目标硬件实测判断。TVM 的 PassContext 提供了 opt_level 和配置项,方便按需裁剪 Pass;relay.build 后的 profiling 数据,则是检验组合效果的唯一标尺。
组合调优的最终目标,是让端到端延迟降到最低:
同样的后厨、同样的菜单、同样的食材,排班表排得好,翻台率就能翻倍。Pass 组合调优没有银弹,只有对着 profiling 数据一遍遍试——这正是图优化最有意思的部分。
- 优化前延迟$\text{latency}_{\text{before}}$ 是未做图优化时的模型推理耗时,作为基准线,相当于没排班时的后厨一团乱麻。
- 优化后延迟$\text{latency}_{\text{after}}$ 是应用一组 Pass 后的推理耗时,反映了排班表的实际效果。
- 加速比公式两者相除得到 $\text{speedup}$。大于 1 说明优化有效;接近 1 甚至小于 1,就要回头调整 Pass 组合和顺序。
AI 应用场景
移动端实时图像分类加速
阶段:模型部署优化(图优化阶段)
解决的问题:手机端内存带宽有限,原始 PyTorch 模型直接在端侧跑,卷积 + 激活的中间张量反复读写内存,延迟高、发热大。通过在 RELAY IR 上启用 FuseOps 算子融合和 ConvertLayout 布局变换,把 conv+bn+relu 三合为一,中间结果留在缓存里,推理延迟可下降 30% 以上。
具体案例:某图像识别 App 将一个 MobileNetV2 模型转为 RELAY IR 后,开启 opt_level=3 的 Pass 管线,融合了主干中的连续卷积和激活层,并调整布局适配手机 CPU 的 NHWC 偏好,最终端到端推理从 45ms 降到 28ms。
推荐系统在线推理低延迟
阶段:推理服务优化(图优化阶段)
解决的问题:推荐系统需在高并发下保持毫秒级响应,模型里的 embedding 拼接、常量矩阵等有大量可提前计算的部分。通过 FoldConstant 把与输入无关的子图在编译期算完,再用 DeadCodeElimination 清掉无效分支,图规模变小,在线推理时少做无用计算。
具体案例:某公司广告点击率预估模型每请求需处理上百个特征,应用常量折叠后,原本每次推理都要重复计算的统计量变成打包好的常量,单请求延迟下降 18%,服务端 GPU 吞吐提升约 25%。
自动驾驶多传感器模型部署
阶段:车载芯片适配优化(图优化阶段)
解决的问题:自动驾驶同时运行检测、分割多个模型,GPU 资源紧张。通过 FuseOps 融合检测模型中的卷积链,结合布局变换匹配车规芯片的 tensor 排布,减少 kernel 启动次数和显存占用,提高整体 GPU 利用率。
具体案例:某自动驾驶团队将视觉检测模型通过 TVM 编译,配合自定义 Pass 组合(先融合后布局变换再常量折叠),在车规级 GPU 上实现了三个模型并行推理,帧率从 18 FPS 提升到 30 FPS。
Python 演示
class Node: def __init__(self, op, args=None, value=None): self.op = op # 算子类型: add / mul / const self.args = args or [] # 输入节点名列表 self.value = value # 常量节点保存数值 class Graph: def __init__(self, nodes): self.nodes = dict(nodes) def is_const(node): return node.op == "const" def constant_fold(graph): changed = True while changed: changed = False for name, node in graph.nodes.items(): if not is_const(node) and all(is_const(graph.nodes[a]) for a in node.args): vals = [graph.nodes[a].value for a in node.args] if node.op == "add": node.value = vals[0] + vals[1] elif node.op == "mul": node.value = vals[0] * vals[1] node.op = "const" node.args = [] changed = True return graph def dead_code_eliminate(graph, outputs): alive = set() stack = list(outputs) while stack: name = stack.pop() if name in alive: continue alive.add(name) stack.extend(graph.nodes[name].args) graph.nodes = {n: graph.nodes[n] for n in graph.nodes if n in alive} return graph def run_pipeline(graph, outputs): graph = constant_fold(graph) graph = dead_code_eliminate(graph, outputs) return graph def main(): g = Graph({ "c1": Node("const", [], 2.0), "c2": Node("const", [], 3.0), "sum": Node("add", ["c1", "c2"]), "dead": Node("mul", ["c1", "c2"]), "out": Node("add", ["sum", "c1"]), }) g2 = run_pipeline(g, ["out"]) print("optimized nodes:", sorted(g2.nodes)) print("out value:", g2.nodes["out"].value) main()
class Node:每个节点像分解单上的一道工序,记录算子类型、输入依赖和常量值。def constant_fold(graph):这是“常量折叠” Pass,对应 TVM 的FoldConstant。if not is_const(node) and all(...)折叠触发条件:该算子的所有输入都已是常量,才值得现场算掉。node.value = vals[0] + vals[1]编译期直接求值,像后厨提前熬好的高汤,运行时直接取用。def dead_code_eliminate(graph, outputs):这是“死代码消除” Pass,对应 TVM 的DeadCodeElimination。stack.extend(graph.nodes[name].args)从输出节点反向扩散,把上游依赖全部标记为“活”。graph.nodes = {n: graph.nodes[n] for n in ...}只保留活节点,dead 节点被过滤掉,就像砍掉不上桌的雕花。g2 = run_pipeline(g, ["out"])按顺序执行两个 Pass,展示管线思想的雏形。
常见误区
误区我理解了 FuseOps 和 FoldConstant 这些图优化 Pass,那整个 TVM 的优化是不是就都搞定了?模型性能应该直接拉满了吧。
正解图优化只是“排班表”,它决定哪些工序合并、哪些提前做、哪些砍掉,但真正决定每一步怎么炒得快的,是算子级优化(TIR 层)、AutoTVM 模板、后端代码生成和底层库。一个融合算子如果没有高效的 kernel 实现,照样跑不快。图优化负责减少开销,算子级优化负责提升计算本身效率,两者缺一不可。
为何常见:因为图优化在 IR 层面效果直观,容易让人误以为它覆盖了编译全流程,忽略了 TVM 的多层栈设计。
误区优化嘛,肯定多多益善。我把 TVM 提供的所有 Pass 全部塞进 Sequential,优化级别开到最大,性能一定最好。
正解Pass 不是越多越好。比如布局变换插入的转置节点可能阻断后续算子融合的模式匹配;某些 Pass 之间还会互相抵消收益,甚至引入额外拷贝。合理做法是根据目标硬件和模型结构,选择少量必要的 Pass 并调好顺序,再用 profiling 数据验证。五个精心搭配的 Pass 往往胜过二十个无脑堆叠。
为何常见:人容易把“多”等同于“全”,但编译器优化是有约束的资源分配问题,副作用往往藏在不直观的交互里。
误区常量折叠把一堆计算提前算好,那模型文件里岂不是要塞进更多预计算结果?部署包肯定要变大了吧。
正解折叠后的常量通常会以权重/参数的格式存储在模型或可执行文件里,体积变化通常很小,因为原本的常量数据本来就在。折叠省去的不是存储,而是运行时的计算和临时内存。真正需要担心体积的是量化、剪枝等手段,常量折叠的存储代价可以忽略不计。
为何常见:人们看到“提前计算结果”直觉上觉得多存了东西,忽略了这些常量原本就以某种形式存在。
误区FuseOps 太香了,把能融合的全都融进来,访存少了,速度肯定全面提升。
正解融合也可能引入负面效果。如果一个中间张量被多个分支消费(访客众多),融合后它可能要在同一 kernel 里被重复计算多次;某些融合会让寄存器压力过大,触发寄存器溢出,反而变慢。TVM 的融合 Pass 通过访问函数分析使用次数,正是为了规避这类情况,但特殊模型下仍需实测确认。
为何常见:看到了融合在典型 CNN 上的收益后,容易把它推广为“万灵药”,忽略了数据流拓扑的多样性。
面试题
Q1. RELAY IR 在 TVM 中承担什么角色?
- A.图级优化的中间表示← 正确答案
- B.直接生成机器码的汇编
- C.硬件指令的描述语言
- D.前端框架的源码格式
答案:A。RELAY IR 是 TVM 的高层图级 IR,前端把 PyTorch/TensorFlow 模型翻译成它,图优化 Pass 在它上面执行。汇编是后端 TIR 和 codegen 的事,故选 A。
Q2. 下面哪个是典型的图优化 Pass?
- A.FuseOps 算子融合← 正确答案
- B.CUDA 内核编译
- C.显存池分配
- D.模型量化算法
答案:A。FuseOps、FoldConstant、DeadCodeElimination 等都是 RELAY IR 图优化 Pass。CUDA 编译和显存管理属于后端与运行时,量化属于另一个优化维度。
Q3. 算子融合最直接的性能收益是什么?
- A.减少中间张量的访存← 正确答案
- B.减少模型参数量
- C.降低计算精度
- D.压缩输入图像的尺寸
答案:A。融合把多个算子合成一个 kernel,中间结果不再写回内存又读回,访存次数从 2n 降到 2。它不改变参数和精度,故选 A。
Q4. 常量折叠的触发条件是什么?
- A.算子所有输入都是常量← 正确答案
- B.算子是卷积操作
- C.模型采用动态形状
- D.权重经过随机初始化
答案:A。FoldConstant 要求算子的每个输入都是编译期常量且算子无副作用,才能在编译期求值。卷积、动态形状、随机权重都不满足“全常量”前提。
Q5. 死代码消除删除节点的关键依据是什么?
- A.无副作用且输出不可达← 正确答案
- B.算子的计算量比较小
- C.节点位于图的早期层
- D.输入张量的尺寸较小
答案:A。DCE 从最终输出反向做可达性分析,只有“输出不被任何输出使用”且“无副作用”的节点才可删。计算量、位置、尺寸都不是判据。
Q6. PassContext 在 TVM 中的作用是什么?
- A.配置 Pass 并记录优化状态← 正确答案
- B.代替前端完成模型转换
- C.直接执行融合后的算子
- D.为算子分配 GPU 显存
答案:A。PassContext 保存优化选项、统计信息和调试配置,是 Pass 执行的“环境记录板”。模型转换由前端负责,算子执行归后端与运行时。
Q7. 布局变换把 NCHW 转为 NHWC 的主要目的是?
- A.匹配硬件的访存偏好← 正确答案
- B.增大模型的存储体积
- C.取消层间依赖关系
- D.减少卷积层的计算量
答案:A。NCHW 与 NHWC 数据内容等价,只是内存排布不同。NHWC 在 CPU SIMD 等场景下访存更连续,所以布局变换是为了匹配硬件访存偏好,不改变计算量。
Q8. 先布局变换后算子融合,最容易出现什么问题?
- A.插入的转换算子阻碍融合匹配← 正确答案
- B.融合后参数量成倍增加
- C.常量折叠不再有效
- D.死代码消除被自动跳过
答案:A。布局变换会在图中插入 layout transform 节点,这些节点可能打断 FuseOps 期望的连续算子链,使融合模式匹配失败。参数和常量折叠不受影响,故选 A。
Q9. 下面哪些优化发生在 RELAY IR 图优化阶段?
- A.算子融合← 正确答案
- B.常量折叠← 正确答案
- C.内层循环展开
- D.死代码消除← 正确答案
答案:ABD。算子融合、常量折叠、死代码消除都是 RELAY IR 上的 Pass。内层循环展开发生在 TIR(张量表达式)一级,属于算子级优化,不是图优化。
Q10. 关于算子融合,下列说法正确的有哪些?
- A.conv+bn+relu 常被融合← 正确答案
- B.融合减少中间结果写回← 正确答案
- C.融合必定跳过激活函数
- D.融合受硬件调度限制← 正确答案
答案:ABD。卷积+批归一化+激活是经典融合组合;融合让中间结果留在寄存器或缓存,减少内存写回;但融合后 kernel 更复杂,受寄存器与调度能力限制。融合不会跳过激活,C 错误。