深夜十一点,重庆小面馆的后厨依然热火朝天。大厨老周面前排着十几张单子,他必须在十五分钟内全部出餐。突然他灵机一动:既然好几桌都点了回锅肉和蒜蓉青菜,为什么不把两道菜倒进同一口锅里一起炒?反正都是大火快炒,省下的时间够多出三碗面!可当他真把青菜倒进肉锅里时,老板娘劈头盖脸一顿骂:“青菜要断生才脆,肉要煸出油才香,火候根本不是一回事!”老周这才明白——菜能“合并”是好事,但哪些菜能合、哪些必须分开,中间那条线就是后厨的“融合边界”。 AI 编译器里的算子融合,和老周面对的难题一模一样。一个神经网络模型里动辄几百个算子——卷积、ReLU、加法、归一化——每个算子都要从“内存”这个食材仓库里取货、加工、再送回仓库。如果能把几个算子合并成一个内核,中间结果就不用来回搬运,训练和推理都能快不少。但问题是:不是所有算子都能随便合并。数据依赖是“菜的先后顺序”,精度要求是“口味不能串”,硬件资源是“锅就那么大、灶就那么多”。判断哪些算子能融合、哪些必须保持独立的规则体系,就是本节要讲的融合边界。

岗位关联

理解融合边界,是读懂编译器优化日志、定位算子性能瓶颈、设计高效自定义算子的基本功——这是从“会写模型”走向“懂系统优化”的必经一步。

目录
  1. 可视化演示
  2. 融合收益来源:为什么大厨要“一锅出”
  3. 数据依赖分析:菜的先后顺序
  4. 精度约束:口味不能串
  5. 硬件资源限制:锅有多大灶有几个
  6. 不可融合特征:必须单做的菜
  7. 边界判定规则:大厨的决策清单
  8. 融合权衡决策:不是所有融合都划算
  9. 调优实践方向:大厨如何精进
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

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

融合收益来源:为什么大厨要“一锅出”

老周后厨的墙上贴着一张泛黄的纸,上面写着“并菜三不炒”——这是他自己总结的规矩。可这规矩是怎么来的?老周说:“并一锅菜,最省的是来回跑腿。原来炒完回锅肉,要端出去放架子上,等下一道菜再端回来;合并了,这盘菜不出锅直接进另一道菜的锅。”算子融合的动机一模一样。GPU 执行一个算子,要从全局内存(相当于后厨仓库)把数据读进计算单元,算完再写回仓库。算子越多,数据在仓库和灶台之间往返的次数就越多。融合后,中间结果就留在灶台旁的调料架上(片上缓存或寄存器),下一道菜直接用,跑腿次数大幅减少。

每个算子单独执行时,GPU 都要经历一次“点火”——内核启动的开销。几千个算子就是几千次点火,每次点火都有固定的指令分发成本。融合把 N 个内核合并成 1 个,启动成本直接除以 N。更妙的是缓存局部性:原本被写回仓库的中间数据,现在留在高速缓存里被紧接着的算子反复使用,命中率上升,整体延迟下降。

Saved=(m+n+k)−(m+k)=n\displaystyle \text{Saved} = (m + n + k) - (m + k) = n

1. \(\displaystyle (m + n + k)\)(融合前总访存量):三个独立算子各自运行,每个算子的中间结果都要完整写回全局内存一次。m 是第一个中间张量,n 是第二个中间张量,k 是最终输出。

(m+n+k)\displaystyle (m + n + k)

2. \(\displaystyle (m + k)\)(融合后总访存量):三个算子合并成一个内核后,中间张量 m 和 n 不再写回全局内存,只保留最终输出 k。

(m+k)\displaystyle (m + k)

3. \(\displaystyle = n\)(净节省量):相减之后,节省的访存量恰等于第二个中间张量 n 的大小——这就是融合带来的最直接收益。

=n\displaystyle = n

当然,融合不是免费的午餐。把多道菜倒进一口锅,锅里的空间、火候的调配、食材下锅的顺序都成了新问题。要判断哪些算子值得融合、哪些硬融合反而翻车,需要先看清束缚融合的四道无形边界。

数据依赖分析:菜的先后顺序

老周知道,有些菜是“喂”出来的:蒜香排骨的炸蒜末,是另一道蒜蓉娃娃菜的灵魂;如果为了省火把两锅硬并成一锅,蒜末还没炸够火候就被娃娃菜的水汽泡软了。算子之间也有这种“喂养”关系。在编译器里,它叫数据依赖。最重要的依赖叫 RAW(读后写)依赖:算子 A 的输出恰好是算子 B 的输入,B 必须等 A 算完才能开工。好消息是,链式的 RAW 依赖(A→B→C)恰恰是融合最理想的场景——A 的产物留在片上直接喂给 B,连写回都省了。

真正卡住融合的是另外两种情况。第一种是多消费者:A 的输出同时被 B 和 C 两个算子使用,B 和 C 在图中分处不同分支。如果要让 B 和 A 融合,中间结果就必须复制一份;这份拷贝的代价可能抵消融合收益。第二种是 WAR/WAW 依赖——在循环体或并行上下文中,后一个算子可能覆盖前一个算子还没读完的数据,这种依赖限制了内核的重新排序。此外,如果算子之间形成了依赖环,整个环上就不能做任何跨算子的调度优化。

consumers(t)=∣{op∈Graph:t∈inputs(op)}∣≤1\displaystyle \text{consumers}(t) = \left|\{op \in \text{Graph} : t \in \text{inputs}(op)\}\right| \leq 1

1. \(\displaystyle \text{consumers}(t)\)(张量 t 的消费者数量):统计整个计算图里,有多少个算子把张量 t 作为输入。食材 t 被几道菜取用,决定了它能不能安心待在调料架上。

consumers(t)\displaystyle \text{consumers}(t)

2. \(\displaystyle |\{op \in \text{Graph} : t \in \text{inputs}(op)\}|\)(集合的大小):花括号里筛选出所有输入中包含 t 的算子,外层竖线表示这个集合的元素个数。

∣{op∈Graph:t∈inputs(op)}∣\displaystyle |\{op \in \text{Graph} : t \in \text{inputs}(op)\}|

3. \(\displaystyle \leq 1\)(不超过一个消费者):只有消费者数量不大于 1 时,融合才可能在依赖层面放行。

≤1\displaystyle \leq 1

数据依赖是边界的内圈——它划定了“能不能合”的最低底线。但即使依赖没问题,有些菜依然不能一锅出,因为口味会串。

精度约束:口味不能串

老周最头疼的客人是点“清蒸鲈鱼”的:这道菜必须单独一口蒸锅,小火慢蒸八分钟,期间开盖次数都有讲究。有一次他图省事,把鲈鱼架在麻辣香锅上面一起蒸,结果鱼肉吸足了麻辣味,被客人骂了三天。算子里也有这种“口味敏感”的主儿。数值稳定性差的算子,对中间结果的舍入时机极其敏感。

把两个算子融合,意味着中间结果的命运被改写:原来要先写回全局内存(相当于端到架子上晾着),按照目标精度做一次舍入;融合后则留在寄存器里直接传递,少了一次舍入。多数情况下这会更精确,但如果融合改变了运算顺序——比如把 A+B+C 的逐次相加改成一次树状归约——浮点数的累积误差就会产生偏差。对于训练中梯度的累积、Softmax 这类对数值范围极其敏感的操作,编译器宁可保守一点:一旦模拟出的误差超过阈值,就拒绝融合。

∣ffused(x)−forig(x)∣≤τ\displaystyle |f_{\text{fused}}(x) - f_{\text{orig}}(x)| \le \tau

1. \(\displaystyle f_{\text{fused}}(x)\)(融合后的算子):把多个算子合并成一个内核后,输入 x 得到的计算结果。

ffused(x)\displaystyle f_{\text{fused}}(x)

2. \(\displaystyle f_{\text{orig}}(x)\)(融合前的算子组合):没有做融合、按原始顺序逐个执行时的计算结果。它是精度的“基准线”。

forig(x)\displaystyle f_{\text{orig}}(x)

3. \(\displaystyle |\cdot|\)(绝对误差):两条计算路径的输出差异,取绝对值保证比较的是差距大小。

∣ffused(x)−forig(x)∣\displaystyle |f_{\text{fused}}(x) - f_{\text{orig}}(x)|

4. \(\displaystyle \le \tau\)(容忍阈值):误差不超过 τ 才能放行融合;超过了,说明“串味”严重,编译器会维持原状。

≤τ\displaystyle \le \tau

精度这道边界像后厨的口味红线——过了线的菜宁可不卖也不能砸招牌。但就算依赖和精度都放行,还有一道更物理的边界拦在面前:锅就那么大。

硬件资源限制:锅有多大灶有几个

小面馆的灶台只有四个,最大的炒锅只能装下三斤食材。老周并菜前必须心里过一遍:这道菜要占多大锅、多少油、几个灶眼。GPU 里的“锅”就是寄存器文件和片上共享内存。每个算子都声明需要多少寄存器、多少共享内存;融合后的内核要把所有算子的资源需求累加在一起。一旦总和超过硬件上限,编译器就必须把一部分“食材”倒退回全局内存——这叫寄存器溢出,性能反而暴跌。

资源限制还包括线程块规模:融合后的算子可能对线程/数据映射有不同要求,比如一个算子需要 512 线程,另一个需要 128 线程,合并后统一成 512 并不总是合理。另外不要小看编译时间——把 N 个算子揉进一个内核,指令调度、寄存器分配的复杂度会急剧上升,编译器可能要花几十分钟去优化一个每秒执行几十次的小算子,这在动态图训练场景下是不可接受的。

∑op∈fusedregs(op)≤Rmax\displaystyle \sum_{op \in \text{fused}} \text{regs}(op) \le R_{\text{max}}

1. \(\displaystyle \sum_{op \in \text{fused}}\)(对融合组求和):把准备融合到一起的每一个算子的资源需求全部加起来。

∑op∈fused\displaystyle \sum_{op \in \text{fused}}

2. \(\displaystyle \text{regs}(op)\)(算子 op 的寄存器需求):每个算子在编译时生成的代码需要占用多少个寄存器。

regs(op)\displaystyle \text{regs}(op)

3. \(\displaystyle \le R_{\text{max}}\)(硬件寄存器上限):当前 GPU 微架构每个流处理器可用的寄存器总数。资源超限,融合方案直接被否决。

≤Rmax\displaystyle \le R_{\text{max}}

硬件边界是刚性的——锅就那么大,装不下就是装不下。可就算资源够用,有些算子天生“性格孤僻”,压根不适合跟别人挤一口锅。

不可融合特征:必须单做的菜

老周的后厨有这么几样宝贝,永远单独占一个灶:老鸭汤要文火慢炖六小时,期间不能揭盖;炸串的油锅温度必须恒定在 180 度,放别的菜进去就会串味、降油温。这类“单做菜”在算子世界里同样存在,它们有鲜明的特征。

首当其冲的是归约算子(如全局求和、Softmax 的分母计算)。它需要跨线程、跨线程块地通信汇总,天然要求一个独立的 kernel 来协调同步;强行把它和逐元素算子融合,要么引入巨大的同步开销,要么需要用复杂的置换让整个线程块停下来等待。其次是动态形状——如果算子的输出尺寸取决于数据本身(比如非极大值抑制或条件筛选),编译器在编译期无法确定中间张量多大,融合后难以分配片上存储。控制流分支多的算子(如包含 if-else 的复杂算子供 逻辑)会让 fused kernel 的指令调度变成一团乱麻。最后是多消费者输出:一个算子的结果被图上多个算子使用,融合后无论跟哪边“绑定”,另一边都会被迫重复计算或追加拷贝。

NoFuse(B):Reduce(B)∨Dynamic(B)∨Branch(B)∨(consumers(B)>1)\displaystyle \text{NoFuse}(B) : \text{Reduce}(B) \lor \text{Dynamic}(B) \lor \text{Branch}(B) \lor (\text{consumers}(B) > 1)

1. \(\displaystyle \text{NoFuse}(B)\)(不可融合判定):判定算子 B 是否属于不可融合的类型。

NoFuse(B)\displaystyle \text{NoFuse}(B)

2. \(\displaystyle \text{Reduce}(B) \lor \text{Dynamic}(B) \lor \text{Branch}(B)\)(或逻辑连接):B 是归约算子、动态形状、含控制流分支——三者命中其一即可判负。符号 \(\lor\) 表示逻辑“或”,像后厨的黑名单上列了四类禁并菜。

Reduce(B)∨Dynamic(B)∨Branch(B)\displaystyle \text{Reduce}(B) \lor \text{Dynamic}(B) \lor \text{Branch}(B)

3. \(\displaystyle \text{consumers}(B) > 1\)(多消费者):B 的输出被不止一个算子依赖。即使前三个特征都没命中,这一条也会把它推回“单做”阵营。

consumers(B)>1\displaystyle \text{consumers}(B) > 1

这四个特征像是后厨黑名单:凡是命中,无论融合收益多诱人,编译器都会坚决守住边界。这四类“硬钉子”划定了融合边界的最外围。

边界判定规则:大厨的决策清单

老周的灶台边贴着一张脏兮兮的“并菜检查单”,上面四行字:一看先后(依赖)、二看口味(精度)、三看锅量(资源)、四看值不值(收益)。编译器里的融合边界判定器,执行的是同一套流程——按顺序过四关,任何一关不过就否决。

第一关依赖优先检查:确认目标算子间是否存在连接关系。链式 RAW 依赖是融合的“绿灯”,多消费者或依赖环则是“红灯”。第二关精度兼容检查:在模拟器上跑一遍融合前后的数值误差,超阈值则打回。第三关资源预算核算:把寄存器、共享内存、线程数需求逐项累加,对照硬件上限。最后一关收益预估:估算融合到底能省多少访存、多少启动开销,如果收益小于某个经验阈值,编译器觉得“不值得为省一毛钱费这么大的劲”,也会放弃。

CanFuse(A,B)=DepOK(A,B)∧PrecOK(A,B)∧ResOK(A,B)∧GainOK(A,B)\displaystyle \text{CanFuse}(A,B) = \text{DepOK}(A,B) \land \text{PrecOK}(A,B) \land \text{ResOK}(A,B) \land \text{GainOK}(A,B)

1. \(\displaystyle \text{CanFuse}(A,B)\)(融合可行性结论):判定算子 A 和 B 能否被合并进同一个内核,结果是一个布尔值。

CanFuse(A,B)\displaystyle \text{CanFuse}(A,B)

2. \(\displaystyle \land\)(逻辑与):四个条件必须全部为真。符号 \(\land\) 表示“且”,有一个假就是假。

∧\displaystyle \land

3. \(\displaystyle \text{DepOK}(A,B)\)(依赖检查):A 与 B 之间不存在禁止融合的依赖模式(多消费者、依赖环)。

DepOK(A,B)\displaystyle \text{DepOK}(A,B)

4. \(\displaystyle \text{PrecOK}(A,B) \land \text{ResOK}(A,B) \land \text{GainOK}(A,B)\)(精度、资源、收益):精度模拟误差在容忍阈值内;寄存器与共享内存预算充足;预估收益大于设定阈值。

PrecOK(A,B)∧ResOK(A,B)∧GainOK(A,B)\displaystyle \text{PrecOK}(A,B) \land \text{ResOK}(A,B) \land \text{GainOK}(A,B)

这套检查单把融合从“经验之谈”变成了可以自动执行的工程规则。但规则的背后,永远藏着更微妙的权衡——因为每道菜都同时受多个目标拉扯。

融合权衡决策:不是所有融合都划算

并菜一定要赚钱吗?老周吃过一次大亏:为了把一个回锅肉和一个水煮肉片并锅,他省下了 40 秒,但两道菜同时起锅导致锅温不够,肉片全老在锅里,客人退菜赔了 20 分钟。这个教训说明:省下的时间≠净赚的时间。融合决策同样是一场得失权衡。

融合最大的隐性成本是并行度损失。独立的算子执行时,GPU 可以在两个 kernel 之间重叠部分计算;融合后这种重叠消失,不同算子之间的并行机会化为乌有。同时,融合把多个中间值都塞进寄存器,可能让单个线程的寄存器压力逼近上限——一旦触发溢出,代价相当高昂。反过来,融合带来的缓存重用收益十分可观:中间结果在 L1/L2 里被当下一个算子的输入,命中一次能省数倍于计算本身的延迟。这里没有恒定的公式,只能靠经验阈值——编译器通常内置一套收益预测模型,根据目标硬件的访存带宽和缓存大小,估算每个融合点的预期收益,低于阈值就收手。

ΔT=Tmem(A,B)−Toverlap(A,B)−Tspill(A,B)>0\displaystyle \Delta T = T_{\text{mem}}(A,B) - T_{\text{overlap}}(A,B) - T_{\text{spill}}(A,B) > 0

1. \(\displaystyle \Delta T\)(净收益):融合相比不融合真正节省的执行时间。正数才值得做。

ΔT\displaystyle \Delta T

2. \(\displaystyle T_{\text{mem}}(A,B)\)(省下的访存时间):中间张量不再写回全局内存所节省的时间,这是融合的主要收益来源。

Tmem(A,B)\displaystyle T_{\text{mem}}(A,B)

3. \(\displaystyle T_{\text{overlap}}(A,B)\)(损失的并行重叠时间):原本两个独立内核可以在不同流上并行执行,融合后这种重叠被取消。

Toverlap(A,B)\displaystyle T_{\text{overlap}}(A,B)

4. \(\displaystyle T_{\text{spill}}(A,B)\)(寄存器溢出代价):融合导致寄存器不够用,数据被挤回内存的额外开销;如果为负收益,说明融合方案该放弃了。

Tspill(A,B)>0\displaystyle T_{\text{spill}}(A,B) > 0

权衡的艺术让融合边界变成了一条动态的曲线,而不是一堵僵硬的墙。但好在,这条曲线是可以被量化和调校的——这就是调优实践要做的事。

调优实践方向:大厨如何精进

每个季度末,老周都会翻一遍点单记录,找出“被退菜最多”的几道并菜组合,重新调整他的并菜清单。编译器工程师也这样做。性能剖析定位是第一步:用 NVIDIA Nsight Compute 或 PyTorch Profiler 打出每个 kernel 的耗时,凡是“执行时间短但访存量极大”的 kernel 序列,就是融合的重点候选。编译器开关探索则是第二板斧——TVM 里的 allow_fuse 配置、XLA 的融合等级、Triton 的手写 kernel,都是一层层揭开边界的方式:调松融合等级看是否更快,调紧是否更稳。

更精细的手法叫手工融合标注:当自动融合器因为保守规则放弃某个边界案例时,工程师可以用自定义算子手写一个融合 kernel,比如把 LayerNorm 的均值、方差、归一化三个算子手工合并成一个。但踩过坑的人都知道,任何手工优化都必须经过融合后验证——数值误差对比、吞吐对比、多硬件平台回归。毕竟,老周的新菜谱也是先给熟客试吃一周,确认不砸招牌才正式上菜单的。

min⁡F∈Flatency(graph,F)\displaystyle \min_{F \in \mathcal{F}} \text{latency}(\text{graph}, F)

1. \(\displaystyle \min_{F \in \mathcal{F}}\)(在所有合法方案中取最小):遍历所有满足边界条件的融合方案,找到延迟最小的那一个。

min⁡F∈F\displaystyle \min_{F \in \mathcal{F}}

2. \(\displaystyle \text{latency}(\text{graph}, F)\)(端到端延迟):给定融合方案 F 时,整个计算图的运行时间——这是衡量调优效果的最终指标。

latency(graph,F)\displaystyle \text{latency}(\text{graph}, F)

从收益来源到边界判定,从权衡取舍到调优实践,融合边界始终在回答那个朴素的问题:并菜到底省不省? 了解这条边界,你就能在编译器的决策日志里看出门道,在性能剖析中找到真正的瓶颈,甚至亲手优化出一条更高效的执行路径。

AI 应用场景

场景一:XLA 编译器自动融合策略

阶段:模型编译优化

解决的问题:TensorFlow 模型在 GPU 上训练时,默认逐个执行几百个小算子,每个算子都有内核启动开销和中间结果的全局内存写回。传统手工 fuse 耗时且容易出错。XLA 编译器根据融合边界规则,自动将满足依赖、精度、资源条件的算子合并成大的融合内核,同时拒绝把归约算子与逐元素算子硬凑在一起。

具体案例:在 BERT 的 LayerNorm 子图中,XLA 识别出均值(Reduce)、方差(Reduce)、归一化(Elementwise)三者的依赖链,安全地将其融合为单个 kernel;而对被多头注意力共享的中间张量,XLA 通过多消费者检测保持了独立计算。融合后 GPU 利用率提升约 30%。

场景二:FlashAttention 手工融合内核

阶段:推理优化与 Kernel 开发

解决的问题:标准 Attention 计算会多次读写 Q、K、V 矩阵和中间注意力分数,内存开销高达 \(O(N^2)\)。按照融合边界规则,注意力分数矩阵的中间结果是一个“多消费者 + 大中间张量”的危险组合,但手写 Kernel 可以在 Tiling 级别将每个 tile 的 Score、Softmax、输出融合进一个 kernel。

具体案例:FlashAttention 作者在 CUDA 内核中手动融合了矩阵乘法、Softmax 和 Dropout,把 \(N \times N\) 的中间矩阵控制在片上 SRAM 中分块处理,使 GPT 级别模型的注意力计算速度提升 2-4 倍,显存占用从 \(O(N^2)\) 降到 \(O(N)\)。

场景三:算子库开发者的边界调优

阶段:算子库设计与性能回归

解决的问题:开发新的融合算子后,需要判断它是否真的比分开执行更快。开发者用 profiling 工具对照融合前后的访存总量、寄存器溢出次数、内核启动数,验证融合边界内的收益预估是否准确。

具体案例:在 oneDNN 的卷积 + 偏置 + ReLU 融合算子开发中,团队通过三种精度的数值误差对比(FP32、BF16、FP16)确认了融合后的精度损失在容忍阈值内,并通过 onemkl 后端在不同 CPU 微架构上验证了寄存器预算,最终将融合算子设为默认实现。

Python 演示

python83 行
"""融合边界检查器:模拟编译器对算子融合的四关检查。"""from dataclasses import dataclass, fieldfrom typing import List, Set  @dataclassclass Operator:    name: str    op_type: str          # "elementwise" 或 "reduce"    inputs: List[str]    outputs: List[str]    uses_branch: bool = False     def regs(self) -> int:        return 8 if self.op_type == "elementwise" else 32  class FusionBoundaryChecker:    def __init__(self) -> None:        self.ops: Set[str] = set()        self.cons_map: dict[str, Set[str]] = {}        self.reg_limit = 48     def add_op(self, op: Operator) -> None:        self.ops.add(op.name)        self.cons_map[op.name] = set(op.outputs)        self._ops_list.append(op)     def _find(self, name: str) -> Operator | None:        for op in self._ops_list:            if op.name == name:                return op        return None     def consumers(self, tensor: str) -> int:        count = 0        for op in self._ops_list:            if tensor in op.inputs:                count += 1        return count     def dep_ok(self, a: Operator, b: Operator) -> bool:        for t in a.outputs:            if self.consumers(t) > 1:                return False        shared = set(a.inputs) & set(b.inputs)        return len(shared) == 0 or all(self._find(x) is None for x in shared)     def prec_ok(self, a: Operator, b: Operator) -> bool:        return not (a.op_type == "reduce" and b.op_type == "reduce")     def res_ok(self, a: Operator, b: Operator) -> bool:        return a.regs() + b.regs() <= self.reg_limit     def fuse(self, a: Operator, b: Operator) -> bool:        checks = {            "dep": self.dep_ok(a, b),            "prec": self.prec_ok(a, b),            "res": self.res_ok(a, b),            "branch": not (a.uses_branch or b.uses_branch),        }        return all(checks.values()), checks  def main():    checker = FusionBoundaryChecker()    relu = Operator("Relu", "elementwise", ["x"], ["t1"])    add = Operator("Add", "elementwise", ["t1", "b"], ["t2"])    softmax = Operator("Softmax", "reduce", ["t2"], ["y"])    mul = Operator("Mul", "elementwise", ["t2"], ["z"])    for op in [relu, add, softmax, mul]:        checker.add_op(op)     pairs = [("Relu", "Add"), ("Add", "Softmax"), ("Softmax", "Mul")]    for na, nb in pairs:        oa = checker._find(na)        ob = checker._find(nb)        ok, why = checker.fuse(oa, ob)        print(f"fuse {na} + {nb}: {ok} {why}")  if __name__ == "__main__":    main()
目的:这个简化版融合边界检查器演示了编译器判断两个算子能否融合的完整流程——依赖、精度、资源、控制流四关逐一核查,任何一关不过就返回不融合。
  1. class FusionBoundaryChecker:检查器的主体,类比老周后厨的“并菜检查单”,每个方法对应一道关卡。
  2. def consumers(self, tensor) -> int:统计一个张量被多少个算子消费。返回值大于 1 意味着多消费者,融合需要额外拷贝。
  3. def dep_ok(self, a, b) -> bool:第一关:依赖检查。任一中间张量被多个消费者使用时返回 False。
  4. def prec_ok(self, a, b) -> bool:第二关:精度检查。归约算子与归约算子融合容易造成数值不稳定,拒绝放行。
  5. def res_ok(self, a, b) -> bool:第三关:资源检查。两算子寄存器需求之和不能超过硬件上限(此处简化为 48 个寄存器)。
  6. checks = {"dep": ..., "prec": ..., ...}收集四项检查结果,用 all() 做逻辑与,对应公式中的 \(\land\) 判定。
  7. pairs = [("Relu", "Add"), ...]依次测试三对算子的融合可能性,输出每一对的检查明细。

常见误区

误区“既然融合能减少访存,那我写算子时把所有能拼在一起的运算全写进一个 kernel,肯定最快。编译器不帮我并,我自己硬并。”——很多初学者写自定义算子时,恨不得把十个操作塞进一个循环里。

正解融合的收益有上限,成本却是边际递增的。像后厨并菜,省了跑腿的功夫,却可能因为锅温下降把所有菜都焖老。实践中,融合过多算子会导致寄存器溢出、并行度丧失、编译时间爆炸。边界检查器里的“收益预估”关卡正是为此设计的:收益低于阈值的融合做了等于白做。正确做法是先用 profiling 找出访存瓶颈,再有针对性地融合。

为何常见:教材里反复强调融合减少访存,初学者自然形成“融合=优化”的线性联想,忽略了权衡的另一面。

误区“A 和 B 之间没有任何张量传递,两者完全独立,那把它们合进一个内核肯定没问题啊。”

正解无依赖只是融合的“必要非充分”条件。即便两个算子完全独立,仍然要检查精度约束(融合可能改变浮点运算顺序)、硬件资源(两个大算子的寄存器需求可能超限)、以及收益是否为正(融合两个都很大的算子可能因为没有访存复用而毫无收益)。老周并菜也不只看“肉和菜有没有关系”,还得看锅装不装得下、火候跟不跟得上——独立的两道菜照样可能因为资源冲突而必须分开做。

为何常见:初学者把融合边界简化为“依赖分析”一个维度,忽略了它是多约束共同作用的结果。

误区“Softmax、全局求和这些归约算子,编译器直接标成不可融合就完事了,反正它们在图上就一个大方块。”

正解归约算子并非绝对不可融合,而是“直接整算子融合”几乎不可行。更精细的边界判定绕道分块:把归约分解到 tile 级别,让每个 tile 内的归约与相邻的逐元素算子融合。FlashAttention 正是这样做的——它没有融合整个 Softmax kernel,而是把 Softmax 的每个分块和矩阵乘法 fuse 到一起。边界是条曲线,不是一道墙。

为何常见:初学者看到“归约算子是不可融合特征之一”就形成一把尺子量到底的思维定式,忽略了编译器在更细粒度上的调度手段。

误区“FP16 的算子融合,中间结果不写回内存做精度截断,误差肯定会累积变大。我选择一律不融合。”

正解实际情况往往相反——融合绕过了中间张量写回时的舍入步骤,理论上能减少一次量化误差,精度可能反而更好。真正的风险点在于融合改变了运算顺序,比如把流水线式的逐次相加改成树状归约,这才会引入不可忽略的数值偏差。边界检查器的精度关卡是用数值模拟对比融合前后的输出差异,而不是一刀切地拒绝。

为何常见:把“低精度”和“数值不稳定”两件事画了等号,忽略了误差来源的具体机制。

面试题

Q1. 算子融合最核心的收益来自哪里?

单选题 · easy

  • A.减少算子代码行数
  • B.减少中间结果的访存开销← 正确答案
  • C.减少模型的参数量
  • D.提高学习率上限

答案:B。融合把多个算子合并成一个内核,中间张量不再写回全局内存,访问内存的时间大幅缩短。代码行数、参数量、学习率都与融合无关。

Q2. 下列哪种算子通常最难与其他算子直接融合?

单选题 · easy

  • A.逐元素加法
  • B.ReLU 激活
  • C.全局归约求和← 正确答案
  • D.逐元素乘法

答案:C。归约算子需要跨线程通信和同步,像炖汤必须单独占一个灶眼,强行融合会引入巨大的同步开销。逐元素算子则像快炒,很容易和其他算子合并。

Q3. 在融合边界判定中,RAW 依赖指的是什么?

单选题 · mid

  • A.两个算子同时读同一块数据
  • B.算子 A 的输出是算子 B 的输入← 正确答案
  • C.算子 B 的输出是算子 A 的输入
  • D.两个算子竞争同一块缓存

答案:B。RAW(Read After Write)即读后写依赖:A 先把结果写出来,B 才能读它。链式 RAW 依赖恰好是融合的理想场景,因为中间值可以留在片上直接传递。

Q4. 一个算子的输出被图中多个算子消费,这会导致什么后果?

单选题 · mid

  • A.融合时中间结果需要复制,代价高← 正确答案
  • B.该算子自动变成不可融合类型
  • C.该算子无法被编译器识别
  • D.该算子的精度一定下降

答案:A。多消费者场景下,如果把这个算子与其中一个消费者融合,其他消费者要么被迫重复计算,要么需要一份额外拷贝,成本可能抵消融合收益。注意它不是“自动不可融合”,而是边界判定会倾向否决。

Q5. 下列哪些因素会影响融合边界的判定?

多选题 · mid

  • A.数据依赖关系← 正确答案
  • B.数值精度容忍度← 正确答案
  • C.目标硬件的寄存器容量← 正确答案
  • D.模型训练时的批大小

答案:ABC。融合边界由四道关卡共同决定:依赖、精度、资源、收益预估。批大小只是训练超参数,不影响编译器在当前硬件上的算子融合决策。

Q6. 融合后寄存器溢出为什么会损伤性能?

单选题 · mid

  • A.溢出使寄存器数量变多
  • B.中间数据被挤回全局内存,访存成本剧增← 正确答案
  • C.溢出会直接导致程序崩溃
  • D.溢出会让 GPU 频率自动降低

答案:B。融合把所有算子的寄存器需求累加,一旦超过硬件上限,编译器必须把部分数据“吐”回全局内存。这相当于把并菜时装不下的食材放回仓库,多跑一趟腿,访存成本可能超过融合省下的时间。

Q7. 融合边界中“收益预估”这关主要想拦下什么?

单选题 · easy

  • A.精度不兼容的融合
  • B.收益极低但风险不小的融合← 正确答案
  • C.依赖冲突的融合
  • D.动态形状的算子

答案:B。收益预估是对“融合是否划算”的终审。即使依赖、精度、资源都放行,如果省下的访存时间微乎其微、却要承受编译时间增加和并行度损失,编译器就不值得做这个融合。

Q8. 哪些特征会让算子被判定为“不可融合”?

多选题 · hard

  • A.算子内部有动态形状输出← 正确答案
  • B.算子含复杂控制流分支← 正确答案
  • C.算子输出被两个算子消费← 正确答案
  • D.算子输入来自全局显存

答案:ABC。动态形状让编译器无法在编译期确定中间张量大小,控制流分支会让指令调度复杂化,多消费者则可能引入复制开销。而“输入来自全局显存”是绝大多数算子的常态,与可融合性无关。

Q9. 关于低精度下融合的精度影响,下列哪种说法正确?

单选题 · hard

  • A.任何低精度融合都会放大误差
  • B.融合避免中间写回舍入,可能反而更准← 正确答案
  • C.低精度算子只能与低精度算子融合
  • D.精度约束只在高精度模式下启用

答案:B。融合后的中间张量不再写回内存,少了一次按目标精度的舍入,误差可能变小。真正的风险在于融合改变了运算顺序(如归约变成树状结构)导致的浮点偏差,所以边界检查会做数值模拟,而不是以精度高低一刀切。

Q10. 以下哪些是编译器调优融合边界的实践手段?

多选题 · hard

  • A.用性能剖析工具定位访存热点← 正确答案
  • B.调整编译器融合开关观察耗时变化← 正确答案
  • C.手工编写融合算子绕过保守规则← 正确答案
  • D.无限调大融合级别保证收益最大

答案:ABC。调优三板斧是剖析定位、开关探索、手工标注,每一步都要配合数值和性能验证。而“无限调大融合级别”是误区——融合过度会导致寄存器溢出和并行度损失,收益曲线是先升后降的。