前几章的最低压缩记录止步在 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 的信息。
既然三值只有三个档次,决定"质量"的就不再是码的多少,而是两个问题:
- 零的分布:哪些权重被归零?如果权重矩阵本身很多值接近零,把这一大片归零几乎不损失质量;但如果该保留的权重被错误地归零,损失会立刻显现。
- 尺度的粒度:(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/weightbpw 的计算过程:
[
\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++(示意结构体,具体位规则以实现为准):
#defineQK_STQ256typedefstruct{uint8_tqs[QK_STQ/8];// 32 字节:模式码本索引uint8_tsign[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+):
usingSystem.Runtime.InteropServices;publicstaticclassSTQ1_0{publicconstintQK_STQ=256;[StructLayout(LayoutKind.Sequential,Pack=1)]publicunsafestructBlockSTQ1_0{publicfixedbyteqs[QK_STQ/8];// 32 字节publicfixedbytesign[QK_STQ/32];// 8 字节publicushortd;// fp16 位模式,2 字节}// sizeof(BlockSTQ1_0) == 42}Python:
fromdataclassesimportdataclass QK_STQ=256@dataclassclassBlockSTQ1_0:d:bytes# 2 字节 fp16 位模式qs:bytes# 32 字节模式索引sign:bytes# 8 字节符号位def__post_init__(self):assertlen(self.d)==2assertlen(self.qs)==32assertlen(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:
defdecode_trits(byte:int,n:int=5)->list[int]:"""把一个字节的 base-3 值解成 n 个 trit(0/1/2)。"""trits=[]v=bytefor_inrange(n):trits.append(v%3)v//=3returntrits每个 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_tpack_trits(constinttrits[5]){uint8_tv=0;for(inti=4;i>=0;--i){// 高位在前:v = t0 + 3*t1 + 9*t2 + 27*t3 + 81*t4v=(uint8_t)(v*3+trits[i]);}returnv;}// 把一个字节解成 5 个 tritvoidunpack_trits(uint8_tbyte,intout[5]){for(inti=0;i<5;++i){out[i]=byte%3;byte=(uint8_t)(byte/3);}}// 反量化一组 128 个权重:y[i] = d * trit_to_signstd::vector<float>dequantize_ptq1_0(constfloatd,constuint8_t*bytes,intn_trits){std::vector<float>y(n_trits);inttrits[5];for(inti=0;i<n_trits;i+=5){unpack_trits(bytes[i/5],trits);for(intj=0;j<5&&i+j<n_trits;++j){y[i+j]=d*(float)(trits[j]-1);// trit 0→-1, 1→0, 2→+1}}returny;}C#(.NET 5+):
usingSystem;publicstaticclassPTQ1_0{// 打包:5 个 trit → 一个字节publicstaticbytePackTrits(ReadOnlySpan<int>trits){bytev=0;for(inti=4;i>=0;--i)v=(byte)(v*3+trits[i]);returnv;}// 解包:一个字节 → 5 个 tritpublicstaticvoidUnpackTrits(bytevalue,Span<int>outTrits){for(inti=0;i<5;++i){outTrits[i]=value%3;value=(byte)(value/3);}}// 反量化:y[i] = d * (trit - 1)publicstaticfloat[]Dequantize(floatd,ReadOnlySpan<byte>bytes,intnTrits){vary=newfloat[nTrits];vartrits=newint[5];for(inti=0;i<nTrits;i+=5){UnpackTrits(bytes[i/5],trits);for(intj=0;j<5&&i+j<nTrits;++j)y[i+j]=d*(trits[j]-1);}returny;}}Python:
defpack_trits(trits:list[int])->int:"""把 5 个 trit(0/1/2)打包成一个字节(0..242)。"""assertall(0<=t<=2fortintrits)v=0fortinreversed(trits):# 高位在前v=v*3+tassertv<256returnvdefunpack_trits(byte:int,n:int=5)->list[int]:"""把一个字节的 base-3 值解成 n 个 trit。"""trits=[]for_inrange(n):trits.append(byte%3)byte//=3returntritsdefdequantize_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=[]foriinrange(0,n_trits,5):trits=unpack_trits(packed[i//5])fortintrits[:n_trits-i]:out.append(d*(t-1))returnout三种实现共享同一个关键映射: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 与原始几乎相等)——听起来"三值几乎无损"。但这个数字不能推广到任意模型:
- 27B 是一个参数很充足的模型,冗余度高,容得下三值的粗粒度;换成 1B 甚至更小的模型,同样的格式可能完全崩溃。
- PPL 比接近 1.000 只说明"整体分布"接近,不说明每个下游任务都保得住。指令跟随、代码生成这类对个别权重敏感的任务,可能仍然明显退化。
- 模型是否"三值友好"取决于训练时是否感知三值:为三值设计的模型(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后缀和逐张量混合精度,就是把"格式选择"上升为"策略选择"。