← 返回首页
17_quantization_viz.html

量化:把 124M 从 32 位压到 4 位

第 6 章用 10B token 训出来的那个 GPT-2 124M,每个权重是 32 位浮点,整个模型 497.9 MB。 量化 把每个权重换成几位的格点编号:先定一个 scale, 权重除以它、四舍五入,用的时候再乘回来。本关用配套代码 phase4-efficiency/15_quant.py 把 15 种做法挨个跑一遍, 在同一份 FineWeb-Edu val 上看 loss 涨多少。 Phase 4 讲效率,这是最后一章。

STEP 1
一个权重要几位
STEP 2
一个 scale 管多少个
STEP 3
离群值撑大格子
STEP 4
三种 4 位格式
STEP 5
124M 的真实账本
这一关 模型 124M 第 6 章 · step 19072 被量化 84.9M 12 层 48 个 Linear 权重 不量化 39.5M 嵌入 / LayerNorm / bias,按 16 位 评测 131,072 val token group 32 每 32 个权重一个 scale bits 8/4/3/2 格点编号几位 group 的 32 是"管几个权重",fp32 的 32 是"每个数几位",含义不同

① 一个权重要几位

最朴素的做法是整个矩阵共用一个 scale:scale = 最大绝对值 / qmax,qmax = 2bits−1 − 1。 权重除以 scale、四舍五入,落到 −qmax … qmax 这些整数格点上。 下图是第 6 层 mlp.c_fc 全部 2,359,296 个权重的真实分布,橙色竖线是这个 scale 下能表示的值。拖滑块改位数:

4 位
权重个数(80 格直方图,±8σ 以外并进两端) ±3σ 能表示的值 最大绝对值
–
能表示几个值
–
格距 scale
–
±3σ 里分到几个格点
–
被舍成 0 的权重(按直方图估)
–
这个矩阵占多大
↳ 代码:15_quant.py 的 int_absmax(w, bits)(group=None,整矩阵一个 scale)
为什么只用 −qmax … qmax,不把 int4 的 −8 … 7 用满?
对称取值让 0 精确落在格点 0 上,正负两侧格距相同,一个 scale 就够,不必再存偏移量。代价是少用一个编码(int4 用 15 个,空着 −8)。 LLM.int8() 论文里的 absmax 量化也是这样定 scale 的,原文: "Absmax quantization scales inputs into the 8-bit range [−127, 127] by multiplying with s_xf16 which is 127 divided by the absolute maximum of the entire tensor" (Dettmers et al., arXiv 2208.07339,§2)。
这里量化完,显存真的省下来了吗?
没有。15_quant.py 做的是 假量化: 每个矩阵量化成格点后立刻乘回 scale,得到浮点数放回模型,再算 loss 和续写。 这组浮点数和真 4 位存储在计算时解码出来的值相同,所以 loss 与真 kernel 的结果一致,可以拿来比较格式好坏。 但权重在内存里仍是浮点,不会省显存,也不会变快;要真的省,得把格点编号按位打包存储,再配专门的反量化 / 矩阵乘算子。 本页里的 MB 都是按位数算出来的存储大小。
fp16 为什么一点没掉?
fp16 仍然是浮点:1 位符号、5 位指数、10 位尾数,每个数自带自己的"scale"(指数),相对精度约 2−10 ≈ 千分之一。 实测 fp16 的 val loss 是 3.0213,和 fp32 在 4 位小数上相同,贪心续写逐字一样。 第 6 章训练时前向本来就跑在 bf16 混合精度下,模型对 16 位的舍入早就适应了。 整数量化不同:所有权重共用少数几个 scale,舍入误差由 scale 管的那一片里最大的数决定。
↳ 下一步:一个 10σ 的最大值把 4 位的格子撑开,一半权重被舍成 0。让一个 scale 只管一小段权重,会怎样?

② 一个 scale 管多少个权重

scale 管的范围越小,它只需要照顾这一小段里的最大值,格子就贴得越紧。 下面是同一个矩阵(第 6 层 mlp.c_fc)第 123 行的前 32 个权重:空心圈是原值,实心点是 4 位量化再乘回来的值,竖线是误差。切换 scale 的管法:

原值 量化后 被舍成 0 用到的格点值
–
一个 scale 管几个权重
–
每权重平均几位
–
这 32 个的均方根误差
–
被舍成 0 的(共 32 个)
–
整模型 val loss(fp32 3.0213)
↳ 代码:15_quant.py 的 int_absmax(w, 4, group)(group = None / 'row' / 32)· nvfp4
管得越细越好,为什么不每 4 个权重配一个 scale?
scale 也要存。16 位的 scale 摊到 4 个权重上是每个 +4 位,4 位量化的存储就变成了 8 位。实测三档: 每行 1 个 4.016 位、Δ +0.2243;每 128 个 1 个 4.125 位、Δ +0.1147;每 32 个 1 个 4.5 位、Δ +0.0736。 位数与误差要取折中,第 4 步的三种工业格式把块大小定在 16 和 32。
NVFP4 那一档的点为什么更密?
它的元素是 4 位浮点 E2M1,8 个非负值是 0、0.5、1、1.5、2、3、4、6,靠近 0 的地方更密,正好对上权重扎堆在 0 附近的分布。 这 32 个权重在 NVFP4 下用到 12 种绝对值(含 0),int4 每 32 个一个 scale 只用到 7 种。 说明:生成这组示例时,15_quant.py 只把这一行传进 nvfp4,所以示例里的张量级 scale 取自这一行本身;块级 scale 照常每 16 个一个。
↳ 下一步:管细了就不崩。整矩阵一个 scale 为什么会崩到 9.93?看看每个矩阵的最大值离正常权重有多远。

③ 离群值:一个大数撑大所有格子

给 12 层里全部 48 个被量化的矩阵各算一个数:最大绝对值是标准差的几倍(记作 r)。 int4 整矩阵一个 scale 时,格距 = r/7 个标准差,r 越大,正常大小的权重分到的格点越少。 这些远离主体的少数权重叫 离群值。点柱子看某个矩阵:

attn.c_attn attn.c_proj mlp.c_fc mlp.c_proj
–
选中的矩阵
–
最大绝对值 / 标准差
–
int4 整矩阵的格距
–
这个范围内全部舍成 0
–
±3σ 里的格点(共 15 个)
对照只把 0.1% 的离群值挪出去

int4_keep_outliers:每个矩阵里绝对值最大的 0.1% 权重原样留 16 位(另存 32 位位置),剩下 99.9% 照旧用整矩阵一个 scale 量化, 平均每个权重多 0.001 × (16 + 32) = 0.048 位。点按钮看 val loss 和续写:

整矩阵 int4 的 val loss 是 9.9342;只挪走 0.1% 的权重,回到 3.2633,续写重新成句。
↳ 代码:15_quant.py 的 int4_keep_outliers · 每个矩阵的倍数在脚本末尾 per_layer_outlier
LLM.int8() 是怎么处理离群值的?
Dettmers et al.,LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale(arXiv 2208.07339,NeurIPS 2022)。 §3 的做法分两部分:按行 / 列分别定 scale 的 vector-wise 量化,加上 mixed-precision decomposition,把含离群值的特征维单独拆出来用 FP16 算,其余走 int8。 §4 报告离群特征在约 6.7B 参数处集中出现,原文 "at the 6.7B scale, 150,000 outliers occur"。

论文处理的是激活里的离群特征维。本页把同一个思路搬到权重上,按单个权重挑出最大的 0.1%。
0.1% 这个比例是怎么定的?
代码里固定取 frac=0.001,没有扫这个参数。拿第 6 层 mlp.c_fc 对照:超过 6σ 的权重占 0.0002%,超过 3σ 的占 0.369%,0.1% 落在两者之间。 比例取大,离群值的存储开销(每个 48 位)跟着涨;取小,剩下的最大值仍然偏大。
留离群值和"每行一个 scale"效果差不多,选哪个?
实测留离群值 4.048 位、3.2633,每行一个 scale 4.016 位、3.2456,相差 0.018,只评测了一次,不宜据此排先后。 工程上每行一个 scale 更规整:存储是定长的,反量化是一次乘法;留离群值要额外存稀疏位置,算的时候再按位置填回去。 两者都比每 32 个一个 scale(Δ +0.0736)差得多,这就是主流 4 位格式都用分组的原因。
↳ 下一步:分组 scale 是几种工业 4 位格式共同的出发点。Q4_K、NVFP4、MXFP4 的差别在 scale 怎么存。

④ 三种 4 位格式:scale 怎么存

三种格式的每个权重都是 4 位,差在块多大、scale 用几位、元素是整数还是浮点。 下面画的是同样 256 个权重的存储布局:一格 = 4 位,色块宽度按位数画。选一种格式:

–
每权重平均几位
–
124M 实测 val loss
–
比 fp32 多
3.0949
对照:int4 每 32 个(4.5 位)
E2M14 位浮点只有 8 个非负值

NVFP4 和 MXFP4 的元素都是 E2M1 (1 位符号、2 位指数、1 位尾数)。把块内的值除以块 scale 后,四舍五入到最近的 E2M1 值。 上排是 E2M1,下排是同样最大到 6 的均匀 int4 格点(−7 … 7 乘 6/7)。拖滑块:

0.70
–
舍到 E2M1
–
舍到均匀 int4
scale块 scale 能不能是任意数

NVFP4 的块 scale 是 8 位浮点 E4M3,可以取 amax/6 附近的小数,块内最大值差不多正好落在 6 上。 MXFP4 的块 scale 是 E8M0,只能是 2 的整数次幂:15_quant.py 取 2^(floor(log2 amax) − 2),于是 amax/scale 落在 [4, 8),超过 6 的部分被截断。拖块内最大绝对值:

–
↳ 代码:15_quant.py 的 q4_k_like · nvfp4 · mxfp4 · E2M1 / e4m3_grid
Q4_K 为什么要多存一个 min(非对称)?
对称格式把 16 个格点正负各分一半。子块里的数如果偏向一侧,另一侧的格点就空着。 Q4_K 按 x = a·q + b 反量化(llama.cpp ggml-common.h 里 block_q4_K 的注释),q 取 0 … 15, 格点覆盖 [min(子块最小值, 0), 子块最大值],15 个格距都用在这一段上。 每个子块的 scale 和 min 自己也被量化成 6 位,再由超块的两个 FP16(d、dmin)还原, 合计 4 + (6+6)/32 + (16+16)/256 = 4.5 位,注释原文 "Effectively 4.5 bits per weight"。 出处:llama.cpp k-quants,PR ggml-org/llama.cpp#1684(Iwan Kawrakow,2023-06)。
MXFP4 在这里为什么比 NVFP4 差一截?
两处差别叠在一起。块大小:NVFP4 每 16 个一个 scale,MXFP4 每 32 个,块越小,一个离群值牵连的值越少。 scale 精度:E8M0 只能表示 2 的幂,块内最大值落在 E2M1 的 4 到 8 之间,要么被截到 6,要么用不满 6 以上的格点;E4M3 能取小数,最大值基本正好落在 6 上。 NVIDIA 博客用一个示例比较两种 scale,MSE 分别是 0.08 和 0.72。 本页实测:NVFP4 Δ +0.0425(4.5 位),MXFP4 Δ +0.0817(4.25 位),MXFP4 省下的是每权重 0.25 位。

出处:NVIDIA 技术博客 "Introducing NVFP4 for Efficient and Accurate Low-Precision Inference"(2025-06-24); MXFP4 标准见 OCP Microscaling Formats (MX) v1.0 Spec 与 Rouhani et al.,arXiv 2310.10537,块大小 32 与 E8M0 这两个参数以 NVIDIA 博客的对照为准。
E2M1 的 8 个值是怎么排出来的?
2 位指数 e、1 位尾数 m。e = 0 时是次正规数,值为 m × 0.5,得到 0、0.5; e ≥ 1 时值为 (1 + m/2) × 2e−1,e = 1、2、3 分别得到 1、1.5,2、3,4、6。 再加 1 位符号,一共 15 个不同的数(+0 与 −0 相同)。15_quant.py 里直接写成常量 E2M1 = [0, 0.5, 1, 1.5, 2, 3, 4, 6]。
Q4_K 和 NVFP4 只差 0.0018,哪个更好?
这个量级分不出来。两者都是 4.5 位,val loss 3.0656 对 3.0638,只在固定的 131,072 个 token 上评了一次; 换一段评测文本,小于 0.01 的先后完全可能翻过来。能确定的是:同样 4.5 位,两者都比 int4 每 32 个一个 scale(3.0949)低 0.03 左右。
↳ 下一步:格式都看过了。回到第 6 章那个 124M,把 15 组实测放进一张账本。

⑤ 124M 的真实账本

同一份权重(第 6 章 10B token 训练,step 19072),每种格式量化后在 131,072 个 FineWeb-Edu val token 上算 loss, 再用 The history of ancient Rome 做 贪心续写 32 个 token。 切换排序,点一行看它的大小构成和续写:

格式位 / 权重模型 MBval lossΔ(比 fp32,条长 0 → 0.6,超出画满)
48 个 Linear 权重矩阵 词嵌入(与输出头共用) 位置嵌入 / LayerNorm / bias
fp32 原始续写:
大小口径:被量化的 48 个矩阵按"位 / 权重"计,其余参数(词嵌入、位置嵌入、LayerNorm、bias)按 16 位计;fp32 那一行全部按 32 位计。最后一行把词嵌入也量化成 int4 每 32 个一个 scale。
4.5 位的 NVFP4 / Q4_K 把 497.9 MB 压到 126.9 MB,val loss 多 0.04;连词嵌入一起压到 71.3 MB,多 0.09;int3 多 0.53,int2 崩到 9.24。
Δ 小于 0.01 的差异能信吗?
当成噪声量级看。fp16 与 fp32 在 4 位小数上相同;int8 每行一个 scale +0.0006,int8 整矩阵 +0.0087;Q4_K 与 NVFP4 相差 0.0018。 评测是固定的 131,072 个 token、确定性计算,重跑结果不变,但换一段评测文本,这个量级的先后可能改变。 能直接读出来的是大台阶:int8 与 fp32 几乎重合,4.5 位多 0.04 到 0.07,3.5 位多 0.53,整矩阵 int4 与 int2 崩掉。
为什么 4 位的模型还有 127 MB?
词嵌入表有 50,304 × 768 = 38,633,472 个参数,按 16 位存是 77.3 MB,占 int4 每 32 个那一行 126.9 MB 的六成。 124M 这个规模里嵌入占的比例高(约 31% 的参数),量化 Linear 层省下的只是剩下那部分。 把嵌入也量化成 int4 每 32 个一个 scale,模型降到 71.3 MB,Δ 从 +0.0736 变成 +0.0914。 词嵌入同时是输出头(权重共享),15_quant.py 的 apply 里要把 lm_head.weight 一起换掉。
同样 4 位,还能把误差再压低吗?
能。本页所有格式都是逐个权重四舍五入到最近格点,不看数据。另有两类方法用少量校准数据改进舍入:

GPTQ(Frantar et al.,arXiv 2210.17323,ICLR 2023):"one-shot weight quantization method based on approximate second-order information", 逐步量化权重,并调整还没量化的权重去抵消已产生的误差;论文里 175B 模型约 4 GPU 小时量化到 3 到 4 位。
AWQ(Lin et al.,arXiv 2306.00978,MLSys 2024):按激活分布找出约 1% 的显著通道,做逐通道缩放加以保护,不需要反向传播或重建。

两者都能和分组 scale 叠加使用。本项目没有实现它们。
只量化了权重,KV cache 和激活呢?
另算。本页量化的只有权重,前向时的激活和 KV cache 都还是浮点。KV cache 随上下文长度线性增长,长对话里可能比权重还大,它的显存账见 第 12 章 STEP 5(每个数从 FP16 换成 FP8,cache 减半)。 激活量化更难,激活里的离群特征维就是第 3 步 LLM.int8() 要处理的对象。
llama.cpp 常见的 Q4_K_M 就是全部 Q4_K 吗?
不全是。引入 k-quants 的 PR(ggml-org/llama.cpp#1684)原文:Q4_K_M "uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q4_K", 一部分对误差敏感的矩阵用 6 位。之后版本的混合规则可能调整过,以当前源码为准。本页的 q4_k 一行是所有 48 个矩阵全用 Q4_K。
达标本关实测(15 组 · 131,072 个 val token)
↳ 跑法:python 15_quant.py --json runs/ch17_quant.json(需要 phase1-124m/ckpt10b/latest.pt)· --tokens 65536 少评一点
🎉 量化 · 通关 · Phase 4 收尾
你把第 6 章的 124M 从 497.9 MB 压到了 126.9 MB,val loss 多 0.04;也看到了一个 10σ 的离群值怎样让整矩阵 int4 崩掉,以及 Q4_K、NVFP4、MXFP4 各自怎么存 scale。Phase 4 从线性注意力、稀疏注意力、N-gram 查表、投机解码讲到量化,效率这一阶段到这里结束。