GGUF 量化内幕:第5章 Q8_0 与简单块家族
GGUF 量化内幕:第5章 Q8_0 与简单块家族 - 绝不原创的飞龙
文章拆解了 GGUF 简单块家族 Q8_0、Q4_1、Q5_0、Q5_1 的字节布局:Q8_0 为 34 字节/32 值、8.5 bit/weight,公式 y=d·q,PPL 劣化约 +0.1%,常作量化正确性验证的基线。
第 4 章把 Q4_0 的每一个字节都拆开了看。这一章站在它旁边,补全"简单块"(simple block)的完整谱系:Q8_0、Q4_1、Q5_0、Q5_1。它们有一个共同点——每个块都是"一块权重 + 一个或两个共享参数",32 个值打包成紧凑的字节,没有超级块,没有子块,没有两级尺度。
你可能会问:这些"老格式"还有必要学吗?K-quants 不是把它们都超越了吗?答案是:有必要,而且有三个理由。
第一,Q8_0 是质量验证的"尺子"。 它的误差极小(PPL 劣化约 +0.1%),因此常被用来做"量化正确性验证"的参考:如果一个新格式在 Q8_0 参照下出现异常误差,问题多半在格式实现而非量化本身。
第二,Q4_1/Q5_0/Q5_1 是理解"显式最小值"的载体。 Q4_0 的 q-8 是存储偏移,不占字节;而 Q4_1 的 m 是真真实实存了 2 个字节。搞清楚这两者的区别,就理解了第 1 章 1.2 节那个"对称、非对称、伪对称"的三分法在字节层面长什么样。
第三,简单块在特定场景下仍然是最优解。 极小模型、嵌入式极端约束、调试、单元测试基准、旧文件兼容——这些场景要的是"实现最简单、后端覆盖最全、数值行为最可预测",而这恰好是简单块的强项。
先统一几个术语。近无损(near-lossless):量化后的误差小到在绝大多数评测指标上无法察觉。显式最小值(explicit min):把块内最小值的近似值作为参数直接存进文件,反量化时加回来。int8:8 位有符号整数,取值范围 -128 到 +127。
5.1 Q8_0:近无损基线
5.1.1 34 字节/32 值的布局与 (y_i = d \cdot q_i)
Q8_0 的结构几乎是 Q4_0 的"放大版":每个 32 值块仍然共享一个 FP16 尺度 d,但这次每个权重不再用 4 bit,而是用完整的 8 位有符号整数 int8。
block_q8_0 的 34 字节布局(磁盘/内存顺序)
偏移 0 1 32 33
┌────────┬───────────────────────────────────────────┐
│ d │ qs[0..31] │
│ 2 字节 │ 32 字节 │
│ FP16 │ 32 个 int8 码(-128..+127) │
└────────┴───────────────────────────────────────────┘
总计 = 2 + 32 = 34 字节 / 32 个权重
有效位宽 = 34 × 8 / 32 = 8.5 bit/weight
三种语言的结构体定义如下。
C++(与 llama.cpp 的 ggml-common.h 一致):
#define QK8_0 32
typedef struct {
ggml_half d; // 尺度(FP16 位模式),2 字节
int8_t qs[QK8_0]; // 32 个 int8 码,32 字节
} block_q8_0;
static_assert(sizeof(block_q8_0) == sizeof(ggml_half) + QK8_0, "wrong q8_0 block size/padding");
C#(.NET 5+):
using System.Runtime.InteropServices;
public static class Q8_0
{
public const int QK8_0 = 32;
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public unsafe struct BlockQ8_0
{
public ushort d; // FP16 位模式,2 字节
public fixed sbyte qs[QK8_0]; // 32 个 int8,32 字节
}
// sizeof(BlockQ8_0) == 34
}
fixed sbyte qs[32] 是 C# 的固定大小缓冲区,sbyte 对应 C++ 的 int8_t,需要 unsafe 上下文和 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>。
Python:
QK8_0 = 32
def block_layout_q8_0() -> dict:
return {
"d": 2, # FP16 位模式
"qs": QK8_0, # 32 个 int8
} # 总字节 = 2 + 32 = 34
关键差异:为什么 Q8_0 不需要减偏移?
Q4_0 的反量化是 (y_i = d \cdot (q_i - 8)),要减 8;Q8_0 的是:
[
y_i = d \cdot q_i
]
原因是 Q4_0 的码是无符号 4-bit(q ∈ [0,15]),存不了负数,必须靠减 8 平移成近似对称的量;而 Q8_0 的码是有符号 int8(q ∈ [-128,127]),天然覆盖正负,不需要任何偏移。用图对照:
Q4_0 vs Q8_0:为什么一个有减偏移、一个没有
Q4_0: 无符号码 q ∈ [0, 15] Q8_0: 有符号码 q ∈ [-128, 127]
需要 q - 8 变对称 天然对称,直接 d·q
┌──────┬──────┐ ┌────────────────────────┐
│ 0..7 │ 8..15│ │ -128 ... 0 ... +127 │
│ 负半 │ 正半 │ │ 对称于 0 │
└──────┴──────┘ └────────────────────────┘
减 8 免费获得对称性 i8 本身就带符号
5.1.2 量化端:尺度如何选择
llama.cpp 的 quantize_row_q8_0_ref 用块内绝对值最大的值来定尺度:
void quantize_row_q8_0_ref(const float * x, block_q8_0 * y, int64_t k) {
assert(k % QK8_0 == 0);
const int nb = k / QK8_0;
for (int i = 0; i < nb; i++) {
float amax = 0.0f;
for (int j = 0; j < QK8_0; j++) {
const float v = x[i*QK8_0 + j];
amax = MAX(amax, fabsf(v)); // 只取绝对值,不管符号
}
const float d = amax / ((1 << 7) - 1); // 除以 127
const float id = d ? 1.0f/d : 0.0f;
y[i].d = GGML_FP32_TO_FP16(d);
for (int j = 0; j < QK8_0; ++j) {
const float x0 = x[i*QK8_0 + j]*id;
y[i].qs[j] = roundf(x0); // 四舍五入,不夹取
}
}
}
注意两点与 Q4_0 的区别:
- 不保留符号。 Q4_0 取
d = max / -8,故意让d带符号来让极值命中码范围端点;Q8_0 直接d = amax / 127,因为 int8 的码范围是对称的,±amax都落在±127上,无需符号技巧。 - 不需要夹取(clamp)。 int8 的容量远超 8-bit 码的实际范围需求,
amax/127保证|x·id| ≤ 127,round 后不会溢出。(Q4_0 里min(15, ...)那一步在这里没有存在的必要。)
C#:
public static BlockQ8_0[] QuantizeRow(float[] x)
{
int k = x.Length;
if (k % QK8_0 != 0) throw new ArgumentException("k must be multiple of 32");
var result = new BlockQ8_0[x.Length / QK8_0];
for (int i = 0; i < result.Length; i++)
{
float amax = 0f;
for (int j = 0; j < QK8_0; j++)
amax = Math.Max(amax, Math.Abs(x[i * QK8_0 + j]));
float d = amax / 127f;
float id = d != 0 ? 1f / d : 0f;
var block = new BlockQ8_0 { d = BitConverter.HalfToUInt16Bits((Half)d) };
unsafe
{
for (int j = 0; j < QK8_0; j++)
block.qs[j] = (sbyte)MathF.Round(x[i * QK8_0 + j] * id);
}
result[i] = block;
}
return result;
}
Python:
import struct
QK8_0 = 32
def quantize_row_q8_0(x: list[float]) -> bytes:
assert len(x) % QK8_0 == 0
out = bytearray()
for i in range(0, len(x), QK8_0):
block = x[i:i + QK8_0]
amax = max(abs(v) for v in block)
d = amax / 127.0
id_ = 1.0 / d if d != 0 else 0.0
out += struct.pack('<e', d) # 2 字节尺度
out += bytes(round(v * id_) for v in block) # 32 个 int8
return bytes(out)
5.1.3 为什么 Q8_0 是"量化正确性验证"的参考
8.5 bpw 的 Q8_0 在 PPL 上的劣化通常只有约 +0.1%,小到在实际使用中几乎不可察觉。这使它成为整个量化体系的一个锚点:
- 实现验证:任何新格式的参考反量化写完,第一步是和一个 Q8_0 的同样流程做对比。如果新格式在 Q8_0 参照下出现异常误差(比如大得多的 PPL 增量),那多半是格式实现(位序、打包)有 bug,而不是量化原理不行。
- 误差归因:Q8_0 几乎只留下"码位宽不足"这一种误差(还是接近可忽略的那种),因此用它当分母,可以把"格式实现的 bug"和"量化本身的损失"分开。
第 18 章会详细讲如何把"参考实现交叉验证"做成一个可运行的工作流。这里只需要记住结论:遇到任何格式可疑时,先拿 Q8_0 的同一套流程跑一遍作为参照。
5.2 非对称变体:Q4_1 / Q5_1 与显式零点
5.2.1 Q4_1 的双参数布局
Q4_1 在 Q4_0 的尺度 d 之外,额外存储一个 最小值 m。反量化公式变成:
[
y_i = d \cdot q_i + m
]
字节布局从 Q4_0 的 18 字节增加到 20 字节:
block_q4_1 的 20 字节布局
偏移 0 1 2 3 19
┌────────┬────────┬────────────────────────────────────────┐
│ d │ m │ qs[0..15] │
│ 2 字节 │ 2 字节 │ 16 字节 │
│ FP16 │ FP16 │ 32 个 4-bit 码,打包 │
└────────┴────────┴────────────────────────────────────────┘
总计 = 2 + 2 + 16 = 20 字节 / 32 个权重
有效位宽 = 20 × 8 / 32 = 5.0 bit/weight
C++:
#define QK4_1 32
typedef struct {
ggml_half d; // 尺度,2 字节
ggml_half m; // 最小值,2 字节
uint8_t qs[QK4_1 / 2]; // 16 字节,32 个 4-bit 码
} block_q4_1;
static_assert(sizeof(block_q4_1) == 2 * sizeof(ggml_half) + QK4_1 / 2, "wrong q4_1 block size/padding");
(llama.cpp 里 d 和 m 用一个联合体 ggml_half2 dm 打包,但布局等价:d 在前、m 在后。)
C#:
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public unsafe struct BlockQ4_1
{
public ushort d; // FP16,2 字节
public ushort m; // FP16 最小值,2 字节
public fixed byte qs[QK4_1 / 2]; // 16 字节
}
Python:
QK4_1 = 32
def layout_q4_1() -> tuple[int, int, int]:
return (2, 2, QK4_1 // 2) # (d, m, qs),共 20 字节
量化端的 m 是怎么定的? 参考实现 quantize_row_q4_1_ref 的做法是:
float min = FLT_MAX, max = -FLT_MAX;
for (int j = 0; j < qk; j++) {
const float v = x[i*qk + j];
if (v < min) min = v;
if (v > max) max = v;
}
const float d = (max - min) / ((1 << 4) - 1); // 15
const float id = d ? 1.0f/d : 0.0f;
y[i].d = GGML_FP32_TO_FP16(d);
y[i].m = GGML_FP32_TO_FP16(min); // 最小值直接存
// 码: (x - min) * id,再四舍五入
可以看到 Q4_1 的 m 是真正的块内最小值,不是 Q4_0 那个固定的 -8 偏移。这就是 1.2 节说的"显式零点":它消耗 2 个真实字节,换来对非对称分布(最小值离 0 很远)的适应能力。
5.2.2 Q5_0 / Q5_1:5-bit 的第一次尝试
在 K-quants 出现之前,要突破 4.5 bpw 的质量瓶颈,最直接的办法是把码从 4-bit 提到 5-bit。但 5-bit 不是字节的整数倍,怎么存?答案是 Q5_0 的"4 字节高位平面"方案:32 个 5-bit 码,低 4 位沿用 nibble 打包(16 字节),第 5 位单独集中存进 4 字节的 qh。
block_q5_0 的 22 字节布局
偏移 0 1 2 5 21
┌────────┬────────┬────────┬──────────────────────────────────┐
│ d │ qh │ qs[0..15] │
│ 2 字节 │ 4 字节 │ 16 字节 │
│ FP16 │第5位平面│ 32 个 4-bit 低码,打包 │
└────────┴────────┴──────────────────────────────────────────┘
总计 = 2 + 4 + 16 = 22 字节 / 32 个权重
有效位宽 = 22 × 8 / 32 = 5.5 bit/weight
Q5_0 的高位位序(最容易写错的地方):
解码时,第 j 个 4-bit 低码(j = 0..15)对应权重 j 和 j+16:
uint32_t qh; // 把 4 字节 qh 按小端读成 uint32
memcpy(&qh, x[i].qh, sizeof(qh));
for (int j = 0; j < qk/2; ++j) { // j = 0..15
const uint8_t xh_0 = ((qh >> (j + 0)) << 4) & 0x10; // 权重 j 的高位:qh 的 bit j
const uint8_t xh_1 = ((qh >> (j + 12)) ) & 0x10; // 权重 j+16 的高位:qh 的 bit j+12
const int32_t x0 = ((x[i].qs[j] & 0x0F) | xh_0) - 16;
const int32_t x1 = ((x[i].qs[j] >> 4) | xh_1) - 16;
y[i*qk + j + 0 ] = x0 * d;
y[i*qk + j + qk/2] = x1 * d;
}
逐位看:
- 低 nibble 的第 5 bit(0x10 那一位)来自
qh的 bit j(j = 0..15 对应权重 0..15 的低 nibble); - 高 nibble 的第 5 bit 来自
qh的 bit j+12(j = 0..15 对应权重 16..31 的高 nibble)。
之所以不是 bit j+16,是因为每个字节的高 nibble 只需要 16 位,而 qh 的 bit 0..15 已经给了低 nibble 的高位——高 nibble 的高位从 bit 12 开始(j+12),这样 4 个字节的 32 bit 恰好被用满。实现时若按"bit j 与 bit j+16"或"每字节 8 位"去读,解码就错了。
Q5_1 在 Q5_0 基础上再加一个显式最小值 m,布局变成 24 字节、6.0 bpw:
block_q5_1 的 24 字节布局
偏移 0 1 2 3 4 7 23
┌────────┬────────┬────────┬────────┬────────┬──────────────────┐
│ d │ m │ qh │ qs[0..15] │
│ 2 字节 │ 2 字节 │ 4 字节 │ 16 字节 │
└────────┴────────┴────────┴──────────────────────────────────┘
总计 = 2 + 2 + 4 + 16 = 24 字节 / 32 个权重
有效位宽 = 24 × 8 / 32 = 6.0 bit/weight
公式 (y_i = d \cdot (q_i^5 - 16) + m),-16 把 5-bit 无符号码平移成对称量(0..31 → -16..+15),再加回最小值 m。
5.2.3 为什么 Q4_1 没有成为主流
理论上 Q4_1 比 Q4_0 多了最小值参数,对分布不对称的块更友好。但实践中它没有流行起来,原因有三:
-
2 字节的代价在 4-bit 面前不小。 从 18 到 20 字节,bpw 从 4.5 涨到 5.0。同样的字节数,后来的 Q4_K(4.5 bpw)用子块最小值实现了更细粒度的非对称性,误差改善远大于 Q4_1 那 2 字节换来的。
-
显式最小值对"每块 32 值"粒度过粗。 一个 32 值块内部,最小值可能落在局部而非全局;Q4_1 只能记录"整块最小",无法区分块内不同区域的偏移。K-quants 把 256 值切 8 个子块、每子块一个最小值,粒度细得多。
-
生态惯性。 Q4_1 是早期格式,Q4_K 出现后成为事实标准(第 7 章),社区量化的文件绝大多数走 Q4_K 系列,Q4_1 只留在兼容性名单里。
一句话总结:显式零点的思路是对的,但"整块一个 m"的粒度不够,Q4_K 用子块尺度+子块最小值把同一个思路做细了。 这正是第 6 章 K-quants 的动机。
5.3 简单块量化家族的剩余价值
讲完了"为什么被超越",还要讲"为什么仍然有用"。简单块在四个场景里依然不可替代。
5.3.1 极小模型与调试场景
- 极小模型:当模型只有几百万参数时,尺度开销的占比变得重要,但更重要的是实现不能有 bug。Q8_0/Q4_0 的结构最简单,出现位序错误的概率最低,适合作为嵌入式/微控制器的起点。
- 调试:当你怀疑某个 K-quant 或 IQ 格式的解码有问题时,最快的排查方法是在同一张量上跑 Q4_0/Q8_0——它们解码逻辑简单,如果它们正常而 K-quant 异常,问题就在 K-quant 的打包/位序,而不是整个量化体系。
5.3.2 兼容性与确定性
- 跨后端覆盖最全:Q4_0/Q8_0 是所有 llama.cpp 后端(CPU、Metal、CUDA、Vulkan、OpenCL……)实现最早、最稳定的格式。一个只在 Q8_0/Q4_0 上验证过的模型,几乎在任何构建、任何设备上都能跑。
- 数值行为可预测:Q4_0 的
y = d(q-8)、Q8_0 的y = dq没有条件分支、没有查表、没有交错位流,反量化结果只由d和码决定——这让单元测试和跨语言交叉验证变得简单(参考实现可以直接逐字节比对)。 - 旧文件兼容:大量历史 GGUF 文件就是 Q4_0/Q8_0 量化,新版本 llama.cpp 永远需要正确读取它们。
简单块的"不完美",恰恰是 K-quants 设计动机的起点:当 32 值块共享单一尺度时,块内异质性会把误差集中到小值(第 4 章 4.5 节);当需要更低位宽或更高保真时,简单块既压不动(4-bit 到底)、也保不住(8-bit 太贵)。解决这两端的方案,就是第 6 章开始的超级块架构。
本章小结与通往"超级块"的桥梁
这一章可以压缩成四个事实。
第一,Q8_0 是近无损的 8-bit 基线:d + 32 个 int8,34 字节 = 8.5 bpw,公式 y = d·q。 因为 int8 自带符号,不需要减偏移;量化端用 amax/127 定尺度,不用夹取。PPL 劣化约 +0.1%,是所有格式验证的"尺子"。
第二,Q4_1 引入了显式最小值:d + m + 16 字节码,20 字节 = 5.0 bpw,公式 y = d·q + m。 m 是真正的块内最小值,消耗 2 个真实字节——这与 Q4_0 的 q-8(存储偏移、不占字节)是两回事。
第三,Q5_0/Q5_1 用"高位平面"把 4-bit 推到 5-bit:22/24 字节 = 5.5/6.0 bpw。 第 5 bit 集中存进 qh,低 nibble 配 qh bit j、高 nibble 配 qh bit j+12——这是最容易写错的地方。
第四,简单块的剩余价值在"确定性"与"兼容性"。 实现最简单、后端覆盖最全、数值行为最可预测,因此仍是极小模型、调试、测试基准和旧文件兼容的首选。
这四个事实共同指向一个关键结论:简单块的两个缺陷——32 值单一尺度的异质性问题、以及为了适应分布不对称必须牺牲字节——都来自同一个根源:每个块只有一个"全局"参数。 如果给块再加一层结构,让"全局"和"局部"分开管理,这两个问题就能同时缓解。
下一章就进入这个新的架构。超级块(super-block)把 256 个权重编成一组,共享两个全局尺度 d 和 d_min;内部再切 8 个 32 值子块,每子块有自己的 6-bit 尺度和 6-bit 最小值。 这就是 K-quants——从这一章起,block_q4_0 的 18 字节将变成 block_q4_K 的 144 字节,而"为什么值得多花这些字节"的答案,就藏在下一章的 6.3 节里。
来源:博客园 · cnblogs.com