折进 int8 再展开,究竟变了多少
一句话总结
量化是把实数换成一个缩放因子和一个整数,而损失多少,由缩放因子决定。决定这个缩放因子的,是列表里最大的那一个绝对值。
为什么需要它
把模型转成 int8,权重会缩小到四分之一,还能使用整数乘法器。所以大家都会试一次。问题出在之后——精度掉了一点,却说不出是在哪里掉的。
工具一行就搞定。调用量化函数,模型就出来了。如果不知道里面发生了什么算术,能做的只有不断改选项重新跑一遍,那不是在修,而是在试运气。
所以这篇文章和接下来的实验不使用工具。我们从用手把一个含有八个值的列表折叠、再展开开始。在这里看到的东西,在大模型里也原样可见。
「折叠」是什么意思
要把一个实数列表搬进 int8,需要定下两件事:缩放因子(scale)和零点(zero point)。
最简单的是对称量化。把缩放因子定为 max(|x|) / 127,用缩放因子去除每个值再四舍五入。最大的绝对值会落在码值 127 上,0 则原样落在码值 0 上。
scale = max(abs(x) for x in values) / 127
codes = [round(x / scale) for x in values] # -127 부터 127 까지
back = [c * scale for c in codes] # 편 값. 원본이 아니다
非对称(仿射)量化用于值偏向一侧的情形。把宽度定为 (max - min) / 255,把 256 档全部用上,而实数 0 落在哪个码值上,则作为零点另外带着。还原(展开)是 (code - zero_point) * scale。整理了仅用整数算术做推理的方法的论文原样使用这个式子。
两种方式损失的东西都在同一个地方。那就是缩放因子的一半。缩放因子是 0.007 时,不论哪个值,最多都会偏差 0.0035。不管值大还是小,都同样是这么多。
要先把四舍五入钉死
人们首先在这里栽跟头。Python 的 round 不是学校里学的那种四舍五入。
round(0.5) # 0 — 1 이 아니다
round(1.5) # 2
round(2.5) # 2 — 3 이 아니다
恰好是 0.5 的位置会被送往偶数一侧。如果总是把 0.5 往上进,四舍五入后的值的平均数就会一点点被推高,所以才这样规定。量化是对整个数组各做一次四舍五入,所以这种偏移会原样变成模型的偏差。
问题不在规则,而在于规则有两种这个事实。有的实现送往偶数,有的实现送往远离 0 的一侧。同样的权重,用同样的缩放因子折叠,码值却差了一档,那么此后所有的比较都失去意义。所以阅读量化代码时,比缩放因子的式子更先要确认的是四舍五入规则。
决定缩放因子的是一个值
对称量化的缩放因子是 max(|x|) / 127。这个式子里既没有平均数,也没有方差。最大的那一个绝对值就是全部。
所以,列表里只要混进一个特别大的值,这一个就决定了其余所有值的精度。其余的全都在 1 以下而有一个是 42,缩放因子就变成 42/127,1 以下的值会被挤压在码值 0 到 3 之间。明明有 256 档,却只用了四档。
这是 LLM.int8() 论文的出发点。大语言模型的隐藏状态中,会出现比其他值大得多的分量,由于这几个,其余全部都变得不能用了——这就是那个观察。解决的方向也由此而来:把缩放因子取在更小的单位上,或者把大的那些单独拿出来。
把缩放因子取在更小的单位上,是要先做的事。与其整个矩阵用一个缩放因子来折叠,不如每一行(或每一列)单独取一个缩放因子,这样小的行就不会被大的行拖着走。不过并非免费。缩放因子得按行带着,而且要让矩阵乘法成立,缩放因子必须是按行或按列的单位。如果每个值的缩放因子都不同,就无法在整数累加中把缩放因子提出来。
用整数相乘,会剩下什么
整数推理的核心在于乘法和累加全是整数。码值之间相乘再相加的过程中,一次四舍五入都不会发生。全部加完之后,在最后乘上两个缩放因子,展开成实数。
acc = sum(a_code[k] * b_code[k] for k in range(d)) # 정수만
value = acc * a_scale * b_scale # 마지막에 한 번
所以矩阵乘法产生的误差不是在累加中增大的,而是在最初折叠时就已经产生的。这意味着找原因的地方只有一个,这是个好消息。
剩下的问题是,这个误差在后面会怎样。注意力分数要经过 softmax 变成概率。softmax 用指数把差距拉开,所以分数的小误差,在概率上可能变大,也可能反而被淹没。是哪一种,不去测量就无法说。下一个实验会亲自测量。
在现场相遇的样子
第一,精度掉了一点,却不知道是在哪里掉的。工具只有一行,没有往里面看的眼睛。缩放因子、四舍五入、单位,改了哪一个时哪个东西会动,只有亲手做过一次的人才能指出来。
第二,同一个模型用两个工具量化,结果不同。即使缩放因子的式子相同,四舍五入规则不同,码值也会一档一档地错开。要先确认的不是哪边对,而是什么不同。
第三,按张量折叠之后,只在特定的层崩溃。这是那一层的权重分布里有特别大的值的情况。整体的平均误差看上去没问题,只有小的行的相对误差爆炸。
第四,激活值(activation)比权重棘手得多。权重是固定的,测一次就结束,而激活值每个输入都不同。要用校准(calibration)数据来测范围,如果那份数据与实际输入不同,缩放因子就会错位。
第五,把大小和速度说成同一回事。文件缩小到四分之一,与实际变快,是两回事。整数 kernel 是否真的运行了,要另行确认。
实际工作中真正重要的事
- 先看缩放因子由什么决定。如果是
max(|x|)一个值,找到那一个,就是诊断的开始。 - 把四舍五入规则写进文档。这是比较两个实现时最先出现不一致的地方。
- 不看平均误差,而看小的一侧的相对误差。最大绝对误差由最大的行决定,所以会掩盖问题。
- 在放大单位之前,先收窄。仅仅从一个张量降到按行,就能解决的情况很多。
- 测量后面会怎样。分数的误差在概率上是变大还是被淹没,不是靠猜,而是靠测。如果知道 Attention Is All You Need 里的分数计算会流向哪里,也就看得出该在哪里测。
下一项实验要做什么
把 /root/work/tf-quant/quant.py 一步一步做大。不调用工具,亲手做算术——这个 Pod 的系统 Python 里没有 numpy(numpy 只在 /opt/onnx-lab 里有)。所以用标准库来处理列表和列表的列表。Python math 模块的 floor、exp 就足够了。
从做出两种四舍五入规则,确认它们在哪里产生分歧开始。接着做出对称和非对称量化,再做出测量折叠后又展开的值偏离原值多远的尺子。到这里是一半。
另一半是本实验的要点。在普通的值后面加上一个大值,数一数其余的值被压缩成了几档。取一个各行宽度差别很大的矩阵,把按张量和按行并排测量。确认码值之间只用整数相乘时累加是否恰好吻合,最后测量这个误差经过 softmax 之后在概率上是多少。再用植入了离群值的 key 测一次,看看前面看到的事情在注意力中如何表现。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的输入调用函数,并与评分器另行计算的值对照。对照大部分是整数数组之间的——码值只要错开一档,马上就会暴露。