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

GGUF 量化内幕第4章:Q4_0 块量化格式解析

GGUF 量化内幕:第4章 Q4_0:块量化的起点 - 绝不原创的飞龙

AI 导读

Q4_0 是 llama.cpp 生态中最早的量化格式之一,将每 32 个权重分为一组共享一个 FP16 尺度,每权重仅用 4 bit 存储,单块占 18 字节,有效位宽 4.5 bit/weight,体积缩至 FP32 的约 14%。其 4-bit 码以 nibble 打包,低 4 位存块内前 16 个权重、高 4 位存后 16 个,反量化时先减 8 再乘尺度还原浮点值。

正文

一个 7B 参数的模型,权重以 FP32(每个权重 4 字节)存储时需要约 28 GB 磁盘和内存。即使换成 FP16(每权重 2 字节),仍需约 14 GB。这对笔记本、手机和嵌入式设备来说是一道几乎不可逾越的门槛。

Q4_0 是 llama.cpp 生态中最早出现、也最简单的量化格式之一。它做的事情可以用一句话概括:每 32 个权重分为一组,这 32 个权重共享一个 FP16 尺度,每个权重本身只用 4 个 bit 存储。32 个 FP32 权重原本占 (32 \times 4 = 128) 字节,在 Q4_0 下变成 (2 + 16 = 18) 字节。体积缩小到约 14%,代价是数值精度的损失。

本章的目标不是让你背下 Q4_0 的每个字节,而是借这个最简格式建立一套后面所有量化格式都会复用的分析框架:看字节布局 → 写反量化公式 → 理解量化端如何选参数 → 分析误差从哪来。Q4_0 的局限,恰好是 K-quants 要解决的问题的起点。

先统一几个术语。张量(tensor) 在这里就是多维数组;权重(weight) 是模型训练得到的参数,推理时从磁盘读入、参与矩阵乘法。量化(quantization) 指用更少的比特表示这些数值,反量化(dequantization) 指推理时把压缩后的比特还原成浮点数。FP16 是 IEEE 754 半精度浮点,占 2 字节;FP32 是单精度,占 4 字节。

4.1 先理解“块”这个概念

4.1.1 为什么不能对整个张量用一个尺度

假设一个权重张量有 (N) 个数值。最朴素的想法是:找出整个张量的最大值 (x_{\max}),令尺度 (d = x_{\max} / 7),把每个权重映射到 4-bit 整数 (q_i = \mathrm{round}(x_i / d)),反量化时用 (x_i \approx d \cdot q_i) 还原。

这个方案数学上成立,实践中会失效。原因是一个张量里的数值分布非常不均匀:绝大多数权重集中在零附近,少数权重绝对值较大。如果用一个全局尺度去覆盖那个最大的绝对值,大部分小权重除以 (d) 后会变成 0 或 (\pm 1),彼此之间的细微差别完全丢失。可以用一张示意图理解:

整个张量的权重分布(示意)

  数量
   │
   │        ███
   │       █████
   │      ███████
   │     █████████
   │    ███████████
   │   █████████████
   │  ███████████████
   │ ██████████████████
   └──────────────────────────► 权重值
     -10   -5   0   +5   +10
                ▲
                │
        绝大多数权重挤在这里
        (幅度很小,差异细微)

  全局尺度 d = max/7 由最右端的 +10 决定,
  小权重除以 d 后全部塌缩到 0 附近。

Q4_0 的答案是分块:把张量切成连续的 32 个值一组,每组独立计算自己的尺度。这样一个块里的小值不会被另一个块里的大值“拖累”。块越小,尺度越能适应当地分布;但块越小,存储尺度本身的开销占比越高。32 是 Q4_0 选定的折中值。

分块量化示意

  张量: [ ... 权重 ... ]
          │
          ├── 块 0: 32 个值 → 尺度 d0,32 个 4-bit 码
          ├── 块 1: 32 个值 → 尺度 d1,32 个 4-bit 码
          ├── 块 2: 32 个值 → 尺度 d2,32 个 4-bit 码
          └── ...

  每个块只看自己内部的 32 个值,尺度互不影响。

4.1.2 一个块里到底存了什么

Q4_0 的一个块对应连续 32 个权重,在内存中恰好占 18 字节。字节布局如下:

block_q4_0 的 18 字节布局(磁盘/内存中的顺序)

  偏移  0        1        2                              17
        ┌────────┬────────────────────────────────────────┐
        │   d    │              qs[0..15]                 │
        │ 2 字节 │              16 字节                   │
        │  FP16  │        32 个 4-bit 码,打包             │
        └────────┴────────────────────────────────────────┘
        ▲                         ▲
        │                         │
     尺度(共享)          每字节装 2 个 4-bit 码

  总计 = 2 + 16 = 18 字节 / 32 个权重
  有效位宽 = 18 × 8 / 32 = 4.5 bit/weight

三种语言的结构体定义如下。

C++(与 llama.cpp 的 ggml-common.h 一致):

#define QK4_0 32

typedef struct {
    ggml_half d;            // 尺度(FP16 位模式),2 字节
    uint8_t   qs[QK4_0 / 2]; // 16 字节,承载 32 个 4-bit 码
} block_q4_0;

static_assert(sizeof(block_q4_0) == 2 + QK4_0 / 2, "wrong q4_0 block size");

ggml_half 是 llama.cpp 对 16 位无符号整数的 typedef,里面存的是 FP16 的位模式,不是 float。static_assert 锁定了大小,因为磁盘字节就是按这个布局写进去的,任何编译器填充都会破坏“结构体即磁盘布局”的约定。

C#(需要 .NET 5+,System.Half 从 .NET 5 开始提供):

using System.Runtime.InteropServices;

public static class Q4_0
{
    public const int QK4_0 = 32;

    [StructLayout(LayoutKind.Sequential, Pack = 1)]
    public unsafe struct BlockQ4_0
    {
        public ushort d;                  // FP16 位模式,2 字节
        public fixed byte qs[QK4_0 / 2];  // 16 字节
    }

    // sizeof(BlockQ4_0) == 18
}

Pack = 1 关闭对齐填充,fixed byte qs[16] 是 C# 的固定大小缓冲区,需要在 unsafe 上下文中使用,并在项目文件中启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>。这种写法与 C++ 的内存布局一一对应。

Python:

from dataclasses import dataclass

QK4_0 = 32

@dataclass
class BlockQ4_0:
    d: bytes      # 2 字节 FP16 位模式
    qs: bytes     # 16 字节打包码

    def __post_init__(self):
        assert len(self.d) == 2
        assert len(self.qs) == 16

Python 没有“结构体即内存布局”的概念,对象和磁盘字节是分离的。这里用 bytes 保存原始字节,用 struct 模块做 FP16 转换。读文件时按 18 字节切块,写入时再拼回去。这一差异在后文会反复出现:C/C# 直接操作结构体,Python 需要显式序列化。

有效位宽是衡量压缩程度的核心指标。Q4_0 每 32 值块占 18 字节,所以每权重的平均比特数是:

[
\text{bpw} = \frac{18 \times 8}{32} = 4.5 \ \text{bit/weight}
]

比纯 4 bit 多出的 0.5 bit,就是那个 FP16 尺度分摊到 32 个权重上的开销。

4.2 4-bit 码的编码方式:nibble 打包

4.2.1 每个字节装两个数

4 bit 在计算机里有个专门名字:nibble(半字节)。一个 uint8_t 是 8 bit,可以拆成低 4 位和高 4 位。Q4_0 把两个 4-bit 码塞进同一个字节。

一个字节 = 8 bit = 两个 nibble

        bit 7 6 5 4 │ bit 3 2 1 0
        ┌───────────┼───────────┐
        │  高 4 位   │  低 4 位   │
        │ (hi nibble)│ (lo nibble)│
        └───────────┴───────────┘
             │             │
             ▼             ▼
        块内第 j+16     块内第 j
        个权重的码      个权重的码

qs[j] 承载块内第 (j) 个权重和第 (j+16) 个权重的码:低 4 位放第 (j) 个,高 4 位放第 (j+16) 个。这是“lo-hi split”布局,不是奇偶交替。16 个字节覆盖全部 32 个权重:

qs[0..15] 与块内 32 个权重的映射

  qs 字节:   qs[0]   qs[1]   qs[2]  ...  qs[15]
              │        │        │            │
         ┌────┴────┐   │        │            │
         ▼         ▼   ▼        ▼            ▼
  权重索引: 0       16  1       17   ...    15      31
            (lo)   (hi) (lo)   (hi)        (lo)    (hi)

  前 16 个权重的码在各字节的低 nibble;
  后 16 个权重的码在各字节的高 nibble。

三种语言里,取低 4 位都是 x & 0x0F,取高 4 位都是 (x >> 4) & 0x0F:

C++:

int lo = qs[j] & 0x0F;
int hi = (qs[j] >> 4) & 0x0F;

C#:

int lo = block.qs[j] & 0x0F;
int hi = (block.qs[j] >> 4) & 0x0F;

Python:

lo = qs[j] & 0x0F
hi = (qs[j] >> 4) & 0x0F

& 0x0F 是位与,掩掉高 4 位;>> 4 把高 4 位移到低 4 位的位置。C# 的 byte 在做 >> 时会自动提升为 int,不需要额外转换;Python 的整数是任意精度,也不会有溢出问题。

4.2.2 4-bit 码的取值范围

4-bit 无符号整数可以表示 0 到 15。Q4_0 存储的就是 (q_i \in [0, 15])。反量化时不会直接用 (q_i) 乘以尺度,而是先减 8:

[
y_i = d \cdot (q_i - 8)
]

减 8 之后 (q_i - 8) 落在 ([-8, 7]),相当于把无符号码“平移”成有符号量,零点对应 (q_i = 8)。减 8 不消耗额外存储,只是解码端的固定偏移。从效果上看,Q4_0 是在用 4 bit 表示一个近似对称的量化范围:

4-bit 无符号码经过 -8 偏移后的有符号范围

  q_i:   0    1    2   ...   7    8    9  ...  15
         │    │    │         │    │    │        │
  q_i-8: -8   -7   -6  ...  -1    0   +1  ...  +7
         └──────── 负半轴 ────────┘    └── 正半轴 ──┘
                                    ▲
                                 零点 q=8

  负半轴有 8 个码(-8..-1),正半轴有 7 个码(0..+7),
  所以是“近似对称”而非严格对称。

4.3 反量化:把 18 字节变回 32 个浮点数

反量化是推理时真正执行的操作:读取压缩字节,按公式还原浮点数值,送入矩阵乘法。Q4_0 的反量化几乎是 4.2 节的直译:读取尺度 d,遍历 16 个打包字节,分别处理低 nibble 和高 nibble。整个流程可以画成:

反量化一个 block_q4_0 的数据流

  磁盘/内存 18 字节
  ┌────────┬──────────────────────────────┐
  │   d    │          qs[0..15]           │
  └───┬────┴──────────────┬───────────────┘
      │                   │
      ▼                   ▼
  FP16→FP32          逐字节拆 nibble
      │                   │
      │        ┌──────────┴──────────┐
      │        ▼                     ▼
      │   lo = qs[j]&0xF       hi = qs[j]>>4
      │        │                     │
      │        ▼                     ▼
      │   (lo - 8)              (hi - 8)
      │        │                     │
      └───┬────┘                     │
          ▼                          ▼
   y[j] = d*(lo-8)           y[j+16] = d*(hi-8)
          │                          │
          └──────────┬───────────────┘
                     ▼
              32 个 float 输出

C++:

#include <cmath>

void dequantize_row_q4_0(const block_q4_0 * x, float * y, int k) {
    const int nb = k / QK4_0;
    for (int i = 0; i < nb; i++) {
        const float d = GGML_FP16_TO_FP32(x[i].d);
        const uint8_t * qs = x[i].qs;

        for (int j = 0; j < QK4_0 / 2; j++) {
            const int lo = qs[j] & 0x0F;
            const int hi = (qs[j] >> 4) & 0x0F;
            y[i * QK4_0 + j]           = d * (lo - 8);
            y[i * QK4_0 + j + QK4_0/2] = d * (hi - 8);
        }
    }
}

GGML_FP16_TO_FP32 是 llama.cpp 的 FP16→FP32 转换宏,底层可能是硬件指令或查表。k 是总元素数,必须是 32 的倍数。

C#(.NET 5+):

using System;

public static class Q4_0
{
    public const int QK4_0 = 32;

    public static void DequantizeRow(BlockQ4_0[] blocks, float[] y, int k)
    {
        int nb = k / QK4_0;
        for (int i = 0; i < nb; i++)
        {
            float d = (float)BitConverter.UInt16BitsToHalf(blocks[i].d);
            unsafe
            {
                for (int j = 0; j < QK4_0 / 2; j++)
                {
                    int lo = blocks[i].qs[j] & 0x0F;
                    int hi = (blocks[i].qs[j] >> 4) & 0x0F;
                    y[i * QK4_0 + j]             = d * (lo - 8);
                    y[i * QK4_0 + j + QK4_0 / 2] = d * (hi - 8);
                }
            }
        }
    }
}

BitConverter.UInt16BitsToHalf 把 16 位位模式直接解释为 Half,再显式转 float。访问 fixed 字段必须在 unsafe 块中。

Python:

import struct

QK4_0 = 32

def dequantize_row_q4_0(data: bytes, k: int) -> list[float]:
    assert k % QK4_0 == 0
    nb = k // QK4_0
    y = [0.0] * k

    for i in range(nb):
        base = i * 18
        d = struct.unpack_from('<e', data, base)[0]  # 小端 FP16
        qs = data[base + 2 : base + 18]

        for j in range(16):
            lo = qs[j] & 0x0F
            hi = (qs[j] >> 4) & 0x0F
            y[i * QK4_0 + j]      = d * (lo - 8)
            y[i * QK4_0 + j + 16] = d * (hi - 8)

    return y

'<e' 是 Python struct 模块的小端半精度格式,从 Python 3.6 开始支持。unpack_from 直接从字节缓冲区按偏移读取,避免切片拷贝。

三种实现逐行对应,输出完全一致。反量化只有位操作、整数减法和一次浮点乘法,没有分支或查表。这个简单性也意味着:只要字节布局正确,反量化的正确性几乎不可能出错。后面更复杂的格式(Q4_K 的 6-bit 交错打包、IQ 的码本查表)就没有这种奢侈了。

4.4 量化端:尺度是怎么选出来的

反量化假设 (d) 和 (q_i) 已经存在。给定 32 个 FP32 权重,如何决定 (d) 和每个 4-bit 码?

llama.cpp 的参考实现 quantize_row_q4_0_ref 采用直接而经典的策略:用块内绝对值最大的权重来定尺度。流程如下:

量化一个 32 值块的流程

  输入: 32 个 FP32 权重
        │
        ▼
  ┌─────────────────────────┐
  │ 扫描一遍,找 amax 和带   │
  │ 符号的 max              │
  └───────────┬─────────────┘
              │
              ▼
      d = max / -8          ← 保留符号,让极值命中边界
      id = 1 / d
              │
              ▼
  ┌─────────────────────────┐
  │ 对每个权重 x:            │
  │   q = min(15, int(x*id+8.5)) │
  │   两个 q 打包进一个字节   │
  └───────────┬─────────────┘
              │
              ▼
  输出: d (FP16) + 16 字节 qs

C++:

#include <cassert>
#include <cmath>
#include <algorithm>

void quantize_row_q4_0_ref(const float * x, block_q4_0 * y, int k) {
    assert(k % QK4_0 == 0);
    const int nb = k / QK4_0;

    for (int i = 0; i < nb; i++) {
        float amax = 0.0f;
        float max  = 0.0f;

        for (int j = 0; j < QK4_0; j++) {
            const float v = x[i * QK4_0 + j];
            if (amax < std::fabs(v)) {
                amax = std::fabs(v);
                max  = v;               // 保留符号
            }
        }

        const float d  = max / -8.0f;
        const float id = d ? 1.0f / d : 0.0f;

        y[i].d = GGML_FP32_TO_FP16(d);

        for (int j = 0; j < QK4_0 / 2; j++) {
            const float x0 = x[i * QK4_0 + j]             * id;
            const float x1 = x[i * QK4_0 + j + QK4_0 / 2] * id;

            const uint8_t xi0 = std::min(15, (int)(x0 + 8.5f));
            const uint8_t xi1 = std::min(15, (int)(x1 + 8.5f));

            y[i].qs[j] = xi0 | (xi1 << 4);
        }
    }
}

C#:

using System;

public static class Q4_0
{
    public const int QK4_0 = 32;

    public static BlockQ4_0[] QuantizeRow(float[] x)
    {
        int k = x.Length;
        if (k % QK4_0 != 0) throw new ArgumentException("k must be multiple of 32");
        int nb = k / QK4_0;
        var result = new BlockQ4_0[nb];

        for (int i = 0; i < nb; i++)
        {
            float amax = 0.0f;
            float max  = 0.0f;

            for (int j = 0; j < QK4_0; j++)
            {
                float v = x[i * QK4_0 + j];
                if (amax < Math.Abs(v))
                {
                    amax = Math.Abs(v);
                    max  = v;
                }
            }

            float d  = max / -8.0f;
            float id = d != 0 ? 1.0f / d : 0.0f;

            var block = new BlockQ4_0
            {
                d = BitConverter.HalfToUInt16Bits((Half)d)
            };

            unsafe
            {
                for (int j = 0; j < QK4_0 / 2; j++)
                {
                    float x0 = x[i * QK4_0 + j]             * id;
                    float x1 = x[i * QK4_0 + j + QK4_0 / 2] * id;

                    int xi0 = Math.Min(15, (int)(x0 + 8.5f));
                    int xi1 = Math.Min(15, (int)(x1 + 8.5f));

                    block.qs[j] = (byte)(xi0 | (xi1 << 4));
                }
            }

            result[i] = block;
        }
        return result;
    }
}

(int)(x + 8.5f) 在 C# 中向零截断,与 C++ 的 (int) 行为一致;Math.Min(15, ...) 限制上界。

Python:

import struct

QK4_0 = 32

def quantize_row_q4_0(x: list[float]) -> bytes:
    assert len(x) % QK4_0 == 0
    nb = len(x) // QK4_0
    out = bytearray()

    for i in range(nb):
        amax = 0.0
        max_v = 0.0
        for j in range(QK4_0):
            v = x[i * QK4_0 + j]
            if amax < abs(v):
                amax = abs(v)
                max_v = v

        d   = max_v / -8.0
        id_ = 1.0 / d if d != 0 else 0.0

        out += struct.pack('<e', d)          # 2 字节尺度
        qs = bytearray(16)
        for j in range(16):
            x0 = x[i * QK4_0 + j]      * id_
            x1 = x[i * QK4_0 + j + 16] * id_

            xi0 = min(15, int(x0 + 8.5))
            xi1 = min(15, int(x1 + 8.5))

            qs[j] = xi0 | (xi1 << 4)

        out += qs

    return bytes(out)

为什么 (d = \text{max} / -8) 要保留符号? 这是理解 Q4_0 尺度选择的关键。假设块内绝对值最大的值是 (v_{\max} > 0)。如果取正尺度 (d = v_{\max}/7),那么 (v_{\max}) 映射到 (q = 7),而 (q = 15) 那一端就被浪费了。取 (d = v_{\max}/-8 < 0),则 (v_{\max} / d = -8),加 8.5 得 0.5,截断为 0,恰好落在码范围的一端。另一端 (-v_{\max}) 映射到 (q = 15)。这样绝对值最大的值至少有一端能精确命中边界。可以用图表示:

尺度带符号时,极值命中码范围端点

  情形 A: max > 0,取 d = max / -8 < 0

     x 轴(权重值):   -max       0       +max
                       │         │        │
     x / d:            +7  ...   0  ...  -8
                       │         │        │
     q = round(x/d+8): 15  ...   8  ...   0
                       ▲                  ▲
                    命中上端            命中下端
                    (q=15)              (q=0)

  情形 B: max < 0,取 d = max / -8 > 0

     x 轴:             -max       0       +|max|
                       │         │        │
     x / d:            -8  ...   0  ...  +7
                       │         │        │
     q:                 0  ...   8  ...  15
                       ▲                  ▲
                    命中下端            命中上端

  无论哪种情形,极值都落在 q=0 或 q=15,
  码范围被完整利用,不会在一端留空。

需要指出:用最大绝对值定尺度是一个启发式,不是最小化重建误差的最优解。它极快、无需搜索,缺点是块内存在远离其他值的离群点时,尺度会被离群点“绑架”,其余 31 个值的码挤在窄区间里。这正是 4.5 节的核心误差来源。

4.5 误差从哪里来:Q4_0 的失效模式

Q4_0 的误差可以粗略分解为舍入误差和尺度失配误差。

舍入误差来自 4 bit 的有限分辨率:即使尺度选得完美,每个值除以 (d) 后仍要舍入到整数码,最大相对舍入误差约为半个码步。这是任何 4-bit 量化都无法避免的底噪。

Q4_0 的主要问题不在舍入误差,而在尺度失配误差。考虑一个构造性例子: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 截断后几乎全部变成同一个整数码。反量化时它们被还原成同一个值,彼此差异完全消失。

块内异质性导致小值塌缩

  块内 32 个权重的真实分布(示意):

    10.0 │●
         │
         │
         │
         │
         │
   0.01  │  ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
         │  ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
  -0.01  │
         └──────────────────────────────────► 权重索引
           0  1  2  3 ...                 31

  尺度 d = 10 / -8 = -1.25
  小值 x/d ≈ ±0.008,加 8.5 后 ≈ 8.49 或 8.51
  截断后全部变成 8(或 7/8 两档)

  重建结果:

    10.0 │●
         │
         │
         │
         │
         │
     0.0 │  ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
         │  ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
         └──────────────────────────────────► 权重索引
           所有小值被抹平成同一个数

用 Python 可以直观复现这个现象:

def quantize_block_demo(values):
    amax = max(abs(v) for v in values)
    max_v = next(v for v in values if abs(v) == amax)
    d = max_v / -8.0
    id_ = 1.0 / d if d else 0.0

    codes = [min(15, int(v * id_ + 8.5)) for v in values]
    recon = [d * (q - 8) for q in codes]
    return d, codes, recon

values = [0.01 * (i - 15) / 15 for i in range(31)] + [10.0]
d, codes, recon = quantize_block_demo(values)

print("尺度 d =", d)
print("小值区域的码:", codes[:8], "...")
print("小值区域重建:", recon[:8], "...")

运行结果会显示:31 个小值几乎全部映射到同一个码(通常在 7 或 8 附近),重建后差异被抹平;只有那个 10.0 落到边界码。在真实模型权重中,离群点没有这么极端,但块内异质性普遍存在。当一组 32 个权重同时包含幅度差异较大的值时,单一尺度会系统性地牺牲小值的精度。Q4_0 的误差因此集中在低幅度权重上,而这类权重在模型中数量最多。

这也解释了为什么 Q4_0 在相同约 4.5 bpw 下质量劣化明显高于后来的 Q4_K。在一个 7B 模型的实测中,Q4_0 的困惑度(PPL)增加约为 0.25,文件约 3.5 GB;同样约 4.5 bpw 的 Q4_K_M,PPL 增加约为 0.05,文件约 3.8 GB。两者文件大小接近,质量差距显著——多出的约 0.3 GB 不是浪费,而是层次化尺度信息换来的精度。

困惑度(PPL,perplexity) 是衡量语言模型预测能力的一个指标,越低越好;量化后 PPL 增加得越少,说明质量损失越小。需要谨慎:PPL 的绝对值依赖模型、数据集和评测方式,不同来源的数字不能直接横向比较。这里的重点不是“0.25 vs 0.05”这组具体数字,而是在相同位宽级别下,Q4_0 的单一尺度结构决定了它的误差结构比多层次尺度格式更粗糙。

4.6 本章小结与通往 K-quants 的桥梁

Q4_0 的全部内容可以压缩成三个事实。

第一,它按 32 个权重一块量化,每块 18 字节,有效位宽 4.5 bpw。 块内 32 个权重共享一个 FP16 尺度 d,每个权重存为 4-bit 无符号码 (q_i \in [0, 15]),两个码打包进一个字节。C++ 用 block_q4_0 结构体直接映射磁盘字节,C# 用 Pack=1 的 unsafe fixed 结构体达到同样效果,Python 用 18 字节 bytes 加 struct 模块显式序列化。

Q4_0 块结构一览

  ┌────────┬────┬────┬────┬─────┬────┐
  │   d    │qs[0]│qs[1]│qs[2]│ ... │qs[15]│
  │ 2 字节 │ 1B │ 1B │ 1B │     │ 1B │
  └────────┴────┴────┴────┴─────┴────┘
      │      │
      │      └─ 每字节 = 2 个 4-bit 码
      │
      └─ 32 个权重共享这一个尺度

  有效位宽 = 18×8/32 = 4.5 bpw

第二,反量化是 (y_i = d \cdot (q_i - 8))。 减 8 把无符号码平移成近似对称的量,不消耗额外存储。解码只需要位操作、整数减法和一次浮点乘法。三种语言的实现逐行对应,输出完全一致。

第三,量化端用块内绝对值最大的值来定尺度,(d = \text{max} / -8)。 保留符号是为了让极值恰好落在码范围的一端。这是一个快速但非最优的启发式,核心弱点是:块内幅度差异较大时,小值的精度被系统性地牺牲。

第三条事实直接指向 K-quants 的设计动机。Q4_K 没有改变“分块”这个基本思想,但把“一个块一个尺度”升级成了“一个 256 值的超级块拥有全局尺度,内部 8 个 32 值子块各自拥有自己的 6-bit 尺度和最小值”。可以用一张对比图概括这个演进:

从 Q4_0 到 Q4_K:尺度层次的变化

Q4_0(32 值 / 块):

  ┌───────────────────────────────┐
  │          32 个权重             │
  │    共享 1 个尺度 d             │
  └───────────────────────────────┘
  问题: 块内异质时,小值被大值拖累

Q4_K(256 值 / 超级块):

  ┌─────────────────────────────────────────────────────────┐
  │  超级块全局尺度 d + 全局最小值尺度 d_min                 │
  │  ┌─────────┬─────────┬─────────┬─────┬─────────┐        │
  │  │ 子块 0  │ 子块 1  │ 子块 2  │ ... │ 子块 7  │        │
  │  │ 32 值   │ 32 值   │ 32 值   │     │ 32 值   │        │
  │  │ s0, m0  │ s1, m1  │ s2, m2  │     │ s7, m7  │        │
  │  └─────────┴─────────┴─────────┴─────┴─────────┘        │
  └─────────────────────────────────────────────────────────┘
  每个子块有自己的 6-bit 尺度 s_j 和最小值 m_j,
  误差预算分配到 8 个子块,不再由单一尺度承担。

理解了 Q4_0 的 (y = d(q-8)) 为什么会在异质块上失效,也就理解了后面 Q4_K 的 (y = d \cdot s_j \cdot q - d_{\min} \cdot m_j) 为什么要存在。

来源:博客园 · cnblogs.com