3. 模型加载与初始化:模型格式解析

模型加载,说白了就是让推理引擎认识你的模型文件。我见过不少团队,模型训好了,结果加载时卡住半天,最后发现是格式不兼容。嗯,今天我们就来把这层窗户纸捅破。

3.1 主流模型格式:SafeTensors vs GGUF

目前主流格式就两个:SafeTensors 和 GGUF。我个人习惯是,服务器端用 SafeTensors,边缘设备用 GGUF。为什么?往下看。

特性 SafeTensors GGUF
设计目标 安全、快速加载 量化、跨平台
序列化方式 FlatBuffers 自定义二进制
支持量化 有限(需额外处理) 原生支持(Q4_0, Q5_1等)
加载速度 极快(零拷贝) 较快(需反序列化)
典型场景 HuggingFace生态 llama.cpp, Ollama

核心区别一句话:SafeTensors 是「安全第一」,GGUF 是「量化第一」。

3.2 SafeTensors 格式深度解析

SafeTensors 的设计初衷,就是解决 PyTorch 的 pickle 反序列化安全问题。你想想看,一个恶意构造的 .bin 文件,能直接执行任意代码——这谁受得了?

它的结构其实很简单:

// SafeTensors 文件布局
[Header] -> JSON 元数据(包含张量名称、形状、偏移量)
[Data]   -> 原始张量数据(连续存储)

// 元数据示例
{
  "metadata": {"total_size": 8589934592},
  "tensors": [
    {"name": "model.layers.0.self_attn.q_proj.weight",
     "shape": [4096, 4096],
     "dtype": "BF16",
     "data_offsets": [0, 33554432]}
  ]
}

我在项目中遇到过一个问题:加载 70B 模型时,内存直接爆了。后来发现是 SafeTensors 的 mmap 机制没用好。它默认会把整个文件映射到虚拟内存,但物理内存不够时,就会疯狂触发 swap。

我的建议:safetensors.safe_open() 时,指定 device="cpu"mmap=True。这样只有被访问的张量才会真正加载到内存。

3.3 GGUF 格式深度解析

GGUF 是 llama.cpp 的专属格式。说白了,它就是为 CPU 推理量身定做的。我记得第一次看到 GGUF 的源码时,心里想:这哥们儿把量化玩到极致了。

GGUF 的文件结构是这样的:

// GGUF 文件布局
[MAGIC]     -> "GGUF" 魔数
[VERSION]   -> 版本号(当前是 3)
[TENSOR_COUNT] -> 张量数量
[METADATA_KV]  -> 键值对元数据
[TENSOR_INFO]  -> 每个张量的名称、类型、偏移量
[TENSOR_DATA]  -> 原始张量数据(已量化)

GGUF 最牛的地方在于,它把量化参数直接嵌入到张量元数据里。比如 Q4_K_M 量化,每个 block 的 scale 和 min 都跟着数据走。加载时直接按 block 反量化,不需要额外查表。

注意:GGUF 的版本兼容性是个坑。我曾经用 GGUF v2 的加载器去读 v3 的文件,结果张量偏移全乱了。建议统一使用最新版 llama.cpp 的转换工具。

3.4 模型加载流程

不管什么格式,加载流程都差不多。我把它总结成四步:

  1. 文件探测:读魔数,判断格式(SafeTensors 是 "safetensors",GGUF 是 "GGUF")
  2. 元数据解析:提取张量名称、形状、数据类型、偏移量
  3. 内存映射:用 mmap 把数据映射到进程地址空间(懒加载)
  4. 张量注册:把映射好的张量注册到引擎的计算图中

这里有个细节:SafeTensors 的元数据是 JSON,解析起来慢。GGUF 的元数据是二进制 KV 对,解析快得多。但 SafeTensors 的数据是连续存储的,mmap 后可以直接用指针访问,零拷贝。GGUF 的数据是分块存储的,需要额外处理。

// 伪代码:模型加载核心逻辑
def load_model(path):
    magic = read_magic(path)
    if magic == "safetensors":
        metadata = parse_json_header(path)
        for tensor_info in metadata["tensors"]:
            tensor = mmap_tensor(path, tensor_info["data_offsets"])
            register_tensor(tensor_info["name"], tensor)
    elif magic == "GGUF":
        reader = GGUFReader(path)
        for tensor_info in reader.tensors:
            tensor = reader.load_tensor(tensor_info)
            register_tensor(tensor_info.name, tensor)
    else:
        raise ValueError("未知模型格式")

3.5 权重初始化与内存映射

权重初始化,说白了就是给模型参数赋初值。但推理引擎里,我们更关心的是「如何高效地把预训练权重塞进内存」。

内存映射(mmap)是这里的关键技术。它的好处是:

  • 懒加载:只有访问到的页才会真正加载到物理内存
  • 共享内存:多个进程可以共享同一个 mmap 区域,节省内存
  • 零拷贝:数据直接从磁盘映射到内存,不需要 read() 系统调用

但 mmap 也有坑。我曾经在加载 130B 模型时,发现内存占用比模型文件还大。排查了半天,原来是 mmap 的 page cache 在作祟。Linux 内核会把 mmap 的数据缓存到 page cache 里,再加上进程自己的虚拟内存,就 double 了。

避坑指南:madvise(MADV_RANDOM) 告诉内核,我们访问模式是随机的,不要预读。再用 MADV_DONTFORK 防止 fork 时复制。

3.6 知识体系总览

下面这张图,是我自己梳理的模型加载与初始化的核心逻辑。你看一眼,基本就全明白了。

模型加载与初始化核心流程 模型文件 格式探测(魔数判断) SafeTensors 解析 GGUF 解析 mmap 内存映射 张量注册完成

嗯,这张图把整个流程串起来了。从模型文件开始,经过格式探测,分两条路走,最后汇合到 mmap 内存映射,完成张量注册。你想想看,是不是很清晰?

核心要点:模型加载的本质,就是把磁盘上的二进制数据,高效地映射成内存中的张量对象。格式解析只是第一步,真正的性能瓶颈在内存映射和量化处理上。

好了,这一章的内容就到这里。模型加载这块,说白了就是「读文件、解析元数据、映射内存」三步。但每一步都有不少细节,尤其是 mmap 的坑,我建议你亲自跑一遍代码试试。

专注资料整理