GGUF-量化内幕-第13章-三值量化-STQ1_0-与-PTQ1_0

GGUF 量化内幕:第13章 三值量化:STQ1_0 与 PTQ1_0

前几章的最低压缩记录止步在 2-bit(Q2_K,2.625 bpw)和 IQ 系列(IQ1_M,1.75 bpw)。这一章我们冲向位宽的绝对下限:每个权重只用三种可能值 ({-d, 0, +d})——不是 0/1,不是 0/1/2/3,而是"负、零、正"三个档次。这样的量化格式叫三值量化(ternary quantization)。

直觉上,三值量化能压得很狠。一个权重只需要回答"它是负、是零、还是正",理论上每个权重只需要 (\log_2 3 \approx 1.585) 个比特(这是三选一的最小信息量)。但"信息量 1.585 bit"和"文件里真的只占 1.585 bit/权重"是两回事——把三选一的结果塞进字节,需要巧妙的打包方案;而且零的位置如何编码(是固定分布还是任意出现)直接决定格式的字节开销和推理时的解码复杂度。

更关键的是,这一章要面对一个前面所有格式都没遇到过的、反直觉的事实:有些三值格式在磁盘上是"正确的格式",但直接拿去推理会得到完全错误的输出。这不是格式定义错了,而是格式要求推理时额外做一步数学变换(Hadamard 折叠),缺了这一步,logits 就是错的。

先统一几个术语。三值(ternary):每个权重只能取三个值。trit:三进制的一个数字,取值 0/1/2,就像 bit 是二进制的一个数字。base-3 打包:把多个 trit 塞进一个字节的编码方式。稀疏:权重矩阵中很多位置是零的现象;结构化稀疏:零的位置遵循固定模式(比如每 4 个里恰好 1 个零)。Hadamard 矩阵:一种元素全是 +1/−1 的正交方阵,常用于把权重"旋转"到误差分布更均匀的坐标系。折叠(folding):把权重预先做一次数学变换后再存入文件的过程。

13.1 三值量化的共同约束与不同答案

13.1.1 每个权重取 ({-d, 0, +d}),尺度 (d) 共享

三值量化把每个权重约束为三个可能值之一:

[
w_i \in {-d, 0, +d}
]

其中 (d) 是尺度(scale),通常按块或按组共享——同一块里的所有权重用同一个 (d) 把三值映射回真实量级。这和第 4 章的 Q4_0 共享尺度思想一致,但码位宽从 4 bit 降到了"三值",即每个权重本质上只需要 (\log_2 3 \approx 1.585) bit 的信息。

既然三值只有三个档次,决定"质量"的就不再是码的多少,而是两个问题:

  1. 零的分布:哪些权重被归零?如果权重矩阵本身很多值接近零,把这一大片归零几乎不损失质量;但如果该保留的权重被错误地归零,损失会立刻显现。
  2. 尺度的粒度:(d) 按多大范围共享?尺度越细(块越小),越能适应局部量级;但尺度本身也要花字节。

一句话:三值格式的公式很平凡,难点全在"零放在哪"和"尺度怎么分"。

13.1.2 零的编码方式决定实际 bpw 和运行时

纯三值的理论下限是 (\log_2 3 \approx 1.585) bpw,但实际文件的 bpw 可以在这个值上下浮动,因为"零"不是免费的:

  • 如果零可以出现在任意位置(稠密三值),你就必须记录每个权重是三值中的哪一个,这会逼近 1.585 bpw,但解码要处理"任意位置的三进制"。
  • 如果零的位置被强制成固定模式(如每 4 个权重恰好 1 个零),你就不用单独记录"是否为零"了——模式本身隐含了零的位置,文件可以比 1.585 bpw 更低,但代价是"强制稀疏"可能把不该归零的权重也归了零。

这一章的两个主角恰好分别代表两条路:

格式 零的约束 编码方式 实际 bpw
STQ1_0 每 4 个权重恰好 1 个零(3:4 稀疏) 模式码本(32 种模式,5 bit) 1.3125
PTQ1_0 零任意分布(稠密) base-3 打包 1.75

同一个"三值"前提,因为对零的处理不同,产生了完全不同的文件布局和解码路径。接下来分别看。

13.2 STQ1_0:3:4 稀疏三值

13.2.1 每 4 个权重强制 1 个零:32 种模式与 5-bit 编码

STQ1_0(Sparse Ternary)的核心规则极其简单:每 4 个连续的权重为一组,这 4 个里面恰好有 1 个是零,另外 3 个是 (\pm d)。

为什么是"恰好 1 个零"而不是"任意多个零"?因为这是一个结构化稀疏约束:硬件解码时,每组的非零位置是固定可枚举的,不需要额外的稀疏掩码。4 个位置里选 1 个放零,有 4 种选法;剩下 3 个非零权重各自是正还是负,有 (2^3 = 8) 种符号组合。合起来:

[
4 \times 2^3 = 32 \ \text{种模式}
]

32 种模式恰好需要 (\log_2 32 = 5) bit 编码。这 5 bit 的划分,按实现约定可表述为 4-bit 模式码本索引 + 1-bit 符号:4 bit 足够索引 16 种"零位置 × 部分符号"的组合,再用 1 bit 提供整体符号翻转,拼出完整的 32 种模式。这里的精确位打包规则以具体实现文档为准(STQ1_0 来自研究分支,见 13.5 节),但"32 种模式 = 5 bit"是格式的本质。

STQ1_0 的一组(4 个权重)

  ┌──────┬──────┬──────┬──────┐
  │  w0  │  w1  │  w2  │  w3  │
  └──────┴──────┴──────┴──────┘
    │      │      │      │
    ▼      ▼      ▼      ▼
   零?    ±d     ±d     ±d
  (4 选 1 个位置放零)

  模式数 = 零位置(4) × 符号(2³) = 32
  编码 = 5 bit/组

13.2.2 42 字节/256 值 = 1.3125 bpw

STQ1_0 的块覆盖 256 个权重(与 K-quants 的超级块同尺寸),块布局是:

block_stq1_0 的 42 字节布局

  偏移  0                        32       40   42
        ┌────────────────────────┬────────┬──────┐
        │       qs[32]           │ sign[8]│  d   │
        │   32 字节码本索引       │ 8 字节  │ f16  │
        └────────────────────────┴────────┴──────┘

  qs[32]:256 个权重的模式码本索引
  sign[8]:符号位
  d:fp16 尺度

  总计 = 32 + 8 + 2 = 42 字节 / 256 个权重
  有效位宽 = 42 × 8 / 256 = 1.3125 bit/weight

bpw 的计算过程:

[
\text{bpw} = \frac{42 \times 8}{256} = 1.3125
]

这比纯三值的理论下限 1.585 bpw 还低——原因正是 13.1.2 说的:强制 3:4 稀疏让"零的位置"不必逐权重记录,模式码本用 5 bit 一次编码了"零在哪 + 符号怎么排",摊到 4 个权重上是 1.25 bit/权重,加上尺度开销得到 1.3125。

C++(示意结构体,具体位规则以实现为准):

#define QK_STQ 256

typedef struct {
    uint8_t   qs[QK_STQ / 8];   // 32 字节:模式码本索引
    uint8_t   sign[QK_STQ / 32]; // 8 字节:符号位
    ggml_half d;                 // 2 字节:fp16 尺度
} block_stq1_0;

static_assert(sizeof(block_stq1_0) == 32 + 8 + 2, "wrong stq1_0 block size");

C#(.NET 5+):

using System.Runtime.InteropServices;

public static class STQ1_0
{
    public const int QK_STQ = 256;

    [StructLayout(LayoutKind.Sequential, Pack = 1)]
    public unsafe struct BlockSTQ1_0
    {
        public fixed byte qs[QK_STQ / 8];    // 32 字节
        public fixed byte sign[QK_STQ / 32]; // 8 字节
        public ushort d;                     // fp16 位模式,2 字节
    }

    // sizeof(BlockSTQ1_0) == 42
}

Python:

from dataclasses import dataclass

QK_STQ = 256

@dataclass
class BlockSTQ1_0:
    d: bytes     # 2 字节 fp16 位模式
    qs: bytes    # 32 字节模式索引
    sign: bytes  # 8 字节符号位

    def __post_init__(self):
        assert len(self.d) == 2
        assert len(self.qs) == 32
        assert len(self.sign) == 8

与第 4 章的 Q4_0 一样:C/C# 用结构体直接映射磁盘字节,Python 用 bytes 显式切块。

13.2.3 stride-16 分组与 SIMD 友好性

STQ1_0 有一个反直觉但非常精巧的排列决定:它不把连续的 (w_0, w_1, w_2, w_3) 分进同一组,而是在 64 值块内按步长 16 取四个值组成一组:

[
(w_0,\ w_{16},\ w_{32},\ w_{48}),\quad (w_1,\ w_{17},\ w_{33},\ w_{49}),\quad \dots
]

stride-16 分组示意(64 值块)

  块内 64 个权重,排成 4 行 × 16 列:

        col0  col1  col2  ...  col15
  row0  w0    w1    w2         w15
  row1  w16   w17   w18        w31
  row2  w32   w33   w34        w47
  row3  w48   w49   w50        w63

  分组方式:每一列(同一列的 4 个值)为一组

  组 0 = {w0,  w16, w32, w48}   ← 同一列
  组 1 = {w1,  w17, w33, w49}
  ...

  关键:列索引 = 值在块内的位置 mod 16

为什么这么别扭?答案在 ARM NEON 的向量寄存器布局。用 vqtbl2q(双寄存器查表)从向量寄存器里取"同一列"的四个值,恰好能映射成一条点积指令 vdotq_s32 直接处理的形态,不需要任何重排(permute)操作。如果按连续位置分组,解码时就得先把打乱的权重挪回原位,多花指令。用一张图表示组与 SIMD 的关系:

NEON 向量寄存器视角

  寄存器 R0 = [ w0  w1  ...  w15 ]   (16 个权重)
  寄存器 R1 = [ w16 w17 ...  w31 ]
  寄存器 R2 = [ w32 w33 ...  w47 ]
  寄存器 R3 = [ w48 w49 ...  w63 ]

  vqtbl2q 按"同一列"取组:
  组 i = { R0[i], R1[i], R2[i], R3[i] }

  取出后直接送入 vdotq_s32 做乘加,
  无需把 4 个值搬回连续内存。

一句话:stride-16 分组是"为了让 SIMD 无重排地解码"而设计的,是格式定义的一部分,不是实现细节。

13.2.4 与 TQ1_0/TQ2_0 的位宽对比

llama.cpp 主线在 2026 年前后合入过两个三值格式 TQ1_0 和 TQ2_0(block_tq1_0、block_tq2_0,见 ggml-common.h)。它们的 bpw 与 STQ1_0 形成一张对比表:

格式 约束 块大小 块字节数 bpw(自算)
STQ1_0 3:4 稀疏 256 42 (42\times8/256 = 1.3125)
TQ1_0 base-3 打包 256 54 (54\times8/256 = 1.6875)
TQ2_0 2 bit/权重 256 66 (66\times8/256 = 2.0625)

STQ1_0 选择"更低位宽 + SIMD 友好"(1.3125 bpw),代价是 3:4 稀疏约束把质量不确定性带进了格式:它强制每 4 个权重归零 1 个,这对某些模型是微小损失,对某些任务可能是断崖。TQ 系列走"低实现风险"路线,位宽更高、解码更常规。这张表也预告了 13.4 的主题:三值格式之间的取舍不是"谁更准",而是"谁在你部署的那个模型上还能用"。

13.3 PTQ1_0:稠密三值 + base-3 编码

13.3.1 稠密三值:每个权重独立取三值

与 STQ1_0 的"强制稀疏"相对,PTQ1_0(Prism Ternary)不强制任何位置的零。每个权重独立地取 ({-d, 0, +d}) 之一,零可以出现在任何位置——这就是稠密三值。

"零任意分布"看起来更自由,但它有一个直接后果:不能用"固定模式"来隐含零的位置。每个权重都必须明确记录"我是负、零、还是正"。这就是三进制:每个权重一个 trit(取值 0/1/2),对应符号 ({-1, 0, +1})。

13.3.2 28 字节/128 值 = 1.75 bpw

PTQ1_0 的块覆盖 128 个权重(不是 256),块布局:

block_ptq1_0 的 28 字节布局

  偏移  0                      24       26   28
        ┌──────────────────────┬────────┬──────┐
        │    base-3 数字        │ 余数    │  d   │
        │   24 字节(5 trits/字节)│ 2 字节  │ f16  │
        └──────────────────────┴────────┴──────┘

  24 字节 × 5 trits = 120 个 trit
  2 字节  × 4 trits =   8 个 trit
  合计 trits = 128,恰好覆盖 128 个权重
  d:fp16 尺度

  总计 = 24 + 2 + 2 = 28 字节 / 128 个权重
  有效位宽 = 28 × 8 / 128 = 1.75 bit/weight

为什么每字节装 5 个 trit? 因为一个字节能表示 0–255,而 5 个三进制数字最大是 (3^5 = 243),小于 256——5 个 trit 可以塞进一个字节,还剩一点余量。反过来 6 个 trit 是 (3^6 = 729 > 256),装不下。所以:

[
3^5 = 243 < 256 = 2^8
]

这就是"每字节 5 trits"的数学理由。24 字节装 (24\times5=120) 个 trit,剩下 8 个 trit 用 2 个字节各装 4 个((3^4 = 81 < 256),同样可行),凑齐 128。bpw 计算:

[
\text{bpw} = \frac{28 \times 8}{128} = 1.75
]

13.3.3 base-3 解码:(y = d \cdot \mathrm{trit}_i)

base-3 解码是编码的逆过程。一个字节存的是一个 0–242 的整数,把它反复除以 3 取余数,就能逐位还原出 5 个 trit:

def decode_trits(byte: int, n: int = 5) -> list[int]:
    """把一个字节的 base-3 值解成 n 个 trit(0/1/2)。"""
    trits = []
    v = byte
    for _ in range(n):
        trits.append(v % 3)
        v //= 3
    return trits

每个 trit 再映射到符号 ({-1, 0, +1}),乘上块尺度 (d) 就得到重建值:

[
y_i = d \cdot \mathrm{trit}_i,\qquad \mathrm{trit}_i \in {-1, 0, +1}
]

下面给完整的三语言参考实现(重点是 base-3 打包/解包,这是 PTQ1_0 的核心算法)。

C++:

#include <cstdint>
#include <vector>

// 把 5 个 trit(0/1/2)打包成一个字节(0..242)
uint8_t pack_trits(const int trits[5]) {
    uint8_t v = 0;
    for (int i = 4; i >= 0; --i) {   // 高位在前:v = t0 + 3*t1 + 9*t2 + 27*t3 + 81*t4
        v = (uint8_t)(v * 3 + trits[i]);
    }
    return v;
}

// 把一个字节解成 5 个 trit
void unpack_trits(uint8_t byte, int out[5]) {
    for (int i = 0; i < 5; ++i) {
        out[i] = byte % 3;
        byte   = (uint8_t)(byte / 3);
    }
}

// 反量化一组 128 个权重:y[i] = d * trit_to_sign
std::vector<float> dequantize_ptq1_0(const float d, const uint8_t * bytes, int n_trits) {
    std::vector<float> y(n_trits);
    int trits[5];
    for (int i = 0; i < n_trits; i += 5) {
        unpack_trits(bytes[i / 5], trits);
        for (int j = 0; j < 5 && i + j < n_trits; ++j) {
            y[i + j] = d * (float)(trits[j] - 1); // trit 0→-1, 1→0, 2→+1
        }
    }
    return y;
}

C#(.NET 5+):

using System;

public static class PTQ1_0
{
    // 打包:5 个 trit → 一个字节
    public static byte PackTrits(ReadOnlySpan<int> trits)
    {
        byte v = 0;
        for (int i = 4; i >= 0; --i)
            v = (byte)(v * 3 + trits[i]);
        return v;
    }

    // 解包:一个字节 → 5 个 trit
    public static void UnpackTrits(byte value, Span<int> outTrits)
    {
        for (int i = 0; i < 5; ++i)
        {
            outTrits[i] = value % 3;
            value       = (byte)(value / 3);
        }
    }

    // 反量化:y[i] = d * (trit - 1)
    public static float[] Dequantize(float d, ReadOnlySpan<byte> bytes, int nTrits)
    {
        var y = new float[nTrits];
        var trits = new int[5];
        for (int i = 0; i < nTrits; i += 5)
        {
            UnpackTrits(bytes[i / 5], trits);
            for (int j = 0; j < 5 && i + j < nTrits; ++j)
                y[i + j] = d * (trits[j] - 1);
        }
        return y;
    }
}

Python:

def pack_trits(trits: list[int]) -> int:
    """把 5 个 trit(0/1/2)打包成一个字节(0..242)。"""
    assert all(0 <= t <= 2 for t in trits)
    v = 0
    for t in reversed(trits):   # 高位在前
        v = v * 3 + t
    assert v < 256
    return v

def unpack_trits(byte: int, n: int = 5) -> list[int]:
    """把一个字节的 base-3 值解成 n 个 trit。"""
    trits = []
    for _ in range(n):
        trits.append(byte % 3)
        byte //= 3
    return trits

def dequantize_ptq1_0(d: float, packed: bytes, n_trits: int) -> list[float]:
    """反量化 128 个权重:y[i] = d * (trit - 1),trit∈{0,1,2} → {-1,0,+1}"""
    out = []
    for i in range(0, n_trits, 5):
        trits = unpack_trits(packed[i // 5])
        for t in trits[:n_trits - i]:
            out.append(d * (t - 1))
    return out

三种实现共享同一个关键映射:trit 0→−1、1→0、2→+1。这正是"稠密三值":任意权重都可以是零,不需要稀疏掩码。

13.3.4 Hadamard 折叠权重:格式之外的必要条件

这是 PTQ1_0 最关键的工程区别,值得单独一节反复强调。

普通量化格式(Q4_0、Q4_K、IQ 系列)中,磁盘上存的是"重建权重",推理时反量化得到近似权重矩阵 (W),直接做 (y = W x) 就行。PTQ1_0 不一样:它磁盘上存的不是原始权重的三值化,而是先对权重做了一次 Hadamard 折叠(旋转),再把旋转后的坐标三值化的结果。推理时,你必须先对输入激活施加对应的变换,才能让折叠后的权重发挥作用。

形式化地,PTQ1_0 的推理是:

[
y = W' \cdot \big(H (s \odot P, x)\big)
]

其中:

  • (W') 是磁盘上存储的折叠后权重;
  • (x) 是输入激活向量;
  • (P) 是一个置换矩阵(permutation),把向量元素重排;
  • (s) 是一个 (\pm1) 符号向量(逐元素符号翻转);
  • (H) 是 Hadamard 矩阵(或它的子集)。

如果把存储的 (W') 直接当普通权重用(只做 (y = W' x)),logits 会完全错误——因为 (W') 表达的是"旋转坐标系"里的映射,不在原坐标系里工作。这不是格式实现错误,而是缺了必需的前置变换。

PTQ1_0 的正确推理数据流

  输入激活 x
     │
     ▼
  P(置换)──►  s⊙(逐元素符号)──►  H(Hadamard 变换)
     │                                     │
     │                                     ▼
     │                           旋转后的激活
     │                                     │
     ▼                                     ▼
  W'(折叠权重)◄────────────────── 这里相乘
     │
     ▼
  y(正确的输出 logits)

  任何一个环节缺失(尤其 H 这一步),
  输出的 logits 就是错的。

用一个小例子直观感受"为什么旋转后再三值化更准"。假设一个权重向量的两个分量分别是 1.0 和 −0.01。直接三值化,1.0 变成 +d,−0.01 因为太接近零被归零,丢失了它承载的细微信息。但如果先做一次 Hadamard 旋转,把能量"摊开"到两个分量上,两个分量都变得既不那么极端也不那么接近零,三值化就能保留更多信息。Hadamard 折叠的动机,就是让权重在三值化之前先变成"误差更容易分配"的形态——这与第 2 章讲的"让误差预算向重要分量倾斜"是同一思路的数学实现。

为什么这一节必须单独强调? 因为读者很容易遇到这种情形:拿到一个 PTQ1_0 模型,用某个只实现了反量化、没实现激活侧变换的运行库去跑,输出完全乱码。排错时第一反应通常是"格式定义错了"或"解码字节错了",但实际上字节解码完全正确,缺的只是那个数学变换。部署三值格式时,验证清单里必须包含"激活侧变换已正确应用"这一项(第 18 章还会专门讲怎么验证)。

13.4 STQ1_0 vs PTQ1_0:设计取舍

13.4.1 三值约束、实际位宽、编码方式与运行时依赖的对照

两个格式的工程画像对比如下:

维度 STQ1_0 PTQ1_0
三值约束 3:4 稀疏(每 4 个权重恰好 1 个零) 稠密(任意权重可为零)
块大小 256 值 128 值
块字节数 42 28
实际 bpw 1.3125 1.75
编码方式 模式码本(32 种模式,5 bit/组) base-3 打包(每字节 5 trits)
零的位置 隐含在模式中(结构化) 逐权重记录(trit 之一)
SIMD 友好性 stride-16 分组,vqtbl2q+vdotq_s32 无重排 常规 base-3 解包
额外运行时依赖 无(反量化即重建) Hadamard 激活侧变换(必需)
质量风险 强制稀疏可能误杀权重 稠密更自然,但依赖旋转质量

核心区别一句话:STQ1_0 用"结构化稀疏"换更低的 bpw 和更快的解码;PTQ1_0 用"必须做旋转"换更自由的稠密三值。

13.4.2 质量边界的非线性:为什么不能用 PPL 单指标外推

第 2 章 2.4.1 节讲过三值格式的阈值效应:PPL 劣化不是平滑曲线,而是某个位宽/模型组合下的断崖。这一节把方法论落到两个具体格式上。

研究报道中,PTQ1_0 在 Bonsai-27B 上取得了接近 1.000 的 PPL 比(量化后 PPL 与原始几乎相等)——听起来"三值几乎无损"。但这个数字不能推广到任意模型:

  1. 27B 是一个参数很充足的模型,冗余度高,容得下三值的粗粒度;换成 1B 甚至更小的模型,同样的格式可能完全崩溃。
  2. PPL 比接近 1.000 只说明"整体分布"接近,不说明每个下游任务都保得住。指令跟随、代码生成这类对个别权重敏感的任务,可能仍然明显退化。
  3. 模型是否"三值友好"取决于训练时是否感知三值:为三值设计的模型(BitNet 系)与事后三值的模型,在相同 bpw 下表现天差地别。

所以,评估三值格式必须逐模型验证,而不是拿着一个 PPL 数字外推"再压一点还能用"。这也是第 18 章验证框架里"三值格式必须逐模型验证阈值效应"这一条的由来。

13.5 type id、后端覆盖与部署风险

13.5.1 STQ1_0 与 PTQ1_0 的 id 归属和 fork 依赖

三值格式的生态现实是残酷的:截至 2026 年,STQ1_0 和 PTQ1_0 都不在 llama.cpp 主线(ggml/include/ggml.h 中没有它们的枚举项)。这意味着:

  • 它们的 type id 没有主线注册。文献中对 STQ1_0 有 42/43 两种记载,对 PTQ1_0 记载为 143——这些 id 来自各自的 fork/PR,彼此不统一。
  • 更麻烦的是,主线在 2026 年已经把 41 和 42 分配给了新的 Q1_0、Q2_0 格式。也就是说,即使某个 fork 里"42 = STQ1_0"成立,这个编号在主线里已经指向完全不同的格式。同一 id 在不同构建里指代不同格式,是互操作里最危险的情形(第 19 章会展开互操作矩阵)。

因此,看到模型文件名里有 STQ1_0 / PTQ1_0,必须先确认:

  • 你的运行库是哪个 fork、什么版本,是否注册了该 type id;
  • 该 fork 的 id 分配是否与你持有的文件一致。

不要假设"格式名可识别即可运行"——三值格式必须逐构建验证。

13.5.2 GPU 后端不完整时的回退路径

即便某个构建注册了三值类型,也不代表所有后端都能跑它。实际工程里常见的局面:

  • CPU 有内核:STQ1_0 有 ARM NEON 和 AVX2 的参考/优化内核(stride-16 分组正是为 NEON 设计的);
  • GPU 覆盖不完整:Metal 等后端的支持往往滞后,或只在特定 commit 里可用;
  • PTQ1_0 的额外依赖:Hadamard 激活侧变换依赖额外的 PR 合入,普通构建可能只有"反量化"而没有"激活变换"。

端侧部署的检查顺序:先在目标后端上跑一次短生成(冒烟测试),确认输出合理,再谈性能。只看 CPU 测试通过是不够的——你的手机用的可能是 GPU/Metal 路径,而那条路径可能根本没实现这个格式(第 17 章会讲端侧基准怎么做)。一旦目标后端不支持,回退路径通常是:换同体积的 IQ/传统格式(如 IQ2_XXS 或 Q2_K),或在该后端支持的格式里选一个"已注册且已实现内核"的替代品。

本章小结与通往"混合精度"的桥梁

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

第一,三值量化的共同约束是 (w \in {-d, 0, +d}),公式平凡,难点在"零怎么放"和"尺度怎么分"。 纯三值理论下限约 1.585 bpw,但强制 3:4 稀疏可以把 bpw 压到 1.3125(STQ1_0),任意分布 + base-3 打包则是 1.75(PTQ1_0)。

第二,STQ1_0 用模式码本(32 种模式、5 bit/组)+ stride-16 分组换低 bpw 和 SIMD 无重排解码;PTQ1_0 用稠密三值 + base-3 打包,代价是"必须做 Hadamard 激活侧变换"。

第三,PTQ1_0 的 Hadamard 折叠是"格式之外的必要条件"。 格式字节解码正确 ≠ 推理输出正确;缺了激活侧变换 (y = W'(H(s\odot Px))),logits 完全错误。这是三值部署里最容易被误判的陷阱,第 18 章会给出验证方法。

第四,三值格式的质量边界是非线性的,且生态仍在演进。 STQ1_0/PTQ1_0 不在主线,id 归属未定(42/43/143 与主线的 41/42 冲突),GPU 后端覆盖不完整。任何 PPL 数字都不能外推到任意模型,必须逐模型、逐构建、逐后端验证。

下一章我们要换一个视角:不再追求"某个格式更准",而是问"一个模型里不同张量,该分别用哪种格式"。_S/_M/_L 后缀和逐张量混合精度,就是把"格式选择"上升为"策略选择"。

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