GGUF-量化内幕-第2章-数值基础与量化误差
GGUF 量化内幕:第2章 数值基础与量化误差
第 1 章我们建立了直觉:量化用更少的比特表示权重,换来体积和速度,代价是精度损失。但"损失"到底是多少?从哪里损失的?怎么衡量?这些问题的答案,需要一套精确的数学工具——这就是本章的内容。
本章是全书的分析工具箱。后面每一章讨论一个具体格式(Q4_0、Q4_K、IQ、三值)时,都会反复用到这里的三件套:
- 浮点与定点的表示能力——一个数用 16 bit 或 8 bit 存,能精确到什么程度?
- 量化通式与误差分解——量化-反量化这个不可逆过程,误差来自哪两个源头?
- 评价指标——拿什么数字衡量"一个量化模型还够不够好"?
先统一几个术语。浮点数(floating point):计算机里表示实数的方式,用"尾数 + 指数"编码,能在很大范围内保持相对精度。动态范围:能表示的最大值与最小值的比值。尾数(mantissa/fraction):浮点数里决定精度的部分,尾数位数越多,同一量级下能区分的最小间隔越小。量化(quantization):用有限个离散码字表示连续数值的过程。反量化(dequantization):把离散码字还原成浮点数的过程(有损,不可逆)。MSE:均方误差,所有误差的平方的平均值。PPL(perplexity,困惑度):语言模型的评价指标,数值越小表示模型对文本的预测越准。KL 散度:衡量两个概率分布差异的指标。
2.1 浮点与定点表示的取舍
2.1.1 fp16、bf16、fp32 的动态范围与精度
先看三种最常见的浮点格式。IEEE 754 标准把浮点数分成"符号位 + 指数位 + 尾数位"三部分:
| 格式 | 总位宽 | 符号 | 指数 | 尾数 | 最大有限值(约) | 相对精度(约) |
|---|---|---|---|---|---|---|
| fp16 | 16 | 1 | 5 | 10 | ±65504 | (2^{-11} \approx 0.00049) |
| bf16 | 16 | 1 | 8 | 7 | ±3.4e38 | (2^{-8} \approx 0.0039) |
| fp32 | 32 | 1 | 8 | 23 | ±3.4e38 | (2^{-24} \approx 6\times10^{-8}) |
动态范围由指数位数决定:fp16 的 5 位指数只能覆盖约 (2^{\pm 15}),最大才 65504;bf16 用了和 fp32 一样的 8 位指数,动态范围很大,但尾数只有 7 位,精度低。有效位数由尾数位数决定:fp16 有 10 位尾数(含隐藏位共 11 位有效),bf16 只有 7 位。
那么"相对精度"意味着什么?假设一个权重值是 (x \approx 1.0),fp16 存储它时,相邻可表示的浮点数间隔约 (2^{-11} \approx 0.0005),也就是说这个值的表示误差约 0.05%;bf16 的间隔约 0.4%。在权重量化里,尺度、最小值这些参数量级往往和权重本身接近,范围窄,因此 fp16 的 10 位尾数足够——这是 GGUF 的块尺度普遍用 fp16 而非 bf16 的原因。bf16 的优势在大范围(如激活值跨越多个数量级)和深度学习训练中,那里 16 位省显存、范围大比尾数精度更重要;但在"存一个窄范围的尺度"这件事上,bf16 的低精度反而有害。
fp32 只在少数地方出现:F32 张量(归一化层,见第 14 章)、部分参考实现、以及磁盘上一小部分没被量化的权重。它的 23 位尾数保证了在权重量化的噪声面前几乎"零误差",因此常被当作精度参照。
2.1.2 尺度的量化误差如何传播到权重
量化格式的反量化公式往往形如 (y = d \cdot q):(q) 是码字,(d) 是尺度。问题来了:(d) 本身可能不是精确值。在 GGUF 里 d 以 fp16 存储,fp16 表示 (d) 时的相对误差约 (2^{-11})。这个误差会直接乘到每个权重上。
做个量级估算。(d) 的相对误差 (\epsilon_d \approx 2^{-11} \approx 0.0005),那么重建值 (y = d(1+\epsilon_d) \cdot q) 相对真实值的误差也就约 0.05%。在 4-bit 量化(码范围 0–15)里,量化本身的舍入误差通常达到几个百分点,0.05% 的尺度误差是可忽略的底噪——这就是为什么 Q4_0/Q4_K 用 fp16 存尺度毫无问题。
但把话说回来:当位宽降到 1–2 bpw(如 IQ 码本、三值格式),量化噪声本身已经很小吗?恰恰相反——码字变少,每一步重建都更粗糙。 此时 0.05% 的尺度误差虽然不是主导,却不再可以无视:它叠加在码本选择误差之上,成为"额外加的一层噪声"。结论是:fp16 存尺度对所有格式都够用,但在低 bpw 格式里,尺度的舍入误差会从"可忽略"变成"值得记账的底噪"。这也是为什么 IQ 系列对尺度的处理更精细(第 11 章)。
2.2 仿射量化的通式与误差界
2.2.1 量化:(q = \mathrm{round}((x - z)/s)) 与夹取
量化把连续值 (x) 映射到一个整数码 (q)。最常见的通式是:
[
q = \mathrm{round}\left(\frac{x - z}{s}\right), \quad q \in [q_{\min}, q_{\max}]
]
- (s) 是尺度(step),决定"一个码步对应多大的实数间隔";
- (z) 是零点(zero point),决定离散值在数轴上的位置;
- (\mathrm{round}) 是四舍五入;
- 最后一步是夹取(clamp):把超出 ([q_{\min}, q_{\max}]) 的结果强行压回边界。
对称量化里 (z = 0),公式简化成 (q = \mathrm{round}(x/s));Q4_0 的 (q = \mathrm{round}(x/d)) 配合解码端的 (-8) 偏移,就属于这一类。非对称量化则保留 (z),Q4_K 子块的 (q_i = \mathrm{clamp}(\mathrm{round}((x_i + m_j)/d_j), 0, 15)) 是典型(第 7 章)。
这里必须强调一个被低估的点:夹取是误差的非线性来源。当某个值超出码字范围(离群值),它不是被"舍入"到附近的码,而是被"饱和"到边界码——它的误差可以远大于一个码步。这类误差叫饱和误差(saturation error),和舍入误差(rounding error)本质不同:
舍入误差 vs 饱和误差
码范围 [0, 15]
x 在范围内 ──► round 到最近码,误差 ≤ 半个码步(舍入误差)
x 超出范围 ──► clamp 到 0 或 15,误差可能很大(饱和误差)
离群值在真实权重里普遍存在,所以"夹取"不是小事,而是误差结构的关键一环。
2.2.2 反量化:(x = s(q - z)) 与重建误差分解
反量化把码还原成浮点:
[
\hat{x} = s(q - z)
]
把重建误差 (\hat{x} - x) 拆开看,它由两个源头叠加而成:
- 舍入误差(rounding error):由码位宽决定。4-bit 码只有 16 个离散值,(\mathrm{round}) 不可避免地把值挪到邻近码,误差最多半个码步。位宽越窄,这个误差越大。它是任何量化的下限。
- 尺度失配误差(scale mismatch error):由块内分布的异质性决定。一个块只存一个(或少数几个)尺度,如果块内数值差异大,单一尺度无法同时贴合所有值,重建后一部分值系统性偏离。位宽再高,只要尺度失配,误差也消不掉。
关键结论是:Q4_0 的误差主要由尺度失配主导,而不是舍入误差主导。因为 Q4_0 每 32 值一个尺度,块内异质性普遍存在(权重分布"多数小、少数大"),尺度被大值绑架,小值系统性塌缩。这正是第 4 章的核心实验,也是 K-quants 要解决的问题——把单一尺度升级成层次化尺度,直接攻击"尺度失配"这个大头。
2.2.3 块内分布异质性对单一尺度格式的惩罚
用一个构造性例子把"尺度失配"钉死。假设一个 32 值块里有 31 个值在 ([-0.01, 0.01]) 附近,1 个值是 10.0。按 Q4_0 的规则,尺度 (d \approx 10 / -8 = -1.25)。那 31 个小值除以 (d) 后都落在 ([-0.008, 0.008]),加 8.5 截断后几乎全部变成同一个整数码。反量化时它们被还原成同一个值,彼此差异完全消失。
单一尺度被离群值绑架
真实值: 10.0 │●
0.01 │ ● ● ● ● ● ● ● ● ●
-0.01 │
重建值: 10.0 │●
0.0 │ ● ● ● ● ● ● ● ● ● ← 小值全部塌缩成同一个码
这个例子不是学术玩具——真实权重块里"一个大离群值 + 一堆小值"的结构普遍存在(尤其是注意力层和 FFN 的某些行)。它将在第 4 章 4.5 节的实验里被 Python 复现。现在先记住它的结论:单一尺度格式在异质块上的表现,取决于块内是否混入离群值。
顺手给出一个三语言实现"量化 + 反量化 + 计算 MSE"的最小闭环,它是后面所有误差实验的地基:
C++:
#include <cmath>
#include <vector>
#include <cstdio>
// 对称量化:q = round(x/s),夹取到 [-127,127](8-bit 码)
void quantize_symmetric(const std::vector<float> & x, float s,
std::vector<int> & q, std::vector<float> & xhat) {
const int n = (int)x.size();
q.resize(n); xhat.resize(n);
for (int i = 0; i < n; ++i) {
float v = std::round(x[i] / s);
if (v > 127) v = 127; // clamp
if (v < -127) v = -127;
q[i] = (int)v;
xhat[i] = s * q[i]; // 反量化
}
}
float mse(const std::vector<float> & a, const std::vector<float> & b) {
float sum = 0;
for (size_t i = 0; i < a.size(); ++i) {
float e = a[i] - b[i];
sum += e * e;
}
return sum / (float)a.size();
}
int main() {
std::vector<float> x = { 0.001f, -0.002f, 0.005f, 0.0005f, 10.0f };
std::vector<int> q; std::vector<float> xhat;
quantize_symmetric(x, 1.0f, q, xhat); // 尺度选 1.0:被离群值 10 主导
printf("MSE = %.4f\n", mse(x, xhat));
return 0;
}
C#(.NET 5+):
using System;
static class QuantDemo {
static void QuantizeSymmetric(float[] x, float s, out int[] q, out float[] xhat) {
q = new int[x.Length];
xhat = new float[x.Length];
for (int i = 0; i < x.Length; i++) {
float v = MathF.Round(x[i] / s);
v = Math.Clamp(v, -127f, 127f);
q[i] = (int)v;
xhat[i] = s * q[i];
}
}
static float Mse(float[] a, float[] b) {
float sum = 0;
for (int i = 0; i < a.Length; i++) {
float e = a[i] - b[i];
sum += e * e;
}
return sum / a.Length;
}
static void Main() {
float[] x = { 0.001f, -0.002f, 0.005f, 0.0005f, 10.0f };
QuantizeSymmetric(x, 1.0f, out _, out var xhat);
Console.WriteLine($"MSE = {Mse(x, xhat):F4}");
}
}
Python:
import numpy as np
def quantize_symmetric(x: np.ndarray, s: float) -> tuple[np.ndarray, np.ndarray]:
q = np.clip(np.round(x / s), -127, 127) # round + clamp
xhat = s * q # 反量化
return q.astype(np.int32), xhat
def mse(a: np.ndarray, b: np.ndarray) -> float:
return float(np.mean((a - b) ** 2))
x = np.array([0.001, -0.002, 0.005, 0.0005, 10.0])
_, xhat = quantize_symmetric(x, 1.0)
print(f"MSE = {mse(x, xhat):.4f}")
三种实现输出一致。这个脚本的寓意:尺度选得差,MSE 就大——而这个"选尺度"的问题,正是 K-quants 和 IQ 系列各自用不同办法改进的对象。
2.3 重要性加权与码本量化的误差视角
2.3.1 加权最小二乘:(\min \sum_i \alpha_i (x_i - \hat{x}_i)^2)
前面讨论的误差都是"无差别地最小化每个权重的误差"。但真实推理不是这样——不同权重对输出质量的影响不同。一个权重参与的点积,乘上一个激活值;如果激活值本身很大,这个权重对输出的贡献就大,它的量化误差也就更"伤"。
于是量化器不再最小化均匀 MSE,而是最小化加权误差:
[
\min \sum_i \alpha_i (x_i - \hat{x}_i)^2
]
其中 (\alpha_i) 是第 (i) 个权重的重要性权重。llama.cpp 用 imatrix(importance matrix,重要性矩阵) 提供这个权重:校准数据跑一遍模型,统计每个权重对应激活的幅度,激活大的权重 (\alpha_i) 大。这样量化器会把"误差预算"向对输出影响大的权重倾斜——牺牲一些不重要的权重的精度,保住重要的。
这个思想是第 11 章 IQ 系列的数学根基。现在只需要理解一点:均匀最小化 MSE 和加权最小化不是一回事。均匀方案把所有权重一视同仁;加权方案承认"权重不是平等的"。
2.3.2 码本量化的误差结构:索引误差而非尺度误差
IQ 系列用的是码本(grid)量化,它的误差结构和仿射量化根本不同。在仿射量化里,误差来自"尺度选得不够好"(尺度失配)和"码太粗"(舍入)。在码本量化里:
- 每个值是查表得到的离散向量——从一张预先设计的表里挑"最近"的表项;
- 误差由"最近邻选择"决定:值落在哪个表项附近,就用哪个表项近似它;
- 不存在连续的尺度失配项:没有"一个尺度乘所有值"的结构,每个值独立选择重建点。
码本量化特有的误差来源是码本覆盖不足:如果表项太少或分布和真实权重不匹配,某些区域没有表项,值只能被近似到"较远"的表项,产生系统性偏差(不是随机舍入,而是同一片区域都被推向同一个方向)。这让码本量化的误差结构比仿射量化"更硬":随机性小、系统性偏差大,因此格外依赖一张好的码本和好的 imatrix。第 11 章会详细拆解。
2.4 三值量化的特殊误差结构
2.4.1 阈值效应:从"劣化"到"任务崩溃"
前面说的格式(Q4_0、Q4_K、IQ)的 PPL 劣化大体是平滑的:位宽降一点,PPL 涨一点。三值格式(权重被约束到 ({-d, 0, +d}))不是这样。它的劣化曲线存在"断崖":在某一个位宽/模型组合下,模型从"略差"突然变成"几乎不能完成任何任务"。
平滑劣化 vs 阈值效应(示意)
PPL 劣化
│ ╱
│ ╱
│ ╱ ← K-quants/IQ:平滑
│ ╱
│ ╱
│ ╱
│╱
└───────────────────► 位宽下降
│ ___
│ __/ ← 三值:到某个点突然崩溃
│ _/
│ _/
│ _/
│_/
└───────────────────► 位宽下降
因此不能用单一 PPL 数字外推"再压一点还能用"。三值格式必须逐模型、逐任务验证,而不能只看平均指标。这是第 13 章的方法论前提。
2.4.2 为什么三值格式必须逐模型验证
"三值能不能用"高度依赖两件事:
- 模型本身的冗余度:参数越多、训练越充分,模型对量化噪声的容错越高;小模型或能力边缘的模型更容易在三值化后任务崩溃。
- 训练时是否"三值感知":BitNet 系模型在训练时就约束权重为三值,模型学会了在三值空间里工作,事后三值化的效果自然好得多;而普通稠密模型"事后"硬切成三值,往往直接失效。
这解释了为什么同一套三值格式,在模型 A 上 PPL 比接近 1.000(几乎无损),在模型 B 上却一败涂地。结论不是"三值格式好"或"三值格式坏",而是"三值格式的效果是模型相关的,必须逐个验证"。第 13 章会用 STQ1_0/PTQ1_0 的具体例子把这一点讲透。
2.5 评价指标:从逐块误差到端到端任务
量化做完了,怎么知道好坏?答案是"分层次地看":不同层次用不同的指标,就像体检要量血压、也做 CT。
2.5.1 MSE、最大相对误差与逐块可视化(调试工具)
这三个指标是格式调试工具,用来回答"解包是否出错、尺度估计是否失败"这类问题:
- MSE(均方误差):( \frac{1}{N}\sum (x_i - \hat{x}_i)^2 ),衡量总体误差水平。
- 最大相对误差:(\max_i |x_i - \hat{x}_i| / |x_i|),抓住"最坏的那个值"——经常暴露离群值或解包 bug。
- 逐块可视化:把每个块的误差画成直方图或热图,能直观看到"误差是不是集中在低幅度区域"(Q4_0 的特征)或"均匀分布"(Q4_K 的特征)。
注意它们的定位:MSE 低不说明模型好用,只是说明"没有解包错误、尺度没崩"。它们是诊断工具,不是质量判据。
2.5.2 PPL、KL 散度与任务准确率(质量判据)
判断"量化模型还像不像原版",用这三个:
- PPL(困惑度):对整体分布敏感。数值上是"预测概率的几何平均的倒数",量化后 PPL 上升越少,说明整体分布越接近原版。常用"相对 F16 的 PPL 劣化百分比"来横向比较格式。
- KL 散度:对 logits 的形状敏感。它直接比较量化模型和原版模型输出的概率分布差异。IQ 系列常用 KL 与 base 模型对比,因为码本量化的系统性偏差在 KL 上比 PPL 更早暴露。
- 任务准确率:对特定能力敏感。跑具体的 QA、推理、指令跟随任务,看量化模型掉了多少分。三值格式尤其依赖这一类指标,因为 PPL 可能"看起来还行",任务却已经崩溃(2.4.1 的阈值效应)。
选哪个?经验法则是:K-quants 常用 PPL 劣化百分比;IQ 系列常用 KL 与 base 对比;三值格式必须加任务评测。
2.5.3 端侧必须额外测量的指标:TTFT、冷启动、P95 延迟(部署判据)
最后一个层次是"用户体验"。PPL 再漂亮,如果首 token 等 10 秒、延迟忽快忽慢,用户一样不会用。端侧部署必须额外测量:
- TTFT(time to first token,首 token 延迟):从发出请求到收到第一个 token 的时间,用户感知最直接。
- 冷启动(cold start):从进程启动到能生成 token 的时间,涉及 mmap 和页缓存(第 1 章讲过)。
- P95 延迟:95% 分位延迟,衡量"最差情况有多糟",比平均延迟更能反映体验方差。
为什么要强调 P95?因为端侧芯片(Snapdragon、Tensor、Dimensity、Exynos 等)的调度器、GPU delegate 差异会导致延迟方差巨大——实测中不同芯片组的 P95 延迟方差可以从 ±12% 到 ±52%。平均 tok/s 好看不代表体验稳定。第 17 章会给出端侧基准的完整方法。
本章小结与通往容器的桥梁
本章的分析工具可以压缩成三个事实。
第一,浮点表示是"动态范围"和"精度"的权衡,GGUF 的块尺度用 fp16 而非 bf16。 fp16 的 10 位尾数给窄范围的尺度带来约 0.05% 的相对误差,在 4-bit 量化面前可忽略,但在 1–2 bpw 码本格式里会成为需要记账的底噪。
第二,仿射量化的重建误差 = 舍入误差 + 尺度失配误差,Q4_0 主要由后者主导。 夹取制造的是饱和误差而非舍入误差;块内异质性让单一尺度被离群值绑架、小值塌缩。这个"单一尺度的失效",就是 K-quants 层次化尺度的诞生理由。
第三,指标分三层:调试(MSE/最大相对误差)、质量(PPL/KL/任务)、部署(TTFT/冷启动/P95)。 码本量化看 KL 和系统性偏差,三值量化必须逐模型做任务验证,端侧还要额外看延迟方差。
有了分析工具,下一步走进容器内部,看看"压缩后的权重到底存进了什么样的文件"。第 3 章将从字节级剖析 GGUF 的 header、KV 元数据、tensor_infos 和数据段,并给你用 gguf-py 读写的全套 API。

浙公网安备 33010602011771号