系列目录 · 算子与布局 · Read in English
“减法也能照着加法写吧?”
大部分接口确实长得很像。都是两个输入、一个输出,都需要数据类型、地址描述符和量化参数。危险恰恰在“大部分”三个字。加法让很多操作数交换看起来无害,减法则像一个记性特别好的同事:你把谁放前面,它一直记到最后一个结果。
方向与标量识别
教学表达式 x-c 和 c-x,即便 c 是只有一个元素的常量,也不能合并成同一种不带方向的 ScalarOp。假设 x=[-3,2,7]、c=4,两种结果分别为 [-7,-2,3] 和 [7,2,-3]。如果测试只取 x=c,二者都等于 0,错误会非常有礼貌地通过。
历史最初的减法支持补上了机器指令对象、文本与二进制输出、发射器接口、内核封装、算子库入口、代码生成注册和模型示例。这条链值得保留,因为“支持一个算子”从来不只是增加一段算术代码。任何一层没接上,都可能表现为找不到实现、生成空结果、错误来源类型或无法链接。
当导入层开始识别标量时,另一个细节出现了:常量不一定是 rank 为 0。形状为 [1]、[1,1] 或更多单位维的常量,都可能只有一个元素。历史修改用元素总数是否为 1 来识别单元素权重,并将其展开取出常量值。这里还必须要求它确实是编译期常量;运行时单元素输入不能因为 shape 小就被读成常量属性。
通用识别可以写成:
1 | is_constant_scalar(v): |
“恰好一边”很重要。两边都是常量,可以交给常量折叠;两边都不是常量,应保留一般计算。既不该把双常量硬塞进一个含动态输入的模式,也不该把所有单元素张量都变成属性。
反向信息必须贯穿整个编译链。导入得到 SubConst(x,c,reverse=true),lowering 需要把属性带到后端 IR,代码生成需要读取它,内核需要据此选择源操作数的标量或向量模式。任何中间层忘记复制,都会让前面的正确识别归零。已核实的历史差异同步修改了这些接口,说明这是一次跨层语义修复。
零、重写与量化边界
零是检验方向感的最佳面试题:
1 | x - 0 = x |
若 canonicalizer 只看常量是否为零,就可能把取负变成恒等操作。历史修复给消除零减法的路径增加了非反向条件。它看似一个布尔判断,背后维护的是代数恒等式的适用前提。
但布尔方向不是全部前提。若操作还融合了激活、饱和、特殊输出量化,x-0 是否能直接替换为输入,还要检查输出类型和额外语义。浮点带符号零、NaN 等也需要遵守具体 IR 的数值约定。
有人会提议把 c-x 变成 c+(-1)*x,这样就不用为减法保留方向。数学上可行,工程上未必合算。它可能新增一个乘法、一个中间张量和一次量化边界,还可能让舍入与饱和位置发生变化。若后端支持反向标量减法,直接表达通常更清楚;若不支持,分解也应有明确数值证明及成本估计。
量化标量时,c 不能作为浮点位模式直接塞进整数参数。用教学均匀量化关系 real=s*(q-z),一个常量的整数表示通常需要按目标约定做缩放、偏移、舍入和范围处理。历史路径调用了专门的标量量化逻辑,并将失败转换为诊断。不同算子的常量是否都使用输入量化坐标,仍应由各自参数协议决定,不能用一段通用公式包办。
再看除法。历史导入修改把“张量除以单元素常量”引向乘以倒数的已有路径,而“常量除以张量”保留倒数类表达。这两条也不能对称互换。用 c=2、x=[1,4]:x/c=[0.5,2],c/x=[2,0.5]。位置变了,数值不仅换符号,而是完全换了函数。
除以零、小常量倒数溢出、量化后倒数变为零,都是应追加的边界问题。检查到的差异主要展示导入路径选择,不能据此宣称这些问题都已验证。更好的测试说明应该写清“本条检查 IR 形态”,与“本条检查数值误差”分别负责什么。把一个成功生成的模型文件当成正确执行的证明,仍然差了好几层。
测试与性能观察
一组有针对性的教学测试可以这样组织:让常量分别位于左右,值取 0、正数、负数;shape 取标量与多个单位维;再区分动态单元素和真正常量。对于减法,让输入同时覆盖大于、小于和等于常量;对于倒数改写,增加非整数除数。检查结果之后,还要检查 IR 是否保留了正确方向属性和期待的常量路径。
初始 Sub 模型示例提供了从导入、量化、内存分配到发射的链路材料,后续修改则展示为什么简单示例不能替代边界矩阵。一条两个等形状输入的 Sub 测试,几乎不会触发常量在左、单位维识别或零值消除的问题。测试“存在”与测试“覆盖了这条语义”是两回事。
性能分析同样应具体。标量属性可以省去整张广播常量的存储与装载,但可能增加参数装载;直接反向减法可避免显式取负中间结果,但是否减少实际指令或内存访问应检查生成物。对于很小的张量,启动和参数成本可能比算术本身更显著。
规范化中的语义检查
还有一条很容易在规范化时丢掉的区别:shape 只有一个元素,与 IR 规定“这个结果是标量”,不一定是相同信息。一个单元素常量也许仍然以张量身份参与广播,结果 rank 由另一输入决定;一个真正标量结果则可能遵循不同接口约定。把常量提成属性时,应保持原结果类型和需要的广播关系。取出一个数字很容易,证明它离开张量容器后仍在同一位置参与运算,才是重写的职责。
可以用教学图来追踪方向:先给一般 Sub 节点设置一个“反向解释输入列表”的属性,再让常量位于列表的不同位置。如果 canonicalizer 只看列表顺序,不先恢复语义上的左与右,就会把方向反转两次或漏转一次。已检查的历史规则先根据原方向属性选择语义左右,再决定常量提取后的方向。这样的顺序比在最后补一个取反布尔值更容易审查。
常量舍入也会让代数优化出现微妙差别。考虑一个很小的非零实数常量,在某种整数编码下可能量化为零。如果编译器在实数层先认定它“近似零”并删除运算,和先量化为某个整数再运算,是否得到同样结果,取决于误差允许范围及舍入约定。使用固定容差做恒等消除应有明确理由,不能把“比较方便”当成语义规范。历史 lowering 中存在近零判断,公开讨论应保留这项数值前提。
加法可交换、乘法可交换,也不意味着实现可以在任意阶段交换它们的存储对象。例如一侧常量使用较窄类型,另一侧动态输入使用不同量化比例,机器两种来源模式可能拥有不对称的参数字段。正确做法是先决定新角色,再生成相应类型与参数。这个原则和反向减法一致:数学表达式允许的变换,需要在后端协议里重新兑现。
为了定位错误,建议把测试输出分成三个观察点:导入后的常量专用节点、lowering 后的方向属性、最终来源模式。若第一处已错,查常量识别;第一处对而第二处错,查属性传递;前两处对而最终错,查内核接口。这样的分层检查不替代数值测试,却能大幅缩短失败后的搜索范围。它也让一条“标量减法错误”的报告更容易变成可复现的问题。
再做一个性能上的反例:把大张量加常量降低为专用标量路径,往往节省广播存储;对只有一个元素的小张量,普通二元路径的成本可能已经极低,新增规范化和元数据操作反而主要影响编译复杂度。编译器不需要为每个微小情形都追求另一条“更优化”的路径。让语义清楚、生成结果稳定,有时比增加一个难维护的特殊分支更有价值。
这个故事最后留下的不是“减法比较特殊”这一句常识,而是一种接口检查法:从模型里的左与右,一路追到最后的机器来源类型;从数学恒等式,一路追到类型、量化和融合约束。每个阶段都能解释方向去了哪里,减法才不会在半路偷偷转身。
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 !