🦙 llama.cpp 图解教程llama.cpp Visual Guide 第八部分 · 实战与贡献Part 8 · Practice & contributing 38 / 40
第八部分 · 实战与贡献Part 8 · Practice & contributing

从 HF 转换模型Converting HF models

你从 HuggingFace 上下载一个模型,拿到的是一堆 .safetensors 权重分片,加上一个 config.json、一个 tokenizer。可 llama.cpp 从头到尾只认一种文件:.gguf。中间那一步"把 HF 模型转成 GGUF",就是这一课的主角——它由仓库根目录那个 convert_hf_to_gguf.py 负责。很多人把它当成一个黑盒脚本:跑一条命令、等一会儿、出一个 .gguf。这一课要把这个黑盒拆开,看清它到底做了哪三件事。

这三件事其实很朴素:(1) 认出这是什么架构——读 config.json 里的 architectures,分发到对应的转换类(Llama 走 LlamaModel、Qwen 走 Qwen2Model);(2) 把每个张量改名、对齐——HF 把注意力的 Q 投影叫 model.layers.0.self_attn.q_proj.weight,GGUF 要叫 blk.0.attn_q.weight,还要把超参(层数、维度、RoPE 设置)写成 GGUF 的元数据;(3) 按 GGUF 的字节格式落盘——先写文件头,再写一段段元数据键值,再写张量信息表,最后对齐了写真正的权重。看懂这三步,你不仅会"用"这个脚本,还能在它不支持你的模型时,知道该去改哪里。

有一个结构上的变化值得先说:convert_hf_to_gguf.py 以前是个几千行的大文件,现在已经被重构成一个薄薄的命令行入口(约 300 行),真正干活的代码搬进了一个独立的 conversion/ 包——每个架构一个模块。所以这一课讲的是"一个包怎么协作",不是"一个大文件里有什么"。路线图:先看一条命令背后的分发流程(配一张追踪图),再看注册表怎么把"架构名"接到"转换类",然后看张量改名与超参,最后拆开 GGUF 文件本身的字节布局。

🌍 宏观理解
转换的本质,是一次格式翻译 + 重新打包。HF 那一堆文件里,信息是的:权重在 .safetensors 里、超参在 config.json 里、词表在 tokenizer 文件里,张量的命名还跟着 PyTorch 的习惯走。GGUF 的目标恰恰相反——把这些信息收拢进一个自描述的单文件:打开一个 .gguf,里面既有"这个模型是什么"(架构、层数、维度、RoPE、词表全在元数据里),又有"模型的全部权重",而且每个张量都用 llama.cpp 自己的规范名(blk.N.attn_q 这种)命好、按统一对齐排好。转换脚本干的,就是把散落的信息按这套规范重新组织一遍。理解了这一点,你就明白为什么 GGUF 能做到"下载一个文件、不依赖 Python、直接 mmap 就能跑"——所有自描述的功夫,都是在转换这一步一次性付清的(回顾 L13 的 GGUF 格式,这一课正是它的"写入端")。还有一层好处常被忽略:因为"模型是什么"全写进了元数据,同一个加载器不用改一行代码,就能加载今天还没出现的新架构——只要转换脚本按规范把它的超参写进去,运行时照单全收。
🔌 生活类比
把转换想成把一份外文书稿排版成一本正式出版物。原稿(HF 模型)是作者按自己的习惯写的:章节散在不同文件、术语用的是原文的叫法、还附了一堆零散的注释。出版社要做三件事:先认出这是哪一类书(架构分发——小说走小说的版式、教材走教材的版式);再统一术语、对齐格式(张量改名——把作者口语化的叫法换成全书统一的规范名);最后按出版标准装订成册(GGUFWriter 写盘——先扉页和版权页、再目录、最后正文,每一页都对齐好页边距)。装订好的这本书(.gguf)是自带说明书的:翻开第一页就知道它是什么、有多少章、怎么读——这正是 llama.cpp 不需要原始 config.json 就能加载模型的原因。

一条命令背后:薄 CLI 把活分发出去

先跟着一条命令走一遍。你敲下 python convert_hf_to_gguf.py /path/to/hf-model,这个薄薄的入口脚本做的第一件事不是转换,而是认门:它打开模型目录里的 config.json,读出 architectures 字段——比如 "LlamaForCausalLM"。这就是模型的"身份证"。拿到身份后,它去一个注册表里查一句话:"谁负责转这种架构?"查到的是一个 Python 类(LlamaModel),把它实例化,剩下的全交给这个类的 write() 方法。整个 main() 短得出乎意料:

# 简化自 convert_hf_to_gguf.py 的 main()
hparams = ModelBase.load_hparams(dir_model)            # 读 config.json
arch    = get_model_architecture(hparams, model_type)  # 取 architectures[0]
model_class = get_model_class(arch)                   # 注册表里查到对应子类
model = model_class(dir_model, output_type, fname_out, ...)
model.write()                                          # 真正的转换 + 写盘都在这里

这段代码的关键,是入口自己几乎什么都不做——load_hparams 读配置、get_model_architecture 取出架构名、get_model_class 去注册表查类,然后实例化、调 write()。真正的转换逻辑(怎么读张量、怎么改名、怎么写超参)全在那个查到的类里。这种"入口只管分发、细节交给插件"的结构,正是它能从几千行瘦成 300 行的原因:每种架构的特殊处理,都被搬进了 conversion/ 包里各自的模块,互不打扰。那 write() 里到底发生了什么?它依次跑 prepare_tensors(逐张量改名、定量化类型、塞进 writer)和 prepare_metadata(把超参、词表收成元数据),再调 GGUFWriter 写文件头、写 KV、写张量数据,最后 close 收尾——前面那张追踪图的后半截,几乎全压缩在这一个 write() 调用里了。

把这条分发流水定格成一张图最直观:从一个 HF 目录进去,经过"认架构 -> 查类 -> 实例化 -> 转换写盘",最后落出一个 .gguf。看图时抓住一个对比:左边进去的是"按 PyTorch 习惯散落的一堆文件",右边出来的是"按 GGUF 规范收拢的一个文件",中间每一站做的都是同一件事——翻译 + 收拢。

追踪一次转换的分发:薄 CLI 读出架构名、去注册表查到对应的转换类、实例化后调 write(),由它逐张量改名+量化、再交给 GGUFWriter 落盘(示意)。
① HF 目录
.safetensors + config.json
下载来的原始模型
load_hparams
读 config
② 架构名
"LlamaForCausalLM"
architectures[0]
get_model_class
查注册表
③ 转换类
LlamaModel 实例
对应这个架构
set_gguf_parameters
prepare_tensors
④ 规范张量
blk.N.* + 量化
改名 + 定 dtype
GGUFWriter
写盘
⑤ 文件
model.gguf
自描述单文件

注册表:架构名怎么接到转换类

上一步那句"去注册表里查谁负责",是整个 conversion 包的枢纽。注册表本身就是一个普通的字典:架构名(字符串)-> 转换类。神奇的地方在于怎么往里填——靠一个叫 register 的类方法当装饰器。每个架构模块在定义自己的类时,头顶都挂一行 @ModelBase.register("LlamaForCausalLM", ...);Python 加载这个模块时就执行这行,把"这些架构名 -> 这个类"登记进字典。一个类可以认领多个 HF 架构名(Llama / Mistral / Mixtral 共用一套转换逻辑),所以一行 register 往往列着好几个名字:

# conversion/base.py: 注册表 + 装饰器工厂
class ModelBase:
    _model_classes = {ModelType.TEXT: {}, ModelType.MMPROJ: {}}   # 架构名 -> 类

    @classmethod
    def register(cls, *names):          # 传入若干 HF 架构名
        def func(modelcls):
            for name in names:
                cls._model_classes[model_type][name] = modelcls   # 登记
            return modelcls
        return func

# conversion/llama.py: 一个架构 = 一个注册子类
@ModelBase.register("LlamaForCausalLM", "MistralForCausalLM", "MixtralForCausalLM")
class LlamaModel(TextModel):
    model_arch = gguf.MODEL_ARCH.LLAMA
    def set_gguf_parameters(self): ...   # 写层数/维度/RoPE 等
    def modify_tensors(self, data, name, bid): ...  # 改名/permute

读懂这段,你就掌握了"给 llama.cpp 加一个新模型"的入口:不用改任何主干代码,只要在 conversion/ 里新建一个模块,写一个 @ModelBase.register("你的架构名") 的子类,实现 set_gguf_parameters(写超参)和 modify_tensors(按需调整张量),它就自动被分发系统认领。官方的 docs/development/HOWTO-add-model.md 走的正是这条路。这就是"注册表 + 插件"的威力:几十种架构,就是 conversion/ 下几十个互不影响的小文件,谁都能照着已有的一个改出下一个——这也是为什么 llama.cpp 能跟上社区里层出不穷的新模型。

张量改名与超参:从 PyTorch 叫法到 GGUF 规范名

分发到 LlamaModel 之后,真正的转换分两条线并行:一条写超参,一条搬张量。超参由 set_gguf_parameters 负责——它把 config.json 里的层数、隐藏维度、注意力头数、RoPE 设置等,一项项写成 GGUF 的元数据键值(比如 llama.block_countllama.embedding_length)。张量这条线靠 modify_tensors:它遍历每个权重,调 map_tensor_name 把 HF 的命名翻成 GGUF 的规范名,必要时再调整张量本身的形状或排布。核心就这么几行:

# conversion/base.py: 改名 (基类默认实现)
def map_tensor_name(self, name):
    new_name = self.tensor_map.get_name(key=name, try_suffixes=(".weight", ".bias"))
    if new_name is None:
        raise ValueError(f"Can not map tensor {name!r}")
    return new_name

def modify_tensors(self, data_torch, name, bid):
    return [(self.map_tensor_name(name), data_torch)]   # 默认: 只改名, 数据照搬

改名靠的是一张映射表 TensorNameMap:它把各家 HF 模型五花八门的命名,统一收敛到 llama.cpp 的规范名上。比如注意力的几个投影,HF 叫 self_attn.{q,k,v}_proj,GGUF 一律叫 blk.N.attn_{q,k,v};名字里的层号按层展开。Llama 还有一个绕不过的坑:它的 Q/K 权重要先 permute 一下——HF 存 Q/K 的行序,和 llama.cpp 的 RoPE 实现所假设的不一样,不重排的话位置编码会算错。正是这种"每家模型一两个特例"的处理,让每个架构都需要自己一个子类。这也解释了一个常见报错 "Can not map tensor ...":它往往不是模型坏了,而是当前架构的 TensorNameMap 还没收录这个张量名,需要在对应子类里补一条映射、或加一点特例处理。下面这张对照表,就是改名这一步最直观的样子:

HF 名(PyTorch 习惯)GGUF 规范名说明
model.layers.0.self_attn.q_proj.weightblk.0.attn_q.weight注意力 Q 投影(需 permute)
model.layers.0.self_attn.k_proj.weightblk.0.attn_k.weight注意力 K 投影(需 permute)
model.layers.0.mlp.gate_proj.weightblk.0.ffn_gate.weightFFN 门控投影
model.embed_tokens.weighttoken_embd.weight词嵌入表

GGUF 文件长什么样:自描述的字节布局

张量改好名、超参写成 KV 之后,最后一步是把这一切按 GGUF 的字节格式落盘——这活儿交给 gguf-py 里的 GGUFWriter。它写盘严格分四段,顺序不能乱:先文件头(一眼能看出有多少东西),再元数据 KV 段(模型的说明书),再张量信息表(每个权重的目录),最后张量数据段(真正的权重字节)。把这几段竖着叠起来,就是一个 .gguf 文件从头到尾的样子:

header(24 字节)
magic "GGUF" + version=3 + tensor_count + kv_count;打开第一眼就知道有多少元数据、多少张量
元数据KV 段
一串"键 + 类型标记 + 值":架构、层数、维度、RoPE、词表、chat 模板……模型的全部"说明书"都在这
张量表tensor info 段
每个张量一条:名字 + 维度 + dtype + 在数据区的偏移;相当于一份"目录"
对齐padding
补零到 32 字节边界,让后面的数据区起点对齐,便于 mmap 零拷贝
数据tensor data 段
所有权重的原始字节,按张量表里的偏移一块块排开;每块各自对齐

文件头是最简单也最关键的一段,就四个定长字段,write_header_to_file 几行写完——读它的人(llama.cpp 的加载器)也是先读这四个数,才知道后面该读多少:

# gguf-py/gguf/gguf_writer.py: 写文件头 (四个字段)
fout.write(self._pack("<I", GGUF_MAGIC))    # 0x46554747 = "GGUF"
fout.write(self._pack("I",  GGUF_VERSION))  # 3
fout.write(self._pack("Q",  len(tensors)))  # 张量数 (u64)
fout.write(self._pack("Q",  len(kv_data)))  # 元数据条数 (u64)

张量信息表里每条记录都带一个 offset,指明这个张量的数据从数据区的第几个字节开始。写表时偏移是累加出来的:每写完一个张量,下一个的偏移就 += ggml_pad(本张量字节数, 32)——也就是说每个张量都向上对齐到 32 字节。正是这一步让 --outtype 落到实处:你选 f16 还是 q8_0,决定的就是每个张量"占多少字节"、用哪个 GGMLQuantizationType 码写进表里(量化算法本身在 L06/L12 讲过,这里只是把结果按格式记下来)。读到这你应该已经看清:GGUF 没有任何"魔法",它就是一套把"说明书 + 目录 + 数据"对齐着码进一个文件的朴素约定——而正因为朴素,它才能被任何语言、任何平台稳稳地读出来。再补一个常被忽略的点:元数据 KV 段里每个值都是自带类型的——开头一个 GGUFValueType 标记说明它是 u8、i32、f32、字符串还是数组,读的人据此就知道该取几个字节、怎么解析。正因为值自带类型,GGUF 才能把一个整数 block_count、一长串 token 字符串数组、几个浮点的 RoPE 参数全塞进同一段里互不混淆——这正是它能"自描述"的底层支撑。

深入:对齐 与 词表

两个折叠,补两个转换时绕不开、却容易被脚本"自动处理掉"而看不见的细节。

1 为什么所有东西都要对齐到 32 字节? 点击展开

还记得 L14 讲过 llama.cpp 用 mmap 把权重文件直接映射进内存、不做拷贝吗?mmap 要真正发挥威力(尤其是后端做 SIMD / 对齐访问时),数据在文件里的起始地址最好落在整齐的边界上。GGUF 把对齐定为 32 字节(GGUF_DEFAULT_ALIGNMENT = 32,可被元数据 general.alignment 覆盖):每个张量的数据起点都向上取整到 32 的倍数,中间用零填充。代价是文件里多出一点点 padding 字节(每个张量最多浪费 31 字节,对动辄几十 MB 的权重可忽略不计),回报是加载时能整块 mmap、后端能按对齐地址做向量化读取,省掉一次拷贝和潜在的非对齐访问惩罚。这就是为什么上面字节布局那张图里,数据段前面专门留了一截 padding——它不是凑数,是在为运行时的零拷贝映射铺路。一个细节串起两课:L14 讲的是"加载时怎么 mmap 进来",这一课讲的是"转换时怎么把文件码得能被那样 mmap"——同一个对齐约定的两端,一个写、一个读,严丝合缝。顺带一提,对齐这件事在 ggml 里无处不在——张量内存分配、后端缓冲区、KV cache,背后都有类似的"补齐到某个边界"的考量;GGUF 只是把这套思路也带到了磁盘文件上,让"文件里的布局"和"内存里的布局"天然合拍。

2 词表是怎么被写进 GGUF 的? 点击展开

模型的权重只是一半,另一半是词表——没有它,模型吐出的 token id 没法变回文字(回顾 L20)。HF 的词表躺在 tokenizer.json / tokenizer.model 里,转换时由 set_vocab 读出来,把每个 token 的字符串、分数(score)、类型(普通 / 控制 / 未知 / 字节)一并写进 GGUF 的元数据。llama.cpp 支持几大类 tokenizer——SentencePiece(Llama 系常用)、BPE(GPT 系)等,set_vocab 会按模型选对应的读法。除了 token 本身,还有一批特殊 token 也要写:BOS / EOS、padding,以及 chat 模板(还记得 L22 吗?聊天模型的对话格式就存在 GGUF 的 tokenizer.chat_template 里)。所以一个 .gguf 是真正"自带电池"的:词表、特殊 token、chat 模板全在里面,加载后不需要再去找任何原始 tokenizer 文件——这也是为什么你只要下载一个 gguf,就能直接跑起一个会聊天的模型,而不必把 HF 仓库里那一堆配套文件也凑齐。给个实感:HF 仓库里那几十个文件(多个 .safetensors 分片、config.json、一堆 tokenizer.*),转换后浓缩成一个 .gguf;这种"一个文件就能跑"的体验,背后正是 set_vocab 这类步骤把零散信息一点点收进元数据的功劳。

✅ 关键要点
  • 分工:convert_hf_to_gguf.py 现在是薄 CLI;转换机制在 conversion/ 包,写盘在 gguf-py
  • 分发:读 config.jsonarchitectures -> get_model_class -> 注册表 @ModelBase.register("LlamaForCausalLM", ...) 找到对应子类。
  • 改名:map_tensor_nameTensorNameMap 把 HF 名翻成 GGUF 规范名(model.layers.0.self_attn.q_proj -> blk.0.attn_q);Llama 还要 permute Q/K。
  • 超参:set_gguf_parameters 把层数/维度/RoPE 等写成 GGUF 元数据键值。
  • 落盘:GGUFWriter 四段——头(magic/version/tensor_count/kv_count)-> 元数据 KV -> 张量信息表 -> 对齐 32B -> 权重数据;--outtype 决定每个张量存 f16/q8_0/...。
  • 扩展:新增一个架构 = 在 conversion/ 加一个注册子类,实现 set_gguf_parameters/modify_tensors(见 HOWTO-add-model)。
💡 设计洞察
这一课藏着两个反复出现的工程智慧。第一个是自描述格式:GGUF 把"怎么读我"写进了文件本身——元数据段说清架构、维度、词表,张量信息表说清每个权重的形状、类型、偏移。代价是转换时要多写一堆元数据,回报是运行时零外部依赖、能 mmap、跨语言都能解析。第二个是注册表 + 插件式扩展@ModelBase.register 让"支持一个新架构"变成"加一个文件、不动主干"——几十种模型就是几十个互不干扰的小模块。这两招你在别处会一再遇到:自描述格式(想想 ELF、PNG、tar)、注册表分发(想想各种框架的 plugin 机制)。把"转换模型"看成"格式翻译 + 插件分发",你就能在任何一个还没支持的模型面前,知道自己该往哪儿下手——这正是从"会用"迈向"能改、能贡献"的那一步(下一课就讲怎么把这一步真正提成一个 PR)。说到底,这一课是从"读者"到"作者"的转身:前面三十多课你一直在读 llama.cpp 怎么跑,从这里起,你开始有能力往里写——而写,正是贡献的起点,也是这门课最后想带你抵达的地方。

🧪 自测 · 想一想为什么这么设计

1. 现在的 convert_hf_to_gguf.py 在转换里扮演什么角色?
  1. 一个 C++ 程序,运行时加载 gguf
  2. 负责训练模型
  3. 薄薄的命令行入口:读架构、查到对应转换类后就把活交出去,真正的机制在 conversion/ 包里
  4. 一个几千行的大文件,所有架构的转换逻辑都堆在里面
看答案与解析 点击展开
答案:C。它已被重构成约 300 行的薄 CLI:main() 只做 load_hparams 读 config.json -> get_model_architecture 取架构名 -> get_model_class 去注册表查类 -> 实例化 -> 调 write()。每种架构的具体转换逻辑都搬进了 conversion/ 包的独立模块(conversion/llama.py 等)。所以读这个脚本别只盯根文件,要看 conversion/base.py 的 ModelBase 和各架构子类。
2. @ModelBase.register("LlamaForCausalLM", ...) 这个装饰器是干嘛的?
  1. 检查输入是否合法
  2. 给函数加一层缓存
  3. 把这些 HF 架构名登记到一张“架构名 -> 转换类”的注册表,让分发能按 config.json 找到对应子类
  4. 把模型权重注册到 GPU
看答案与解析 点击展开
答案:C。register 是 ModelBase 的类方法,当装饰器工厂用。Python 加载某个架构模块时执行这行,把括号里列的若干 HF 架构名都映射到被装饰的类(一个类可认领多个名字,如 Llama/Mistral/Mixtral 共用)。于是 get_model_class 拿到 config.json 里的 architectures 就能查到该用哪个子类。新增一个架构 = 加一个带 register 的子类、不动主干——这就是“注册表 + 插件”。
3. 一个 GGUF 文件最开头的 header 是哪四个字段?
  1. 文件名 + 大小 + 校验和 + 时间戳
  2. 架构 + 层数 + 维度 + 词表大小
  3. magic("GGUF") + version(3) + tensor_count + kv_count
  4. 第一个张量的名字、形状、类型、数据
看答案与解析 点击展开
答案:C。GGUFWriter.write_header_to_file 只写四个定长字段:magic 0x46554747("GGUF")、version=3、tensor_count(u64)、kv_count(u64)。加载器先读这四个数,才知道后面有多少元数据 KV、多少张量信息要读。架构/层数/维度/词表这些写在 header 之后的元数据 KV 段,不在 header;张量的名字/形状/类型/偏移在再后面的张量信息段。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 假设你想让 llama.cpp 支持一个它还不认识的新架构(比如某个新出的 HF 模型 "FooForCausalLM")。顺着这一课的分发流程想:(1) 你会在 conversion/ 里新建一个怎样的类、它继承谁、头顶那行 register 要写什么?(2) 这个类至少要实现哪两个方法、分别负责“超参”和“张量”的哪件事?(3) 如果转换时报 "Can not map tensor ...",通常意味着什么、该去改哪里?(4) 为什么这套“加子类、不动主干”的设计,能让几十种架构互不干扰地共存?

You download a model from HuggingFace and get a pile of .safetensors weight shards, plus a config.json and a tokenizer. But llama.cpp speaks exactly one file format end to end: .gguf. That middle step - "convert an HF model to GGUF" - is the star of this lesson, handled by convert_hf_to_gguf.py at the repo root. Many treat it as a black box: run one command, wait a bit, out comes a .gguf. This lesson pries the box open to see the three things it actually does.

Those three things are plain: (1) recognize the architecture - read architectures from config.json and dispatch to the matching converter class (Llama goes to LlamaModel, Qwen to Qwen2Model); (2) rename and align every tensor - HF calls the attention Q projection model.layers.0.self_attn.q_proj.weight, GGUF wants blk.0.attn_q.weight, and the hyper-parameters (layer count, dims, RoPE settings) must be written as GGUF metadata; (3) serialize in GGUF's byte format - write the header, then the metadata key-values, then the tensor-info table, then the actual weights after alignment. Understand these three steps and you can not just "use" the script but, when it does not support your model, know where to go fix it.

One structural change is worth flagging first: convert_hf_to_gguf.py used to be a multi-thousand-line monolith; it has been refactored into a thin command-line entry (about 300 lines), and the real work moved into a standalone conversion/ package - one module per architecture. So this lesson is about "how a package collaborates", not "what is inside one big file". Roadmap: first the dispatch flow behind one command (with a trace), then how the registry wires an "architecture name" to a "converter class", then tensor renaming and hyper-parameters, and finally the byte layout of the GGUF file itself.

🌍 Big picture
Conversion is at heart a format translation plus repack. In the HF files the information is scattered: weights live in .safetensors, hyper-parameters in config.json, the vocab in tokenizer files, and tensor names follow PyTorch habits. GGUF aims for the opposite - gather it all into a self-describing single file: open one .gguf and it holds both "what this model is" (architecture, layer count, dims, RoPE, vocab, all in metadata) and "all of the model's weights", with each tensor named by llama.cpp's own canonical scheme (blk.N.attn_q and friends) and laid out at a uniform alignment. What the converter does is reorganize the scattered information to this spec. Grasp that and you see why GGUF can "download one file, no Python needed, just mmap and run" - all the self-describing effort is paid once, here at conversion time (recall L13 on the GGUF format; this lesson is its "write side"). One more benefit is easy to miss: because "what the model is" lives entirely in the metadata, the same loader can - without changing a line - load architectures that do not exist yet, as long as the converter wrote their hyper-params to spec; the runtime just takes them as given.
🔌 Analogy
Think of conversion as typesetting a foreign manuscript into a formal publication. The manuscript (the HF model) was written to the author's own habits: chapters scattered across files, terminology in the original wording, a heap of loose notes attached. The publisher does three things: first recognize what kind of book this is (architecture dispatch - a novel gets a novel's layout, a textbook a textbook's); then unify the terminology and align the format (tensor renaming - swap the author's casual names for the book's one canonical naming); finally bind it to the publishing standard (GGUFWriter serialization - title and copyright page first, then the table of contents, then the body, every page aligned to the margins). The bound book (.gguf) carries its own manual: open the first page and you know what it is, how many chapters, how to read it - exactly why llama.cpp can load the model without the original config.json.

Behind one command: the thin CLI dispatches the work

Follow one command through. You type python convert_hf_to_gguf.py /path/to/hf-model, and the first thing this thin entry script does is not convert but identify: it opens config.json in the model directory and reads the architectures field - say "LlamaForCausalLM". That is the model's ID card. With the identity in hand it asks a registry one question: "who converts this architecture?" The answer is a Python class (LlamaModel); it instantiates that class and hands everything else to its write() method. The whole main() is surprisingly short:

# simplified from main() in convert_hf_to_gguf.py
hparams = ModelBase.load_hparams(dir_model)            # read config.json
arch    = get_model_architecture(hparams, model_type)  # take architectures[0]
model_class = get_model_class(arch)                   # look up the subclass in the registry
model = model_class(dir_model, output_type, fname_out, ...)
model.write()                                          # the real conversion + serialization live here

The key to this code is that the entry does almost nothing itself - load_hparams reads the config, get_model_architecture pulls out the architecture name, get_model_class looks up the class in the registry, then instantiate and call write(). The real conversion logic (how to read tensors, how to rename, how to write hyper-params) all lives in the looked-up class. This "the entry only dispatches, the details go to plugins" structure is exactly why it could slim from thousands of lines to 300: each architecture's special handling moved into its own module under the conversion/ package, out of each other's way. So what happens inside write()? It runs prepare_tensors (rename each tensor, pick its quant type, hand it to the writer) then prepare_metadata (gather hyper-params and vocab into metadata), then calls GGUFWriter to write the header, the KV, and the tensor data, and finally close - the second half of that trace above is almost entirely compressed into this one write() call.

The dispatch flow is clearest frozen into one diagram: in goes an HF directory, through "identify arch -> look up class -> instantiate -> convert and serialize", and out comes a .gguf. As you read the diagram, hold one contrast: what goes in on the left is "a pile of files scattered to PyTorch habits", what comes out on the right is "one file gathered to the GGUF spec", and every station in between does the same one thing - translate plus gather.

Tracing the dispatch of one conversion: the thin CLI reads the architecture name, looks up the matching converter class in the registry, instantiates it and calls write(), which renames+quantizes tensor by tensor and hands them to GGUFWriter to serialize (schematic).
(1) HF dir
.safetensors + config.json
the downloaded model
load_hparams
read config
(2) arch name
"LlamaForCausalLM"
architectures[0]
get_model_class
look up registry
(3) converter
LlamaModel instance
matches this arch
set_gguf_parameters
prepare_tensors
(4) canonical tensors
blk.N.* + quantized
renamed + dtype set
GGUFWriter
serialize
(5) file
model.gguf
self-describing file

The registry: how an architecture name reaches a converter class

That line "look up the registry for who is responsible" is the hinge of the whole conversion package. The registry itself is just an ordinary dictionary: architecture name (a string) -> converter class. The clever part is how it gets filled - through a classmethod called register used as a decorator. Each architecture module, when it defines its class, hangs one line above it: @ModelBase.register("LlamaForCausalLM", ...). Python runs that line when it loads the module, registering "these architecture names -> this class" into the dictionary. One class can claim several HF architecture names (Llama / Mistral / Mixtral share one conversion path), so a single register line often lists several names:

# conversion/base.py: the registry + decorator factory
class ModelBase:
    _model_classes = {ModelType.TEXT: {}, ModelType.MMPROJ: {}}   # arch name -> class

    @classmethod
    def register(cls, *names):          # takes some HF architecture names
        def func(modelcls):
            for name in names:
                cls._model_classes[model_type][name] = modelcls   # register it
            return modelcls
        return func

# conversion/llama.py: one architecture = one registered subclass
@ModelBase.register("LlamaForCausalLM", "MistralForCausalLM", "MixtralForCausalLM")
class LlamaModel(TextModel):
    model_arch = gguf.MODEL_ARCH.LLAMA
    def set_gguf_parameters(self): ...   # write layer count / dims / RoPE
    def modify_tensors(self, data, name, bid): ...  # rename / permute

Read this and you hold the entry point for "adding a new model to llama.cpp": without touching any trunk code, you just create a module under conversion/, write a subclass decorated with @ModelBase.register("YourArchName"), implement set_gguf_parameters (write hyper-params) and modify_tensors (adjust tensors as needed), and it is automatically claimed by the dispatch system. The official docs/development/HOWTO-add-model.md walks exactly this path. That is the power of "registry plus plugins": dozens of architectures are dozens of independent little files under conversion/, and anyone can copy an existing one into the next - which is how llama.cpp keeps up with the steady stream of new community models.

Tensor renaming and hyper-parameters: from PyTorch names to GGUF canonical names

After dispatch to LlamaModel, the real conversion runs two lines in parallel: one writes hyper-parameters, the other moves tensors. Hyper-params are handled by set_gguf_parameters - it writes the layer count, hidden dim, attention head count, RoPE settings and so on from config.json as GGUF metadata key-values (such as llama.block_count, llama.embedding_length). The tensor line runs through modify_tensors: it walks each weight, calls map_tensor_name to translate the HF name into the GGUF canonical name, and reshapes or re-lays-out the tensor itself when needed. The core is just a few lines:

# conversion/base.py: renaming (base-class default)
def map_tensor_name(self, name):
    new_name = self.tensor_map.get_name(key=name, try_suffixes=(".weight", ".bias"))
    if new_name is None:
        raise ValueError(f"Can not map tensor {name!r}")
    return new_name

def modify_tensors(self, data_torch, name, bid):
    return [(self.map_tensor_name(name), data_torch)]   # default: rename only, data as-is

Renaming relies on a mapping table, TensorNameMap: it converges the wildly varied naming across HF models onto llama.cpp's canonical names. For example the attention projections that HF calls self_attn.{q,k,v}_proj all become blk.N.attn_{q,k,v} in GGUF, with the layer number expanded per layer. Llama also has one unavoidable wrinkle: its Q/K weights must first be permuted - the row order HF stores Q/K in differs from what llama.cpp's RoPE implementation assumes, and without the re-permute the positional encoding computes wrong. It is exactly this "one or two special cases per model" handling that makes each architecture need its own subclass. It also explains a common error "Can not map tensor ...": usually it is not that the model is broken, but that this architecture's TensorNameMap has not yet recorded this tensor name, and you need to add a mapping or a small special case in the matching subclass. The table below is the most direct picture of this renaming step:

HF name (PyTorch habit)GGUF canonical namenote
model.layers.0.self_attn.q_proj.weightblk.0.attn_q.weightattention Q projection (needs permute)
model.layers.0.self_attn.k_proj.weightblk.0.attn_k.weightattention K projection (needs permute)
model.layers.0.mlp.gate_proj.weightblk.0.ffn_gate.weightFFN gate projection
model.embed_tokens.weighttoken_embd.weighttoken embedding table

What a GGUF file looks like: the self-describing byte layout

Once the tensors are renamed and the hyper-params written as KV, the last step is to serialize all of it in GGUF's byte format - the job of GGUFWriter in gguf-py. It writes in four sections, strictly in order: first the header (so you can see at a glance how much is inside), then the metadata KV section (the model's manual), then the tensor-info table (a directory of every weight), and finally the tensor-data section (the actual weight bytes). Stack those sections vertically and you have a .gguf file end to end:

headerheader (24 bytes)
magic "GGUF" + version=3 + tensor_count + kv_count; the first glance tells you how much metadata and how many tensors
metadataKV section
a run of "key + type tag + value": architecture, layer count, dims, RoPE, vocab, chat template... the model's whole "manual"
tensor tabletensor info section
one record per tensor: name + dims + dtype + offset into the data area; effectively a "table of contents"
alignpadding
zero-pad to a 32-byte boundary so the data area starts aligned, for zero-copy mmap
datatensor data section
the raw bytes of every weight, laid out block by block per the table's offsets; each block aligned too

The header is the simplest yet most crucial section - just four fixed-length fields, written in a few lines by write_header_to_file. Its reader (llama.cpp's loader) likewise reads these four numbers first to know how much follows:

# gguf-py/gguf/gguf_writer.py: write the header (four fields)
fout.write(self._pack("<I", GGUF_MAGIC))    # 0x46554747 = "GGUF"
fout.write(self._pack("I",  GGUF_VERSION))  # 3
fout.write(self._pack("Q",  len(tensors)))  # tensor count (u64)
fout.write(self._pack("Q",  len(kv_data)))  # metadata entry count (u64)

Each record in the tensor-info table carries an offset stating which byte of the data area this tensor's data starts at. The offset is accumulated as the table is written: after each tensor, the next offset goes += ggml_pad(this tensor's byte size, 32) - that is, each tensor is rounded up to a 32-byte boundary. This is exactly where --outtype lands: choosing f16 versus q8_0 decides how many bytes each tensor occupies and which GGMLQuantizationType code is written into the table (the quantization algorithm itself was covered in L06/L12; here we only record the result per the format). By now you can see GGUF has no "magic" at all: it is a plain convention for laying "manual + directory + data" into one aligned file - and precisely because it is plain, it can be read back reliably from any language on any platform. One more easily-missed point: every value in the metadata KV section is self-typed - a leading GGUFValueType tag says whether it is a u8, i32, f32, string, or array, so the reader knows how many bytes to take and how to parse them. Because values carry their own type, GGUF can pack an integer block_count, a long array of token strings, and a few floating-point RoPE parameters all into the same section without confusion - exactly the underpinning that lets it be "self-describing".

Deeper: alignment and the vocabulary

Two accordions, filling in two details that conversion cannot avoid yet the script "auto-handles" so quietly you never see them.

1 Why must everything align to 32 bytes? click to expand

Recall L14, where llama.cpp uses mmap to map the weight file straight into memory with no copy. For mmap to truly pay off (especially when the backend does SIMD / aligned access), the data's start address in the file should land on a tidy boundary. GGUF sets alignment to 32 bytes (GGUF_DEFAULT_ALIGNMENT = 32, overridable via the general.alignment metadata): each tensor's data start is rounded up to a multiple of 32, with zero padding in between. The cost is a few extra padding bytes in the file (at most 31 wasted per tensor, negligible against weights that run to tens of MB); the payoff is that loading can mmap whole blocks and the backend can do vectorized reads at aligned addresses, saving a copy and a potential misaligned-access penalty. That is why the byte-layout diagram above leaves a stretch of padding before the data section - it is not filler, it paves the way for zero-copy mapping at runtime. One detail ties two lessons together: L14 was about "how to mmap it in at load time", this lesson is about "how to lay the file out at conversion time so it can be mmap'd that way" - the two ends of one alignment convention, one writing, one reading, fitting exactly. Incidentally, alignment is everywhere in ggml - tensor memory allocation, backend buffers, the KV cache all carry a similar "round up to some boundary" consideration; GGUF merely carries that idea onto the on-disk file too, so that "the layout in the file" and "the layout in memory" naturally agree.

2 How does the vocabulary get written into GGUF? click to expand

The weights are only half; the other half is the vocabulary - without it the token ids the model emits cannot turn back into text (recall L20). HF's vocab lives in tokenizer.json / tokenizer.model; at conversion time set_vocab reads it out and writes each token's string, score, and type (normal / control / unknown / byte) into the GGUF metadata. llama.cpp supports a few tokenizer families - SentencePiece (common for the Llama line), BPE (the GPT line), and others - and set_vocab picks the matching reader per model. Beyond the tokens themselves, a batch of special tokens must be written too: BOS / EOS, padding, and the chat template (remember L22? a chat model's conversation format is stored in GGUF's tokenizer.chat_template). So a .gguf is genuinely "batteries included": vocab, special tokens, chat template all inside, and after loading you need not hunt down any original tokenizer file - which is why downloading one gguf is enough to run a chatting model, without rounding up the pile of companion files from the HF repo. To make it concrete: the dozens of files in an HF repo (several .safetensors shards, config.json, a heap of tokenizer.*) condense after conversion into one .gguf; that "one file and it runs" experience is precisely the work of steps like set_vocab gathering scattered information bit by bit into the metadata.

✅ Key points
  • Split: convert_hf_to_gguf.py is now a thin CLI; the conversion machinery is in the conversion/ package, serialization in gguf-py.
  • Dispatch: read architectures from config.json -> get_model_class -> registry @ModelBase.register("LlamaForCausalLM", ...) finds the subclass.
  • Rename: map_tensor_name via TensorNameMap turns HF names into GGUF canonical names (model.layers.0.self_attn.q_proj -> blk.0.attn_q); Llama also has to permute Q/K.
  • Hyper-params: set_gguf_parameters writes layer count / dims / RoPE etc. as GGUF metadata key-values.
  • Serialize: GGUFWriter in four sections - header (magic/version/tensor_count/kv_count) -> metadata KV -> tensor-info table -> align to 32B -> weight data; --outtype decides whether each tensor is stored f16/q8_0/...
  • Extend: adding an architecture = add a registered subclass in conversion/, implement set_gguf_parameters/modify_tensors (see HOWTO-add-model).
💡 Design insight
Two recurring pieces of engineering wisdom hide in this lesson. The first is the self-describing format: GGUF writes "how to read me" into the file itself - the metadata section states architecture, dims, vocab; the tensor-info table states each weight's shape, type, offset. The cost is writing a pile of metadata at conversion time; the payoff is zero external dependencies at runtime, mmap-ability, and parseability from any language. The second is registry plus plugin-style extension: @ModelBase.register turns "support a new architecture" into "add a file, do not touch the trunk" - dozens of models become dozens of independent little modules. You will meet both moves elsewhere: self-describing formats (think ELF, PNG, tar), registry dispatch (think the plugin mechanism of countless frameworks). See "converting a model" as "format translation plus plugin dispatch" and, faced with any not-yet-supported model, you know where to start - the very step from "can use it" to "can change it, can contribute" (the next lesson is about turning that step into a real PR). At bottom, this lesson is a turn from "reader" to "author": for thirty-some lessons you have been reading how llama.cpp runs; from here on you start to be able to write into it - and writing is where contributing begins - and the place this course has been quietly leading you toward.

🧪 Self-test - think about the design

1. What role does today's convert_hf_to_gguf.py play in conversion?
  1. a C++ program that loads gguf at runtime
  2. it trains the model
  3. a thin command-line entry: read the architecture, look up the matching converter class, then hand off the work; the real machinery lives in the conversion/ package
  4. a multi-thousand-line monolith with every architecture's conversion logic piled inside
Show answer & explanation click to expand
Answer: C. It has been refactored into a ~300-line thin CLI: main() just does load_hparams to read config.json -> get_model_architecture for the arch name -> get_model_class to look up the class in the registry -> instantiate -> call write(). Each architecture's concrete conversion logic moved into its own module under conversion/ (conversion/llama.py and friends). So do not read only the root file - look at ModelBase in conversion/base.py and the per-architecture subclasses.
2. What does the decorator @ModelBase.register("LlamaForCausalLM", ...) do?
  1. validates that the input is well-formed
  2. adds a caching layer to a function
  3. registers these HF architecture names into an 'arch name -> converter class' table, so dispatch can find the subclass from config.json
  4. registers the model weights onto the GPU
Show answer & explanation click to expand
Answer: C. register is a classmethod on ModelBase used as a decorator factory. Python runs the line when it loads an architecture module, mapping the several HF architecture names in the parentheses onto the decorated class (one class can claim several names, e.g. Llama/Mistral/Mixtral share one). So get_model_class, given config.json's architectures, finds which subclass to use. Adding an architecture = add a subclass with register, no trunk changes - that is 'registry plus plugins'.
3. What four fields make up the header at the very start of a GGUF file?
  1. filename + size + checksum + timestamp
  2. architecture + layer count + dims + vocab size
  3. magic("GGUF") + version(3) + tensor_count + kv_count
  4. the first tensor's name, shape, type, and data
Show answer & explanation click to expand
Answer: C. GGUFWriter.write_header_to_file writes just four fixed-length fields: magic 0x46554747 ("GGUF"), version=3, tensor_count (u64), kv_count (u64). The loader reads these four numbers first to know how much metadata KV and how many tensor-info records follow. Architecture/layers/dims/vocab are written in the metadata KV section after the header, not in it; a tensor's name/shape/type/offset live in the tensor-info section further on.
💭 Open questions (no single right answer - just think or try)
  • Suppose you want llama.cpp to support a new architecture it does not yet know (say a freshly released HF model "FooForCausalLM"). Follow this lesson's dispatch flow: (1) what kind of class would you add under conversion/, what does it inherit, and what does the register line above it say? (2) which two methods must it implement at minimum, and what part of 'hyper-params' vs 'tensors' does each handle? (3) if conversion raises "Can not map tensor ...", what does that usually mean and where do you go fix it? (4) why does this 'add a subclass, do not touch the trunk' design let dozens of architectures coexist without interfering?