GGUF 文件
主线位置:推理主线的第一步。
gguf_file::Content::read读头部和张量目录,ModelWeights::from_gguf再按名字调Content::tensor把每个权重读成QTensor。各阶段的耗时见生成一个 token,本页讲文件里有什么、candle 怎么解析、对坏文件挡住了什么。
flowchart TB hdr["魔数 版本 两个计数"] --> kv["元数据键值"] kv --> ti["张量目录"] ti --> pad["补齐到 alignment"] pad --> data["张量数据区"] ti -.->|offset| data read["Content read"] --> hdr read --> kv read --> ti tensor["Content tensor"] -.->|seek| data
GGUF 是 llama.cpp(ggml)的权重格式:一个文件装下超参数、分词器和所有量化权重。代码在 candle-core/src/quantized/gguf_file.rs(638 行),读写都在这一个文件里。旁边的 ggml_file.rs 是更老的 ggml 格式,imatrix_file.rs 读 llama.cpp 的重要性矩阵,本卷不讲。
实验 crate 是 exp-candle/quant-exp(candle-core 0.11.0,开 metal、accelerate),release 构建;平台 M2 Max、macOS 27、rustc 1.99.0。
文件布局和 Content
解析的结果是 Content:
#[derive(Debug)]
pub struct Content {
pub magic: VersionedMagic,
pub metadata: HashMap<String, Value>,
pub tensor_infos: HashMap<String, TensorInfo>,
pub tensor_data_offset: u64,
}
四个字段对应文件的前四段:开头 4 字节魔数和 4 字节版本,接着张量个数和元数据条数,然后是元数据键值,再是张量目录,补齐到对齐边界之后是数据区。Content 不装任何权重,只记下数据区从哪个字节开始(tensor_data_offset)。权重留在文件里,等 Content::tensor 按名字去取。
Content::read 从 reader 的当前位置开始解析(先 stream_position 记下起点),所以传进来之前不要把 reader 挪到别处,否则读到的「魔数」是别的字节,报 unknown magic。所有整数都是小端。VersionedMagic::read 先读魔数,再读版本:
impl TryFrom<u32> for Magic {
type Error = crate::Error;
fn try_from(value: u32) -> Result<Self> {
let magic = match value {
0x46554747 | 0x47475546 => Self::Gguf,
_ => crate::bail!("unknown magic 0x{value:08x}"),
};
Ok(magic)
}
}
0x46554747 是字节 GGUF 按小端读出来的值,0x47475546 是它的字节反序。两种都认,但后面的字段一律按小端读,所以反序魔数的文件也会被当成普通文件解析(实测一个反序魔数、版本 3、两个计数都是 0 的 24 字节文件,read 返回 Ok)。版本只认 1、2、3。v1 的长度字段(字符串长度、数组长度、两个计数)是 u32,v2 起改成 u64,read_length 按版本选。candle 自己写文件用的是 v2(write 里 w.write_u32::<LittleEndian>(2)?; // version 2.)。
元数据:13 种值
每条元数据是「字符串键、u32 类型号、值」。ValueType 有 13 种:8 种整数、F32、F64、Bool、String、Array,类型号照抄 GGUF 规范(0 是 U8,8 是 String,9 是 Array,10 到 12 是后加的 U64、I64、F64)。Value::read 按类型号逐个读:Bool 只接受 0 和 1;字符串是「长度 + 字节」,去掉末尾的 \0,再 String::from_utf8_lossy(源码注释说规范要求不带结尾的 0、也要求 UTF-8,但实际文件里两条都有人违反);数组是「元素类型号 + 长度 + 元素」,元素可以又是数组。
取值用 to_u32、to_f32、to_string 这些方法,类型不对就是 Err。只有 to_u64 会把 U8、U16、U32、Bool 自动放宽成 u64。模型代码用哪个方法要和转换工具写的类型对上:SmolLM2 文件里的 llama.block_count 是 U32(30),用 to_u64 能取,用 to_i32 就报 not a i32 U32(30)。
实验程序 gguf 打印 SmolLM2-135M-Instruct-Q8_0.gguf 的全部 37 个键(数组只给元素类型和长度):
== SmolLM2-135M-Instruct-Q8_0.gguf (144811360 bytes)
magic GgufV3, 37 metadata keys, 272 tensors, tensor_data_offset 1785952
general.architecture = String("llama")
general.basename = String("smollm2")
general.file_type = U32(7)
...
general.quantization_version = U32(2)
general.size_label = String("135M")
general.type = String("model")
llama.attention.head_count = U32(9)
llama.attention.head_count_kv = U32(3)
llama.attention.layer_norm_rms_epsilon = F32(1e-5)
llama.block_count = U32(30)
llama.context_length = U32(8192)
llama.embedding_length = U32(576)
llama.feed_forward_length = U32(1536)
llama.rope.dimension_count = U32(64)
llama.rope.freq_base = F32(100000.0)
llama.vocab_size = U32(49152)
quantize.imatrix.chunks_count = I32(141)
quantize.imatrix.dataset = String("/training_dir/calibration_datav3.txt")
quantize.imatrix.entries_count = I32(210)
quantize.imatrix.file = String of 68 bytes
tokenizer.chat_template = String of 368 bytes
tokenizer.ggml.add_bos_token = Bool(false)
tokenizer.ggml.add_space_prefix = Bool(false)
tokenizer.ggml.bos_token_id = U32(1)
tokenizer.ggml.eos_token_id = U32(2)
tokenizer.ggml.merges = Array<String> len 48900
tokenizer.ggml.model = String("gpt2")
tokenizer.ggml.padding_token_id = U32(2)
tokenizer.ggml.pre = String("smollm")
tokenizer.ggml.token_type = Array<I32> len 49152
tokenizer.ggml.tokens = Array<String> len 49152
tokenizer.ggml.unknown_token_id = U32(0)
键分三组。general.* 是描述;llama.* 是 ModelWeights::from_gguf 要的超参数,前缀就是 general.architecture 的值(30 层、9 个查询头、3 个 KV 头、隐层 576、MLP 1536、rope 底数 100000);tokenizer.* 是整份分词器:49152 个 token 字符串、48900 条 BPE merge、每个 token 一个类型号。头部 1785952 字节里几乎全是这三个数组,这就是读头部要逐个解析近十万个字符串的原因(下面的计时)。quantize.imatrix.* 说明 bartowski 量化时用了重要性矩阵,这个文件是 Q8_0 也带着它。candle 可以从这些 tokenizer.ggml.* 键直接构造分词器(TokenizerFromGguf,见源码地图)。
张量目录
元数据之后是 tensor_count 条张量信息,Content::read 里每条读名字、维数、各维大小、dtype 号、偏移:
let mut dimensions: Vec<usize> = match magic {
// ... v1 读 u32,v2 / v3 读 u64
};
dimensions.reverse();
let ggml_dtype = reader.read_u32::<LittleEndian>()?;
let ggml_dtype = GgmlDType::from_u32(ggml_dtype)?;
let offset = reader.read_u64::<LittleEndian>()?;
dimensions.reverse() 是因为 ggml 的维度从最内层(连续的那一维)写起,ne[0] 是一行的长度;candle 的 Shape 和 PyTorch 一样从最外层写起。所以文件里写成 [576, 49152] 的 embedding,在 candle 里是 [49152, 576]:49152 行,每行 576 个数,量化块沿着这 576 个数切。dtype 号也照搬 ggml(0 是 F32,8 是 Q8_0,12 是 Q4K,30 是 BF16),不认识的号报 unknown dtype for tensor {u}。各种 dtype 的块结构见量化格式。
offset 是相对数据区起点的字节数。数据区起点等于目录读完时的位置向上取整到对齐值:
let position = reader.stream_position()?;
let alignment = match metadata.get("general.alignment") {
Some(Value::U8(v)) => *v as u64,
// ... U16、U32,以及非负的 I8、I16、I32
_ => DEFAULT_ALIGNMENT,
};
let tensor_data_offset = position.div_ceil(alignment) * alignment;
对齐值来自 general.alignment,没有就是 DEFAULT_ALIGNMENT = 32。SmolLM2 的文件没有这个键,目录读完时位置是 1785944(实验程序 endpos),向上取整到 32 的倍数就是 1785952。
gguf 程序按偏移排序后打印前几条和 dtype 统计(HashMap 不保留文件里的顺序):
first tensors in file order:
token_embd.weight Q8_0 [49152, 576] offset 0
blk.0.attn_norm.weight F32 [576] offset 30081024
blk.0.ffn_down.weight Q8_0 [576, 1536] offset 30083328
blk.0.ffn_gate.weight Q8_0 [1536, 576] offset 31023360
blk.0.ffn_up.weight Q8_0 [1536, 576] offset 31963392
blk.0.ffn_norm.weight F32 [576] offset 32903424
blk.0.attn_k.weight Q8_0 [192, 576] offset 32905728
blk.0.attn_output.weight Q8_0 [576, 576] offset 33023232
blk.0.attn_q.weight Q8_0 [576, 576] offset 33375744
blk.0.attn_v.weight Q8_0 [192, 576] offset 33728256
...
... last: output_norm.weight F32 [576] offset 143023104
dtype F32 61 tensors, 35136 elems, 140544 bytes, 32.000 bits/weight
dtype Q8_0 211 tensors, 134479872 elems, 142884864 bytes, 8.500 bits/weight
272 个张量:每层 9 个(4 个注意力投影、3 个 MLP 投影、2 个 RmsNorm 权重)乘 30 层,加 token_embd.weight 和 output_norm.weight。归一化权重是一维的 F32,没有量化;矩阵全是 Q8_0。没有 output.weight:SmolLM2 的输出层和 embedding 共享权重,from_gguf 取不到 output.weight 时就用 token_embd.weight(见量化 llama 的一次前向)。
Q4_K_M 文件:大部分不是 Q4K
同一个模型的 Q4_K_M.gguf 元数据和张量名字完全一样,dtype 却是混合的。gguf 程序把 blk.N. 前缀去掉,看每类张量拿到什么 dtype:
== SmolLM2-135M-Instruct-Q4_K_M.gguf (105454432 bytes)
magic GgufV3, 37 metadata keys, 272 tensors, tensor_data_offset 1785952
dtype F32 61 tensors, 35136 elems, 140544 bytes, 32.000 bits/weight
dtype Q4K 16 tensors, 14155776 elems, 7962624 bytes, 4.500 bits/weight
dtype Q5_0 166 tensors, 78077952 elems, 53678592 bytes, 5.500 bits/weight
dtype Q6K 14 tensors, 12386304 elems, 10160640 bytes, 6.562 bits/weight
dtype Q8_0 15 tensors, 29859840 elems, 31726080 bytes, 8.500 bits/weight
attn_k.weight Q5_0 x30
attn_norm.weight F32 x30
attn_output.weight Q5_0 x30
attn_q.weight Q5_0 x30
attn_v.weight Q5_0 layers [2, 4, 5, 11, 12, 14, 15, 17, 18, 20, 22, 23, 25, 26, 28, 29] | Q8_0 layers [0, 1, 3, 6, 7, 8, 9, 10, 13, 16, 19, 21, 24, 27]
ffn_down.weight Q4K layers [2, 4, 5, 11, 12, 14, 15, 17, 18, 20, 22, 23, 25, 26, 28, 29] | Q6K layers [0, 1, 3, 6, 7, 8, 9, 10, 13, 16, 19, 21, 24, 27]
ffn_gate.weight Q5_0 x30
ffn_norm.weight F32 x30
ffn_up.weight Q5_0 x30
output_norm.weight F32
token_embd.weight Q8_0
名叫 Q4_K_M 的文件里只有 16 个 Q4K 张量,166 个是 Q5_0。原因在形状:K-quants(Q4K、Q6K 等)一个块 256 个数,一行的长度必须是 256 的倍数;SmolLM2 的隐层是 576 = 2.25 × 256,凡是「每行 576 个数」的矩阵(q、k、v、o 投影,MLP 的 gate 和 up)都用不了 K-quants,量化工具退而用块大小 32 的类型。只有 ffn_down(每行 1536 = 6 × 256)能用 K-quants:一半层是 Q4K,另一半是 Q6K;attn_v 也按同样的层号在 Q5_0 和 Q8_0 之间交替。这种「M」配方是 llama.cpp 量化工具的规则,GGUF 文件只记结果,candle 只管按每个张量自己的 dtype 读。整体是每个权重约 6.2 位,文件比 Q8_0 小 27%。这对速度的影响(Q5_0 在 CPU 上没有 SIMD 实现)见 QTensor 与 QMatMul。
按偏移读一个张量
Content::tensor 按名字查 tensor_infos,查不到报 cannot find tensor info for {name},查到就交给 TensorInfo::read:
let size_in_bytes = tensor_elems / block_size * self.ggml_dtype.type_size();
let tensor_start = tensor_data_offset.saturating_add(self.offset);
let file_size = reader.seek(std::io::SeekFrom::End(0))?;
let remaining = file_size.saturating_sub(tensor_start);
if size_in_bytes as u64 > remaining {
crate::bail!(
"tensor needs {size_in_bytes} bytes at offset {tensor_start}, only {remaining} remaining in file"
)
}
let mut raw_data = vec![0u8; size_in_bytes];
reader.seek(std::io::SeekFrom::Start(tensor_start))?;
reader.read_exact(&mut raw_data)?;
super::ggml_file::qtensor_from_ggml(
self.ggml_dtype,
&raw_data,
self.shape.dims().to_vec(),
device,
)
字节数由元素个数、块大小和每块字节数算出来,文件里没有单独存。读出的字节先进一个 Vec<u8>,再由 qtensor_from_ggml 按 dtype 把它看成块数组:CPU 上再 to_vec() 拷一份成 Vec<BlockQ8_0> 之类,Metal 上拷进一个新的 MTLBuffer。所以每个张量在内存里过两次,没有 mmap。qtensor_from_ggml 只认 F32、F16、BF16 和 Q4_0 到 Q6K,Q8_1、Q8K 这两种只在计算时给激活值用的类型出现在文件里会报 quantized type ... is not supported yet。
TensorInfo::read 每次都 seek 到文件末尾取文件大小,所以调用方可以按任意顺序取张量,from_gguf 就是按层、按名字取的,不按文件顺序。
读头部的代价:一个字符串一次 seek
生成一个 token 里测过:Content::read 直接读 File 要 130 ms,套 BufReader 降到 31 ms。gguf 程序再加一种:整个文件先读进内存,用 Cursor 解析:
read: File 132.0 ms, BufReader 34.8 ms, Cursor(in memory) 6.9 ms
同样是在用户态解析同样的字节,BufReader 还比 Cursor 慢 5 倍。实验程序 craft count 用一个计数的包装层数清楚是谁在调底层文件:
raw File: 247059 read calls, 98384 seek calls
BufReader: 219 read calls, 98384 seek calls reach the File
BufReader 把 24 万次小读取合并成了 219 次,但 98384 次 seek 一次不少地落到了 File 上,每次都是一个 lseek 系统调用。它们来自每个长度前缀之后的边界检查 remaining_bytes:
fn remaining_bytes<R: std::io::Seek>(reader: &mut R, file_size: u64) -> Result<u64> {
let cur = reader.stream_position()?;
Ok(file_size.saturating_sub(cur))
}
BufReader::stream_position 要知道底层文件的当前位置再减去缓冲区里没读完的部分,于是每次都调一次底层的 stream_position。98384 正好对得上:文件里 98375 个字符串(37 个键、14 个字符串值、49152 个 token、48900 条 merge、272 个张量名)各一次,4 个数组各一次,头部计数检查一次,开头取文件大小三次(取位置、跳到末尾、跳回来),读完目录取位置一次。这些检查是 0.11.0 才加的(见下一节),检查之前的版本没有这些 seek。把检查之前的 gguf_file.rs 拷进实验 crate(src/old_gguf.rs,取自 #3556 的父提交,删掉写文件的部分),和 0.11.0 在同一个进程里交替计时(oldbench,三轮):
round 0: old: File 104.1 ms, BufReader 5.8 ms, Cursor 5.5 ms; 0.11.0: File 133.4 ms, BufReader 32.0 ms, Cursor 5.7 ms;
round 1: old: File 102.9 ms, BufReader 5.6 ms, Cursor 5.2 ms; 0.11.0: File 131.6 ms, BufReader 31.5 ms, Cursor 5.6 ms;
round 2: old: File 119.1 ms, BufReader 5.8 ms, Cursor 5.2 ms; 0.11.0: File 131.5 ms, BufReader 31.5 ms, Cursor 5.4 ms;
旧版本套 BufReader 和纯内存一样快(5.6 ms 对 5.2 ms);0.11.0 套 BufReader 是 31.5 ms,多出来的 26 ms 全是这 98384 次 lseek。直接读 File 时两版都慢(103 ms 对 132 ms),那里的大头是 24 万次 read 系统调用。所以在 0.11.0 上读 GGUF,最快的是先把头部读进内存(或者整个文件 mmap 后用 Cursor),其次才是 BufReader。
分配上限:坏文件挡在哪一步
GGUF 头部的每个长度字段都来自文件本身。0.11.0 之前,read_string 读到长度就 vec![0u8; len],数组读到长度就 Vec::with_capacity(len),张量按目录里的形状直接分配。一个改了几个字节的文件就能让进程申请上 PB 的内存,或者在嵌套数组里递归到栈溢出。0.11.0 在文件开头加了四个常数:
// Caps mirror ggml-org/llama.cpp#19856 (GGUF_MAX_STRING_LENGTH,
// GGUF_MAX_ARRAY_ELEMENTS) and GGML_MAX_DIMS from ggml.h. The depth cap
// bounds recursion through nested `Value::Array` so a crafted file cannot
// overflow the stack with a chain of arrays-of-arrays.
const GGUF_MAX_STRING_LENGTH: u64 = 1 << 30;
const GGUF_MAX_ARRAY_ELEMENTS: u64 = 1 << 30;
const GGUF_MAX_TENSOR_DIMS: u32 = 4;
const GGUF_MAX_VALUE_DEPTH: usize = 64;
再加上「剩下的字节够不够」的检查:头部声明的条数按每条最小字节数(一条元数据至少 长度前缀 + 4 + 1,一条张量信息至少 长度前缀 + 4 + 4 + 8)乘起来不能超过文件剩余;字符串长度不能超过剩余;数组长度乘以元素的最小字节数(min_disk_size)不能超过剩余;读张量时字节数不能超过剩余。前三个检查在 Content::read 里,最后一个在 TensorInfo::read 里。
实验程序 craft caps 在自己的目录里造了一组文件(真实模型只读,截断的副本是拷出来再截的),逐个交给 Content::read:
[trunc_1MB] 1000000 bytes -> Err: gguf: array of 48900 elements needs at least 391200 bytes, only 39805 remaining
[trunc_2MB] 2000000 bytes -> Ok: 37 kvs, 272 tensors, data offset 1785952
[trunc_2MB] tensor blk.0.ffn_down.weight -> Err: tensor needs 940032 bytes at offset 31869280, only 0 remaining in file
[tensor_count_2^40] 24 bytes -> Err: gguf: tensor_count 1099511627776 exceeds max 1073741824
[kv_count_1e6] 24 bytes -> Err: gguf: header declares 0 tensors and 1000000 metadata entries, needs at least 13000000 bytes, only 0 remaining
[key_len_2^40] 96 bytes -> Err: gguf: string length 1099511627776 exceeds max 1073741824
[key_len_1MiB_short_file] 132 bytes -> Err: gguf: string length 1048576 exceeds remaining file bytes 100
[u32_array_2^28] 113 bytes -> Err: gguf: array of 268435456 elements needs at least 1073741824 bytes, only 64 remaining
[nested_70] 896 bytes -> Err: gguf: value nesting depth exceeds max 64
[dims_5] 153 bytes -> Err: gguf: tensor 't' has 5 dimensions, max is 4
[version_4] 24 bytes -> Err: gguf: unsupported magic/version Gguf/4
[magic_ggml] 12 bytes -> Err: unknown magic 0x67676d6c
- 截在 1 MB 处,正好落在
tokenizer.ggml.merges数组中间:数组长度 48900 乘每个字符串至少 8 字节的长度前缀,超过剩下的 39805 字节,在分配之前就报错。 - 截在 2 MB 处,头部完整(数据区从 1785952 开始),
Content::read成功;取张量时才发现数据不够。头部能读不代表文件完整。 - 「超过上限」和「超过剩余字节」是两道独立的检查,大文件里写一个 2^40 的长度会先撞上限,小文件里写一个 1 MiB 的长度会撞剩余字节。
同一组文件交给检查之前的旧代码(oldcaps,每个文件一个进程),这台 Mac 上大多数只是晚一点报 failed to fill whole buffer:macOS 对大块清零内存是按需映射的,vec![0u8; 1 << 40] 会成功,读的时候才失败。长度再大一些、或者嵌套再深一些就不行了:
== key_len_2^50
memory allocation of 1125899906842624 bytes failed
old exit=134
Err: gguf: string length 1125899906842624 exceeds max 1073741824
== u32_array_2^50
memory allocation of 36028797018963968 bytes failed
old exit=134
Err: gguf: array length 1125899906842624 exceeds max 1073741824
nested_1e6: exit=134
thread 'main' (3807238) has overflowed its stack
fatal runtime error: stack overflow, aborting
memory allocation ... failed 是 Rust 分配失败时直接 abort,Result 来不及返回;36028797018963968 是 2^50 乘 size_of::<Value>() 的 32 字节,说明旧代码按声明的长度预留了整个 Vec<Value>。一百万层嵌套的数组让递归的 Value::read 爆栈。0.11.0 对这三个文件都返回 Err,嵌套的那个在深度超过 64 时停下。
#3556「Cap allocations in the GGUF loader」(2026-06-06)的提交说明是:「The loader passed caller-controlled length fields into allocation calls without bounds checks. Adds size caps matching ggml-org/llama.cpp#19856, a remaining-bytes check, a GGML_MAX_DIMS cap on tensor dimensions, and a recursion depth cap on Value::Array.」它管的是头部:字符串、数组、两个计数、维数、嵌套深度。四天后的 #3585「Verify gguf tensor size before allocating」补上了 TensorInfo::read 里那一段,之前这里是 vec![0u8; size_in_bytes] 之后直接 seek(tensor_data_offset + self.offset) 再 read_exact。两个都首次发布于 0.11.0。#3556 的第二个提交「Avoid re-seeking on every GGUF length check」说最初的写法每次检查都跳到文件末尾再跳回来,「roughly doubled load time」,于是改成开头取一次文件大小;剩下的那一次 stream_position 就是上一节的 98384 次 lseek。
还没挡住的两处
上限检查之外,craft 还试了两个文件,都能让 0.11.0 的 Content::read 不返回 Err:
thread 'main' (3801484) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/quantized/gguf_file.rs:560:43:
attempt to divide by zero
这是 general.alignment 写成 U32(0) 的文件(release 和 debug 都一样):position.div_ceil(alignment) 除以零,panic。另一个是一个四维张量,每维 2^32:
[dims_overflow] 145 bytes -> Ok: 0 kvs, 1 tensors, data offset 96
shape [4294967296, 4294967296, 4294967296, 4294967296]
tensor -> Ok QTensor[[4294967296, 4294967296, 4294967296, 4294967296]; F32], 0 bytes
dequantize -> Ok([4294967296, 4294967296, 4294967296, 4294967296])
维数没超过 4,每维也没有上限,元素个数 2^128 在 release 下按 64 位回绕成 0,于是要读 0 字节,剩余字节检查通过,得到一个形状声称有 2^128 个元素、存储是空的 QTensor。debug 构建里同一个文件在 Shape::elem_count 的乘法上 panic(attempt to multiply with overflow)。两处都只影响构造出来的坏文件;正常转换出来的 GGUF 不会这样写。
写文件
gguf_file::write 是反过来的:写 v2 头部、元数据、张量目录,每个张量的数据补齐到 32 字节。它不看传进来的元数据里有没有 general.alignment,总是按 32 补齐,而读的一方会按 general.alignment 算数据区起点。实验程序 writer 写两个各含两个 Q8_0 张量的小文件,一个不带这个键,一个带 general.alignment = U32(64):
alignment None: magic GgufV2, data offset 128, file 256 bytes, a[..4] = [0.0, 0.9765625, 1.953125, 2.9296875], b[..4] = [0.99993896, 0.99993896, 0.99993896, 0.99993896]
Error: tensor needs 34 bytes at offset 256, only 32 remaining in file
不带的那个读回来没问题;带 64 的那个,读的一方把数据区起点算到了 192(写的一方按 32 对齐放在 160),第二个张量的偏移落到文件外面。用 write 时不要在元数据里写 general.alignment。
小结
- GGUF = 魔数、版本、两个计数、元数据键值、张量目录、补齐、数据区;
Content::read只解析到数据区起点,不读权重。 - 元数据 13 种值,SmolLM2 的头部 1.7 MB 几乎全是分词器的三个数组;
llama.*是建模型用的超参数。 - 维度在文件里从最内层写起,读进来要
reverse;块沿着最后一维(一行)切,所以 K-quants 要求行长是 256 的倍数,SmolLM2 的 Q4_K_M 文件因此大半是 Q5_0。 Content::tensor按偏移seek、读进Vec<u8>,CPU 上再拷一份成块数组,Metal 上拷进缓冲区,没有 mmap。- 0.11.0 加的上限和剩余字节检查把坏文件的 abort 和爆栈变成了
Err;代价是每个字符串一次stream_position,BufReader下 98384 次lseek,解析从 5.6 ms 变成 31.5 ms,读进内存再解析可以避开。 - 仍会 panic 的输入:
general.alignment = 0;维度乘积溢出时 release 下得到一个空的巨大张量。