GGML:内存分配、量化体系与 GGUF
「本系列第 3/17 章」
上一章说明算子只建图、ggml_build_forward_expand 只排序。图一旦可执行,立刻出现两个工程问题:中间激活往哪放,以及磁盘上的量化权重如何变成可点积的 block。本章把内存分配、量化体系和 GGUF 放在一起讲,因为 llama.cpp 加载模型时三条线同时发生。
两套内存,各管各的
| 体系 | 机制 | 装什么 |
|---|---|---|
| Context | bump allocator | 张量 / 图 元数据 |
| Backend | gallocr + dyn_tallocr |
张量 data |
Context 在 ggml_init 时一次性拿到一块 CPU 内存,按对象顺序往前推。它不知道 GPU VRAM,也不给 7B 权重当堆。权重与激活的真实字节在 Backend buffer 里。ggml_tallocr 是单 buffer 线性 bump,适合按文件顺序把权重量进去、分配后不再 free。中间激活则走 ggml_dyn_tallocr:best-fit 空闲块,最多 256 个 free block,节点算完可以归还。gallocr 是图级指挥:为每个 Backend buffer type 持有一套 dyn_tallocr,按拓扑给节点发地址。
权重加载用 ggml_backend_alloc_ctx_tensors_from_buft,一次把 Context 里所有权重量到指定 buffer。这些地址不参与 decode 过程中的 free。激活才走 ggml_gallocr_alloc_graph。llama.cpp 初始化时先对 worst-case 图做 reserve,避免每步 token 都 realloc。
何时原地写,何时绝不覆盖
gallocr 按拓扑遍历。对每个节点:若是 view,则共享父地址加偏移;若满足 in-place,则复用父 buffer;否则向 dyn_tallocr 要一块新的。节点「完成」后把依赖它的 n_children 减一,减到 0 且不是输出,就把块还回去。
in-place 同时要求四件事:ggml_op_can_inplace 为真(ADD、MUL、SCALE、SOFT_MAX、ROPE、RMS_NORM、UNARY、GLU 等);父节点 n_children==1;同一个 buffer_id;父尺寸不小于子尺寸。少一条都不能复用。上一章的 use_counts 与这里的 n_children 是同一事实的两面:一个张量被两个下游读,就绝不能让第一个下游原地写爆。
GGML_TENSOR_FLAG_OUTPUT 是硬约束:永不覆盖、永不提前 free。logits、需要回读的 hidden、要留给采样器的最后一层,都必须打这个标志。外部已经设置 tensor->data 或 tensor->buffer 的张量同样不参与回收。漏打 OUTPUT,再叠加 pipeline 的 n_copies 轮转,就会出现「偶发错词、复现不稳定」——那不是采样温度的问题,是内存别名。
峰值因此远小于「层数 × 每层激活」。十层 FFN 若每层中间 1GB,无复用要 10GB;拓扑允许时往往只要约两层的量级。这是 GGML 能在消费级显存上跑 7B/14B 的前提之一,另一半来自量化。
量化:热路径是点积,不是反量化
GGML 的量化不是「先 decode 成 F32 再调用 BLAS」。生产路径是:
1 | GGUF 中的 Q4 权重 |
标准 block 以 32 元素为单位,例如 block_q4_0 带一个 half 尺度和 16 字节 nibble。K-quant 把 super-block 放到 256,尺度分层,压缩率与误差都更好。Q4_K_M 是目前最常见的生产 ftype:体积大约 4.5–5 bit/weight 量级,质量明显稳过老的 Q4_0,又比 Q8_0 省一半以上空间。
激活走 Q8,是因为 8bit 点积在 AVX-VNNI、NEON dotprod、CUDA DP4A / tensor core 路径上都有现成吞吐,而权重保持 4bit 负责省带宽。CPU 上函数形态是 ggml_vec_dot_q4_K_q8_K 这类组合;CUDA 上小形状走 mmvq,批量量化矩阵走 mmq,浮点批量才落到 cublas。不要在热路径里对整层调用 dequantize_row_*——那些函数给 llama-quantize、调试和个别不支持的 op 用。
IQ 系列(IQ1/IQ2/IQ3/IQ4)用 codebook,而不是均匀网格。ggml_quantize_requires_imatrix 对它们为真:必须先用校准文本跑重要性矩阵,再按列加权选码。没有 imatrix 的 IQ 文件可以写出,但生成质量会差到失去「更低 bit」的意义。K-quant 也能吃 imatrix,但不是强制。
GGML_CPU_REPACK 默认打开。权重从 GGUF 读入后,可能再变成对 SIMD 更友好的 layout(例如 Q4_X)。这是加载期一次性 CPU 开销,换运行期 vec_dot 更快。它不改变 GGUF 文件本身,只改变 Backend buffer 里的排列。
GGUF:自描述容器,而不是「裸权重 dump」
GGUF 的文件头很短,语义却完整:
1 | magic "GGUF" | version=3 | n_tensors | n_kv |
GGUF_MAGIC 是四个字符 GGUF,GGUF_VERSION 为 3,GGUF_DEFAULT_ALIGNMENT 为 32。更高版本直接拒绝;alignment 可被 general.alignment 覆盖,offset 必须按它计算,否则后面所有张量都会错位。
KV 至少要能回答:架构名(general.architecture)、文件量化类型(general.file_type)、量化版本、上下文长度、隐层宽度、层数、Q/KV 头数、词表与 bos/eos。张量名遵循 llama.cpp 的约定:token_embd.weight、blk.{i}.attn_q.weight、blk.{i}.ffn_gate.weight、output_norm.weight。转换脚本写错名字,加载器会建出缺边的图。
llama.cpp 如何打开一个文件
关键调用是 gguf_init_from_file,参数里 no_alloc=true:
1 | struct ggml_context * ctx = NULL; |
四步缺一不可:
- 解析 KV,填 hparams。
- 为每个张量创建 只有元数据 的
ggml_tensor,data仍为空。 ggml_backend_alloc_ctx_tensors_from_buft按设备分配权重 buffer。- 按
offset把 data blob 读进对应 buffer;CPU 上再视情况 repack。
这解释了为什么同一份 Q4_K_M 既能全 CPU 跑,也能 -ngl 上 GPU:文件不包含设备信息,设备是加载期选的。也解释了为什么 Context 只要十几 MB:7B 的 Q4 权重从来不进 bump 池。
写入路径是对称的。convert_hf_to_gguf.py 用 gguf-py 写 F16/F32 GGUF;llama-quantize 再 gguf_init_from_file 读入,逐张量 dequant → quantize_* → gguf_write_to_file。量化发生在离线工具里,推理进程只做 vec_dot。
把三条线收束到一次 decode
一次 decode 的内存画面是:权重量在 Backend buffer 里,生命周期等于模型;input token 与 RoPE 位置是 INPUT;中间 RMSNorm / SILU / 残差在 n_children==1 时原地写;最后 logits 是 OUTPUT。量化只影响权重与激活的 type,不影响这套生命周期。GGUF 只在加载与导出时出现,不在逐 token 热路径上再解析一遍 KV。
排查时按层提问。OOM 且发生在加载:看量化类型和 alloc_ctx_tensors 的设备。OOM 且发生在运行:看 gallocr_reserve 是否按最大 batch / 最长上下文预留。结果错但 loss 在训练侧正常:先核对 GGUF 的 architecture 与 tokenizer,再核对是不是 OUTPUT 被覆盖。IQ 模型「能跑但很蠢」:先问 imatrix 在不在。
从训练产物走到可推理文件
把本章三块机制嵌回总览里的转换桥,顺序就固定了。LLaMA-Factory 写出 HF 目录后,convert_hf_to_gguf.py 只负责翻译:架构名写成 general.architecture,词表写成 tokenizer.ggml.tokens,每个 SafeTensors 矩阵变成带 offset 的 GGUF 张量,默认对齐 32 字节。这一步通常输出 F16 或 F32,体积接近原模型,目的是留下一份「尚未损伤、但已经自描述」的中间文件。
llama-quantize 读这份中间文件时同样 no_alloc 思路:先看元数据,再按目标 ggml_type 逐行 quantize_chunk。目标是 Q4_K_M 时走 K-quant;目标是 IQ 时必须传入 imatrix。写出的新 GGUF 换了 general.file_type 和每张量的 type,但名字与 dims 应保持不变。推理进程加载新文件时,Context 仍然只持有元数据,gallocr 仍然只为激活工作,变化仅仅是 vec_dot 选了另一对类型组合。
因此「量化」不是 llama-server 启动后的一个选项,而是文件已经写死的 type。server 能做的是:把已量化权重放到 CUDA 或 Metal buffer,在运行期把激活收成 Q8,以及决定要不要 CPU repack。它不能把一份 Q4_K 文件「临时当成 Q8」而不经过重新量化工具。分清离线量化与在线激活量化,才能理解为什么同一套 GGUF 在 CPU 与 GPU 上数值应接近,而 HF 的 bitsandbytes 量化不能直接改后缀当 GGUF 用。
再补一条对齐相关的实务:32 字节不是装饰。GPU alloc_size 往往比 ggml_nbytes 更大,为的是满足 buffer type 的 alignment。手写解析器若按「张量字节数简单相加」算下一个 offset,会在中途偏离 data blob,后面所有层都错。用官方 gguf_init_from_file 的原因正在于此:KV、对齐、version=3 的拒绝逻辑已经写死,不必自己再实现一遍容器。分片文件只是把同一套规则切成多个 blob,加载器合并权重表之后,对 gallocr 与 vec_dot 仍然是一张完整的模型图,并不需要改变 in-place 条件或 OUTPUT 标志。
下一章讨论 GGML 的 Backend 抽象与硬件加速。
正在加载留言…