亲手算一遍 int8 的算术
目标
只用标准库亲手做整数量化的算术。从钉死四舍五入规则开始,做出对称和非对称量化,测量折叠后又展开的值的误差,数出一个离群值把缩放因子毁到什么程度,把按张量与按行并排对比,让码值之间只用整数相乘,最后测量这个误差经过 softmax 之后在概率上是多少。
为什么重要
量化工具只有一行。如果不知道里面发生了什么算术,精度下降时就只能不断改选项重新跑。缩放因子由什么决定、四舍五入往哪一侧走、收窄单位会有什么变化——在含有八个值的列表上看过一次之后,在大模型里也能指出同样的地方。
本实验不使用工具。这个 Pod 的系统 Python 里没有 numpy(numpy 只在 /opt/onnx-lab/bin/python 里有),也不调用模型。所以“某个模型在 int8 下精度掉了百分之几”这样的话,这里不说。只使用在你做出的列表和矩阵上测得的数字。
难的不是式子,而是细节。0.5 往哪边送、超出范围的要不要截断、缩放因子取什么的最大值,如果这些没有规定,即使输入相同,码值也会一档一档地错开。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的输入调用函数,并与评分器另行计算的值对照。对照大部分是整数数组之间的,所以不会有晃动。
步骤
- 在 /root/work/tf-quant/quant.py 中创建
QMAX = 127、UMAX = 255以及round_half_even(x)、round_half_away(x)、rounding_gap(values)。亲眼确认两种四舍五入规则在哪里产生分歧。 - 增加
sym_scale(values)、quantize_sym(values, scale)、dequantize_sym(codes, scale),做出对称量化。缩放因子是max(|x|) / 127。 - 增加
affine_params(values)、quantize_affine(values, scale, zero_point)、dequantize_affine(codes, scale, zero_point),做出非对称量化。缩放因子是(max - min) / 255。 - 增加
levels_used(codes)和error_stats(original, restored),做出测量误差的尺子。 - 增加
outlier_effect(values, outlier),测量一个大值对其余的值做了什么。 - 增加
quantize_tensor(matrix)、quantize_rows(matrix)、granularity_gap(matrix),对比按张量与按行。 - 增加
transpose(matrix)、int_matmul(left_codes, right_codes)、float_matmul(left, right)、quant_matmul(left, right),只用整数来做矩阵乘法。 - 做出
WEIGHTS、OUTLIER、QUERIES、KEYS以及softmax(scores)、attention_shift(queries, keys),并把结果记录到 /root/work/tf-quant/quant_report.json 和 /root/work/tf-quant/quant_report.md 中。
参考
- 执行契约:评分器会把
/root/work/tf-quant/quant.py当作 Python 模块导入,直接使用QMAX、UMAX、round_half_even、round_half_away、rounding_gap、sym_scale、quantize_sym、dequantize_sym、affine_params、quantize_affine、dequantize_affine、levels_used、error_stats、outlier_effect、quantize_tensor、quantize_rows、granularity_gap、transpose、int_matmul、float_matmul、quant_matmul、softmax、attention_shift、WEIGHTS、OUTLIER、QUERIES、KEYS。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 round_half_even(x)就是 Python 默认的round。恰好是 0.5 的位置会走向偶数一侧——round(0.5)是 0,round(1.5)是 2,round(2.5)是 2。请亲自运行确认。返回的值是整数。round_half_away(x)把恰好是 0.5 的位置送往远离 0 的一侧。如果写成math.floor(x + 0.5)一行,负数会出错——-1.5应该变成-2。rounding_gap(values)只把两种规则产生分歧的值按收到的顺序收集起来返回。并非以 0.5 结尾就一定会产生分歧。sym_scale(values)是max(|x|) / QMAX。如果列表中的值全是 0,就无法相除,所以请返回1.0。quantize_sym(values, scale)用缩放因子相除,用round_half_even四舍五入,然后截断到-QMAX至QMAX的范围。dequantize_sym(codes, scale)只是乘以缩放因子。affine_params(values)返回(scale, zero_point)。scale = (max - min) / UMAX,zero_point = round_half_even(-min / scale)再截断到 0 至UMAX的范围后的值。如果最大值与最小值相同,则是(1.0, 0)。quantize_affine把round_half_even(x / scale) + zero_point截断到 0 至UMAX的范围。截断确实会起作用——如果两端在四舍五入时各挪了一档,和就会变成 256。dequantize_affine是(code - zero_point) * scale。levels_used(codes)是不同码值的个数。error_stats(original, restored)是带有max_abs、mean_abs、max_rel三个键的字典。max_rel是最大绝对误差除以原值的最大绝对值得到的值——因为逐个值去除的话,在 0 附近会跳到无穷大。如果原值的最大绝对值是 0,则max_rel是0.0。outlier_effect(values, outlier)分别测量只折叠values时和折叠values + [outlier]时,并且只对比原来values的位置。返回的键有clean_scale、dirty_scale、clean_levels、dirty_levels、clean_max_abs、dirty_max_abs六个。quantize_tensor(matrix)返回(배율, 코드 행렬),quantize_rows(matrix)返回(배율 목록, 코드 행렬)(占位符依次为缩放因子、码值矩阵,以及缩放因子列表、码值矩阵)。granularity_gap(matrix)的键有tensor_max_abs、row_max_abs、tensor_worst_row_rel、row_worst_row_rel、tensor_worst_row_levels、row_worst_row_levels六个。worst_row_rel是对每一行用该行的error_stats求出max_rel后最大的那个值,worst_row_levels是对每一行求出levels_used后最小的那个值。int_matmul(left_codes, right_codes)只用整数来相乘。中途不能混进实数。quant_matmul(left, right)把左边按行、右边按列折叠,再用int_matmul相乘,并用acc * left_scale * right_scale展开。返回的值是(복원 행렬, 정수 누적 행렬, 왼쪽 배율 목록, 오른쪽 배율 목록)(占位符依次为还原矩阵、整数累加矩阵、左侧缩放因子列表、右侧缩放因子列表)这样的四元组。softmax(scores)接收一个列表,返回概率列表。attention_shift(queries, keys)用float_matmul(queries, transpose(keys))求分数,用quant_matmul(queries, transpose(keys))的第一个值求近似分数,然后返回score_max_abs、prob_max_abs、argmax_changed、top_prob_max_abs四个键。本实验直接使用分数。- 第 8 步材料的形状:
WEIGHTS是 6 行 8 列,QUERIES是 4 行 8 列,KEYS是 5 行 8 列,所有值的绝对值都在 2.0 以下。WEIGHTS必须各行的宽度差别很大——最大那一行的最大绝对值必须是最小那一行的 10 倍以上,按张量与按行才会产生分歧。OUTLIER是一个绝对值在 20.0 以上的值。数字可以自由决定。 - 第 8 步报告中的离群值 key 矩阵,是复制
KEYS后只把[0][0]位置换成OUTLIER的矩阵。原始的KEYS要保持不变。 - 这个 Pod 没有互联网。
pip install无法使用,在系统 Python 中import numpy也不可用(numpy 只在/opt/onnx-lab/bin/python里有)。只用math就足够了。 - 官方文档:仅用整数算术做推理 · LLM.int8() · Attention Is All You Need · Python math
- 常见错误:把
round_half_away写成math.floor(x + 0.5)一行;不截断超出范围的值;把缩放因子取为最大值(而不是绝对值);error_stats的相对误差逐个值分别去除;在outlier_effect中把离群值自身的误差也算进去;在quant_matmul中把右边按行而不是按列折叠;在int_matmul里面提前乘上缩放因子。
先把四舍五入钉死
在 /root/work/tf-quant/quant.py 中创建 QMAX = 127、UMAX = 255 以及 round_half_even(x)、round_half_away(x)、rounding_gap(values)。前者与 Python 默认的 round 一样,把恰好是 0.5 的位置送往偶数一侧,后者送往远离 0 的一侧。rounding_gap 只把两种规则产生分歧的值按收到的顺序收集起来返回。
先运行 python3 -c "print(round(0.5), round(1.5), round(2.5))"。会得到 0 2 2。如果把 round_half_away 写成 math.floor(x + 0.5) 一行,负数会出错——-1.5 应该变成 -2,而那个式子给出的是 -1。请用 math.floor(x) 求出下取整,再单独看小数部分,只对恰好是 0.5 的位置按符号分开处理。两个函数都返回整数。
用对称量化折叠再展开
增加 sym_scale(values)、quantize_sym(values, scale)、dequantize_sym(codes, scale)。缩放因子是 max(|x|) / QMAX,如果值全是 0 则是 1.0。折叠时用缩放因子相除,用 round_half_even 四舍五入,然后截断到 -QMAX 至 QMAX 的范围。
缩放因子要取最大绝对值而不是最大值,负数一侧才会落在范围内。截断一行 max(-QMAX, min(QMAX, code)) 就行。展开的函数只是乘以缩放因子,一行就够——展开后的值与原值不同才是正常的。损失的量落在缩放因子的一半之内。
用非对称量化把 256 档全用上
增加 affine_params(values)、quantize_affine(values, scale, zero_point)、dequantize_affine(codes, scale, zero_point)。缩放因子是 (max - min) / UMAX,零点是 round_half_even(-min / scale) 截断到 0 至 UMAX 的范围后的值。如果最大值与最小值相同,则是 (1.0, 0)。折叠时把 round_half_even(x / scale) + zero_point 截断到 0 至 UMAX 的范围。
漏掉截断,会在这里真正出问题——两端在四舍五入时各挪了一档,round(x/scale) + zero_point 就会变成 256,超出 uint8 范围。展开的式子是 (code - zero_point) * scale。多亏这个式子,实数 0 才能毫无误差地还原——这就是需要零点的原因。
测量偏离多远的尺子
增加 levels_used(codes) 和 error_stats(original, restored)。前者是不同码值的个数,后者是带有 max_abs、mean_abs、max_rel 三个键的字典。max_rel 是最大绝对误差除以原值的最大绝对值得到的值,如果那个最大绝对值是 0,则是 0.0。
如果把相对误差逐个值去除,在 0 附近的值上会跳到无穷大。所以要除以列表所包含的宽度——缩放因子就是由这个宽度决定的,因此比较的对象也是它。levels_used 是 len(set(codes)) 一行。明明有 256 档,却只用了几档,这是下一步的内容。
一个大值对其余的值做了什么
增加 outlier_effect(values, outlier)。分别测量只折叠 values 时和折叠 values + [outlier] 时,并只对比原来 values 的位置。返回的键有 clean_scale、dirty_scale、clean_levels、dirty_levels、clean_max_abs、dirty_max_abs 六个。
把附上离群值的列表整个折叠之后,只从前面截取 len(values) 个来对比。如果把离群值自身的误差也算进去,结论就会颠倒——被毁掉的不是离群值,而是它旁边的普通值。请亲眼看看 dirty_levels 会掉到几。明明有 256 档,现在用了几档?
整个张量一个缩放因子与每一行各一个缩放因子
增加 quantize_tensor(matrix)、quantize_rows(matrix)、granularity_gap(matrix)。前两个分别返回 (배율, 코드 행렬) 和 (배율 목록, 코드 행렬)(占位符依次为缩放因子、码值矩阵,以及缩放因子列表、码值矩阵)。granularity_gap 的键有 tensor_max_abs、row_max_abs、tensor_worst_row_rel、row_worst_row_rel、tensor_worst_row_levels、row_worst_row_levels 六个。
worst_row_rel 是对每一行用该行的 error_stats 求出 max_rel 后的最大值,worst_row_levels 是对每一行求出 levels_used 后的最小值。只看最大绝对误差,两种方式的差别几乎看不出来——因为那个值是由最大的那一行决定的。小的行是怎样被压扁的,要从相对误差和用到的档数上才看得出来。
只用整数来做矩阵乘法
增加 transpose(matrix)、int_matmul(left_codes, right_codes)、float_matmul(left, right)、quant_matmul(left, right)。int_matmul 的乘和加都必须是整数。quant_matmul 把左边按行、右边按列折叠后用整数相乘,再用 acc * left_scale * right_scale 展开,返回 (복원 행렬, 정수 누적 행렬, 왼쪽 배율 목록, 오른쪽 배율 목록)(占位符依次为还原矩阵、整数累加矩阵、左侧缩放因子列表、右侧缩放因子列表)。
要把右边按列折叠,就用 transpose 取出列,对每一列使用 quantize_sym,再用 transpose 转回来。在累加里面绝对不要乘缩放因子——只在最后乘一次。所以矩阵乘法的误差不是在累加中增大的,而是最初折叠时就已经产生的。评分器会原样对照整数累加矩阵,所以只要错开一档就会被抓到。
经过 softmax 之后剩下的
做出 WEIGHTS(6 行 8 列)、OUTLIER(绝对值 20.0 以上)、QUERIES(4 行 8 列)、KEYS(5 行 8 列)以及 softmax(scores)、attention_shift(queries, keys)。WEIGHTS 最大那一行的最大绝对值必须是最小那一行的 10 倍以上,三个矩阵的所有值的绝对值都在 2.0 以下。然后在 /root/work/tf-quant/quant_report.json 中写入 sym_scale、sym_max_abs、sym_mean_abs、sym_max_rel、sym_levels、affine_scale、affine_zero_point、affine_max_abs、affine_levels、outlier_clean_scale、outlier_dirty_scale、outlier_clean_levels、outlier_dirty_levels、outlier_clean_max_abs、outlier_dirty_max_abs、tensor_max_abs、row_max_abs、tensor_worst_row_rel、row_worst_row_rel、tensor_worst_row_levels、row_worst_row_levels、matmul_max_abs、matmul_max_rel、clean_score_max_abs、clean_prob_max_abs、clean_argmax_changed、dirty_score_max_abs、dirty_prob_max_abs、dirty_argmax_changed,并在 /root/work/tf-quant/quant_report.md 中用 ## 무엇을 쟀나(韩文,意为“测量了什么”)、## 대칭과 비대칭은 어디서 갈렸나(韩文,意为“对称与非对称在哪里产生了分歧”)、## 이상치 하나가 한 일(韩文,意为“一个离群值做了什么”)、## 행렬 곱과 소프트맥스를 지나면(韩文,意为“经过矩阵乘法和 softmax 之后”)四节来写。
数字不要手写,要用实际运行你的代码得到的值来填。sym_* 和 affine_* 是对把 WEIGHTS 展平成一行的列表得到的值,outlier_* 是用那个列表和 OUTLIER 调用 outlier_effect 的结果。tensor_*、row_* 是 granularity_gap(WEIGHTS) 的值,matmul_* 是把 quant_matmul(QUERIES, transpose(KEYS)) 的还原矩阵与 float_matmul(QUERIES, transpose(KEYS)) 对比的 error_stats 中的 max_abs、max_rel。clean_* 是 attention_shift(QUERIES, KEYS),dirty_* 是用复制 KEYS 后只把 [0][0] 位置换成 OUTLIER 的矩阵调用的结果。不要修改原始的 KEYS。看分数误差和概率误差朝哪个方向变动,并如实写下来——不要猜着写。