跳到正文
博客园· 绝不原创的飞龙·· 3 小时前AI 评分22

GGUF 量化内幕:第5章 Q8_0 与简单块家族

GGUF 量化内幕:第5章 Q8_0 与简单块家族 - 绝不原创的飞龙

AI 导读

文章拆解了 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 的区别:

  1. 不保留符号。 Q4_0 取 d = max / -8,故意让 d 带符号来让极值命中码范围端点;Q8_0 直接 d = amax / 127,因为 int8 的码范围是对称的,±amax 都落在 ±127 上,无需符号技巧。
  2. 不需要夹取(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 多了最小值参数,对分布不对称的块更友好。但实践中它没有流行起来,原因有三:

  1. 2 字节的代价在 4-bit 面前不小。 从 18 到 20 字节,bpw 从 4.5 涨到 5.0。同样的字节数,后来的 Q4_K(4.5 bpw)用子块最小值实现了更细粒度的非对称性,误差改善远大于 Q4_1 那 2 字节换来的。

  2. 显式最小值对"每块 32 值"粒度过粗。 一个 32 值块内部,最小值可能落在局部而非全局;Q4_1 只能记录"整块最小",无法区分块内不同区域的偏移。K-quants 把 256 值切 8 个子块、每子块一个最小值,粒度细得多。

  3. 生态惯性。 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