GGUF-量化内幕-第15章-量化工具链与工作流
GGUF 量化内幕:第15章 量化工具链与工作流
从第 3 章到第 14 章,我们一直在研究"格式"本身:Q4_0 的 18 字节块怎么解码,Q4_K 的 6-bit 尺度怎么交错打包,IQ1_S 的码本怎么查表,STQ1_0 的 3:4 稀疏是怎么一回事。但你可能会问一个非常实际的问题:这些格式,到底是怎么从一个 Hugging Face 上的模型变成我手机里能跑的那个 .gguf 文件的?
这一章回答这个问题。它把前文的所有格式落到一条可执行、可复现的命令流水线上:从 safetensors 权重出发,先转换成 F16 GGUF,再生成重要性矩阵(imatrix),再量化成目标格式,最后做验证和部署前检查。这条流水线用到的工具不多——主要是 convert_hf_to_gguf.py、llama-quantize、llama-imatrix、llama-perplexity 这几个,但每一步之间都有必须遵守的依赖关系,尤其是 IQ 系列和三值格式,它们对工具链有额外的要求。
先看整条流水线的全貌:
从 Hugging Face 权重到端侧部署的完整流水线
safetensors / PyTorch 权重
┌───────────────────────────────┐
│ ① convert_hf_to_gguf.py │ ← 只做格式转换,不量化
│ 模型被"翻译"成 GGUF 容器 │
└──────────────┬────────────────┘
▼
model-f16.gguf(F16 基线)
│
┌───────────┴───────────┐
│ ② 需要 IQ 系列吗? │
│ ┌──────────────────┐ │
│ │ ③ llama-imatrix │ │ ← 生成权重重要性矩阵
│ │ imatrix.dat │ │ (IQ 量化必需)
│ └────────┬─────────┘ │
└──────────┼────────────┘
▼
④ llama-quantize model-f16.gguf \
model-q4_k_m.gguf Q4_K_M \
--imatrix imatrix.dat ← 有则传入
│
▼
model-q4_k_m.gguf(量化产物)
│
┌──────────┴──────────┐
│ ⑤ 验证与部署前检查 │
│ llama-perplexity → PPL/KL
│ gguf-py → 类型分布检查
│ llama-cli → 冒烟生成
└─────────────────────┘
这一章的很多内容是对前文知识的"落地打包":第 11.2 节提过 imatrix 的数学形式,第 14 章讲过 _M 是混合策略,这里把它们串成可以照着敲的命令。先统一几个术语。safetensors:Hugging Face 生态的权重存储格式,把每个张量以二进制形式存进一个文件。calibration data(校准数据):一批用于"观察"模型行为的文本,imatrix 和量化误差评估都会用到它——它不是训练数据,只是让量化器知道真实输入长什么样。PPL(perplexity,困惑度):衡量模型预测能力的指标,越低越好;量化后 PPL 相对基线的抬升越小,质量损失越小。KL 散度:衡量两个概率分布(这里指量化前后模型的输出分布)差异的指标。冒烟测试(smoke test):一次极短的运行,用来确认"能跑起来、没有明显错误",而不是做完整评测。
15.1 转换与量化基础命令
15.1.1 convert_hf_to_gguf.py:从 safetensors 到 F16 GGUF
流水线的第一步是转换,但它不做量化。convert_hf_to_gguf.py 是 llama.cpp 仓库里的一个 Python 脚本,它的工作是:读取 Hugging Face 目录里的 safetensors(或 PyTorch checkpoint)权重,把它们搬进 GGUF 容器,并为每个张量写好第 3 章讲的那些 tensor_infos(名字、维度、类型、偏移),同时写入模型架构、词表等元数据。
最基本的用法:
python convert_hf_to_gguf.py <hf-dir> --outfile model-f16.gguf --outtype f16
<hf-dir>:包含config.json、model.safetensors的 Hugging Face 模型目录。--outfile model-f16.gguf:输出文件名(不写就默认用模型名)。--outtype f16:输出类型。默认就是 f16,也就是说转换阶段输出的通常是一个 F16 GGUF——每个权重仍是 2 字节浮点,没有量化。
这里有一个常常被新手忽略的关键点:这个 F16 GGUF 不是部署用的产物,而是后续所有量化的"原料"。为什么不能直接从 safetensors 量化?因为量化工具统一吃 GGUF 输入,而且 F16 是一个"无损中间格式"——从 F16 再量化,和从原始浮点量化,数值几乎一致(F16 对权重本身已经足够精确,见第 2 章)。更重要的是,imatrix 也需要这个 F16 GGUF 作为输入来跑模型。所以流水线里永远存在一个 model-f16.gguf,后面所有命令都指向它。
为什么转换阶段不做量化?
safetensors ──convert──► model-f16.gguf ──quantize──► model-q4_k_m.gguf
(FP32/FP16) 只搬容器 (原料) (真正量化)
▲
llama-imatrix 也吃这个文件做校准
第 3 章讲过 GGUF 是"容器":convert 只负责装箱,
量化是装完箱之后、单独用 llama-quantize 做的一步。
重要概念需要附带代码。 转换是"搬容器",对编译型语言来说最直观的等价物是一个"读入权重目录、写出 GGUF header + tensor_infos + 数据"的小工具。下面用三种语言各写一个最小示例——它们做的事情相同:接收一系列"名字 + 浮点数组",写成一个合法的 F16 GGUF 文件头和数据段。注意这里展示的是概念骨架(真正 convert_hf_to_gguf.py 还要做张量命名映射、架构检测、词表处理),但足以说明"容器转换"和"量化"是两回事。
C++:
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
// 一个最小 GGUF 写入器:只支持 F16 张量
void write_gguf_f16(const char * path,
const std::vector<std::string> & names,
const std::vector<std::vector<float>> & tensors) {
FILE * f = fopen(path, "wb");
// header:magic "GGUF" + version 3 + tensor_count + kv_count
uint32_t magic = 0x46554747; // "GGUF"
uint32_t vers = 3;
uint64_t tc = tensors.size();
uint64_t kc = 0;
fwrite(&magic, 4, 1, f);
fwrite(&vers, 4, 1, f);
fwrite(&tc, 8, 1, f);
fwrite(&kc, 8, 1, f);
// tensor_infos:name + n_dims + dims[] + type + offset
// type 1 = GGML_TYPE_F16;offset 相对数据段起点
uint64_t offset = 0;
for (size_t i = 0; i < tensors.size(); i++) {
uint64_t nlen = names[i].size();
fwrite(&nlen, 8, 1, f);
fwrite(names[i].data(), nlen, 1, f);
uint32_t n_dims = 1; // 假设一维
uint64_t dim0 = tensors[i].size();
fwrite(&n_dims, 4, 1, f);
fwrite(&dim0, 8, 1, f);
uint32_t type = 1; // F16
fwrite(&type, 4, 1, f);
fwrite(&offset, 8, 1, f);
offset += tensors[i].size() * 2; // F16 = 2 字节/权重
}
// 对齐到 32 字节后写数据(这里假设已对齐,略去 padding)
for (size_t i = 0; i < tensors.size(); i++)
for (float v : tensors[i])
fwrite(/* FP32→FP16 编码后 */ " ", 2, 1, f); // 示意
fclose(f);
}
C#(.NET 5+):
using System;
using System.IO;
using System.Text;
static class GgufWriter {
public static void WriteF16(string path, string[] names, float[][] tensors) {
using var w = new BinaryWriter(File.Create(path), Encoding.UTF8);
w.Write(Encoding.ASCII.GetBytes("GGUF"));
w.Write((uint)3); // version
w.Write((ulong)tensors.Length); // tensor_count
w.Write((ulong)0); // kv_count
ulong offset = 0;
for (int i = 0; i < tensors.Length; i++) {
byte[] nameBytes = Encoding.UTF8.GetBytes(names[i]);
w.Write((ulong)nameBytes.Length);
w.Write(nameBytes);
w.Write((uint)1); // n_dims
w.Write((ulong)tensors[i].Length); // dims[0]
w.Write((uint)1); // type = F16
w.Write(offset); // 相对数据段偏移
offset += (ulong)tensors[i].Length * 2;
}
// 数据段:每个 float 先转成船村 16 位位模式再写(示意,见下)
for (int i = 0; i < tensors.Length; i++)
foreach (float v in tensors[i])
w.Write(BitConverter.HalfToUInt16Bits((Half)v)); // F16 2 字节
}
}
HalfToUInt16Bits 把 Half 转成它的 16 位位模式——这就是 F16 在磁盘上的样子,和 llama.cpp 的 ggml_half 完全一致。
Python(struct + array,最接近官方脚本的思路):
import struct
def write_gguf_f16(path: str, names: list[str], tensors: list[list[float]]) -> None:
with open(path, "wb") as f:
# header(小端)
f.write(struct.pack("<I", 0x46554747)) # "GGUF"
f.write(struct.pack("<I", 3)) # version
f.write(struct.pack("<Q", len(tensors))) # tensor_count
f.write(struct.pack("<Q", 0)) # kv_count
# tensor_infos
offset = 0
for name, tensor in zip(names, tensors):
name_b = name.encode("utf-8")
f.write(struct.pack("<Q", len(name_b)))
f.write(name_b)
f.write(struct.pack("<I", 1)) # n_dims
f.write(struct.pack("<Q", len(tensor))) # dims[0]
f.write(struct.pack("<I", 1)) # type = F16
f.write(struct.pack("<Q", offset)) # 相对数据段偏移
offset += len(tensor) * 2
# 数据段:FP32 → FP16 位模式('<e' 是小端半精度)
for _, tensor in zip(names, tensors):
f.write(struct.pack(f"<{len(tensor)}e", *tensor))
三份代码的字段逐一对应,写出来的都是 "GGUF" | version=3 | tensor_count | kv_count | 目录 | 数据 的合法 v3 文件。注意转换阶段从头到尾只有 <Q/<I/<e 三种打包,没有任何 quantize——这就是"转换不等于量化"在代码层面的证据。
15.1.2 llama-quantize:格式选择与输出
真正做量化的是第二步:llama-quantize。用法是 input output type 三段式:
./llama-quantize model-f16.gguf model-q4_k_m.gguf Q4_K_M
model-f16.gguf:15.1.1 产出的 F16 原料。model-q4_k_m.gguf:输出文件名。Q4_K_M:目标格式名。
敲回车后,llama-quantize 会逐个张量地调用第 4–14 章讲的那些参考量化函数(quantize_row_q4_K_ref 等),把 F16 数据压成目标和每个张量的 ggml_type。如果目标格式是 Q4_K_M,内部实际的分配就是第 14 章讲过的那套混合规则:主体张量量化成 Q4_K,输出层用 Q6_K,归一化层保持 F32。
这里必须再强调一次第 3、14 章反复出现的结论:Q4_K_M 是一个策略名(量化策略),不是一个 ggml_type。ggml_type 是文件里每个张量 type 字段填的单一类型编号(如 GGML_TYPE_Q4_K = 12);Q4_K_M 是 llama-quantize 认识的一个目标格式名,它背后是"对每类张量分别选类型"的规则。可以这样对照:
Q4_K_M 不是一种 ggml_type
文件名目标: Q4_K_M (llama-quantize 的参数)
│
内部逐张量分配 ────┬─────────────────────────────────┐
▼ ▼
主体张量: Q4_K (type id 12) 输出层: Q6_K (14) 归一化: F32 (0)
(这些才是文件里真正出现的 ggml_type)
llama-quantize 支持很多目标格式,常用的大致分三类,正好对应本书的三个档次:
| 目标格式 | 说明 | 对应本书章节 |
|---|---|---|
F16 / F32 |
不量化或几乎不量化,只做容器重排 | 第 3 章 |
Q4_0、Q8_0 |
简单块量化,实现最简单 | 第 4、5 章 |
Q2_K…Q6_K 及 _S/_M/_L |
K-quants 家族与混合策略 | 第 6–10、14 章 |
IQ1_S、IQ2_XXS、IQ4_NL 等 |
IQ 码本量化,需要 imatrix | 第 11 章 |
还可以通过 -t 指定线程数、-a 指定架构等。但对本书读者最重要的选项不是这些,而是下一节的主角:--imatrix。
15.2 imatrix 与 IQ 量化工作流
15.2.1 校准数据准备与 llama-imatrix 参数
在讨论 IQ 量化之前,先明白一个前提:K-quants 不需要 imatrix,IQ 需要。 Q4_K 的量化器只根据权重本身(块的最大值、最小值、分布)决定尺度和码;而 IQ 系列的量化器在"权重的绝对值"之外,还要参考"这个权重对模型输出有多重要"。衡量重要性的矩阵就叫 imatrix(importance matrix,重要性矩阵)——它是第 11.2 节里那个加权最小二乘目标中的权重 (\alpha_i) 的具体形态。
imatrix 不能凭空生成,需要跑一遍模型:读一批文本进入模型,观察每个权重在计算中实际被多大的激活值流过,据此累计出每个权重的重要性。生成它的工具是 llama-imatrix:
./llama-imatrix -m model-f16.gguf -f calibration.txt -ngl 99 --chunks 512 -c 2048 -o imatrix.dat
各参数的含义:
| 参数 | 含义 | 典型值 |
|---|---|---|
-m |
输入模型,必须是 F16 GGUF(原料) | model-f16.gguf |
-f |
校准数据文件(纯文本) | calibration.txt |
-b |
批大小(每次前向处理多少 token) | 512 |
-c |
上下文长度(每次处理的序列多长) | 2048 |
--chunks |
校准文本块数(重复次数总量) | 512(越大越准、越慢) |
-ngl |
有多少层放到 GPU 上跑 | 99(等于全量放 GPU) |
-o |
输出 imatrix 文件 | imatrix.dat |
校准数据的选择直接决定 imatrix 的质量,进而决定 IQ 量化的质量。两个要点:
- 规模要够。
--chunks太小,累计的重要性统计噪声大,量化会不稳定。实践经验中,几百个 chunk(每 chunk 一个上下文长度)是常见起点。 - 领域要匹配部署场景。如果模型部署后主要处理中文对话,校准数据最好也是中文对话文本,而不是英文维基百科。原因很朴素:imatrix 衡量的是"哪些权重对这个输入分布更重要",输入分布偏了,重要性就偏了。llama.cpp 官方建议校准文本覆盖多种风格(
--file可以多次传入不同文件),理想情况下含代码、对话、文章等混合内容。
对 IQ 系列,这个文件几乎决定了上限——同样一个模型、同样一个目标格式,用不用 imatrix、用什么样的校准数据,量化后的 PPL 可以拉开明显差距。所以它不是一个"可选的优化项",而是 IQ 工作流的事实上的标准环节。15.2.2 会看到它在量化时被显式传入。
15.2.2 --imatrix 在 llama-quantize 中的传入
生成 imatrix.dat 之后,量化时用 --imatrix 把它交给 llama-quantize:
./llama-quantize model-f16.gguf model-iq2_xxs.gguf IQ2_XXS --imatrix imatrix.dat
在量化器内部,imatrix 会参与每个块的尺度估计:加权目标 (\min \sum_i \alpha_i (x_i - \hat{x}_i)^2) 中的 (\alpha_i) 现在不再是均匀的 1,而是来自 imatrix 的激活重要性。放行误差预算多的权重少损失一点精度,不重要的权重多牺牲一点——这就是第 11 章那套"误差预算按重要性重新分配"思想的落地。
再强调一次这个容易踩的坑:对 IQ 系列,这个参数不是可选的。原因有两层:
- 工具层:
llama-imatrix若不提供 imatrix,很多 IQ 格式的参考量化实现(from_float_ref)在主线里甚至是NULL——注意第 11 章提到过,IQ 系列某些类型的量化实现依赖量化权重(quant_weights);没有 imatrix 时llama-quantize要么拒绝量化,要么退化成明显更差的质量。 - 格式设计层:IQ 的码本是为"加权误差"设计的。把权重本身的重要性当作常数来压,结果会系统性地在重要位置引入误差。这不只是"少了一个优化配置",而是没有 imatrix 就不满足这个格式的工作前提。
为什么 IQ 离不开 imatrix?
无 imatrix: 有 imatrix:
┌───────────────────────┐ ┌───────────────────────┐
│ 误差均匀分摊: │ │ 误差按重要性分摊: │
│ 每个权重扣同样多精度 │ │ 重要权重少扣、次要权重多扣│
│ ███ ███ ███ ███ │ │ ██ ████ █ █████ │
│ 错误分布平摊 → 处处中低 │ │ 错误集中在不影响输出的位│
└───────────────────────┘ │ 置 → 整体质量更好 │
└───────────────────────┘
对于 K-quants(Q4_K_M 等),--imatrix 同样可以传,量化器会用它做加权估计(第 14 章提过 quantize_row_q4_K_impl 的 quant_weights 参数),通常也能带来小幅改善,但不是必需。换句话说:传 imatrix 对 K-quants 是锦上添花,对 IQ 是没有它就没有好质量。
15.3 三值格式的工具链差异
15.3.1 PTQ1_0 的 Hadamard 折叠依赖
第 13 章专门强调过:PTQ1_0 的磁盘权重是以 Hadamard 折叠(Hadamard folding) 后的形式存储的。也就是说,文件里每个三值块对应的不是原始权重,而是经过一层正交变换后的权重;推理时必须先在激活侧做对应的逆变换,计算才能正确。
这条性质对工具链有一个非常直接的后果:PTQ1_0 的量化端不能是"拿权重直接压三值"这么简单。它需要:
- 先把原始权重做 Hadamard 折叠,得到折叠后的权重;
- 把折叠后的权重量化为三值(base-3 编码)写进文件;
- 同时,把折叠所需的元信息(变换的缩放/符号结构等)也写进文件,让推理端能重建变换。
缺了第一步或第三步,文件里的字节可能是"合法"的(解析器能读懂三值码),但推理结果完全错误——这正是"格式正确但缺失旋转仍然是错误的"那句话在工具链层面的含义。所以使用 PTQ1_0 时的第一个检查项是:你用的量化工具(很可能是某个特定版本或 fork)是否真的执行了 Hadamard 折叠? 不能只看它认不认得 PTQ1_0 这个名字。
15.3.2 分支/fork 与上游合并状态
第 13 章还提到一个生态现实,这里要把它明确为操作纪律:STQ1_0 和 PTQ1_0 不在 llama.cpp 主线(截至写作时,主线 type id 41/42 已被更早合入的 Q1_0/Q2_0 占用,三值格式出现在特定 fork 和未合并的 PR 里)。这意味着:
- 量化端可能不在你的 llama.cpp 构建里。 你手头的
llama-quantize是不是带 STQ1_0/PTQ1_0 支持的那个版本?需要确认,而不是假设。 - 推理端同理。 即使你能量化出 STQ1_0 文件,运行时的 llama.cpp 构建若没有这个类型的
dequantize_row_stq1_0和后端内核,加载会失败或解码错误。 - PTQ1_0 还叠加了 Hadamard 变换 PR。 支持 PTQ1_0 的运行时必须同时包含折叠变换的推理路径。
所以面对三值格式,工具链的黄金法则不是"格式名可识别就可量化、可运行",而是:找到承载该格式的具体 fork/PR,确认它包含量化端、推理端、必要的变换三步,并且把这一步记录进你的工程文档。这也是第 3.4 节"type id 兼容性风险"的实战版——三值格式就是当下最典型的案例。
15.4 验证与部署前检查
15.4.1 类型分布检查
量化完成不等于可以部署。部署前最小检查清单的第一步,是用 gguf-py 统计一下文件里真实出现的张量类型分布——这能一次性抓住三类常见问题:
- 策略是否按预期落盘:选
Q4_K_M却看到大片 F32 或 Q8_0,说明混合规则没生效或格式名敲错; - 有没有意外的主营类型:文件里混进一个
F32权重本来预期缩水,体积因此虚高; - 三值/IQ 格式的 type id 是否被识别:如果读取方的
GGMLQuantizationType不认识某个 id,这里就会现出原形。
Python(用 gguf-py,最直接的验证脚本):
from collections import Counter
from gguf import GGUFReader, GGMLQuantizationType
reader = GGUFReader("model-q4_k_m.gguf")
# 逐张量统计类型
dist: Counter[GGMLQuantizationType] = Counter(t.tensor_type for t in reader.tensors)
# 打印分布 + 体积占比
total_bytes = sum(t.n_bytes for t in reader.tensors)
print(f"{'类型':<20} {'张量数':>6} {'字节':>12} {'占比':>8}")
for qtype, count in dist.most_common():
nbytes = sum(t.n_bytes for t in reader.tensors if t.tensor_type == qtype)
print(f"{qtype.name:<20} {count:>6} {nbytes:>12} {nbytes/total_bytes:>7.1%}")
# 快速断言:Q4_K_M 主体应是 Q4_K
assert dist[GGMLQuantizationType.Q4_K] > 0, "未发现预期的 Q4_K 张量"
**C++(用 llama.cpp 的 GGUF 读取接口的思想——即第 3.3.2 节 gguf-py 的 C 层对应物,示意):
#include "gguf.h" // llama.cpp 的 GGUF 读取头文件
#include <cstdio>
#include <unordered_map>
void check_type_distribution(const char * path) {
// llama.cpp 内部流程(示意):gguf_init → 逐张量读 type
struct gguf_init_params params = { /* .no_alloc=true 等 */ };
struct gguf_context * ctx = gguf_init_from_file(path, params);
int n = gguf_get_n_tensors(ctx);
std::unordered_map<int, int> dist; // type id → 张量数
for (int i = 0; i < n; i++) {
int id = gguf_find_tensor(ctx, gguf_get_tensor_name(ctx, i));
// 实际是 gguf_get tensor type;这里示意统计逻辑
dist[id]++;
}
for (auto & [id, count] : dist)
printf("type_id=%d count=%d\n", id, count);
gguf_free(ctx);
}
这段是结构示意——llama.cpp 的 C API 细节(gguf_get_tensor_type、按名查找等)会随版本演进,重点是逻辑:遍历张量 → 读出每个 type → 计数。这正是 ReaderTensor.tensor_type 在 Python 侧做的同一件事。
C#(手写 GGUF 头解析,统计类型 id——把第 3 章的方法用到验证上):
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
static class TypeCheck {
static long ReadStr(BinaryReader r, out byte[] data) {
long len = r.ReadInt64(); // 字符串长度(u64)
data = r.ReadBytes((int)len); // 字符串本身
return len;
}
public static void Main(string[] args) {
using var r = new BinaryReader(File.OpenRead(args[0]));
r.ReadBytes(4); // magic "GGUF"
r.ReadInt32(); // version
long tensorCount = r.ReadInt64();
long kvCount = r.ReadInt64();
// 跳过全部 KV(只读 key + 类型 + 按类型跳过 value)
static int ValueSize(int t) => t switch {
4 or 5 or 6 or 7 => 1, // UINT8/INT8/UINT16/INT16 示意
8 or 9 or 10 => 2, // (演示)实际按完整类型表
_ => 4,
};
for (long i = 0; i < kvCount; i++) {
ReadStr(r, out _);
int vtype = r.ReadInt32();
// 简化:仅处理标量/定长;字符串与数组需递归跳过
r.BaseStream.Seek(ValueSize(vtype), SeekOrigin.Current);
}
// tensor_infos:统计 type 字段
var dist = new Dictionary<int, int>();
for (long i = 0; i < tensorCount; i++) {
ReadStr(r, out _); // 张量名
int nDims = r.ReadInt32();
for (int d = 0; d < nDims; d++) r.ReadInt64(); // dims[]
int type = r.ReadInt32();
r.ReadInt64(); // offset
dist[type] = dist.GetValueOrDefault(type) + 1;
}
foreach (var (t, c) in dist) Console.WriteLine($"type={t} count={c}");
}
}
C# 版为了可运行做了两点简化(KV 的定长值直接 seek、未递归处理字符串/数组),但它足够演示验证思路:类型分布检查本质上就是读 tensor_infos 里的 type 字段。生产上直接用 gguf-py 或 llama.cpp 的 API 即可。
15.4.2 PPL/KL 快速验证与后端冒烟测试
类型分布对了,只说明"文件结构合法",不说明"量化损失可接受"。第二步是质量验证。llama.cpp 提供的工具是 llama-perplexity,它读一段文本,计算模型在文本上的 PPL,并可与基线对比:
# 计算量化模型的 PPL,并指定"基线模型"用于 KL 散度对比
./llama-perplexity -m model-q4_k_m.gguf -f test.txt --kl-divergence-base model-f16.gguf
-f test.txt:评测用的测试文本。评测数据要和校准数据区分开——用喂过 imatrix 的文本去量 PPL 会偏乐观。--kl-divergence-base model-f16.gguf:让工具同时算量化模型与 F16 基线之间的 KL 散度。
读输出时注意第 2.5 节讲的指标口径:
- PPL:看量化后相对基线的抬升比例。Q4_K_M 通常约 +0.8%、Q6_K 约 +0.2%(具体数值依赖模型与测试集,不同来源不可直接比较)。
- KL 散度:对 IQ/三值这类"逐 logits 形状敏感"的格式,KL 往往比单一 PPL 更能反映输出分布的偏离,因为它直接比较量化前后模型对同一输入的输出分布(见第 18.2 节)。
第三步,也是最后一道保险:在目标后端上跑一次短生成作为冒烟测试。这一步专门抓"加载解包错误"和"缺失运行时变换"这两类前面所有检查都发现不了的问题——可能是某类型在该后端(CPU SIMD?Metal?Vulkan?CUDA?)没有内核,也可能是 PTQ1_0 的 Hadamard 逆变换没被执行。
./llama-cli -m model-q4_k_m.gguf -p "Hello" -n 64 -ngl 99
正常应该看到一句合理的续写。如果输出是乱码、NaN、报 unsupported type 或直接崩溃,就说明某个环节(类型分派、后端内核、缺失变换)出了问题。对三值格式,这一步尤其重要——CPU 测试通过不代表设备的 GPU delegate 支持该类型。
部署前的最小检查清单(按顺序)
① 类型分布检查 gguf-py 统计 → 确认混合策略符合预期
② PPL/KL 快速验证 llama-perplexity → 相对 F16 基线的劣化可接受
③ 目标后端起一次短生成 llama-cli → 无解包错误、无缺失变换
④(三值格式加测) 逐格式确认量化端/推理端/变换三步的版本来源
本章小结与通向"代码架构"的桥梁
这一章把前文的格式知识落成了一条命令流水线。三个事实:
第一,转换与量化是两步。 convert_hf_to_gguf.py 只做容器转换,产出 F16 GGUF 原料;llama-quantize 才真正量化。Q4_K_M 是策略名而非单一 ggml_type,文件内部是"Q4_K 主体 + Q6_K 输出层 + F32 归一化"的混合。
第二,imatrix 对 IQ 系列不是可选项。 llama-imatrix 用 F16 原料和校准数据生成重要性矩阵,量化时用 --imatrix 传入;它让误差预算按权重重要性重新分配。缺了它,IQ 量化的质量会显著下降——这是格式设计的一部分。
第三,三值和特殊格式有额外的工具链纪律。 STQ1_0/PTQ1_0 不在主线(位于 fork/PR),PTQ1_0 还叠加 Hadamard 折叠变换;必须确认工具链版本包含"量化端 + 推理端 + 变换"三步,部署前用类型分布、PPL/KL、后端冒烟测试三道检查兜底。
回顾:一课一图
F16 GGUF ──imatrix──► imatrix.dat ──+──► llama-quantize ──► 部署 SIGMA
│ ▲ │ │
│ └─ 校准数据──┘ ▼
└─────────────────────────────────── llama-perplexity / llama-cli
验证与冒烟
到这里,你已经能把一个模型从 Hugging Face 一路量化到可部署的 GGUF 了。但也许你已经发现一个疑问:llama-quantize 说量化就量化,它内部到底是怎么把"目标格式名"变成"每个张量调一个量化函数"的?那些 quantize_row_q4_K_ref、dequantize_row_q4_K、vec_dot 函数分别在哪里定义、由谁调度?如果我自己想加一个新格式,要改哪几个文件?下一章,我们走进 llama.cpp 的量化代码架构,从"想新增一个格式该改哪些文件"的角度,把这套机制的源代码解剖给你看。

浙公网安备 33010602011771号