系列目录 · 算子与布局 · Read in English
“卷积都能融合,矩阵乘法后面这个 Sigmoid 总可以吧?”
有时可以,有时编译器认真地说不。最有价值的优化文章,不只有成功合并后的漂亮图,还应该解释那些故意留下的节点。它们常常标记着真正的语义边界。
直接邻接与语义屏障
最简单的教学图为:
1 | X:[B,M,K] -- MatMul(W:[K,N]) --> U:[B,M,N] -- Sigmoid --> Y |
如果 U 只被 Sigmoid 使用,目标支持对应后处理,中间类型和 LUT 参数完整,那么可以构造一个最终输出为 Y 的融合计算节点。历史实现为此增加可选 LUT 输入、函数标识、中间类型描述,补齐地址和参数装载,并通过图级及代码生成测试检查结果。
但把下面这个图画在同一张纸上,问题就变了:
1 | MatMul -> Requantize -> Sigmoid |
Requantize 不是一张“路过请忽略”的便签。它可能改变比例、zero point、整数宽度、舍入与饱和。设教学中间实数值为 0.6,某个再量化边界将其映射为步长为 1 的整数代表值;激活拿到的可能是 1,而直接对 0.6 求 Sigmoid 得到的是另一个结果。这里不需要精确小数就能看出函数的输入已经不同。
如果硬件融合后处理能够精确重现这次转换,可以设计更广的融合;但需要额外证明与参数表达。已核实的历史模式只匹配直接生产者,因此遇到 Requantize 会保留原图。测试明确锁住这种行为。拒绝不是缺乏勇气,是当前证明到此为止。
Clip 同样是一道门。把范围 [-1,1] 内截断之后再做 Sigmoid,和对任意原值直接做 Sigmoid 显然不同。大正数先被限制为 1,激活输出就不再趋近 1。优化不能因为看到一个熟悉激活名字,就穿过中间节点把它“拿到手”。
使用者、形状与后端约束
多使用者也是独立边界。如果 U 一边进 Sigmoid、一边被返回或送进其他算子,融合生产者只能替换其中一条边,不能改变另一条的值。历史规则要求唯一使用者,并在测试里保留另一条一元分支。这类负例比大规模随机模型更能直接表达设计意图:这里明确不做复制计算,也不做双输出融合。
另一个小问题是已经融合过的节点。假设 Y=Sigmoid(Sigmoid(MatMul(...)))。重复运行优化 pass 时,不应把第二个激活覆盖到已有融合字段里,从而把两次函数应用变成一次。历史规则发现生产者已有 LUT 就停止。这个约束也给 pass 提供了幂等性方向:相同优化多跑一遍,不应该继续破坏语义。
只允许特定激活也很有必要。图上同类分段线性节点可能承载 GELU、Tanh 或其他函数,MatMul 后处理路径在被检查版本只开放其中一个目标。卷积后处理能处理某种函数,不代表矩阵乘法描述符已经有完全相同的协议。能力要逐路径证明,不能通过类比继承。
形状检查还展示了工程上的折中。历史测试包含前导单位维不同、元素数相同的中间与最终类型,规则据此允许某些表示差异。泛化时仍应核对线性元素对应与内存布局。如果某个 reshape 或 permute 被“等元素数”掩盖,融合可能把矩阵输出按错误坐标写出。总数相同是检查点,绝不是万能通行证。
代码生成层对不完整状态再次校验:有 LUT 则必须有对应元信息,没有 LUT 则不能残留融合属性,中间值必须使用支持的存储类型,表地址必须已经分配。它还拒绝被检查版本尚未实现的分组代码生成情形。这个限制很值得写进设计说明,因为图融合与后续切块、分组并不是天然可交换的变换。
若图优化先融合,后续却把它放进不支持的组中,最后才报错,用户会觉得“原来能编译,优化后反而不能”。可行的工程建议是把目标能力查询前移,或在分组时保留合法替代方案;另一个选择是让融合 pass 在知道最终调度结构后运行。每种顺序都影响其他优化机会。
参数完整性与验证
从参数层观察,融合需要的不是一个布尔值,而是一套自洽数据:表地址、表头参数、中间编码、最终输出编码、函数选择和后处理使能。历史内核在矩阵计算前装载 LUT 参数,测试则查看描述符、地址记录、参数记录以及最终指令,确认独立激活指令消失。正常 MatMul 路径也保留对照测试,避免无融合时多装载参数。
模型级脚本还构造了一个小型 MatMul+Sigmoid 图,检查 lowering 前后结构、融合属性与代码生成产物。这比只手写最终后端 IR 更贴近完整流程,因为可以发现导入或量化阶段没有保留所需信息。它依然主要是结构与产物检查。
本文建议添加的数值实验分三组。第一组在 Sigmoid 变化较快的中心区域取密集点,观察量化误差如何通过斜率传播。第二组覆盖两侧趋于饱和的区域,检查范围处理。第三组让输入恰好落在舍入边界附近,比较融合与原两节点路径。统一中间编码,才是在比较同一个函数。
性能账单与等价边界
性能方面,可以用一个简单账本发问。未融合路径是否把 U 写入大内存?如果是,读回 U 的成本可能显著;如果 U 在片上,收益结构就不同。LUT 是否每次重复装载?矩阵计算与后处理是否能重叠?融合是否改变切块大小,导致更多权重或输入重读?这些问题的答案不能从“少了一个节点”自动得出。
对于很小 M、N 的矩阵,启动成本和参数装载可能占主导;对于 K 很大的矩阵,算术量可能淹没后处理成本;对于带宽敏感的输出规模,省中间搬运可能更有价值。它们是不同的可检验假设。建议同时记录生成指令、实际数据流量、峰值内存和端到端延迟,而不是只报告某一个内核时间。
若进一步研究性能,先把“融合后少了哪些工作”写成可观测假设。例如假设 U 有 E 个逻辑元素、每元素 b 字节,独立激活需要一次读取 U;如果原矩阵计算还必须把 U 写入某级内存,融合可能同时避免对应写入。这能估算一个理想化的数据量上界,却不能直接估算时间,因为同一内存访问可能已与计算重叠,也可能被缓存覆盖。把潜在字节数写出来,比凭节点数量猜速度更扎实。
LUT 的生命周期同样会改变成本。若多次矩阵调用使用同一表,参数与表内容是否能复用?若每次都重新装载,融合可能减少一个任务,却仍保留大部分准备成本。反过来,若多个融合节点共享可变的参数寄存器,还需要确保调度时不会互相覆盖。历史提交补齐了单次调用所需的装载链,跨任务复用属于需要另外设计的范围。
数值验证可以采用一个“故意靠近边界”的教学方法。先在高精度参考中找出使矩阵结果落在量化半步附近的输入,再分别走原两节点路径与融合路径,比较输出差异。随机输入可能很少命中这些位置,而一次额外舍入恰好在这里最容易看见。对于 Sigmoid,两端饱和会把某些上游误差压扁,中心区域则更能暴露细小输入变化,所以两类区域都要覆盖。
还应区分两种“精度更好”。融合如果保留更高精度中间结果,可能更接近实数公式;但如果原图明确要求某个中间量化,这种变化又可能不满足严格等价。优化系统应该提前决定追求逐位一致、某种误差界内一致,还是允许某类重关联和量化边界调整。
对于不支持的分组后端路径,错误出现的位置影响可用性。如果最终调度阶段才发现限制,诊断最好指出是“融合计算尚不能在这种调度结构中生成”,而不是笼统地说矩阵乘法失败。这样用户或上游 pass 才知道可以禁用这次融合、保留原图,或调整分组策略。拒绝原因具体,才有可能设计正确 fallback。
再看优化顺序的反例:先消除一个冗余 reshape,可能让 MatMul 与 Sigmoid 从间接连接变成直接连接,打开融合机会;先插入必要的 requantize,又可能有意关闭机会。pass 顺序因此不是单纯“越早融合越好”。它应围绕语义边界与目标能力安排,并用最终产物检查确认没有因顺序变化而意外扩大匹配范围。
看到编译器拒绝融合时,可以先沿图走一遍:直接邻接吗,夹着什么转换,有几个使用者,已经融合了吗,最终调度路径支持吗?这些问题如果有明确答案,拒绝本身就成为优化设计的一部分。最可靠的融合器,既知道什么时候可以合并,也知道什么时候应该把手收回来。
If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !