Rust in Everything
基于 candle-core 0.11.0

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 时停下。

📝NOTE
冷知识:上限检查是两个 PR 分两步加的

#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 下得到一个空的巨大张量。

💬 关联讨论

发起讨论
加载中...