GGUF-量化内幕-第17章-反量化与矩阵乘性能
GGUF 量化内幕:第17章 反量化与矩阵乘性能
前面第 4–14 章一直在讲一件事:每种量化格式怎么把权重压进更少的字节,又怎么把它们解回来。这一章把视角调转,回答一个务实的问题:不同的格式在端侧跑起来,到底差多少?
先给一个反直觉的结论:在绝大多数端侧设备上,决定模型快不快的根本不是 CPU 或 GPU 的算力,而是"把权重从内存搬到计算单元"的速度。第 1 章讲过端侧部署的"体积—精度—速度"三角,这一章把它落到实处——我们会用 roofline 模型说清楚为什么 tok/s(每秒生成多少 token)几乎完全由内存带宽决定,然后用这个视角解释一个现象:为什么 Q4_K 的尺度解包多出来的那些位运算,通常根本不会拖慢推理;而 IQ1_S 的查表路径,在某些后端上却真的会变成瓶颈。
最后,我们还会给出端侧基准的正确测量方法。平均 tok/s 只是故事的一半——首 token 延迟(TTFT)、冷启动时间和 P95 延迟(长尾延迟)往往才是决定"能不能用"的指标。
先统一几个术语。tok/s(tokens per second):每秒生成的 token 数,衡量生成速度的核心指标。TTFT(Time To First Token):从发出请求到收到第一个输出 token 的时间,衡量"响应感"。P95 延迟:把所有请求的延迟排序,第 95 百分位的那个值——它反映最差的 5% 请求有多慢,是抖动(jitter)的度量。SIMD(Single Instruction Multiple Data):一条指令同时处理多个数据,如一次对 16 个字节做同样的位操作。NEON:ARM 处理器的 SIMD 指令集,手机芯片几乎都有。寄存器(register):CPU 内部最快的存储单元,容量小(通常 32×32 位)。算术强度(arithmetic intensity):每次内存访问配多少算术运算,roofline 模型的核心横轴。
17.1 内存带宽瓶颈下的格式选择
17.1.1 解码阶段的算术强度
先回顾第 1 章讲过的一个物理规律:自回归解码每生成一个 token,几乎要把整个模型的权重从内存读一遍。每一步都要算"下一个 token 是什么",而这需要把各层的权重矩阵乘到当前的激活上。这一步要读的字节数是确定的:
[
\text{每 token 读取量} = \text{参数量} \times \text{每权重字节数}
]
对同一个模型,参数量固定,每权重字节数就是"每权重平均比特数 ÷ 8"(bpw/8)。比如一个 7B 模型用 Q4_K 存(4.5 bpw),每 token 约读 (7\times10^9 \times 4.5/8 \approx 3.9) GB;用 FP16 存(16 bpw),每 token 要读约 14 GB。
关键在于:这个"读一遍权重"的过程,算术量少得可怜。每个权重只参与一次乘加,然后就被丢掉,去读下一批。这就是"算术强度极低"的含义。用 roofline 模型画出来:
roofline 模型:解码落在带宽受限一侧
性能
│ ┌──── 算力上限(FLOPs/s,硬件能算多快)
│ ┌─────┘
│ ┌─────┘
│ ┌─────┘
│────┘ ←── 带宽上限(字节/s,内存能喂多快)
│
└────────────────────────────► 运算强度(FLOPs/字节)
解码任务在这里
(强度极低 → 天花板是带宽)
roofline 模型的意思是:一个计算任务能达到的性能,是"算力上限"和"带宽上限"两条线里较低的那个。解码任务因为算术强度太低,永远撞在带宽这条横线上。这意味着:把 CPU 换得更快、加上更多的浮点单元,对解码速度几乎没有帮助;把内存带宽翻倍,解码速度才会翻倍。
于是解码的理论上限有一个简单公式:
[
\text{理论 tok/s} = \frac{\text{内存带宽(字节/秒)}}{\text{每 token 读取量(字节)}} = \frac{B}{P \times \text{bpw}/8}
]
其中 (P) 是参数量。这个公式有两个立竿见影的推论:
- bpw 越低,理论 tok/s 越高:Q4_K(4.5 bpw)理论上比 FP16(16 bpw)快约 3.5 倍,因为每 token 要读的字节少了 3.5 倍。
- 模型越大,同带宽下越慢:把 3B 换成 7B,每 token 读取量翻倍多,tok/s 相应下降。
这就是为什么量化和端侧部署几乎是一体两面的事——量化不只是为了省存储,更是为了在同样的内存带宽下跑得更快。我们用一个三语言函数把这个公式落地(这是本章最核心的工具):
Python:
def theoretical_tok_s(bandwidth_gb_per_s: float, n_params_b: float, bpw: float) -> float:
"""roofline 理论 tok/s:带宽 ÷ 每 token 读取字节数。
bandwidth_gb_per_s: 端侧实测内存带宽,单位 GB/s
n_params_b: 参数量,单位 B(十亿)
bpw: 每权重平均比特数
"""
bytes_per_token = n_params_b * 1e9 * (bpw / 8.0)
return bandwidth_gb_per_s * 1e9 / bytes_per_token
# 例:一台手机内存带宽约 25 GB/s
print(theoretical_tok_s(bandwidth_gb_per_s=25, n_params_b=3, bpw=4.5)) # ≈ 14.8 tok/s
print(theoretical_tok_s(bandwidth_gb_per_s=25, n_params_b=3, bpw=16.0)) # ≈ 4.2 tok/s
C++:
#include <cstdio>
#include <cstdint>
double theoretical_tok_s(double bandwidth_gb_per_s, double n_params_b, double bpw) {
const double bytes_per_token = n_params_b * 1e9 * (bpw / 8.0);
return bandwidth_gb_per_s * 1e9 / bytes_per_token;
}
int main() {
std::printf("Q4_K (4.5 bpw): %.1f tok/s\n", theoretical_tok_s(25, 3, 4.5)); // ≈ 14.8
std::printf("FP16 (16 bpw): %.1f tok/s\n", theoretical_tok_s(25, 3, 16.0)); // ≈ 4.2
return 0;
}
C#(.NET 5+):
using System;
static class Roofline {
static double TheoreticalTokS(double bandwidthGbPerS, double nParamsB, double bpw) {
double bytesPerToken = nParamsB * 1e9 * (bpw / 8.0);
return bandwidthGbPerS * 1e9 / bytesPerToken;
}
static void Main() {
Console.WriteLine($"Q4_K (4.5 bpw): {TheoreticalTokS(25, 3, 4.5):F1} tok/s"); // ≈ 14.8
Console.WriteLine($"FP16 (16 bpw): {TheoreticalTokS(25, 3, 16.0):F1} tok/s"); // ≈ 4.2
}
}
注意:这是上限。真实 tok/s 一定低于理论值,因为反量化本身要花 CPU 时间、矩阵乘不可能 100% 满带宽、还有缓存、线程同步等各种开销。但它给出了不同 bpw 之间速度差距的量级——这个量级在真实设备上依然成立。
17.1.2 Q4_0 vs Q4_K vs IQ1_S 的真实速度对比
理论公式说"bpw 越低越快",但现实有一个补偿项:反量化/解码本身也要花时间。一个 4.5 bpw 的格式如果解码逻辑极复杂,每读一批字节要额外做一大堆位操作和查表,那么省下的带宽可能被增加的 CPU 时间抵消。
端侧实测的量级大致是(不同模型与设备差异很大,标"约"):
| 格式 | bpw | 相对 FP16 的字节 | 端侧 tok/s 量级(3B 模型,25 GB/s 级带宽) | 解码特点 |
|---|---|---|---|---|
| FP16 | 16.0 | 1× | 约 2–4 | 几乎无反量化开销 |
| Q4_0 | 4.5 | 0.28× | 约 10–15 | 只有 nibble 解包 + 一次乘加 |
| Q4_K | 4.5 | 0.28× | 约 10–15 | 多了 6-bit 尺度解包,但通常隐藏 |
| IQ1_S | 1.5625 | 0.10× | 约 15–25(CPU);部分后端反而更慢 | 查表路径,SIMD 实现难度高 |
这张表里最值得解释的是后面两行:
Q4_K 的尺度解包为什么"隐藏"了。 Q4_K 的 6-bit 尺度解包(get_scale_min_k4,第 7 章)确实比 Q4_0 多不少位操作。但它只在每个 256 值超级块里做 8 次(8 个子块各有 1 个尺度 + 1 个最小值),而 4-bit 码的乘加要对 256 个值各做一次。换句话说:尺度解包是"每块 1 次"的开销,乘加是"每值 1 次"的开销,前者天然被后者摊薄。在矩阵乘的规模下,这个"每块一次"的额外工作在带宽瓶颈面前完全不起眼——矩阵乘大部分时间在等内存,CPU 有空闲去算那点位操作。
IQ1_S 的查表为什么可能成为瓶颈。 IQ1_S 的 bpw 极低(1.5625),理论上应该飞快。但它的解码是"查表":每个 8 值组要用索引去查 iq1s_grid(2048 项的表)。查表在 CPU 上意味着随机访问内存,这恰恰是 CPU 最慢的操作之一——如果表不在缓存里,一次查表可能比十几次乘加还慢。所以 IQ1_S 的速度高度依赖表的缓存命中和 SIMD 查表指令。在实现了查表优化的后端上它飞快;在没实现的朴素后端上,查表开销可能抵消甚至反超低 bpw 的带宽收益。这解释了为什么第 11 章强调"IQ 的 SIMD 实现难度与后端覆盖"——格式的字节数只是上限,实际速度取决于解码路径有没有被优化到硬件上。
17.2 SIMD 与后端覆盖
17.2.1 K-quants 的向量化加载与寄存器布局
理解了"解码是带宽瓶颈"之后,很自然的问题是:解码那点 CPU 开销,怎么做到尽量少? 答案是 SIMD——一条指令同时处理多个值。
以 Q4_K 为例(第 7 章)。它的 qs[128] 正好是 128 字节。在常见的 32 位宽 SIMD 寄存器(NEON 的 q 寄存器)上,128 字节正好可以装进 32 个寄存器(32 × 4 字节)。这意味着数据可以一次性地全部载入寄存器,然后完全在寄存器里做位操作,不需要反复回内存。
用图说明块大小与向量宽度的对齐关系:
Q4_K 的 128 字节 qs 如何装进 32 个 32-bit 寄存器
qs[0..127](128 字节)
┌──────────────────────────────────────────┐
│ 4 字节 │ 4 字节 │ ... │ 4 字节 │
│ reg 0 │ reg 1 │ │ reg 31 │
└──────────┴──────────┴───────┴─────────────┘
每个 4 字节 = 8 个 4-bit 码(每字节 2 个)
→ 32 个寄存器一次装下全部 256 个 4-bit 码
→ 之后所有解包都在寄存器内完成(0 次回内存)
这正是"为什么块大小要跟向量宽度对齐"的意义。llama.cpp 选 256 值超级块、32 值子块,不是随意的(见第 6 章):256 值 × 4-bit = 128 字节,正好是主流 SIMD 向量宽度(128 位 = 16 字节)的整数倍,解码循环可以无尴尬地展开、无浪费地载入。
更细一层,K-quants 的 6-bit 尺度解包也有 SIMD 化路径:get_scale_min_k4 那种"从 12 字节里交错取出 8 个 6-bit 尺度 + 8 个 6-bit 最小值"的位操作,可以用预计算的掩码常量 + 一次 and + 一次位移完成,而不是逐位判断。这就是为什么第 16 章说"参考实现"和"SIMD 实现"是同一份数学的两种写法——SIMD 版本只是把逐位循环换成了向量化位操作。
17.2.2 三值格式的查表与 vqtbl2q/vdotq_s32
如果说 K-quants 的核心是"算术路径",那么三值格式(第 13 章)走的是另一条路:查表路径。以 STQ1_0 为例,它用 3:4 稀疏(每 4 个权重 1 个零)把位宽压到 1.3125 bpw,解码需要从码本索引恢复权重值。
STQ1_0 特别的地方是stride-16 分组(第 13 章详述):它不是把连续的 (w_0, w_1, w_2, w_3) 分为一组,而是在 64 值块内取 (w_0, w_{16}, w_{32}, w_{48})。这个看似奇怪的布局,目的就是让 ARM NEON 的两条指令能"无重排"地完成解码:
vqtbl2q:ARM 的向量查表指令。它接收两个 128 位寄存器作为"表",再接收一个索引向量,一次取出最多 16 个查表结果。STQ1_0 的 32 种模式正好可以放进一张查表,用vqtbl2q一次解码一组。vdotq_s32:ARM 的点积指令。它把两个向量逐元素相乘再累加(每 4 个元素一组)。解码出来的权重直接进点积,无需先落回内存。
用数据流图说明"无重排":
STQ1_0 的 NEON 解码数据流
磁盘字节(qs 码本索引 + sign 符号位)
│
▼
┌─────────────┐
│ vqtbl2q │ 一次查表恢复一组 4 个权重
│(向量查表) │ (stride-16 分组保证 4 个权重
└─────┬───────┘ 在寄存器里就是连续的 4 lane)
▼
┌─────────────┐
│ vdotq_s32 │ 权重向量 × 激活向量 → 点积
└─────┬───────┘ (无需重排、无需回内存)
▼
累加结果
"无重排"是关键:如果权重在磁盘上的顺序和 SIMD 需要的 lane 顺序不一致,就必须先花指令把数据搬正(重排),这一步既费时间又破坏流水线。STQ1_0 把分组顺序直接设计成"查表输出即 lane 顺序",等于把重排省掉了。
但这恰恰是它对 GPU 后端不友好的根源。 上面的两条指令是 ARM CPU 特有的;GPU(如 Metal、CUDA、Vulkan)没有等价的"硬件查表 + 点积"组合。GPU 是 SIMT 模型(大量线程并行),它的"查表"要么靠纹理采样(精度受限),要么靠通用内存访问(慢)。所以 STQ1_0 在 CPU 上设计得很漂亮,搬到 GPU 上却要重新想一套实现,甚至可能根本达不到 CPU 的效率。这就是第 13 章强调的"三值格式必须验证目标后端是否真正支持"的性能版本:一个格式在一个后端快,不代表它在所有后端都快;字节布局是为特定硬件形态设计的。
17.3 端侧基准方法
17.3.1 tok/s、TTFT、冷启动与 P95 延迟
基准测量的第一条教训:不要只看平均 tok/s。平均 tok/s 描述的是"稳定生成阶段"的吞吐,但真实用户感知到的质量还取决于另外三个指标:
一次请求的三个时间切片
用户发出请求
│
▼
┌──────────────────────┬─────────────────────────┐
│ 冷启动 │ 生成阶段 │
│ mmap + 加载 + 预热 │ TTFT → tok 1 → token n │
└──────────────────────┴─────────────────────────┘
↑ ↑
决定"多久能开始" 决定"多快出结果"
冷启动:文件从磁盘加载、权重映射、算子预热
TTFT:请求发出到第一个输出 token 的时间
tok/s:稳定阶段每秒生成的 token 数
P95:延迟排序后第 95 百分位的值(长尾抖动)
- tok/s:稳定吞吐。用
llama-bench或长生成测。 - TTFT(首 token 延迟):对交互式聊天至关重要。用户打出问题后,第一句话多久出来?如果 TTFT 3 秒,即使后续 tok/s 很高,体验也"卡"。
- 冷启动(cold start):进程从零开始到能推理的时间。对端侧 App 来说,用户每次打开都要经历一次冷启动。第 1 章讲过 mmap 加载受缺页影响:第一次访问每个页要读磁盘,第二次就快得多。冷启动和"已缓存后的热启动"可以差一个数量级。
- P95 延迟:把 N 次请求的延迟排序,取第 95 百分位。它捕捉"最差 5% 的请求有多慢"。对端侧尤其重要:芯片调度、温度降频、后台任务抢占都会制造长尾延迟。平均延迟 200ms 但 P95 是 1.2s,对交互式体验就是灾难。
为什么这三个指标都该量?因为它们对应不同的工程问题:tok/s 低 → 换格式/换模型;TTFT 长 → 减预热、提前加载;P95 抖 → 调后端、限制后台竞争。只看 tok/s,你会漏掉后面两个完全不同的故障。
17.3.2 不同芯片组的后端选择与延迟方差
端侧芯片不是一台大而统一的机器,而是各怀禀赋的家族。常见的四大手机 SoC(系统级芯片)家族是:
- Snapdragon(高通):旗舰 GPU 是 Adreno。
- Tensor(谷歌):Pixel 手机专用,GPU 是 Mali(ARM)。
- Dimensity(联发科):GPU 也是 Mali 系。
- Exynos(三星):GPU 既有 Mali 也有自研(AMD 合作)方案。
llama.cpp 在端侧有一个关键选择:用 CPU 推理,还是用 GPU(通过 Vulkan/Metal 等后端)推理? 这个选择对延迟方差的影响极大。实测中,不同芯片组的 GPU delegate 延迟方差可以从约 ±12% 到约 ±52% 的量级——同样是 GPU 后端,有的芯片表现稳定,有的芯片抖动剧烈。原因各不相同:驱动质量、GPU 调频策略、内存控制器争用、后端指令集覆盖等。
这个现象给端侧部署一个方法论级别的结论:后端选择策略本身就是部署质量的一部分。不能只看"CPU 还是 GPU 谁的平均 tok/s 高",还要在同一批请求上测延迟方差:
- 若某芯片的 GPU 后端 tok/s 高但 P95 抖动大,交互式场景可能更适合用 CPU(稳定但慢一点);
- 若某格式在 GPU 后端没有实现(比如三值格式的 Metal 覆盖不完整,见第 13 章),该设备上就必须回退到 CPU 路径——回退路径本身也要基准,因为 CPU 和 GPU 的解码路径可能是两套完全不同的实现。
所以一份合格的端侧基准报告,至少包含这样的表格(示意,数字为量级):
| 设备芯片 | 后端 | 冷启动 | TTFT | 平均 tok/s | P95 延迟 | 方差量级 |
|---|---|---|---|---|---|---|
| Snapdragon 8 | Vulkan GPU | 约 1.2 s | 约 0.4 s | 约 14 | 约 0.6 s | ±12% |
| Tensor G3 | CPU | 约 1.8 s | 约 0.9 s | 约 9 | 约 1.1 s | ±18% |
| Dimensity 9200 | Vulkan GPU | 约 1.5 s | 约 0.5 s | 约 12 | 约 1.4 s | ±35% |
| Exynos 2400 | GPU delegate | 约 2.0 s | 约 0.8 s | 约 10 | 约 2.2 s | ±52% |
(具体数值因模型、格式、版本而变,这里的意义在于展示一套应该采集的指标。)当你在部署前看到这样的表,就知道该把精力花在哪:是换格式降 tok/s 差距,还是换后端治抖动,还是优化冷启动。
本章小结与通向"正确性验证"的桥梁
这一章可以压缩成三个事实。
第一,解码是带宽受限的,不是算力受限的。 自回归解码的算术强度极低,理论 tok/s ≈ 带宽 ÷ (参数量 × bpw/8)。因此 bpw 越低,理论上越快——量化不只是省存储,更是提速。
第二,低 bpw 不等于实际更快。 Q4_K 的尺度解包是"每块一次"的开销,被"每值一次"的乘加摊薄,通常隐藏;IQ1_S 的查表是"每值一次随机访问",在没做 SIMD 优化的后端上可能成为瓶颈。格式的字节数决定理论上限,解码路径的硬件适配决定实际速度。
第三,端侧基准不能只看平均 tok/s。 必须同时测冷启动、TTFT、P95 延迟与延迟方差;不同芯片组(Snapdragon/Tensor/Dimensity/Exynos)的 GPU delegate 方差可以从约 ±12% 到约 ±52%,后端选择本身就是部署质量的一部分。
三句话回顾本章
bpw 越低 → 理论越快 (带宽受限)
解码越复杂 → 实际越可能慢 (CPU 开销)
只看平均 tok/s → 会漏故障 (TTFT/冷启动/P95)
下一章:既然性能和正确性都依赖"解码路径的实现",
怎么证明一个实现是对的?
性能数字再漂亮,如果解码路径是错的,一切都是零。下一章我们进入验证框架:从逐字节的交叉验证(C 与 Python 比对)、到全模型的 PPL/KL 评估、再到三值格式特有的 Hadamard 变换检查——如何确定一个量化实现真的正确。

浙公网安备 33010602011771号