FP16量化质量损失复盘纠正:我们错怪了架构,元凶竟是一个溢出的张量
- 作者

- 姓名
- Nino
- 职业
- Senior Tech Editor
我们先前的fp16量化复盘在最昂贵的方向上犯了错误:它告诉所有拥有掩码门控模型的开发者放弃半精度,而这一结论本会把一个体积缩小52%的高效下载包拒之门外。真正的罪魁祸首是十六个超出fp16最大可表示值的BatchNorm方差张量,它们在转换过程中被静默截断,导致十六个层用错误的除数对其输出进行归一化。这个修复方案只需要两行代数运算,而回报是一个能够匹配fp32精度且下载体积缩减52%的模型。
当开发者构建利用先进大模型API或本地ONNX模型的高性能管道时,量化效率至关重要。在n1n.ai,我们处理大量推理请求,带宽和延迟决定了用户留存,这使得可靠的压缩技术变得不可或缺。
这是一篇更正文章,因此它附带了完整的实验记录:本轮的实验室笔记与代码一同检入,下面的每一个图形都可以从CPU版本的ONNX Runtime安装中复现,整个调查过程仅耗费了一到两天的工作时间。
原始误诊:将工具链问题归咎于架构
本系列第三部分以一个名为当fp16量化质量损失真实存在:LaMa失效的案例研究结束。其中的测量数据是真实且可复现的:今天用相同的权重重新构建该转换,未修复的fp16模型在14个验收案例中平均得分为15.8 dB,而fp32为29.7 dB,其最坏情况得分为11.1 dB——水印完好无损地存活下来,正如描述的那样。第五部分将其重复描述为其在LaMa上尝试时的灾难性镜像。第一部分的系列索引将该教训总结为掩码门控架构与精度敏感性。
我们也曾提供过一个机制解释,并且这个故事听起来很完美。LaMa是掩码门控的:掩码不仅标记了工作区域,还门控了网络的行为。因此,一个掩码门控的图像修复器内部存在一个决策边界——哪些区域是可信的上下文,哪些必须重新生成——半精度噪声将模型推过了这个边界。门控平缓失效,模型开始将带水印的像素直接复制过去而不是进行修复,输出看起来毫无破绽,但水印依然存在。
这个故事完美地解释了症状。它与我们学到的其他所有东西都一致(浏览器推理受限于带宽,因此移动一半的字节是免费的午餐)。并且它产生了一个整洁的规则,我们随后向读者重复了这一结论:
量化是否安全是模型架构的属性,而不是你所使用的工具的属性。
这个规则是错误的。不是带有例外的错误,而是作为预测器完全错误且代价高昂:它本会把一个画质完全相同且体积小48%的模型永远挡在产品之外,并且它告诉每一个拥有注意力机制、门控掩码或长残差链的读者停止调查。本系列的第八部分讲述了一个质量漏洞最后证明是我们自己的问题,而不是模型的问题。这与那种错误形状相同,只是在技术栈中低了一层:是我们的转换工具链出了问题,而不是模型的架构。
将精度与数值范围分离
FP16捆绑了两个独立的改变:尾数精度(你将一个值切分得有多细)以及数值范围(你所能持有的最大量级)。如果失败的原因是精度敏感性,那么在保持fp32范围的同时舍入尾数将会成比例地降低质量。因此我们构建了保持图和所有算术运算在fp32中、仅对权重的尾数进行舍入的模型:
| 权重舍入目标 | 区域PSNR(5个案例) | 与fp32相比的输出保真度 |
|---|---|---|
| fp32(基准) | 28.88 | — |
| 7位尾数(bf16) | 28.83 | 66.9–76.9 dB |
| 10位尾数(= fp16) | 28.88 | 74.5–96.6 dB |
| 13位尾数 | 28.88 | 95.9–109.0 dB |
fp16拥有一个10位的尾数。10位的这一行距离fp32基准仅有0.004 dB的差距,甚至丢弃23个尾数位中的16位(7位,比bf16还要粗糙)也仅仅损失了0.054 dB。LaMa对精度并不敏感,它是对精度无所谓的。无论fp16对它做了什么,绝对不是尾数的问题。
寻找静默溢出
这就只剩下了数值范围,而数值范围有一个硬性的、可检查的边界:fp16的最大的有限值是65,504。因此我们转储了图中所有的fp32初始化器并按量级排序。
该图包含17,480个节点,带有606个float32初始化器张量,51M个参数,204 MB的权重——其中197 MB在FFT路径中(这些节点中有17,022个是傅里叶卷积,占97%)。对这606个张量运行下方的检查,有十六个返回结果超过了65,504,并且每一个都是BatchNorm的running_var:
484322.56 model.generator.model.5.conv1.bn_l.running_var
440853.66 model.generator.model.25.running_var
240497.69 model.generator.model.22.conv1.bn_l.running_var
218544.31 model.generator.model.11.conv1.bn_l.running_var
174809.92 model.generator.model.6.conv1.bn_l.running_var
65564.05 model.generator.model.10.conv1.bn_l.running_var
... 606个张量中的16个,全部都是running_var
注意最后一行:边缘张量超出了上限0.09%,但它被精确截断到了与超出7倍的张量完全相同的65,504。这就是全部的危险所在——被截断的值精确地坐在fp16的上限上,这是一个fp16勉强能够持有的值,因此下一次fp16遍历该张量时就会将其推向无穷大。
当开发者通过像n1n.ai这样面向企业级的高性能API聚合器集成模型时,如果中间缩放未被监控,静默的数据类型强制转换可能会引入类似的边缘情况。
这是一个仅需五行的检查,也是在相信任何该模型无法被量化的结论之前首先要运行的代码:
import numpy as np, onnx
from onnx import numpy_helper
m = onnx.load("lama_fp32.onnx", load_external_data=False)
worst = sorted(
((float(np.abs(numpy_helper.to_array(t)).max()), t.name)
for t in m.graph.initializer if t.data_type == 1), # 1 = float32
reverse=True,
)[:16]
for value, name in worst:
print(f"{value:>14.2f} {name}")
十六个张量。不是十六层掩码逻辑——仅仅是十六个数字。
一个BatchNorm层的计算公式为y = (x − μ) / sqrt(var + ε) · γ + β。running_var是在训练期间测量的跨层输入的尺度量。如果某层的输入处于很大的量级,其方差就会处于这些量级的平方,在fp32中484,322是完全合法的:sqrt(484322) ≈ 696。
将该方差截断到fp16的上限65,504,除数就变成了sqrt(65504) ≈ 256。该层现在通过256而不是696进行归一化——其输出放大了2.7倍——而消耗它的卷积是在正确的尺度下训练的。串联十六个这样的层,网络的内部量级就被完全破坏了,而计算图依然能够运行并产生看起来很合理的图像。
代数修复方案
我们首先尝试了简单粗暴的版本:将这十六个出问题的BatchNorm节点保留在fp32中,其余全部转换为fp16。这确实行得通,代价是34 KB的fp32参数,并且产生了相同的质量——但它留下了一个包含fp32孤岛的图,并且有十六个永久断言来指明哪些层是危险的。
更好的修复方法是注意到BatchNorm的方差和其尺度仅在彼此相对的意义上有意义:
y = (x − μ) / sqrt(var + ε) · γ + β ≡ (x − μ) / sqrt(var/K² + ε/K²) · (γ/K) + β
因此,为每层选择一个二的幂次方K,将方差除以K²,将尺度除以K,并将微小量epsilon除以K²——该层在数学上是完全相同的,现在var/K²可以完美装入fp16中。令K = 2^ceil(log2(max|var| / 40000)),这是一个只需二十行的脚本,影响了75个BatchNorm层中的19个:
k = float(2 ** np.ceil(np.log2(max_abs_var / 40000.0))) # 最坏层的 K = 16
var_init.CopyFrom(numpy_helper.from_array((var / (k * k)).astype(np.float32), var_init.name))
scale_init.CopyFrom(numpy_helper.from_array((scale / k).astype(np.float32), scale_init.name))
# 以及 epsilon 属性: a.f = a.f / (k * k)
这个结果值得精确说明,因为它粉碎了混合精度本质上就是模糊的直觉:转换后的模型拥有606个fp16初始化器和零个fp32残留。它是100%的fp16,每个张量都是,且没有在BatchNorm周围添加任何转换算子。而且运算是精确的——由于采用了二的幂次方,舍入不会导致任何精度损失。
当通过n1n.ai协调多模型架构时,维护精确的数值边界可以确保在各种运行时引擎中实现高吞吐量的稳定性。
基准测试与验证
对相同的转换权重进行三个独立的测量,外加一个对照组和一个测试台的健全性检查:
| 检查项 | fp32 | fp16(已修复) |
|---|---|---|
| 14个案例的掩码区域PSNR | 29.74 dB | 29.73 dB |
| 每个案例的幽灵分数漂移(平均值 / 最大值) | — | 0.0014 / 0.005 |
| 浏览器运行时(ORT Web WASM),同一瓦片,对比fp32参考 | — | 66.5 dB |
| 浏览器运行时(ORT Web WebGPU),同一瓦片,对比WASM参考 | — | 71.6 dB,在0.86秒对比WASM的4.06秒 |
| 未修复的fp16,相同测试(对照组) | — | 22.6 dB |
| 16个真实照片区域,生产检测框 | — | 59.9 dB 平均值,40.3 dB 最小值 |
体积优势正是本次演练的核心意义:
| 格式 | 原始大小 | brotli q11(用户实际下载大小) |
|---|---|---|
| fp32 | 198.4 MiB | 179.2 MiB |
| fp16(已修复) | 102.8 MiB | 86.3 MiB(−52%) |
全新的排查清单
我们以前的顺序是:架构 → 位宽 → 运行时。新的顺序在此之前提前了一步,因为转换产物可以伪装成架构层面的裁决:
- 在审计架构之前先审计数值。 转储每个初始化器的
max |value|,与目标数据类型的上限进行比较,顺便检查次正规数的下限。 - 将精度与范围分离。 舍入尾数,保持范围。如果质量没有变化,精度就不是你的变量。
- 测试网络结构。 门控、长累积链、对抗性谱路径——这些都是真实存在的失效模式,但它们是需要测试的假设,而不是自动的确诊结果。
- 在你实际发布的运行时中进行验证。 Python CPU的运行通过只能证明权重没问题;它不能证明运行时执行提供程序能够产生完全一致的结果。
- 基准测试决定准入。 始终针对基线参考进行验证。
无论你是部署本地权重还是通过n1n.ai连接进行云加速大模型推理,系统化验证都能防止代价高昂的架构误判。
获取免费 API 密钥,请访问 n1n.ai