你是一家餐馆的主厨,厨房里最耗时间的不是做菜本身,而是“端盘子”。炒完一道菜,半成品得端回冰箱;下一道工序要用,再从冰箱端出来。一来一回,菜还没上桌,腿先跑细了。计算机里的 AI 模型也面临同样的困境:一个个“算子”就像一道道做菜工序,每做完一步,都要把中间结果写回显存(放进冰箱),下一步再读出来(端出来)。更麻烦的是,每次启动一个算子内核,就像重新生一次火、热一次锅。算子融合要做的事,就是研究一份“厨房菜谱”:哪些工序可以当场连着做完、不必中途放回冰箱,把端盘子的次数省到最少。这份菜谱的章法,就叫融合规则。

岗位关联

掌握融合规则,你就能读懂 AI 编译器为什么快、怎么更快,是性能优化和推理部署岗位的必备基本功,也是从“会用模型”走向“会调模型”的关键一跃。

目录
  1. 可视化演示
  2. 融合动机:把“端盘子”省下来
  3. 融合规则:什么能一锅出
  4. 资源约束:灶台不够大
  5. 融合决策:算细账还是凭经验
  6. 融合收益:省了几趟路
  7. 融合模式:经典组合拳
  8. 工程实现:流程化改造
  9. 实践陷阱:乱炖的代价
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示融合规则三维度:模式分类(垂直融合生产者消费者串行合并消除中间访存/水平融合共享输入独立算子合并减少kernel启动)、合法性判断(单消费者安全融合消除访存/多消费者部分融合未融合方仍需VRAM读取不改变计算顺序不破坏依赖)、代价模型DP搜索(P0未融合100%开销→P3最优融合25%开销动态规划枚举所有融合分组选最小代价),三模式切换理解编译器融合决策机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+C1+'

融合动机:把“端盘子”省下来

你后厨里有一锅汤已经炖好,下一步要调味、勾芡。按老规矩,每次工序切换都得把半成品端回冰箱,下道菜再端出来。结果你发现:一天下来,腿比锅铲还累。AI 芯片也一样,一个个算子各干各的活,每完成一个,中间结果就要写回显存;下一个算子再把它读出来。这趟“端盘子”的开销,就是访存开销。

在 AI 编译器的眼里,GPU 上执行一个算子就是启动一次内核,也就是“生一次火”。生火要时间,热锅要时间,把食材从冰箱搬进搬出也要时间。当算子本身算得飞快、而数据搬运占了大头时,这类算子称为访存密集算子;只有像矩阵乘法那样长时间“炖煮”的算子,才是计算密集算子。现实中的模型往往由大量访存密集的小算子组成,比如激活函数、张量加法、归一化,它们的计算量不大,却把时间都耗在了端盘子上。

算子融合的思路很简单,却极为有效:在编译阶段把多个算子揉成一个内核。中间结果不再写回显存,而是直接留在寄存器或缓存里,递给下一个算子。假设一个逐元素算子链有 N 个环节、每个环节处理 S 个元素,融合前后的访存开销差距一目了然:

这就是融合的第一性原理:把端盘子的力气省下来,让厨师专心做菜。明白了为什么省、省在哪里,你才能真正理解后面所有规则,都是为了守住这份“省”。

Mbefore=2NS,Mafter=2SM_{\text{before}} = 2NS, \qquad M_{\text{after}} = 2S
融合前每个中间张量都要写一次、读一次,共 2N 份搬运;融合后只剩首尾各一次,共 2 份。
  1. 中间张量的双重访问链上有 N 个算子,会留下 N-1 个中间张量;每个张量被写一次、读一次,因此按张量份数计共 2N 份访问,每份 S 个元素。
    Mbefore=2NSM_{\text{before}} = 2NS
  2. 融合后只留首尾把所有中间结果都留在“灶台上”,只有最开始的输入读一次、最后的输出写一次。
    Mafter=2SM_{\text{after}} = 2S
  3. 省下的比例用差值除以前值,得到被消除的访存占比,大约是 1 - 1/N。
    R=1−MafterMbefore=1−1NR = 1 - \frac{M_{\text{after}}}{M_{\text{before}}} = 1 - \frac{1}{N}

融合规则:什么能一锅出

“什么都往一锅炖”是新手才犯的错。后厨里的两样菜要同锅,得先问三个问题:下料顺序能不能颠倒?这道半成品是不是只有一个人要用?火候时间合不合拍?算子融合的判断逻辑,和这几乎一模一样。

先看数据依赖。算子构成的执行图里,每条边代表张量从生产者流向消费者。如果算子 A 算出张量 T,算子 B 要读 T 才能继续,那么 A 必须严格排在 B 前面,这叫先写后读依赖,编译术语叫 RAW(Read After Write)。融合必须保持这条边原样存在,绝不能因为合并把先后顺序打乱,否则就像红烧肉还没炖烂就出锅,后面的菜全毁了。

再看“这道菜是不是只有一个人要用”。如果张量 T 被两个下游算子同时消费,比如一个分支去做分类,另一个分支去做重构,那就绝不能把 B 与 A 融合后“顺手”把 T 减少成一次计算——因为融合成单内核后,T 可能不再落到显存里,另一个消费者就拿不到它了。强行融合只能靠重复计算来弥补,往往得不偿失。把这两个条件写成一个判定式:

回到厨房:高汤罐子炖一次,好几个菜都要舀一勺,那就不能为了省一道工序把汤并进某一个菜里。融合规则的底线,是任何消费者都还能拿到属于它的那份数据。

Fuse(P,C) 合法  ⟺  (P→C)∧∣Consumer(TP)∣=1\mathrm{Fuse}(P, C)\ \text{合法} \iff (P \to C) \land \bigl|\mathrm{Consumer}(T_P)\bigr| = 1
融合合法的充要条件:数据确实从 P 流向 C,且 P 的中间结果只有一个消费者。
  1. 生产者到消费者的边用箭头 P → C 表示算子 P 的输出张量直接作为算子 C 的输入。
    P→CP \to C
  2. 消费者集合大小取中间张量 T_P 的所有下游消费者,算出集合的元素个数;只有元素个数为 1 时才有资格融合。
    ∣Consumer(TP)∣=1\bigl|\mathrm{Consumer}(T_P)\bigr| = 1
  3. 两条同时成立既要有真实的数据流边,又要消费者唯一,二者缺一不可。
    Fuse(P,C)  ⟺  (P→C)∧(∣Consumer(TP)∣=1)\mathrm{Fuse}(P, C) \iff (P \to C) \land (|\mathrm{Consumer}(T_P)| = 1)

资源约束:灶台不够大

你终于找到了几道可以同锅的菜,一股脑全倒进锅里,结果发现锅装不下,灶台也摆不下碗。硬塞的下场是:本来想省下端盘子的功夫,反而因为手忙脚乱打翻了两个碗。硬件对融合的限制,就是这口锅的尺寸。

GPU 里最宝贵的“工作台”是寄存器。融合后,中间张量不再返回显存,而是以临时值的形式保存在寄存器里,等待下一个算子取用。可每个线程的寄存器数量是有限的,比如一块 GPU 给每个线程最多 255 个寄存器。融合链越长,中间值越多,需要的寄存器就越多。一旦超出上限,编译器只能把部分临时值“ spill 溢出”到本地内存——相当于把碗从灶台挪回冰箱。这一挪,恰恰又产生了访存,之前的融合收益灰飞烟灭。

除了寄存器,还有共享内存这道“操作台”。归约、矩阵分块等操作都要在共享内存里暂存数据,容量一般只有几十到一百多 KB。因此,融合决策本质上是一道预算题:

好菜谱不是把所有菜都塞进一口锅,而是在工作台摆得下的前提下让流程最短。融合规则里最常被忽略的,正是这个“摆不摆得下”的问题。

∑i=1Kri≤Rreg,∑i=1Ksi≤Csmem\sum_{i=1}^{K} r_i \le R_{\text{reg}}, \qquad \sum_{i=1}^{K} s_i \le C_{\text{smem}}
融合链上所有中间结果的寄存器占用之和不能超过硬件寄存器上限,共享内存占用同理。
  1. 逐个统计中间结果融合链上有 K 个中间结果,第 i 个需要 r_i 个寄存器。
    ∑i=1Kri\sum_{i=1}^{K} r_i
  2. 不超过寄存器上限总和必须小于等于硬件给出的寄存器总量 R_reg。
    ∑i=1Kri≤Rreg\sum_{i=1}^{K} r_i \le R_{\text{reg}}
  3. 共享内存同样受限每个中间结果在共享内存里占用 s_i,其总和要小于等于 C_smem。
    ∑i=1Ksi≤Csmem\sum_{i=1}^{K} s_i \le C_{\text{smem}}

融合决策:算细账还是凭经验

面对一长串候选融合方案,你怎么定夺?老厨师凭手感:看到“切丝-焯水-爆炒”三连,闭着眼就知道合在一起划算,这是启发式。精算师父拿账本:把每道工序的耗时、搬运成本列成表,选总成本最低的方案,这是代价模型。还有一位痴迷的学徒,把所有配方排列组合挨个试一遍,这是自动搜索。

真正的 AI 编译器里,三种办法同时存在。最基础的是基于规则的启发式,比如“相邻逐元素算子必融”“归约后接逐元素算子必融”,规则简单、编译快,但容易看走眼。更进一步的是代价模型:编译器为每种融合候选估算访存次数、内核启动次数、计算量,再乘上权重系数求和,得到预估代价 J。最后,像 TVM 的 Ansor 这样的自动调度器,会在搜索空间里采样若干融合方案,直接在真实硬件上跑一遍,用实测数据反馈给搜索算法。

代价模型的核心是一个加权求和:

定菜谱不能只看“看起来顺眼”,要有成本账撑腰。融合决策的进阶之路,就是从“凭经验”到“算细账”,再到“让机器自己试菜”。

J(G′)=α Nmem(G′)+β Nlaunch(G′)+γ Ncomp(G′)J(G') = \alpha\, N_{\text{mem}}(G') + \beta\, N_{\text{launch}}(G') + \gamma\, N_{\text{comp}}(G')
对某一种融合后的图 G',把访存次数、内核启动次数、计算量分别加权求和,作为这份“菜谱”的总成本,择其最小者。
  1. 访存次数项G' 中所有内核的显存读写次数,乘上权重 α;访存往往最贵,α 通常最大。
    α Nmem(G′)\alpha\, N_{\text{mem}}(G')
  2. 启动次数项融合后剩余的内核数量乘上权重 β,表示几次“开火”的成本。
    β Nlaunch(G′)\beta\, N_{\text{launch}}(G')
  3. 计算量项与求和计算量乘上权重 γ 后,三项相加得到总代价,决策时在所有候选 G' 中挑 J 最小者。
    J(G′)=αNmem+βNlaunch+γNcompJ(G') = \alpha N_{\text{mem}} + \beta N_{\text{launch}} + \gamma N_{\text{comp}}

融合收益:省了几趟路

花这么大力气去融合,到底图什么?答案就是图“少跑几趟”。你以前做一道三菜一汤,要往返冰箱七八次;现在配菜师把所有食材按顺序摆上灶台,一趟流水线走完,你只需要站在锅前翻勺。

从数据流的角度看,融合收益最直观地体现在“算术强度”上。算术强度(Arithmetic Intensity)定义为单位字节访存对应的浮点运算次数。它越高,说明每次从显存取来的数据都被充分“压榨”了。对 N 个逐元素算子链,每个算子做 F 次浮点运算,输出张量大小为 S;融合前每算子各读各的输入、写各的输出,访存总量约为 2NS;融合后整体只读一次输入、写一次输出,访存降为 2S。于是融合后的算术强度变成:

当然,算术强度提升不等于总时间也省 N 倍,还要看硬件是否“喂得饱”计算单元,但方向很明确:少搬数据,就是省钱。这份收益账,是验证一切融合方案好坏的试金石。

AIfused=N×F2S\mathrm{AI}_{\text{fused}} = \frac{N \times F}{2S}
融合后整条链的浮点运算总量是 N×F,而访存字节数只剩 2S,算术强度提升约 N 倍。
  1. 总浮点运算量链上每个算子做 F 次浮点运算,N 个算子共计 N×F 次。
    N×FN \times F
  2. 融合后总访存只读入最开始的输入张量 S 个元素、写出最后结果 S 个元素,共 2S 次单位访存。
    2S2S
  3. 两者相除得算术强度浮点运算总量除以访存字节数,单位是 FLOPs/Byte。
    AIfused=NF2S\mathrm{AI}_{\text{fused}} = \frac{N F}{2S}

融合模式:经典组合拳

后厨里流传着几套固定搭配:青菜要先焯水再爆炒,牛肉要切好腌制再下锅,汤要大火烧开再转小火。这些搭配是几代厨师验证过的“模式”(Pattern)。编译器里也有这样一组经典的融合模式,见到就能直接套用。

第一种是逐元素链融合。加法、乘法、激活函数、缩放这类算子,输入输出形状一一对应,每个输出元素只依赖输入的同位置元素。它们像“切丝-焯水-爆炒”一样环环相扣,中间结果天然适合留在寄存器里,等号右边的一串复合函数可以整体压成单个内核:

第二种是归约加逐元素融合。比如 Softmax 要“先求总和再逐元素除以总和”,归一化则是“先算均值方差再逐元素缩放”。归约结果很小,刚好能塞进共享内存,紧随其后的逐元素操作就可以直接使用这个局部量,不必再访存一次。第三种是矩阵乘法加逐元素融合,典型的如卷积后接批归一化、全连接后接激活;QKV 投影三个矩阵乘法拼接成一个大矩阵乘法,也属于这类“组合拳”的延伸。

把经典搭配做成模板,编译器遇到相似结构时就能秒认秒融。这也是 TVM、XLA、oneDNN 等框架里融合实现的核心套路。

EW1∘EW2∘⋯∘EWN  ⟶  Kernelfused\mathrm{EW}_1 \circ \mathrm{EW}_2 \circ \cdots \circ \mathrm{EW}_N \;\longrightarrow\; \mathrm{Kernel}_{\text{fused}}
把一串逐元素算子复合运算压缩成一个融合内核,是编译器里最基础也最常用的改写范式。
  1. 复合记号表示串联A ∘ B 表示先做 B 再做 A,左侧一长串逐元素算子按顺序复合。
    EW1∘EW2∘⋯∘EWN\mathrm{EW}_1 \circ \mathrm{EW}_2 \circ \cdots \circ \mathrm{EW}_N
  2. 箭头表示图改写编译器的改写规则把这一串整体替换成一个新的融合内核节点。
    ⟶\longrightarrow
  3. 融合内核产出替换后的单个内核完成所有逐元素运算,中间结果不出内核。
    Kernelfused\mathrm{Kernel}_{\text{fused}}

工程实现:流程化改造

经验再丰富,也要沉淀成标准流程,厨房才能规模化。融合规则的工程实现,就是把“什么能融、怎么融”固化成编译器里的一个个 Pass(编译优化阶段),像中央厨房的标准化作业手册。

首先,每一道算子都要在“算子注册表”里登记自己的属性:是逐元素型还是归约型?输出形状和输入形状是否一一对应?能否原地写回?这张注册表相当于食材档案——哪些食材能生吃、哪些必须煮熟,系统心里有数。然后,编译器的图遍历器从计算图入口出发,按拓扑序扫描节点,寻找与注册表里融合模式匹配的相邻算子,比如发现“逐元素加 + 逐元素乘”这对组合,就标记为候选。最后,改写器把这对节点替换成一个新的融合算子节点,并复用已有的输出缓冲区,避免额外开辟显存。

这个流程可以表示为一次图重写:

流程化之后,融合不再是某个高手的灵光一现,而是每次编译都会自动执行的固定步骤。老厨师的经验由此变成了后厨的标准作业手册。

Gout=Rewrite(Gin,  PatternEW(n1,n2))G_{\text{out}} = \mathrm{Rewrite}\bigl(G_{\text{in}},\; \mathrm{Pattern}_{\text{EW}}(n_1, n_2)\bigr)
输入计算图 G_in 与匹配到的逐元素融合模式共同作用,输出改写后的新图 G_out。
  1. 输入图未经优化的计算图 G_in,由一个个独立算子节点构成。
    GinG_{\text{in}}
  2. 模式条件Pattern_EW 是一段匹配规则,作用于两个相邻节点 n1、n2,判定二者是否构成逐元素融合模式。
    PatternEW(n1,n2)\mathrm{Pattern}_{\text{EW}}(n_1, n_2)
  3. 重写结果Rewrite 函数把匹配到的两个节点合并成一个融合节点,返回新图。
    Gout=Rewrite(Gin,Pattern)G_{\text{out}} = \mathrm{Rewrite}(G_{\text{in}}, \mathrm{Pattern})

实践陷阱:乱炖的代价

融合不是万灵药,乱炖一锅的代价,往往比分开炒还大。后厨新手最容易踩的坑,有三个。

第一个坑是数值精度变化。浮点数在计算机里不是精确的,加法不满足结合律:同一组数,先加后两个还是先加前两个,结果可能有细微差别。融合改变了中间结果的舍入时机——原来中间值会写回显存“四舍五入”一次,融合后它一直在高精度寄存器里,看似更精确,但最终结果和未融合版本并不完全一致。对精度敏感的模型,这一步差异可能被放大:

第二个坑是调试困难。融合后中间张量不再落盘,你想看看“这一步算出来到底是什么”都没机会,只能靠数学校验。第三个坑是性能回退:前面说过,寄存器溢出、并行度下降都可能让融合后反而更慢。成熟编译器通常提供融合开关、保留中间张量的调试模式,以及“融合后实测变慢就回退”的保险机制。

好菜谱是反复试吃调整出来的。融合规则也一样,必须配合数值校验和性能基准测试,在实践中不断修正,才能从“纸上谈兵”变成“后厨真经”。

(a+b)+c  ≠  a+(b+c)(浮点运算中)(a + b) + c \;\neq\; a + (b + c)\qquad(\text{浮点运算中})
浮点加法在舍入影响下不满足严格结合律,因此融合前后的数值结果可能存在微小差异。
  1. 先算后两个a 加上 (b + c) 的和,先完成 b 与 c 的相加并有一次舍入。
    a+(b+c)a + (b + c)
  2. 先算前两个(a + b) 先相加并舍入,再与 c 相加,舍入时机不同。
    (a+b)+c(a + b) + c
  3. 两者一般不相等除非特殊数值,两个结果在二进制浮点表示下几乎总是不完全相等。
    (a+b)+c≠a+(b+c)(a + b) + c \neq a + (b + c)

AI 应用场景

大模型推理中的注意力融合

阶段:模型推理优化

解决的问题:自注意力计算里有 Q、K、V 三个独立的投影矩阵乘法,紧接着又是缩放点积和 Softmax。传统实现每一步都写回显存,推理时显存带宽吃紧、延迟高。融合后把三个投影拼成一个大矩阵乘法,再把点积与 Softmax 合并进单个内核,端盘子的次数从七八趟降到两三趟。

具体案例:部署 GPT 类模型时,推理引擎把 QKV 投影融合、把 Softmax 与注意力分数计算融合,配合 FlashAttention 的分块策略,推理吞吐量可提升数倍,显存峰值显著下降。

卷积与批归一化的推理融合

阶段:训练后部署优化

解决的问题:批归一化(BN)在训练时依赖 batch 统计量,推理时却变成一组固定的缩放与平移。如果推理图里每一层卷积后都跟一个 BN,就相当于每道菜做完都要过一遍称重台。把 BN 的缩放系数预先折算进卷积核权重里,卷积和 BN 就彻底合成一次计算,省掉一整趟数据搬运。

具体案例:PyTorch 模型导出 ONNX 时,推理引擎自动执行 Conv+BN 融合。对于 ResNet50 这类深层网络,融合后单次推理延迟可降低 20% 左右,且模型精度完全不变。

编译器自动融合搜索

阶段:AI 编译器开发

解决的问题:手写融合规则覆盖不全,面对新算子组合时束手无策。自动调度器把融合方案当作搜索问题:先枚举所有合法的融合候选,再用代价模型打分,最后在真实硬件上采样验证,找到当前模型和硬件下最优的融合方案。

具体案例:TVM 的 Ansor 在搜索算子的调度方案时,会自动尝试不同的融合粒度,比如一个卷积 + BN + ReLU 的复合算子,搜索结果可能是一种手工规则从没想过的分块融合方式,最终比默认方案快 1.5 倍。

Python 演示

python76 行
# 一个简单的算子融合规划器: 判断 DAG 中哪些相邻算子能融合,# 并估算融合前后的显存访存字节数.from dataclasses import dataclass, field @dataclassclass OpNode:    name: str    op_type: str          # elementwise / reduce / matmul    size: int             # 输出张量元素个数    inputs: list = field(default_factory=list)    outputs: list = field(default_factory=list) class FusionPlanner:    def __init__(self, nodes):        self.nodes = nodes        self.fused_groups = []     def can_fuse(self, a, b):        # 逐元素链必然可融; 归约后接逐元素也可融        if a.op_type == "elementwise" and b.op_type == "elementwise":            return True        if a.op_type == "reduce" and b.op_type == "elementwise":            return True        return False     def plan(self):        visited = set()        for node in self.nodes:            if node.name in visited:                continue            group = [node]            cur = node            while cur.outputs:                nxt = cur.outputs[0]                if len(nxt.inputs) == 1 and self.can_fuse(cur, nxt):                    group.append(nxt)                    visited.add(nxt.name)                    cur = nxt                else:                    break            self.fused_groups.append(group)        return self.fused_groups def memory_bytes(size):    return size * 4  # 每个元素按 4 字节 float32 计 def report(planner):    total_before = 0    total_after = 0    for group in planner.fused_groups:        for node in group:            total_before += memory_bytes(node.size) * 2  # 写一次 + 读一次        if len(group) > 1:            total_after += memory_bytes(group[0].size) + memory_bytes(group[-1].size)        else:            total_after += memory_bytes(group[0].size) * 2    print("Before fusion:", total_before, "bytes")    print("After fusion:", total_after, "bytes")    print("Saved:", total_before - total_after, "bytes") if __name__ == "__main__":    # 构造一个小图: add -> relu -> sum -> normalize    a = OpNode("add", "elementwise", 1024)    b = OpNode("relu", "elementwise", 1024)    c = OpNode("sum", "reduce", 256)    d = OpNode("norm", "elementwise", 256)    a.outputs.append(b)    b.inputs.append(a)    b.outputs.append(c)    c.inputs.append(b)    c.outputs.append(d)    d.inputs.append(c)    planner = FusionPlanner([a, b, c, d])    groups = planner.plan()    print("Fused groups:", [[n.name for n in g] for g in groups])    report(planner)
目的:用 Python 模拟编译器融合 Pass 的核心逻辑——遍历算子 DAG、按融合规则分组、统计访存收益。运行后可以看到 add 和 relu 被融成一组,sum 和 norm 被融成一组,并输出省下的显存字节数。
  1. @dataclass class OpNode定义算子节点,记录名字、类型和张量大小,inputs/outputs 表示图里的数据依赖边。
  2. def can_fuse(self, a, b)这里实现了文中的融合规则:逐元素接逐元素、归约接逐元素都合法。
  3. while cur.outputs从当前算子沿输出边向下走,尝试把整条链尽可能长地融在一起。
  4. if len(nxt.inputs) == 1这是“单一消费者”约束的变体:下一个算子只有一个输入时,融合后的中间值不会破坏其他分支。
  5. memory_bytes(node.size) * 2每个中间张量按“写一次 + 读一次”估算访存,对应文中 M_before = 2NS。
  6. group[0].size + group[-1].size融合后整组只读首、写尾,对应 M_after = 2S。

常见误区

误区我只要把 `x + 1` 和 `relu(x)` 写在同一个 for 循环里,不就算算子融合了吗?编译器不是自动会做吗?

正解把代码写在一起只是源码层面的合并,并不等于算子融合。算子融合发生在 AI 编译器把计算图编译成底层内核的阶段,它要将两个独立的“内核启动 + 显存读写”过程变成一个统一的内核——就像把两道菜的工序合并,需要配菜师重新排流程,光把两句话抄到同一张纸上没用。真正起作用的,是编译器的图改写 Pass 识别出这对算子并生成融合内核。

为何常见:初学者接触 Python 层 API 时看不到底层的内核调度过程,容易把“源码组织方式”误当成“执行层面的优化”。

误区融合能减少访存和内核启动,那所有算子都尽可能融在一起,性能一定最好。

正解融合也有代价:中间结果全压在寄存器里,一旦超出硬件上限就会溢出到本地内存,反而制造新的访存;融合后单内核占用资源变多,GPU 上并行执行的线程数可能下降,计算单元被“饿”到。文中举过工作台的例子——锅只有一个,强行塞十道菜只会手忙脚乱。所以成熟编译器都会跑代价模型,甚至实测对比,不划算就自动回退。

为何常见:大家习惯性地把“减少步骤”等同于“更快”,忽略了硬件资源是有限的、约束条件才是决定性能的关键。

误区在计算图里挨着的算子,比如卷积后面跟着池化,就可以放心融合。

正解相邻只是必要条件,不是充分条件。融合前要检查数据依赖是否成立(RAW 不能破坏)、中间张量是否只有一个消费者、算子的访问模式是否匹配融合模板。卷积输出的每个元素可能被池化的多个窗口复用,这种交错访问模式就不像逐元素链那样适合直接融合。就像配菜相邻不代表能下同一个锅,还得看火候和食材属性。

为何常见:很多教程举的例子都是理想的逐元素链,让人误以为所有相邻算子都具备这种“一一对应”的完美结构。

误区既然是编译器做等价变换,融合前后的输出应该一模一样,出现差异就是编译器有 bug。

正解浮点运算不满足结合律,融合改变了中间结果的舍入时机。原来写回显存会按 float32 舍入一次,融合后中间值可能一直保持更高精度,反而和旧结果不一样。这种差异通常很小,但在混合精度或病态条件下可能被放大。所以业界惯例是:融合后要做数值比对,把误差控制在可接受范围内,而不是默认完全一致。

为何常见:人们容易把数学上的“等价”等同于计算机浮点世界里的“完全一致”,忽略了舍入误差的存在。

面试题

Q1. 算子融合最主要的目标是什么?

单选题 · easy

  • A.减少中间张量的显存读写← 正确答案
  • B.增加每个内核的代码行数
  • C.把多台 GPU 合并成一台
  • D.降低源码编译出错几率

答案:A。融合的核心动机是消除中间张量在显存里的写回与读回,减少“端盘子”的访存开销。B 是副作用而非目标,C 是并行概念,D 与融合无关。

Q2. 以下哪一对算子最容易发生融合?

单选题 · easy

  • A.逐元素乘法与逐元素激活← 正确答案
  • B.矩阵乘法与全局排序算子
  • C.卷积核与随机采样算子
  • D.归一化与动态形状变化

答案:A。逐元素算子输入输出一一对应,中间结果天然可以留在寄存器里顺次传递,是融合最理想的模式。全局排序、随机采样、动态形状都会破坏这种规整的对应关系。

Q3. 数据依赖中 RAW 依赖指的是什么?

单选题 · mid

  • A.前一个算子先写后一个算子再读← 正确答案
  • B.两个算子同时写同一块内存
  • C.前一个算子只读后一个只写
  • D.两个算子读写完全互不干扰

答案:A。RAW(Read After Write)是“先写后读”:生产者算出张量,消费者读它。融合必须保持这条顺序不变,就像切好的菜必须等焯水后才能下锅。其他选项分别对应写写冲突、读写角色混淆和无依赖,都不是 RAW。

Q4. 以下哪种情况应当禁止两个算子融合?

单选题 · mid

  • A.中间结果被多个算子顺序消费← 正确答案
  • B.中间结果只被一个算子消费
  • C.两个算子均为逐元素算子
  • D.中间张量尺寸完全相同

答案:A。当中间结果有多个消费者时,融合会让该结果无法落盘共享,需重复计算或破坏其他分支,因此禁止。B、C、D 都是有利于融合的条件。

Q5. 融合链过长时的主要风险是什么?

单选题 · mid

  • A.寄存器溢出引发局部内存访问← 正确答案
  • B.中间张量尺寸变得过于庞大
  • C.算子的输出数据类型全部改变
  • D.编译器无法识别原有数据依赖

答案:A。链越长,中间结果越多,需要的寄存器越多,超出上限后即溢出到本地内存,重新引入访存,收益被抵消。就像工作台摆不下碗,只能端回冰箱。

Q6. 以下哪些场景适合使用算子融合?

多选题 · mid

  • A.成串的逐元素运算链← 正确答案
  • B.归约后紧跟逐元素操作← 正确答案
  • C.需要保住精确舍入结果的数值链
  • D.矩阵乘法后接激活函数← 正确答案

答案:ABD。逐元素链、归约加逐元素、矩阵乘法加激活都是经典融合模式。C 恰好相反,融合改变舍入时机,对舍入极其敏感的场景反而需谨慎。

Q7. 代价模型在选择融合方案时主要依据什么?

单选题 · hard

  • A.估算访存内核与计算开销加权和← 正确答案
  • B.随机挑选一组相邻算子试运行
  • C.比较两个算法的代码复杂程度
  • D.检查所有源码注释的完整程度

答案:A。代价模型把访存次数、内核启动次数、计算量加权求和,选出总成本最低的融合方案,相当于按成本账本定菜谱。B 是随机搜索,C 和 D 与运行期性能无关。

Q8. 关于融合引起的数值差异,下列说法正确的是?

多选题 · hard

  • A.浮点结合律成立,运算顺序可随意调整
  • B.融合改变了中间结果的舍入时机← 正确答案
  • C.混合精度下数值差异会更明显← 正确答案
  • D.融合前后结果必然完全相同

答案:BC。浮点加法不满足结合律,(a+b)+c 与 a+(b+c) 舍入时机不同,结果可能不同;混合精度下中间值精度低,差异更易显现。A 和 D 与事实相反。

Q9. 以下关于融合收益的说法,哪些是正确的?

多选题 · easy

  • A.融合减少内核启动次数← 正确答案
  • B.融合消除部分中间张量访存← 正确答案
  • C.融合必然提升所有算子性能
  • D.融合可以改变数据依赖顺序

答案:AB。A 和 B 正是融合的两大收益。C 太绝对,资源约束下可能性能回退;D 错误,融合必须保持 RAW 依赖顺序不变。

Q10. 融合搜索空间难以穷举的根本原因是什么?

单选题 · extreme

  • A.候选融合组合随算子数指数级增长← 正确答案
  • B.每次编译都要重写整个操作系统
  • C.算子数量在运行时周期性改变
  • D.融合规则无法用程序语言表达

答案:A。N 个算子的任意子集都可能成为融合候选,组合数量随 N 指数增长,所以穷举不可行,才需要启发式、代价模型和搜索算法来缩小范围。B、C、D 都不符合编译器实际。