第 12 章 · KV 缓存与混合注意力Number formats & precision

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。这一个对比,是下面一切的地基:

数轴——整数是均匀格点,浮点是 log 间距
int8uniform256 个档位0int4uniform16 个档位0fp8log-spacedmax 4480fp16log-spacedmax 655040value / max(0 → 该格式自己的最大值)

整数把档位 均匀 铺开——处处步长相同,而且没有 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 的岔路口——同样的 bit,相反的取舍
🔒 你在这里 · demo 用的是 bf16
bf16exponent bias 127把 bit 花在 RANGE 上(8-bit exponent)1exponent 8mantissa 7range ≈ 1e38(max ≈ 3.39e38)~2–3 位十进制有效数字 · 与 FP32 同范围fp16exponent bias 15把 bit 花在 PRECISION 上(10-bit mantissa)1exponent 5mantissa 10max 65504(最小正规数 2^-14 ≈ 6.1e-5)~3.31 位十进制有效数字 · 需要 loss scalingexponent → range · mantissa → precision

同样是 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 宽度都不止一个版本。这就是整条梯子——点任意一行,看看它能表示什么:

格式动物园——共用一把 bit 标尺
🔒 你在这里 · demo 用的是 bf16
signexponentmantissa均匀整数格点
1 格 = 1 bit · 所有条共用一把标尺(fp32 = 满宽)fp32823tf32810fp16510bf1687fp8 E4M343fp8 E5M252int8256fp6 E3M232fp6 E2M323fp4 E2M12int416
bf1616 total bits
字段拆分
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 训练两半都用

fp8 的岔路口——8 个 bit,两种相反的取舍
🔬 fp8 两种都用 · E4M3 → forward(weights/activations) · E5M2 → backward(gradients)
E4M3exponent bias 7把 bit 花在 PRECISION 上(3-bit mantissa)sign 1exponent 4mantissa 3max 448(OCP) · 最小正规数 2^-6 ≈ 0.01563-bit mantissa → 更细 · 没有 inf · 1 个 NaNE5M2exponent bias 15把 bit 花在 RANGE 上(5-bit exponent)sign 1exponent 5mantissa 2max 57,344 · 最小正规数 2^-14 ≈ 6.1e-52-bit mantissa → 更粗 · IEEE-like(有 inf + NaN)exponent → range · mantissa → precision

同样是 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 宽度下,你必须选一个

range 与 precision——每个格式都是一个点
floating pointintegers(uniform)
13103010002468dynamic range(约多少个 decade)→precision(十进制位数)→更好int8int4uniform 格点 · range 只来自外部的 scalefp32tf32bf16fp16fp8 E4M3fp8 E5M2fp6 E3M2fp6 E2M3fp4 E2M1同样的 bit 宽度,相反的角
bf16range ≈ 78 个 decade · precision ≈ 2.4 位

越靠右上越好——但这要用 bit 来换。在 固定的 bit 宽度 下,你必须挑一个 :更多 exponent 换来 dynamic rangefp8 E5M2fp6 E3M2),更多 mantissa 换来 precisionfp8 E4M3fp6 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,最多半格:

quantization 往返——把实数吸附到最近的格点
bits B
7 个格点step s = 0.333−10+1real x = 0.420snapped x̂ = 0.333error = 0.087≤ s/2 = 0.167
step s = 1 / qmax, qmax = 2^(B−1) − 1 = 0.333
q = round(x / s) = 1
x̂ = s · q = 0.333
error = |x − x̂| = 0.087 (error ≤ s / 2 = 0.167)

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 出场:

symmetric 与 asymmetric——zero-point 到底是什么
symmetric · 零中心的 weights · int8 格点 [−127, 127]真实值-11real 0整数 code-127127code 0weights ≈ 零中心zero-point z = 0——无偏移
real 0(保持精确)zero-point(格点锚点)
symmetric(z = 0)
q = round(x / s)
x̂ = s · q
s = m / 127 = 1 / 127 ≈ 0.00787
asymmetric(affine)
q = round(x / s) + z
x̂ = s · (q − z)
s = (r_max − r_min) / (q_max − q_min) = 5 / 255 ≈ 0.01961
z = round(q_min − r_min / s) = round(1 / s) = 51

zero-point 就是整数格点被锚定的位置。零中心的 weights 把 real 0 映到 code 0,不需要偏移(symmetricz = 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(粒度)的梯子:

granularity——一个 scale 覆盖多少个 weight?
weight 矩阵 · 同一色调 = 同一个共享 scale8 行 × 16 列 = 128 个 weightscale 数量1整个 tensor 共用 1 个 scaleeffective bits/param4.00int4 + 16-bit ÷ tensor ≈ 4.00舍入误差序数 · 非测量

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 包起来。切换课程点名的这三种:

Block 格式:每个 block 共享一个 scale(MX 家族)
32 个 fp4 E2M1 元素(1 sign · 2 exponent · 1 mantissa)整个 block 共享per-block scaleE8M0 scale8 exponent · 0 mantissa → 纯粹的 2 的幂2^-127 … 2^127 · 0xFF = NaN4.25 bits/elemvalue = element × 2^scale一个共享 scale · v = X · Pᵢ

有效存储: 4.25 bits/elem4 + 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)。 MXFP4fp4 上用同样的 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 共享。MXFP8fp8 包成 ≈ 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 真正密集的地方密,尾巴上稀:

NF4——把档位放在 weight 真正所在的地方
预训练 weight ≈ Normal(0, 1)NF4 · 16 个档位,按 normal 的 quantile 分布精确的 0int4 · 16 个均匀分布的档位处处步长相同−10+1
靠近 0 处密集尾部稀疏最小间距 ≈ 0.08(0 附近)最大间距 ≈ 0.30(±1 附近)

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 一起重训):

quantization 方法地图 · 量化什么 & 需要多少再训练
weight-onlyW8A8(weights + activations)
QAT · 再训练PTQ · 仅 calibration需要多少再训练 ↑weight-onlyweights + activations(W8A8)RTNGPTQAWQGGUFQLoRA / NF4SmoothQuantLLM.int8()

位置是分类的(在哪一侧 / 需要多少再训练),不是测量出的分数

AWQ量化对象: weight-onlybits: 4-bit论文: arXiv 2306.00978

命名陷阱:名字叫「Activation-aware」,但其实是 WEIGHT-ONLY。activation 幅度只是用来挑出约 1% 的显著 weight 通道,再用 per-channel scaling 去保护它们。

大多数本地推理的 quant(GPTQ / AWQ / GGUF / NF4)都是 weight-only PTQ:它缩小显存、加速 memory-bound 的 decode,却不碰 activations。W8A8SmoothQuantLLM.int8())会连 activations 一起量化,换来更快的整数 matmul——但必须驯服 outlier。QAT(让梯度穿过 fake-quant 再训练)对开放权重很少见,因为它需要完整的训练循环。两个要 记住的陷阱:AWQ 名字里有 activation,但其实是 weight-only;以及 GGUF 的 Q4_K4.5 bpw,不是 4(其它 Q4* 变体各不相同)。

对开放权重你几乎总是做 PTQ——QAT 需要一整套训练循环。绝大多数本地推理的量化(GPTQAWQGGUFNF4)都是 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 读出来:

GGUF Q-type 阶梯 —— 纯类型的 bits/weight
🔒 bf16 = 16 bpw · 这个标签页用的就是它(在图外,约为最宽那一档的 2 倍)
1-bit 地板4-bit 甜点区条的长度 = bits/weight(纯类型)· 点一档看细节
12345678bf1616IQ1_S≈ 1.56IQ2_XXS≈ 2.06Q2_K2.625IQ3_XXS≈ 3.06Q3_K3.4375IQ4_XS≈ 4.25Q4_K4.5Q5_K5.5Q6_K6.5625Q8_08.5
Q4_K4.5 bits/weight(纯类型)struct: 144 B ÷ 256 w × 8 = 4.5

默认之选。4-bit 的 k-quant —— 曲线的拐点,质量损失开始变得难以察觉。

你下载到的是: Q4_K_M 在 8B 上 ≈ 4.9 bpw:一半的 attn_v 与 ffn_down 抬到 Q6_K

两个要说实话的坑。第一,大家真正下载的名字——Q4_K_MQ3_K_MQ5_K_M——并不是纯类型,而是混合_M 把最敏感的张量保持在更高位宽——总是有 attn_vffn_downQ3_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 说了算的地方去花:

把 bits 花在该花的地方 —— dynamic / imatrix 量化
越高 = bits 越多保持高位(敏感)
6embed2q2k6v2o2gate2up6down6out相对值 · 示意相对原模型的误差uniformdynamic

大小预算大致不变,只是花法不同。敏感的那几个 —— 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.58arXiv 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,是保真度最高的选择。这一页上的一切,是你可以拿这份保真度去换的菜单:同一个模型的一个更小、更快的副本,用一张更粗的格点买来。你在这里,站在梯子的顶端,往下看。