number formats 与 precision
深入阅读 · 第 12 章 KV cache——把 quantization 的盖子掀开:一个 weight 到底怎么以 bit 存储、各种格式能表示与不能表示什么,以及一个真实数值是怎样被吸附到一张粗糙的格点上的。
上一个子章节把 quantization 当作一根杠杆:每个 weight 用更少的 bit,要搬运的就更少,memory-bound 的 decode 也更快。这一节把盖子掀开。一个 weight 在 bit 这一层究竟是什么?为什么 bf16 能装下 10³⁸ 这么大的数,而 fp16 一过 65,504 就溢出?「用更少的 bit 存一个 weight」到底发生了什么?归根结底是两件事——float 怎么切分它的 bit,以及一张格点怎样替代一个连续的数。
一个 weight 不过是一串 bit
一个 floating-point 数就是 sign × mantissa × 2^exponent,bit 被切成这三个字段。这个切法就是全部关键:exponent 字段换来 dynamic range(一个数能多大、能多小),mantissa 字段换来 precision(数值之间排得多密)。integer 根本没有 exponent——它是一张均匀的格点,每一步都一样大,本身没有任何 dynamic range。这一个对比,是下面一切的地基:
整数把档位 均匀 铺开——处处步长相同,而且没有 exponent 就没有动态范围。浮点把 bit 花在 exponent 上,于是档位 在 0 附近聚拢(值真正所在之处精度更细),并 向最大值方向散开(粗糙,但范围巨大)。同一个想法,相反的格点。
int8 = 256 个档位 · int4 = 16 个档位 · fp8 E4M3 max 448 · fp16 max 65504
integer 把档位均匀铺满整个范围。float 把它们挤在 0 附近——大多数 weight 真正待的地方——再让它们朝最大值一路稀疏开来。同样数量的档位、相反的布局,而且只有 float 带着 range。把一个 bit 花在 exponent 上,你就能够到 10³⁸;花在 mantissa 上,你就能分辨更细的差别。两者不可能同时免费拥有。
同样的 16 个 bit,两种切法
最干净的示范就是两个 16-bit 格式。它们有相同的 bit 预算,却切得正好相反——这一个选择就决定了各自擅长什么:
同样是 16 bit,取舍却相反。bf16 保留了 fp32 完整的 8-bit exponent,所以它拥有和 fp32 一样巨大的 dynamic range(~1e38)——只是相邻值之间的步长更粗。这正是它在训练里胜出的原因: 梯度很少 overflow 或 underflow,所以通常不需要 loss scaling。fp16 则把这些 bit 花在了 mantissa 上(精度更细),但它的 5-bit exponent 只能给出很小的范围——所以用 fp16 训练 需要 loss scaling,以免微小的梯度下溢到零。
fp16 保留了较大的 mantissa(精度细),但 exponent 只有 5 个 bit,所以最大只到 65,504,且很容易下溢——用 fp16 训练需要 loss scaling 来防止梯度消失。bf16 保留了 fp32 完整的 8-bit exponent,因此 range 与 fp32 一样大(只是步长更粗),通常不需要 loss scaling。这正是 bf16 成为默认训练精度的原因——也正是本 demo 所采用的。你在这里。
格式动物园
低于 16 bit,字段怎么切就成了一个真正的设计抉择,所以每一种 bit 宽度都不止一个版本。这就是整条梯子——点任意一行,看看它能表示什么:
- 字段拆分
- sign 1 · exponent 8 · mantissa 7
- max value
- ≈ 3.39e38
- dynamic range
- 8-bit exponent
- levels / precision
- ~2-3 十进制位
典型用途: 主流训练精度(你在这里)
注意 fp8 有两个版本:E4M3(mantissa 更多,max 448——用于 weight 与 activation)和 E5M2(exponent 更多,max 57,344——用于 gradient)。这和 16-bit 的那一对是同一个 岔路口,只是低了一档——而且 fp8 训练两半都用:
同样是 8 bit,取舍却相反——而 fp8 两种都用。E4M3 保留 3-bit mantissa(精度更细),最大到 448,所以它扛起前向:weights 和 activations。 E5M2 用一个 mantissa bit 换来第 5 个 exponent bit,把范围拉到 57,344(还保留 IEEE 的 inf/NaN),好在反向里装下 gradients 那种很宽的 dynamic range。E4M3 的 448 上限是 OCP/ML 约定——朴素的 IEEE 8-bit float 只到 240;E4M3 回收了 inf 和大部分 NaN 的位模式来扩展它。fp8 落在 Hopper(H100)和 Blackwell 的 Tensor Core 上;而这个浏览器 demo 仍然用 bf16。
把所有格式画到 range 对 precision 的平面上,「为什么每种宽度都有两个」就一目了然了:在固定的 bit 宽度下,你必须选一个角。
越靠右上越好——但这要用 bit 来换。在 固定的 bit 宽度 下,你必须挑一个 角:更多 exponent 换来 dynamic range(fp8 E5M2、fp6 E3M2),更多 mantissa 换来 precision(fp8 E4M3、fp6 E2M3)——同样的总 bit 数, 相反的取舍。integers 完全在这张图之外:uniform 格点 没有内在的 dynamic range,它能触及的范围全靠外部的 scale。range 的数字是约略的 decade。
一个会绊倒 fact-checker 的坑:fp8 E4M3 的最大值 448 是 ML(OCP)的约定——一个朴素的 IEEE 式 8-bit float 只能到 240。E4M3 把 infinity 和大部分 NaN 的 bit 模式回收过来扩展了 range,而更小的 fp6/fp4 干脆彻底丢掉 infinity 和 NaN,换回几个宝贵的码点。subnormal(渐进下溢,隐含的前导 1 变成 0)以降低 precision 为代价,多换来一小撮极小的量级——也就是每种格式还能叫得出名字的最小的数。
把真实数吸附到格点上
量化一个 weight,不过是把它四舍五入到格点最近的一格。格点的间距就是 scale factor s;剩下的零头就是 quantization error,最多半格:
error 在 ±s/2 上近似均匀分布,所以它的 variance 是 s²/12。
每个量化后的 weight 都是离它最近的格点。剩下的那一点——|x − x̂|——就是 quantization error,它永远不会超过 s / 2(半个 step)。所以 bits 越多,格点越密,error 越小—— 但每个 weight 的存储也越多。这里的格点是 symmetric 的,zero-point 为 0;下一张图会为非居中数据加上 zero-point。
整个机制就这些:q = round(x / s) 选最近的档位,x̂ = s · q 把它读回来,误差永远不超过 s / 2。格点越粗(bit 越少)步长越大、误差越大。但 weight 并不总是以 0 为中心——这就轮到 zero-point 出场:
zero-point 就是整数格点被锚定的位置。零中心的 weights 把 real 0 映到 code 0,不需要偏移(symmetric,z = 0)——这同时省掉了 matmul 里多出来的一个交叉项,所以 weight-only quantization 是最容易的第一步。偏斜的 activations(例如 ReLU/GELU 之后,多数 ≥ 0)需要一个非零的 zero-point(asymmetric / affine),让 real 0 仍然 精确落在某个 code 上——这对 padding 和 ReLU 很重要。正是这种非对称,使得同时量化 weights 和 activations(W8A8)成为困难的部分。
weight 大致以零为均值,所以用对称(symmetric)格点(zero-point 为 0)。activation 是偏斜、单边的(想想 ReLU 之后,全都 ≥ 0),所以用非对称(asymmetric)格点——zero-point 把整张格点平移,让真实的 0 依然恰好落在一个整数码上(q = round(x / s) + z)。这也是更深一层的原因:为什么 weight-only 量化是轻松的第一步,而量化 activation(W8A8)才是难处——activation 带着狂野、随输入而变的 outlier,单一一个 scale 兜不住。
一个 scale 管多少个 weight?
整个 tensor 共用一个 scale,存起来便宜但很松——一个 outlier weight 就把所有人的格点拉宽了。给每个 output channel、或每 32–128 个 weight 一个块各自一个 scale,每张格点就能更贴合自己的那批值。这就是 granularity(粒度)的梯子:
per-tensor — 最粗——每个 scale 覆盖的范围最宽
规律:粒度越细 = 舍入误差越低,bit 开销越高。每个 scale 拟合到更小的一组值上,步长更紧、舍入误差下降——但你要存更多 scale,而 scale 本身也占 bit。这就是为什么 per-group-64(4 + 16/64 = 4.25)和 MX 的 per-block-32(4 + 8/32 = 4.25)会以不同路径落到相同的有效位宽:要么用更胖的 scale 覆盖更大的 block,要么用更精简的 8-bit E8M0 scale 覆盖更小的 block。
MX(Microscaling)标准把这个做法推到 固定 的 32 元素 block,配一个共享的 8-bit E8M0 二次幂 scale——所以 mxfp4 就是 int4 风格的 4-bit 元素,外加每 32 个 weight 一个小 scale,是一种对硬件友好的固定粒度。
粒度越细,误差越低——但 scale 也要占 bit,所以一个每 64 个值共享一个 scale 的「4-bit」weight,其实约为4.25 bit。Microscaling(MX)标准把这一点固定在 32 个元素一个块上,这也是通往最小格式的桥。
块格式把 range 借回来
单看一个 4-bit float,它只能叫出寥寥几个量级——{0, 0.5, 1, 1.5, 2, 3, 4, 6}。这本身没什么用,直到一整块它们共享一个负责提供量级的 scale。这就是为什么 fp4 和 fp6 永远只是块格式——而同一套 microscaling recipe 也会把 fp8 包起来。切换课程点名的这三种:
有效存储: 4.25 bits/elem — 4 + 8/32 = 4.25——一个 8-bit 的 scale 摊到 32 个 fp4 元素上
为什么 4-bit 的元素不能单独存在:fp4 E2M1 只有 1 sign · 2 exponent · 1 mantissa 位,单独看几乎没有 dynamic range——这段 编码只携带一个相对值。共享的 scale 提供量级,于是整个 block 能覆盖很宽的范围,而每个 元素仍然只占 4 bit。这就是 microscaling(MX) 的含义。
一套 recipe,三种宽度。MXFP8 把 32 个 fp8 值装进一个 2-的-幂的 E8M0 scale(≈ 8.25 bits/elem,near-lossless)。 MXFP4 在 fp4 上用同样的 32-元素 E8M0 block(≈ 4.25),这里共享 scale 正是让 4-bit 能用起来的关键。NVFP4 把 block 收紧到 16 个元素,换上更丰富的浮点 E4M3 block scale,再在上面叠一个 per-tensor 的 FP32 scale(≈ 4.50)。更小的 block 加浮点 scale 能更贴合局部数据——舍入误差更小——代价是开销略高。误差差异真实但只是定性的;课程没有给出测量数字,所以这里也不画。
OCP 的 MX 家族用同一条规则覆盖三种宽度,始终配一个二的幂 E8M0 scale(8 个 exponent bit、没有 mantissa,跨度 2⁻¹²⁷ … 2¹²⁷),由一个 32-元素的 block 共享。MXFP8 把 fp8 包成 ≈ 8.25 bits/elem——fp8 本身就有 range,所以这是 near-lossless 的那一端。MXFP4 在 fp4 上用同样的 block,≈ 4.25 bits/elem,这里共享 scale 正是让 4-bit 能用起来的关键。NVIDIA 的 NVFP4 把块收紧到 16 个元素,换上更丰富的 fp8 E4M3 block scale(一个真正的 float,带 mantissa,比二的幂更细),再加一个 per-tensor 的 fp32 scale(≈ 4.50 bits/elem)——两级 scaling,把 range 和精度都抠回来。
聪明的 4-bit:NF4
如果 pretrained weight 大致是钟形分布,那为什么还要把档位均匀排开?NF4 不这么做。它把 16 个档位摆在正态分布的分位数上——weight 真正密集的地方密,尾巴上稀:
int4 把它的 16 个档位均匀铺开,于是在罕见的尾部花掉的码和在常见中心区一样多。NF4 则把 16 个档位放在 normal 的 quantile 上——靠近峰值处密集、尾部稀疏——这对正态形状的数据在信息论意义上是最优的。NF4 是一张固定的 16 项 lookup table(不是计算出来的格点),包含一个精确的 0,且是非对称的(7 个负 + 0 + 8 个正 = 16)。
这些只是单位区间内的形状:真实幅度由 per-block scale(block 大小 64)还原。QLoRA 还会对这些 scale 做 double-quantize(双重量化)。由 QLoRA 使用(arXiv:2305.14314)。
均匀的 int4 把它 16 个码里同样多的份额花在罕见的尾巴和拥挤的中心;NF4 顺着数据走,于是它更多的分辨率落在 weight 所在之处。它对正态分布的 weight 是信息论意义上最优的,含一个精确的 0,且本质上是一张 16 项的查找表而非一张算出来的格点——也就是 QLoRA 在其上做微调的那个 4-bit 底座。
挑一套配方
这些零件——一个格式、一个 scale、一种粒度——组合成有名字的方法。它们落在两条轴上:你量化多少(只量 weight,还是 weight 和 activation 都量),以及你愿意重训多少(PTQ,只做 calibration;还是 QAT,连着 fake-quant 一起重训):
位置是分类的(在哪一侧 / 需要多少再训练),不是测量出的分数
命名陷阱:名字叫「Activation-aware」,但其实是 WEIGHT-ONLY。activation 幅度只是用来挑出约 1% 的显著 weight 通道,再用 per-channel scaling 去保护它们。
大多数本地推理的 quant(GPTQ / AWQ / GGUF / NF4)都是 weight-only PTQ:它缩小显存、加速 memory-bound 的 decode,却不碰 activations。W8A8(SmoothQuant、LLM.int8())会连 activations 一起量化,换来更快的整数 matmul——但必须驯服 outlier。QAT(让梯度穿过 fake-quant 再训练)对开放权重很少见,因为它需要完整的训练循环。两个要 记住的陷阱:AWQ 名字里有 activation,但其实是 weight-only;以及 GGUF 的 Q4_K ≈ 4.5 bpw,不是 4(其它 Q4* 变体各不相同)。
对开放权重你几乎总是做 PTQ——QAT 需要一整套训练循环。绝大多数本地推理的量化(GPTQ、AWQ、GGUF、NF4)都是 weight-only:它压缩显存、加速 memory-bound 的 decode,却不碰 activation。有两个值得点名的坑:AWQ 是 weight-only,尽管名字叫 「Activation-aware」(activation 只是挑出哪些 weight 通道要保护),而一个 GGUF Q4_K 算上它存的块 scale 之后,其实约为 4.5 bit 每 weight,而不是 4(其它 Q4* 变体各不相同)。
你真正下载到的:GGUF k-quants
在 Hugging Face、Ollama 或 LM Studio 上,你挑的不是「int4」——你挑的是一个 GGUF Q-type。它们是 llama.cpp 的文件格式,带「_K」的那些是 k-quants:256 个权重一个 super-block,带两级 scale——每个 super-block 一或两个 float(第二个是 dmin,用于带非对称零点的类型),再加每 16 或 32 个权重(视具体类型而定)的 sub-block 一个量化后的小 scale。这正是你刚在 MX 里看到的 block-format 把戏,也正 因如此,它们的代价是带小数的 bits/weight,永远不是个整数。下面每一档都是纯类型的 bits/weight,直接 从 ggml-common.h 里的 C struct 读出来:
默认之选。4-bit 的 k-quant —— 曲线的拐点,质量损失开始变得难以察觉。
你下载到的是: Q4_K_M 在 8B 上 ≈ 4.9 bpw:一半的 attn_v 与 ffn_down 抬到 Q6_K
两个要说实话的坑。第一,大家真正下载的名字——Q4_K_M、Q3_K_M、Q5_K_M——并不是纯类型,而是混合:_M 把最敏感的张量保持在更高位宽——总是有 attn_v 和 ffn_down,Q3_K_M 还会加上 attn_o——所以文件会比纯数字略高(一个 8B 的 Q4_K_M ≈ 4.9 bpw,而不是 4.5)。第二,IQ那几档(I-quant)根本不是 round 的格点——每个权重是一个固定 codebook 的索引,由一个 importance matrix 帮着挑:拿一点 calibration 文本过一遍模型,看哪些 weight 最能改变输出,就把 bits 花在那里。低于 ~3 bit 时,正是这个 imatrix 才让文件勉强能用。
把「保持敏感张量高位」这个想法推广到每一层,你就得到了 dynamic 量化——也就是 Unsloth Dynamic 2.0 GGUF 的核心。同样的大小预算,按 calibration 说了算的地方去花:
大小预算大致不变,只是花法不同。敏感的那几个 —— attn_v、ffn_down、embed、output —— 保持 6-bit;其余降到 2-bit。attn_v 与 ffn_down 正是每一种 Q*_K_M 混合都会抬高的张量 —— Dynamic 2.0 只是把这件事做到每层、且由 calibration 驱动。
Unsloth 的 Dynamic 2.0 从一个 > 1.5M token 的 calibration 集里,逐层、逐模型地挑量化类型,并用相对原 模型的 KL-divergence 来评判,而不是 perplexity(perplexity 的误差会自己抵消——见「Accuracy is Not All You Need」,arXiv 2407.09141)。推到极限,同样的思路把一个 671B 的 DeepSeek-R1 从约 720 GB 压到 131 GB,平均约 1.58-bit 的 IQ1——把少数 dense 与 attention 层保持在 4–6 bit,而 MoE experts 降到约 1.5。有一点要分清楚:这个「1.58-bit」是训练后的 dynamic 量化,和 BitNet b1.58(arXiv 2402.17764)不是一回事——后者是从头训练出 {−1, 0, 1} 的 ternary 权重,两者只是共用了 log₂3 ≈ 1.58 这个数字。
硬件在哪里划线
每一种格式之所以存在,都是因为前一种对某类工作负载用尽了 range 或 precision——而硅片随之跟上。NVIDIA Turing(2018)最早加入了 int8/int4 Tensor Core,用于推理;Ampere(A100)加入了 tf32/bf16/fp64 Tensor Core 支持与结构化稀疏(structured sparsity);Hopper(H100)加入了原生 fp8(E4M3/E5M2);Blackwell(B200)加入了 fp4(NVFP4/MXFP4),而每当精度减半,Tensor Core 的吞吐大致翻倍。Apple-Silicon GPU——以及 NVIDIA Blackwell 之前(RTX 50 系列以前)的消费级显卡——没有原生的低于 8-bit 的 float 单元,所以这些设备上的低比特推理要靠 int4/int8 整数运算来跑——这也正是为什么激进的整数量化是本地推理的故事,而非数据中心的。
诚实的脚注,和入门篇一样:这个浏览器标签页对 weight 和 KV cache 都采用 bf16——完全没有 quantization,是保真度最高的选择。这一页上的一切,是你可以拿这份保真度去换的菜单:同一个模型的一个更小、更快的副本,用一张更粗的格点买来。你在这里,站在梯子的顶端,往下看。