GGUF-量化内幕-第9章-Q6_K-6-bit-子块与输出层

GGUF 量化内幕:第9章 Q6_K:6-bit 子块与输出层

在 k-quant 家族里,Q6_K 是一个"看起来低调、实际上特殊"的成员。它没有 Q4_K 那样显眼的市场地位,也没有 Q5_K 那样清晰的角色描述,但它做了一件其他 k-quant 都没做的事:改变子块结构。

回顾第 7 章,Q4_K 的骨架是"256 值超级块 = 8 个子块 × 32 值,每个子块有 6-bit 尺度 + 6-bit 最小值"。Q5_K 保持了这个骨架,只是加了高位平面。而 Q6_K 把骨架改成了 16 个子块 × 16 值,每个子块用一个 8-bit 有符号整数做尺度,完全去掉了最小值偏移。结构更简单了,精度反而更高——6.5625 bpw 的有效位宽,是 k-quant 家族里仅次于 Q8_K 的高精度档位。

更重要的身份是它的角色:在 Q4_K_M、Q5_K_M 这些文件里,输出嵌入层(output.weight)几乎总是用 Q6_K。为什么偏偏是它?因为输出层直接决定最后 logits 的分布,是全模型对量化误差最敏感的地方之一。这一章会解释这层因果。

先统一几个术语。子块(sub-block):超级块内部更小的单元,Q6_K 是 16 值。子组(sub-group):本处以与细纲一致的方式指代 Q6_K 的 16 值子块。logits:模型最后一层输出的原始分数,softmax 之后变成 token 概率,PPL 和生成质量都从这里来。输出嵌入层(output embedding / output.weight):把最终特征映射到词表概率的那一层。对称量化(symmetric):反量化公式里只有尺度、没有偏移项(最小值),量化范围关于 0 对称。

9.1 结构:16 个 16 值子块

9.1.1 ql[128] 低 4 bit + qh[64] 高 2 bit

先看结构体:

typedef struct {
    uint8_t   ql[QK_K/2];      // 低 4 位平面,128 字节
    uint8_t   qh[QK_K/4];      // 高 2 位平面,64 字节
    int8_t    scales[QK_K/16]; // 16 个子块的 8-bit 有符号尺度
    ggml_half d;               // 超级块尺度,2 字节
} block_q6_K;
static_assert(sizeof(block_q6_K) == sizeof(ggml_half) + QK_K/16 + 3*QK_K/4,
              "wrong q6_K block size/padding");

字段顺序与 Q4_K 不同:ql、qh、scales 在前,d 在最后。每个权重 6 bit,拆成 低 4 位 + 高 2 位 两个平面:

一个权重 6-bit 码的平面拆分

  码 = 6 bit(0..63)
  ┌────────┬────────┐
  │ 高 2 bit │ 低 4 bit │
  └────────┴────────┘
       │          │
       ▼          ▼
  集中在 qh[64]  分散在 ql[128]
  1 字节装 4 个   1 字节装 2 个
  权重的高 2 位   权重的低 4 位

字节总量:

[
128 \text{(ql)} + 64 \text{(qh)} + 16 \text{(scales)} + 2 \text{(d)} = 210 \ \text{字节}
]

有效位宽:

[
\text{bpw} = \frac{210 \times 8}{256} = 6.5625 \ \text{bit/weight}
]

对比 Q5_K 的 5.5:多出来的 1.0625 bit 中,1 bit 是"高位平面从 1 位变 2 位",剩下的 0.0625 来自子块尺度从 6-bit 升级到 8-bit 分摊到每个权重。

9.1.2 8-bit 有符号子块尺度 scales[16]

Q6_K 的每个子块(16 值)有自己的尺度,但和 Q4_K 有两点关键不同:

  1. 类型是 int8_t(8-bit 有符号),取值范围 -128..127,比 Q4_K 的 6-bit 无符号(0..63)精度高一倍以上;
  2. 没有 dmin、没有最小值偏移。block_q6_K 里根本没有 dmin 字段——它是对称量化:只用一个尺度把值映射到关于 0 对称的码范围,不需要偏移项。

这带来一个直观的好处:结构更简单。Q4_K 要处理"尺度+最小值"两组 6-bit 参数的复杂交错打包(第 7 章 7.1.2),Q6_K 只需要一个 int8_t 数组,每个子块一个整数,没有任何打包。解码时 d · scales[j] 直接就是该子块的有效尺度。

9.2 公式与反量化

9.2.1 (y_i = d \cdot \mathrm{scales}[j] \cdot (q_i - 32))

Q6_K 的反量化公式是:

[
y_i = d \cdot \mathrm{scales}[j] \cdot (q_i - 32)
]

其中 (j = \lfloor i/16 \rfloor) 是子块索引,(q_i \in [0,63]) 是 6-bit 码。减 32 把无符号码平移成关于 0 对称的量:(q_i - 32 \in [-32, 31]),正好覆盖一个近似对称的有符号范围。

与 Q4_K 对比:

Q4_K:   y = d · s_j · q_i − d_min · m_j      (非对称:有最小值偏移)
Q6_K:   y = d · scales[j] · (q_i − 32)       (对称:无偏移,码减 32)

Q6_K 用"码减 32"实现对称化,和 Q4_0 的 (d(q-8)) 思路一样(第 4 章 4.2.2),都是把存储偏移变成解码端的一次整数减法,不消耗额外字节。

9.2.2 反量化的精确位序:ql 低 4 位 + qh 高 2 位

6-bit 码的合成和 Q5_K 类似但更细。以 128 值为一个处理单元,ql 每字节的两个 nibble 属于相隔 64 个位置的两个值,qh 每字节的 4 个 2-bit 组属于相隔 32 个位置(stride-32)的 4 个值。设当前处理单元的起点偏移为 (j)((j = 0) 或 128):

ql[l] 与 qh[l] 的位布局(以第 l 个字节为例)

  ql[l]        ┌──────────────┬──────────────┐
               │ 低 4 位       │ 高 4 位       │
               │ position j+l │ position j+l+64│
               └──────────────┴──────────────┘

  ql[l+32]     ┌──────────────┬──────────────┐
               │ position j+l+32│ position j+l+96│
               └──────────────┴──────────────┘

  qh[l]        ┌─────┬─────┬─────┬─────┐
               │bit0-1│bit2-3│bit4-5│bit6-7│
               │ j+l │j+l+32│j+l+64│j+l+96│
               └─────┴─────┴─────┴─────┘

也就是说,一个位置的 6-bit 码由两个平面共同构成:同一位置的低 4 位在 ql、高 2 位在 qh。例如位置 (j+l) 的码 = ql[l] 的低 4 位 + qh[l] 的 bit0-1,而位置 (j+l+96) 的码 = ql[l+32] 的高 4 位 + qh[l] 的 bit6-7。

ggml 的 dequantize_row_q6_K 依次拼出 4 个 6-bit 码(分别属于 4 个相邻子块),减去 32,乘以各自的 d·scales[j]:

for (int n = 0; n < QK_K; n += 128) {          // 两个 128 值半块
    for (int l = 0; l < 32; ++l) {
        int is = l/16;                          // 子块对索引:0 或 1
        const int8_t q1 = (int8_t)((ql[l+ 0] & 0xF)   | (((qh[l] >> 0) & 3) << 4)) - 32;
        const int8_t q2 = (int8_t)((ql[l+32] & 0xF)   | (((qh[l] >> 2) & 3) << 4)) - 32;
        const int8_t q3 = (int8_t)((ql[l+ 0] >> 4)    | (((qh[l] >> 4) & 3) << 4)) - 32;
        const int8_t q4 = (int8_t)((ql[l+32] >> 4)    | (((qh[l] >> 6) & 3) << 4)) - 32;
        y[l +  0] = d * sc[is + 0] * q1;
        y[l + 32] = d * sc[is + 2] * q2;
        y[l + 64] = d * sc[is + 4] * q3;
        y[l + 96] = d * sc[is + 6] * q4;
    }
    y += 128; ql += 64; qh += 32; sc += 8;
}

读这段代码要注意 qh[l] >> 0 / >> 2 / >> 4 / >> 6:一个 qh 字节的 4 个 2-bit 组,依次属于 stride-32 的 4 个位置((j+l, j+l+32, j+l+64, j+l+96)),而 ql[l] 与 ql[l+32] 的低/高 nibble 负责补齐对应位置的低 4 位。这与 Q5_K"一字节放多个位置的位"是同一套思想,只是每组从 1 位变 2 位、且并入了 ql 的 nibble 主体。

C++(即上面的循环,整理为函数):

// 解码一个 128 值半块(8 个 16 值子块)
// ql: 64 字节低 4 位,qh: 32 字节高 2 位,sc: 8 个 int8 尺度
static void unpack_q6_k_half(const uint8_t * ql, const uint8_t * qh,
                             const int8_t * sc, float d, float * y) {
    for (int l = 0; l < 32; ++l) {
        const int is = l/16;
        const int8_t q1 = (int8_t)((ql[l+ 0] & 0x0F) | (((qh[l] >> 0) & 3) << 4)) - 32;
        const int8_t q2 = (int8_t)((ql[l+32] & 0x0F) | (((qh[l] >> 2) & 3) << 4)) - 32;
        const int8_t q3 = (int8_t)((ql[l+ 0] >> 4)   | (((qh[l] >> 4) & 3) << 4)) - 32;
        const int8_t q4 = (int8_t)((ql[l+32] >> 4)   | (((qh[l] >> 6) & 3) << 4)) - 32;
        y[l +  0] = d * sc[is + 0] * q1;
        y[l + 32] = d * sc[is + 2] * q2;
        y[l + 64] = d * sc[is + 4] * q3;
        y[l + 96] = d * sc[is + 6] * q4;
    }
}

C#(.NET 5+):

static void UnpackQ6KHalf(ReadOnlySpan<byte> ql, ReadOnlySpan<byte> qh,
    ReadOnlySpan<sbyte> sc, float d, Span<float> y)
{
    for (int l = 0; l < 32; l++)
    {
        int is = l / 16;
        sbyte q1 = (sbyte)(((ql[l]       & 0x0F) | (((qh[l] >> 0) & 3) << 4)) - 32);
        sbyte q2 = (sbyte)(((ql[l + 32]  & 0x0F) | (((qh[l] >> 2) & 3) << 4)) - 32);
        sbyte q3 = (sbyte)(((ql[l]  >> 4)        | (((qh[l] >> 4) & 3) << 4)) - 32);
        sbyte q4 = (sbyte)(((ql[l + 32] >> 4)    | (((qh[l] >> 6) & 3) << 4)) - 32);
        y[l]       = d * sc[is + 0] * q1;
        y[l + 32]  = d * sc[is + 2] * q2;
        y[l + 64]  = d * sc[is + 4] * q3;
        y[l + 96]  = d * sc[is + 6] * q4;
    }
}

Python:

def unpack_q6_k_half(ql: bytes, qh: bytes, scales: list[int], d: float) -> list[float]:
    """解码一个 128 值半块(8 个 16 值子块)。

    ql: 64 字节低 4 位平面;qh: 32 字节高 2 位平面;
    scales: 8 个 int8 子块尺度;d: 超级块尺度。
    """
    y = [0.0] * 128
    for l in range(32):
        is_ = l // 16
        q1 = ((ql[l]     & 0x0F) | ((qh[l] >> 0 & 3) << 4)) - 32
        q2 = ((ql[l + 32] & 0x0F) | ((qh[l] >> 2 & 3) << 4)) - 32
        q3 = ((ql[l]     >> 4)   | ((qh[l] >> 4 & 3) << 4)) - 32
        q4 = ((ql[l + 32] >> 4)  | ((qh[l] >> 6 & 3) << 4)) - 32
        y[l]      = d * scales[is_ + 0] * q1
        y[l + 32] = d * scales[is_ + 2] * q2
        y[l + 64] = d * scales[is_ + 4] * q3
        y[l + 96] = d * scales[is_ + 6] * q4
    return y

三种实现逐行对应。Python 里 sbyte 的溢出由"减 32 后已是 [-32,31]"自然处理;C# 显式 sbyte 转换保持与 C++ 的 int8_t 一致。

手工验证:设 d = 1.0,8 个尺度全为 1,ql[l] = 0x00、qh[l] = 0xE4。0xE4 = 1110 0100,逐组读出:bit0-1 = 00(0)、bit2-3 = 01(1)、bit4-5 = 10(2)、bit6-7 = 11(3)。按修正后的位序(stride-32):

  • q1(位置 l):ql[l] 低 4 位 0 + (qh[l]>>0)&3 = 0 → 码 0 | (0<<4) = 0 → q1 = 0-32 = -32;
  • q2(位置 l+32):ql[l+32] 低 4 位 0 + (qh[l]>>2)&3 = 1 → 码 16 → q2 = -16;
  • q3(位置 l+64):ql[l] 高 4 位 0 + (qh[l]>>4)&3 = 2 → 码 0 | (2<<4) = 32 → q3 = 32-32 = 0;
  • q4(位置 l+96):ql[l+32] 高 4 位 0 + (qh[l]>>6)&3 = 3 → 码 0 | (3<<4) = 48 → q4 = 48-32 = 16。
scales = [1] * 8
d = 1.0
ql = bytes([0x00] * 64)
qh = bytes([0xE4] * 32)
y = unpack_q6_k_half(ql, qh, scales, d)
assert y[0]    == 1 * 1 * (-32)
assert y[32]   == 1 * 1 * (-16)
assert y[64]   == 1 * 1 * (  0)
assert y[96]   == 1 * 1 * ( 16)
print("手工验证通过:", y[0], y[32], y[64], y[96])

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

量化端为每个子块先算出一个浮点尺度,再用整个超级块的最大绝对值尺度来归一化:

量化一个超级块(参考实现简写)

  每个 16 值子块:
      scale = make_qx_quants(16, 32, 子块, 码, ...)   ← 子块尺度 + 码
  找全部 16 个子块中 |scale| 最大的:max_scale

  iscale = -128 / max_scale
  d      = 1 / iscale                     ← 超级块尺度(fp16)
  scales[j] = nearest_int(iscale * 子块scale[j])   ← 每个子块存 int8
  码 q = nearest_int(x / (d · scales[j])) + 32     ← 再转成 6-bit

关键在 iscale = -128 / max_scale:它把最大的子块尺度映射到 -128(int8 的最小值),其余子块按比例缩进 int8 范围。解码端 d · scales[j] 就还原出每个子块的有效尺度,不需要任何额外的除法常数——归一化已经在量化端完成。这正是第 6 章 6.2.2 提到的"6-bit 参数除以 63 还是 64"问题在 Q6_K 身上的解答:Q6_K 的尺度是 int8,归一化方式明确且统一,不存在 63/64 的歧义。

9.3 实验与质量

9.3.1 Q6_K 的 PPL 与 KL

Q6_K 相对 F16 的 PPL 劣化约 +0.2%(量级参考,具体随模型与评测集变化)。这个数字使它成为 k-quant 家族里"质量几乎无损"的代表:在很多任务上,Q6_K 的输出与 F16 几乎无法区分。

各档位质量/体积一览(示意,量级参考)

  bpw     4.5       5.5       6.5
  ────────┬─────────┬─────────┬──────────►
  PPL劣化  Q4_K     Q5_K     Q6_K
          ≈+0.8%   ≈+0.4%   ≈+0.2%
  体积     小       中        大
  适用     日常部署  质量敏感  高保真/输出层

用 llama-perplexity 与 F16 基准模型对比时,Q6_K 的 KL 散度也远低于 Q4_K。第 2 章说过,KL 对 logits 的形状更敏感,而 Q6_K 恰恰经常用于 logits 的最终源头(输出层),所以它的 KL 表现尤其值得关注——这正是 9.3.2 的内容。

9.3.2 在混合精度配置中的角色

一个 Q4_K_M 文件内部,Q6_K 张量通常只占少数——典型情况下是 output.weight(有时还有 token_embd.weight),一共一两个张量。但它们对整体输出质量有超比例的影响:

Q4_K_M 文件里的 Q6_K 角色(示意)

  ┌──────────────────────────────────────────┐
  │  绝大多数张量:Q4_K(约 98% 的体积)        │
  │  ┌────────────────────────────────────┐  │
  │  │ attn_q/attn_k/attn_v/ffn_* ...    │  │
  │  └────────────────────────────────────┘  │
  ├──────────────────────────────────────────┤
  │  极少数张量:Q6_K(输出层)               │
  │  ┌────────────────────────────────────┐  │
  │  │ output.weight → 直接决定 logits     │  │
  │  └────────────────────────────────────┘  │
  └──────────────────────────────────────────┘

为什么输出层值得这额外的精度?因为输出层是 logits 的直接来源:最终概率分布 (P(\text{token})) 由 (W_{\text{out}} \cdot h) 经过 softmax 得到。这里的量化误差不会像 FFN 内部那样被后续层"稀释",而是直接变成概率分布的扰动——一个权重差 0.05,就可能改变一个 token 的排序。因此对输出层使用更高位宽,是"把误差预算花在最关键的地方"。

这也呼应了第 3 章 3.2.3 那张混合类型表:output.weight 用 Q6_K,attn_*/ffn_* 用 Q4_K,归一化层用 F32。第 14 章会从敏感度角度系统地分析为什么这样分配。

本章小结与通往 Q2_K/Q3_K 的桥梁

这一章可以压缩成三个事实。

第一,Q6_K 是"16 个子块 × 16 值 + 8-bit 有符号尺度 + 无偏移"的结构。 它把 Q4_K 的"8×32 + 6-bit 尺度 + 最小值偏移"骨架改得更简单也更精细:每个子块一个 int8_t 尺度,没有 dmin,没有复杂的交错打包。块共 210 字节,有效位宽 6.5625 bpw。

第二,反量化公式是 (y = d \cdot \mathrm{scales}[j] \cdot (q - 32))。 6-bit 码拆成低 4 位(ql)+ 高 2 位(qh)两个平面,qh[l] 的 4 个 2-bit 组依次属于 4 个相邻子块。归一化在量化端完成,解码端直接乘 int8 尺度,没有 63/64 歧义。

第三,Q6_K 是输出嵌入层的默认选择。 PPL 劣化约 +0.2%,在 Q4_K_M/Q5_K_M 文件里通常只占一两个张量,但因为输出层直接决定 logits,它对整体质量的贡献超比例地大。

k-quant 家族的位宽梯度

  Q2_K   Q3_K   Q4_K   Q5_K   Q6_K
  ├──────┼──────┼──────┼──────┤
  2.6   3.4    4.5    5.5   6.6   bpw
  │      │      │      │      │
  ←─ 极限压缩 ─→     ←─ 高保真 ─→

下一章我们把目光转向梯度的另一端:Q2_K 和 Q3_K。它们把位宽压到 3 bpw 以下,使用的打包技巧更加密集——Q2_K 甚至把尺度系数和最小值系数各塞进同一个字节的半字节里。那是"再压一点"的故事,也是 k-quant 家族失效边界的所在。

posted @ 2026-10-30 04:21  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报