系列目录 · 数值与量化 · Read in English
“浮点模型能编,量化模型连第一阶段都过不了。”有人开始怀疑后端整数指令。可错误发生得太早:还没走到发射指令,函数返回类型就已经对不上了。
真正的原因很小:构造新节点时,形状拿对了,元素类型拿错了。像搬家时地址更新成功,却顺手把住户名字抄成了邻居。
一、一个看起来完全合理的折叠
图中可能有这样的结构:
1 | 原始通道张量 |
若分组关系、参数广播和形状条件满足,它可以折叠为对原张量直接做 GroupNorm。这样删掉辅助 reshape,使图更直接。
比如教学输入 [1,10,3,7],分为 5组,每组包含 2个通道。可将每组空间和通道元素组织到一起进行统计。折叠后的输出 shape应恢复到原来的 [1,10,3,7]。
问题在于“恢复 shape”并没有回答“采用哪个量化域”。标准化可以显著改变数据分布,输出 scale不必等于输入 scale。
二、浮点类型把错误藏起来了
普通浮点路径中,输入元素类型是 f32,标准化输出也是 f32。无论类型构造函数从输入拿元素类型,还是从原输出拿,都得到 f32。测试因此通过。
量化路径中,元素类型可能同时包含存储整数类型、表达实数类型、scale和 zero point。两个张量都“使用 8 位整数”,不代表类型相等。
教学例子:
1 | 输入元素类型:signed 8-bit,scale 0.02,zero 0 |
若重写后的 GroupNorm使用输入元素类型,结果就带上 0.02,而函数承诺返回的是 0.08。错误可能在 verifier直接暴露,也可能被某些宽松的中间转换暂时掩盖,直到后续节点才出现。
历史差异表明,量化信息已在 canonicalize之前导入,因此“这是早期高层图,还没有量化类型”的假设不成立。编译阶段名称不是保证,真正的阶段契约才是。
三、结果类型是一项义务,不能靠输入猜
图重写最重要的等价条件之一,是新表达式满足旧表达式的可观察结果契约。它不只包括元素个数,还包括数值表示、布局或编码信息,以及外部接口要求。
构造折叠结果时,可以采用这份来源表:
| 成分 | 合理来源 | 原因 |
|---|---|---|
| 最终逻辑形状 | 被折叠结构的原输入/最终输出关系 | 恢复外部索引语义 |
| 元素类型 | 原计算节点的输出 | 保留数值域 |
| 参数值 | 验证后的原参数,必要时重排 | 保持仿射语义 |
| 编码信息 | 明确继承或重新推导 | 不因便利复制而错置 |
| 诊断来源 | 原操作与新用途的关联 | 保留可追踪性 |
“从输入复制一个 tensor type再改 shape”是很常见的便利写法,却不适用于会改变数值域的操作。更好的 helper命名或参数设计应迫使调用者表明自己到底继承什么。
这也不意味着永远从输出完整复制。中间 reshape、permute可能需要当前坐标系的形状;某些布局编码会随着维度排列变化。正确做法是逐项推导,而不是把“输入类型错了”修成“所有地方无脑用输出类型”。
四、为什么测试必须让输入输出故意不同
历史原有测试给输入、归一化输出和函数返回使用同一个尺度。这个测试验证了图形折叠,却无法区分元素类型究竟从哪来。
新增回归采用不同输入输出尺度,并明确检查折叠后 GroupNorm结果的量化类型。它让错误实现与正确实现产生不同可观察结果,这才是回归测试的核心价值。
可以把测试设计理解为“制造分叉”:
1 | 若 scale_in == scale_out: |
同理,如果想抓住参数组播错误,不要每组都用相同 gamma;如果想抓住轴置换,不要各维相等;如果想抓住零点丢失,不要只用零点零。测试的任务不是让代码过得舒服,而是让错误无处借用巧合。
五、编译错误有时是最便宜的数值保护
函数返回类型不匹配,听起来像编译体验问题;实际上它阻止了一个数值域错误继续流入后端。若输入整数被用错误尺度解释,后续算子可能每一步都按自己的类型正确计算,却整体偏离模型。
因此类型 verifier值得尽早运行,并在关键重写后检查。编译器开发常有一种冲动:遇到类型报错,就插入 cast让它过。只有明确 cast执行了什么数值转换,且该转换符合原语义,才有理由这么做。把类型强行改成目标类型,不会自动改变底层整数含义。
如果输出契约本来就是原输出类型,最直接的修复是构造正确结果,而不是事后掩饰不匹配。历史补丁属于前者。
六、别只测一个 pattern,要测它和邻居相处
这个问题跨了两个阶段:量化信息导入与规范化重写。单独在纯浮点 IR上测 pattern,很难发现;只测量化导入不做折叠,也不会触发。因此建议同时保留单阶段测试与小型组合测试。
| 待执行场景 | 核心断言 |
|---|---|
| 浮点路径 | 图可折叠且结果形状正确 |
| 同尺度量化 | 保留原有路径行为 |
| 异尺度量化 | 结果元素类型来自原计算输出 |
| 非零零点 | 零点不会被输入或默认值覆盖 |
| 分组参数不相同 | 展开映射正确 |
| 非法组数/参数长度 | 不生成半正确节点 |
| 前后 reshape被继续折叠 | 最终函数签名仍一致 |
还应观察失败路径是否先修改了原操作再返回失败。重写最好在充分验证后统一提交变更;否则一次未匹配的 pattern也可能留下意外属性。历史相关实现把部分属性收集改为在新节点上构造,而不是先修改原节点,这种细节同样有助于维持重写纪律。
性能方面,修正类型来源通常不会直接提高执行速度,它首先恢复正确性与可编译性。折叠本身可能减少辅助节点,但 reshape若只是视图,不能把节点减少换算成访存减少。
最有启发性的地方在于:这不是一个复杂数学推导错误,而是“相同存储位宽”掩盖了“不同数值类型”。当类型开始携带尺度,复制类型就不再是机械文书工作,而是复制一份数值承诺。
七、如何把“正确复制类型”变成长期规则
一次修复最容易停留在把一行输入改成输出。要避免同类错误换个算子回来,可以对重写辅助函数做语义分类:纯视图变换保持数值元素类型;真正的计算节点使用其结果契约;会改变布局编码的变换显式重映射编码。调用者应当能从函数参数看出它属于哪一类,而不是靠函数名中的“like”猜测。
这不是要求每个 helper都写得很长,而是避免一个方便接口同时承诺过多东西。若某个函数既复制 shape又复制 element type还顺带带上 encoding,调用者只想继承其中一项时就容易误用。把独立选择表达出来,往往比加一条“注意量化类型”的注释更可靠。
测试也可以围绕类型来源做变形。固定图结构,只改变原计算输出的 scale,重写后的结果类型必须随它改变;固定输出契约,只改变输入 scale,折叠结果不能无理由跟着输入改。两组测试像给来源关系拉两根线,能直接判断结果究竟跟谁走。若类型还包含零点或存储符号,可以对它们分别采用同样方法。
当函数边界出现错误时,建议从返回值逆向追踪第一个类型发生变化的位置,而不是先检查所有量化参数。若原标准化输出正确,折叠节点错误,问题就已经收敛到重写;若原输出一开始就错,则应回到导入阶段。这个定位顺序利用了类型系统提供的离散证据,比先跑一整套数值误差分析更省力。
还可以检查重写前后所有外部使用者的输入要求。一个结果可能既被返回,又被另一个计算消费;只修函数返回处的类型转换,可能留下另一路错误。保持被替换值的完整结果契约,能同时维护所有使用者,而不是在每条边上补一个临时修正。
最后要防止测试依赖某个恰好发生的后续折叠。若只有把整个优化流水线跑完才看到最终类型,可能难以确认是谁修正或破坏了它。给核心 pattern一份独立回归,再给组合阶段一份小型回归,既能定位问题,又能检查它在真实阶段顺序中仍然成立。两者各有职责,不需要把所有模型都变成庞大的端到端测试。
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 !