GGUF-量化内幕-第1章-为什么需要-GGUF-量化

GGUF 量化内幕:第1章 为什么需要 GGUF 量化

先从一个数字讲起。一个 7B(70 亿参数)的开源大语言模型,如果用 FP32(每个权重 4 字节)保存,需要约 28 GB 磁盘;换成 FP16(每个权重 2 字节),也要约 14 GB。而一部手机、一台 8 GB 内存的笔记本、一块树莓派,物理上就放不下这样的文件——更不要说在推理时把它读进内存了。

量化(quantization)就是回答这个问题的技术:用更少的比特去表示每个权重,把文件从 14 GB 压到 3–4 GB,同时尽量少地牺牲模型能力。GGUF 是这套做法在 llama.cpp 生态里落地的文件格式——它既规定了"压缩后的权重长什么样",也规定了"这些权重如何存进一个文件、推理时又如何读出来"。

本章不写任何一行复杂的公式(那留给第 2 章),只做三件事:先解释端侧部署到底受什么物理约束,再说清楚"量化"这门语言的最小词汇表,最后给出全书的路线图——量化格式的三次范式跃迁。读完本章,你应该能回答一个问题:为什么量化格式不是越简单越好,也不是越复杂越好,而是要分场景选择?

先统一几个术语。参数(parameter)/ 权重(weight):模型在训练时学到的数字,推理时参与矩阵乘法,是文件体积的绝对大头。推理(inference):用训练好的模型做预测——大语言模型的推理通常是一个字一个字(token)生成的。token:模型处理文本的最小单位,大约是"半个词"或"一个词"。比特(bit):计算机里最小的信息单位,0 或 1;8 个 bit = 1 字节(byte)。带宽(bandwidth):单位时间内能从内存读出多少字节,好比水管每分钟能流过多少水。RSS:进程占用的物理内存总量,衡量"运行时占多少内存"。

1.1 端侧部署的"体积—精度—速度"三角

1.1.1 内存带宽是端侧解码的首要瓶颈

大语言模型的推理有个奇怪的物理规律:模型生成每一个 token,几乎都要把整个模型的权重从内存读一遍。想想它的工作方式——每一步都要计算"下一个 token 是什么",而计算要用到所有层的权重矩阵。权重每次都要从内存搬到计算单元,这个搬运量,与模型大小成正比。

于是关键的问题变成:搬运这些权重,到底受什么限制?

这里引入一个分析工具——roofline 模型(屋顶线模型)。它把处理器上的任何计算画成一条"屋顶线":横轴是运算强度(每次内存搬运配多少算术运算),纵轴是可达性能。屋顶有两条边:左边是带宽受限(内存管子的粗细决定天花板),右边是算力受限(计算单元的速度决定天花板)。你的任务落在哪一边,瓶颈就在哪。

自回归解码的算术强度极低——每个权重大约只做几次乘加运算就要被丢掉,再读下一批。换句话说,解码任务落在 roofline 的带宽受限一侧:

roofline 模型:解码落在带宽受限一侧

  性能
   │                      ┌──── 算力上限(FLOPs/s)
   │                ┌─────┘
   │          ┌─────┘
   │    ┌─────┘
   │────┘  ←── 带宽上限(字节/s)
   │
   └────────────────────────────► 运算强度(FLOPs/字节)
       解码任务在这里
       (强度极低 → 带宽受限)

这个结论的实操含义非常直接:端侧能达到的 tok/s(每秒生成的 token 数),约等于"内存带宽 ÷ 每 token 需要读的字节数"。每 token 需要读的字节数 = 参数量 × 每个权重占的字节数。于是:

  • 模型越小、每权重字节越少 → 每 token 搬运越少 → tok/s 越高;
  • 内存带宽越大(芯片越好)→ tok/s 越高。

这就是为什么量化(减少每权重字节数)能直接换速度,而不仅仅是省空间。用公式写出来:

[
\text{tok/s} \approx \frac{\text{内存带宽(字节/秒)}}{\text{参数量} \times \text{每权重字节数}}
]

举个例子。一款中端手机 SoC 的有效内存带宽量级约 20 GB/s(理论带宽更高,但实际利用率通常只有三四成)。运行一个 3B 模型:

  • Q4_K_M(约 4.5 bpw,即每权重约 0.56 字节):(\frac{20\text{ GB/s}}{3\times10^9 \times 0.56} \approx 11.2\ \text{tok/s})
  • FP16(每权重 2 字节):(\frac{20\text{ GB/s}}{3\times10^9 \times 2} \approx 3.3\ \text{tok/s})(实际更低,约 2 个,因为还有其他开销)

一对比就很清楚:同样的芯片,换一种更省字节的量化格式,速度能差好几倍。这不是玄学,是带宽数学。

用代码把这条公式变成一个小函数,可以随时估算任意模型格式的端侧上限。三种语言实现逻辑完全一致:输入带宽、参数量、每权重比特数,返回理论 tok/s。

C++:

#include <cstdio>

// 带宽单位:字节/秒;参数量:个;bpw:每权重比特数
double theoretical_tok_per_s(double bandwidth_bps, long long n_params, double bpw) {
    const double bytes_per_weight = bpw / 8.0;
    const double bytes_per_token  = n_params * bytes_per_weight;
    return bandwidth_bps / bytes_per_token;
}

int main() {
    const long long n_params = 3e9;             // 3B 模型
    printf("Q4_K_M (4.5 bpw): %.1f tok/s\n", theoretical_tok_per_s(20e9, n_params, 4.5));
    printf("FP16    (16 bpw): %.1f tok/s\n", theoretical_tok_per_s(20e9, n_params, 16));
    return 0;
}

C#(.NET 5+):

using System;

static class TokRate {
    static double TheoreticalTokPerS(double bandwidthBps, long nParams, double bpw) {
        double bytesPerWeight = bpw / 8.0;
        double bytesPerToken  = nParams * bytesPerWeight;
        return bandwidthBps / bytesPerToken;
    }

    static void Main() {
        const long nParams = 3_000_000_000L;          // 3B
        Console.WriteLine($"Q4_K_M (4.5 bpw): {TheoreticalTokPerS(20e9, nParams, 4.5):F1} tok/s");
        Console.WriteLine($"FP16    (16 bpw): {TheoreticalTokPerS(20e9, nParams, 16):F1} tok/s");
    }
}

Python:

def theoretical_tok_per_s(bandwidth_bps: float, n_params: int, bpw: float) -> float:
    bytes_per_weight = bpw / 8.0
    bytes_per_token = n_params * bytes_per_weight
    return bandwidth_bps / bytes_per_token

n_params = 3_000_000_000  # 3B
print(f"Q4_K_M (4.5 bpw): {theoretical_tok_per_s(20e9, n_params, 4.5):.1f} tok/s")
print(f"FP16    (16 bpw): {theoretical_tok_per_s(20e9, n_params, 16):.1f} tok/s")

三种实现输出一致。这个估算的局限也要说清楚:它只算"读权重"这一项,忽略了 KV 缓存、中间激活、解码开销、并行效率等次要因素,所以真实速度通常低于这个上限——但它足够解释"为什么省字节能直接换速度",也足够在不同格式之间做相对比较。

1.1.2 存储占用与冷启动延迟

量化带来的第二个收益是"装得下"。这里要区分两个经常被混为一谈的指标:

  • 文件体积(磁盘占用):模型文件在磁盘/存储上占多少空间,影响下载时间、SD 卡占用、分发成本。
  • 运行时 RSS(内存占用):推理进程实际吃掉的物理内存,影响这台设备能不能跑起来、会不会被杀进程。

GGUF 的一个巧妙设计是用 mmap(内存映射) 加载:把文件直接映射进内存,需要哪块读哪块,不必把整个文件先复制进内存。这带来的一个结果是:冷启动(冷启动 latency,从点击到能产出第一个 token)主要取决于"首次读入"的缺页开销和页缓存状态,而不是文件大小本身。对于 3B 模型,一个典型的端侧对比(不同来源的实测量级,取约数)是:

指标 Q4_K_M(约 4.5 bpw) FP16
文件体积 约 1.7 GB 约 6.0 GB
运行时 RSS 约 2.1 GB 约 7.2 GB
端侧 tok/s 约 11.2 约 2.1
冷启动 明显更快 明显更慢

注意三个观察:第一,Q4_K_M 用不到 1/3 的体积,跑出 5 倍以上的速度。第二,RSS 比文件体积略大——因为推理还要给 KV 缓存、中间激活腾内存,这些不全是权重。第三,冷启动的差异不仅来自文件大小,还来自 mmap 的页缓存效率:文件小,能更快被缓存命中。

1.1.3 为什么"一种格式"打不了天下

如果 Q4_K_M 这么好,那所有模型都用它不就行了?不行,因为场景对"精度 vs 体积"的取舍完全不同:

  • 手机、平板:内存可能只有 4–8 GB,必须用 <2 bpw 的极限压缩(IQ 系列甚至三值格式),哪怕牺牲质量,目标是"跑得动"。
  • 工作站、服务器:内存 64 GB 起步,可以承受 Q5_K 甚至 Q8_0 的"奢侈",目标是"质量尽量接近原版"。
  • 调试、研究、对比:需要 Q8_0 甚至 F16 作为"参考基线"——拿它衡量别的格式到底损失了多少。

用一个比喻:你不可能用一把刀既切西瓜又剔牙。单一格式的困境在于,无论选哪个位宽,都会在某个场景里要么浪费空间、要么牺牲质量。

GGUF 的答案不是"折中出一个万能位宽",而是把选择权下放到每个张量:同一个文件里,注意力层可以用 Q4_K,输出层可以用 Q6_K,归一化层干脆保留 F32。这正是第 3 章要讲的容器机制,也是第 14 章"逐张量混合精度"的由来。

1.2 量化术语的最小共识

1.2.1 块、超级块与 bpw 的定义

进入正文前,先把三个最常出现的词钉死。

块(block):一组共享一个量化尺度参数的权重集合。为什么需要"块"?因为整个张量的数值分布不均匀(大多接近零、少数很大),用一个全局尺度会把小值都压成零(第 4 章会详细证明)。于是把张量切成连续的若干组,每组单独算自己的尺度。Q4_0 的块是 32 个权重,Q8_0 也是 32 个。

超级块(super-block):K-quants 系列使用的一种更大单元,包含 256 个权重,内部再分成 8 个 32 值的子块。超级块拥有"全局尺度",子块拥有"局部尺度 + 局部最小值"——这是第 6、7 章的主题。

bpw(bits per weight,每权重比特数):衡量压缩程度的核心指标。它的计算方法有严格定义:

[
\text{bpw} = \frac{\text{文件/块字节数} \times 8}{\text{权重数}}
]

注意:bpw 不是"量化码的位宽"。比如 Q4_0 的码确实是 4 位,但每个 32 值块还要存 2 字节的尺度 d,所以实际 bpw 是 (\frac{18 \times 8}{32} = 4.5)。多出来的 0.5 bit/权重,就是尺度、最小值、码本索引这些"额外开销"摊到每个权重上的结果。任何格式的 bpw 都要按字节数算,而不是按"听起来是几比特"算。

1.2.2 尺度、最小值偏移与零点

量化本质上是用有限个离散值去"代表"连续的数。怎么安排这些离散值,有三种常见参数化方式:

  1. 对称块量化(只有尺度),如 Q4_0、Q8_0:反量化公式是 (y = d \cdot q),其中 (d) 是尺度,(q) 是有符号码。离散值围绕 0 对称分布。
  2. 非对称块量化(尺度 + 最小值),如 Q4_K 的子块:公式是 (y = d \cdot q - m),多了一个最小值/偏移 (m),让离散值可以整体平移,从而更贴合"最小不为负、最大不对称"的分布。
  3. 显式零点(zero point),如 Q4_1:显式存一个"零点",公式 (y = d(q - z)) 或 (y = d \cdot q + m),零点单独占字节。

三者在表示能力和字节开销上有区别:对称最省字节但要求分布近似对称;非对称/显式零点能多表示一个偏移,但要多花字节。

1.2.3 对称、非对称与"伪对称"——一个常见混淆

这里有一个新手最容易绕晕的地方。看两个都带"偏移"的格式:

  • Q4_0 的 q - 8:Q4_0 存的是无符号 4-bit 码 (q \in [0, 15]),反量化时做 (y = d(q-8))。这个"减 8"只是解码端的固定平移,把无符号码变成近似的有符号码。它不增加任何参数开销——没有任何地方存这个 8,它是写死在代码里的。所以 Q4_0 在数学上可以看成"零点固定为 8"的格式,但它不占字节。
  • Q4_K 的 d_{\min} \cdot m_j:这是逐子块学习出来的真实偏移。每个 32 值子块有一个 6-bit 的 (m_j),配合全局的 (d_{\min}) 形成子块自己的最小值。这个偏移需要额外字节(存在 scales 数组里),而且每个子块都不同。

一句话总结区别:"q-8"是免费的固定偏移,"dmin·m_j"是收费的逐块学习偏移。这一区别直接决定了两种格式的误差结构(第 4、7 章会展开)。

1.3 GGUF 在 llama.cpp 工具链中的位置

1.3.1 转换、量化、mmap 加载、推理:四阶段流水线

从 Hugging Face 上的一组权重文件,到你的手机上能跑,中间是一条清晰的流水线:

四阶段流水线

  Hugging Face (safetensors / PyTorch)
        │
        ▼
  ① 转换  convert_hf_to_gguf.py
        │  只做"容器转换",不做量化:把权重搬进 GGUF 容器
        ▼
  F16 GGUF 文件(约 14 GB,无损基线)
        │
        ▼
  ② 量化  llama-quantize(可选 + llama-imatrix 生成重要性矩阵)
        │  把每个张量压成目标格式,如 Q4_K_M
        ▼
  量化后 GGUF 文件(约 3.8 GB)
        │
        ▼
  ③ 加载  llama.cpp 运行时 mmap
        │  映射文件、读元数据、按张量类型分派解码路径
        ▼
  ④ 推理  逐 token 生成

四个阶段各自负责一个"决策点":

  • convert_hf_to_gguf.py 决定张量命名与容器结构(第 3、15 章);
  • llama-imatrix 决定"哪些权重更重要"——这是 IQ 系列格式的前提(第 11、15 章);
  • llama-quantize 决定每个张量用什么格式,即"混合精度策略"(第 14、15 章);
  • 运行时 决定"每个 ggml_type 怎么解码、怎么上后端"(第 16、17 章)。

1.3.2 GGUF 与 safetensors、PyTorch 的分工边界

GGUF 不是训练格式,也不是通用交换格式。它和另外两个格式的关系需要澄清:

  • PyTorch / safetensors:训练和微调使用的格式。它们的目标是"灵活地保存任意张量、支持自动求导图的重建",体积和加载速度不是首要考虑。
  • GGUF:推理格式。它的目标是在三个约束下做到最优——mmap 友好(可以零拷贝加载)、逐张量类型分派(每个张量能选自己的量化格式)、端侧推理(体积小、解码快)。

GGUF 的一些"奇怪之处"——POD 结构体、固定对齐、type id 硬编码——都不是巧合,而是这三个约束的直接产物。第 3 章会逐一解释:为什么结构体必须是 POD(为了 mmap 后能直接当内存结构体用)、为什么有对齐(为了让每个张量从对齐位置开始)、为什么 type id 是硬编码(为了运行时快速分派解码函数)。

一句话:safetensors 是"仓库",GGUF 是"战时补给箱"——一个为了长期保存、一个为了快速装卸。

1.4 量化范式的三次跃迁

全书的叙事主线是:量化格式不是一堆并列的选项,而是沿着"解决上一个范式的失效"这条线演进的。三次跃迁分别对应三组格式。

1.4.1 第一范式:块共享尺度的仿射量化(Q4_0 / Q8_0)

最朴素的做法:每个块(32 个权重)共享一个 FP16 尺度 d,权重存成 4-bit 或 8-bit 码。它的优点是实现简单、解码极快(只有位操作和一次乘法),缺点是块内异质性会惩罚小值——一个块里同时有大幅值和小幅值时,尺度被大值主导,小值全部塌缩成同一个码(第 4 章)。

第一范式:块共享尺度

  ┌─────────────┐
  │  32 权重共享 │  ← 一个尺度 d,一个公式 y=d·(q-8)
  │  1 个尺度 d  │
  └─────────────┘
  简单,但块内异质时小值被牺牲

1.4.2 第二范式:超级块内的层次化尺度与最小值(K-quants)

第二范式不是换码位宽,而是改尺度的结构:把 256 个权重放进一个超级块,超级块有全局尺度 d 和全局最小值尺度 d_min,内部 8 个 32 值子块各自有 6-bit 尺度和 6-bit 最小值。误差预算从"一个尺度"变成"全局尺度 + 子块修正"。

第二范式:超级块内的层次化尺度(K-quants)

  ┌──────────────────────────────────────────┐
  │ 超级块(256 值):d、d_min                │
  │  ┌────┬────┬────┬────┬────┬────┬────┬──┐ │
  │  │子块0│子块1│...│子块7│   每个子块:       │
  │  │ s₀  │ s₁  │   │ s₇  │ 6-bit 尺度+最小值│
  │  └────┴────┴────┴────┴────┴────┴────┴──┘ │
  └──────────────────────────────────────────┘
  代价:多存 12 字节的参数;收益:块内异质不再由单一尺度承担

这解决了第一范式的核心失效:块内异质性不再让单一尺度"背锅"。代价是解码多了一层解包(第 6、7 章)。

1.4.3 第三范式:重要性加权码本与三值结构化稀疏(IQ / STQ1_0 / PTQ1_0)

当位宽压到 2 bpw 以下,第二范式也触顶了——均匀的码字覆盖不了重尾分布,层次化尺度也救不了。第三范式用两个新武器突破:

  • 码本(grid)+ 重要性矩阵(imatrix):不再用"尺度 × 码"的算术公式,而是直接查一张预先设计好的非线性表(码本),并且让量化误差按"权重对输出的影响大小"加权(IQ 系列)。
  • 三值 + 结构化稀疏:把权重硬约束到 ({-d, 0, +d}),用模式编码或 base-3 编码存储(STQ1_0 / PTQ1_0)。
第三范式:码本查表 + 三值稀疏

  第二范式:  y = d·s_j·q − d_min·m_j        ← 算术反量化
  第三范式:  y = d · grid[index]            ← 码本查表
             y = d · trit (∈{-1,0,+1})      ← 三值

这一跃迁的代价是工程复杂度爆炸:查表在 SIMD 上比算术难实现、必须提供 imatrix、部分后端覆盖不全——这些将在第 11、13 章反复出现。

本章小结与通往数值基础的桥梁

本章可以压缩成三个事实。

第一,端侧解码受内存带宽限制,量化直接换速度。 roofline 模型告诉我们自回归解码是带宽受限任务,于是"每权重字节数"既是体积指标也是速度指标。Q4_K_M 用不到 FP16 三分之一的字节,换来端侧几倍以上的 tok/s。

第二,量化语言的最小词汇表是"块、超级块、bpw、尺度、偏移"。 bpw 按"字节数 × 8 / 权重数"算,不是按码位宽算;Q4_0 的 q-8 是免费固定偏移,Q4_K 的 d_min·m_j 是收费的逐块学习偏移。

第三,GGUF 不是单一格式,而是"逐张量选择格式"的容器,位于转换→量化→加载→推理的四阶段流水线中间。 三次范式跃迁(块尺度 → 层次化尺度 → 码本/三值)构成了全书的骨架:每一次都在解决上一次的失效,也都付出了新的代价。

下一章,我们要开始"算账"了。量化到底损失了多少信息?误差从哪里来、怎么衡量?第 2 章会给出贯穿全书的分析工具:浮点表示、量化通式、误差分解,以及 PPL、KL、TTFT 这些评价指标——它们是后面每一个实验对比的裁判。

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