读 GGUF 头部要几十毫秒
主线位置:推理主线的第一步,
gguf_file::Content::read读魔数、元数据和张量目录。格式和逐字段的解析见 GGUF 文件,那里也量了 0.11.0 加的检查让解析慢了多少;整条生成每一段的耗时见 生成一个 token。
flowchart TB
read["Content::read"] --> str["9.8 万个字符串"]
str --> pos["每个一次 stream_position"]
pos --> file{"reader"}
file -->|File| a["读和 seek 都是系统调用 130 ms"]
file -->|BufReader| b["seek 仍落到 File 32 ms"]
file -->|"内存 Cursor"| c["没有系统调用 6 ms"]
实验 crate exp-candle/pb-pit,bin ggufpit,SmolLM2-135M-Instruct-Q8_0.gguf(拷到实验目录里的副本,145 MB,头部 1.78 MB),release,文件已在页缓存中,M2 Max、macOS 27、rustc 1.99.0。
现象
candle 自带的 quantized 例子是这样读的(candle-examples/examples/quantized/main.rs):
let mut file = std::fs::File::open(&model_path)?;
// ...
let model = gguf_file::Content::read(&mut file).map_err(|e| e.with_path(model_path))?;
同一个文件,四种 reader,各三轮:
let c = Content::read(&mut File::open(&q8)?)?;
let _ = Content::read(&mut BufReader::new(File::open(&q8)?))?;
let _ = Content::read(&mut BufReader::with_capacity(1 << 20, File::open(&q8)?))?;
let _ = read_header(&q8)?; // 4 MiB prefix in memory + Cursor, see below
round 0: File 130.1 ms, BufReader 32.6 ms, BufReader 1 MiB 32.1 ms, 4 MiB prefix + Cursor 6.5 ms (272 tensors)
round 1: File 129.9 ms, BufReader 31.7 ms, BufReader 1 MiB 31.4 ms, 4 MiB prefix + Cursor 6.3 ms (272 tensors)
round 2: File 128.9 ms, BufReader 31.6 ms, BufReader 1 MiB 31.4 ms, 4 MiB prefix + Cursor 6.3 ms (272 tensors)
只解析 1.78 MB 的头部(文件已经在页缓存里,没有真正的磁盘读),直接读 File 要 130 ms,比这个模型在 Metal 上生成 20 个 token 还久。套上 BufReader 是 32 ms,把缓冲区从 8 KiB 加到 1 MiB 没有任何改善;同样的字节放在内存里解析只要 6.3 ms。
还有一个相关的坑:先读了几个字节「看一眼魔数」再交给 Content::read:
let mut f = BufReader::new(File::open(&q8)?);
let mut head = [0u8; 4];
f.read_exact(&mut head)?; // sniff the magic first
let c = Content::read(&mut f)?;
sniffed "GGUF"
Error: unknown magic 0x00000003
exit=1
0x00000003 是紧跟在魔数后面的版本号。
出处
Content::read 开头记下 stream_position(),从那里开始解析,不会先回到文件开头,所以先读掉的 4 个字节就让它把版本号当成了魔数。之后每读一个字符串或数组的长度前缀,都要用 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))
}
SmolLM2 的头部有 49152 个词表 token、48900 条 merge 规则,加上键名和张量名,一共约 9.8 万个字符串,就是约 9.8 万次 stream_position。
File的stream_position是一次lseek系统调用;每个小字段的read也是一次系统调用。两样加起来是 130 ms。BufReader把 24 万次小读取合并成 219 次,但它的stream_position要先问底层File的位置、再减去缓冲区里没读完的部分,所以 98384 次lseek一次不少(GGUF 文件 里用计数包装层数过)。缓冲区再大也省不掉这一部分。Cursor<&[u8]>的stream_position只是读一个字段。
这些检查是 0.11.0 为了挡住恶意文件加的(#3556),GGUF 文件 把检查之前的解析代码拷出来对比过:那时 BufReader 下约 5.6 ms。安全检查本身是对的,只是落在了 BufReader<File> 最贵的那个操作上。
修法
把头部读进内存,用 Cursor 解析;张量数据仍然从文件按偏移量读。头部有多大事先不知道,就先读一个前缀,不够再放大:
/// Parse the header from an in-memory prefix of the file; grow the prefix if it was too short.
fn read_header(path: &str) -> anyhow::Result<Content> {
let mut f = File::open(path)?;
let mut len = 4 << 20;
loop {
let mut buf = vec![];
(&mut f).take(len).read_to_end(&mut buf)?;
let short = (buf.len() as u64) < len;
match Content::read(&mut Cursor::new(&buf)) {
Ok(c) => return Ok(c),
Err(e) if short => return Err(e.into()),
Err(_) => {
f.seek(SeekFrom::Start(0))?;
len *= 4;
}
}
}
}
let content = read_header(path)?;
let mut f = BufReader::new(File::open(path)?);
let model = ModelWeights::from_gguf(content, &mut f, &dev)?;
Cursor从 0 开始,和文件的偏移一致,所以解析出来的tensor_data_offset可以直接用在真正的文件上。前缀不够时
Content::read返回Err(不是 panic),这时放大重读;读到整个文件还失败,才是真的坏文件。故意从很小的前缀开始(实验里的PREFIX环境变量打印每次失败的原因):prefix 100000 bytes: gguf: array of 49152 elements needs at least 393216 bytes, only 98893 remaining, retrying with 400000 prefix 400000 bytes: failed to fill whole buffer, retrying with 1600000 prefix 1600000 bytes: gguf: string length 6 exceeds remaining file bytes 0, retrying with 6400000 272 tensors, tensor_data_offset 1785952三种失败分别来自数组的剩余字节检查、
read_exact读到末尾、字符串的剩余字节检查,都是Err。- SmolLM2 的头部 1.78 MB,4 MiB 的前缀一次就够;词表更大的模型多读一两轮。
- 上表最后一列就是这个函数:6.3 ms,其中读 4 MiB 本身也算在内。
不想写这么多,至少给 File 套一层 BufReader,从 130 ms 降到 32 ms。传给 Content::read 的 reader 要在文件开头,「看一眼魔数」之后要 seek(SeekFrom::Start(0))。
怎么确认没中招
- 单独给
Content::read计时。超过 10 ms 就说明 reader 是File或BufReader<File>。 - 数系统调用:给 reader 套一个计数
seek的包装层(GGUF 文件 的做法),或在 Linux 上用strace -c,lseek的次数和头部的字符串个数同一个量级就是这个问题。 - 读之前
assert_eq!(reader.stream_position()?, 0)。
小结
Content::read每个长度前缀调一次stream_position;SmolLM2 的头部有约 9.8 万个字符串,File上 130 ms,BufReader上 32 ms(lseek省不掉,缓冲区再大也没用)。- 把头部读进内存用
Cursor解析,6.3 ms;张量照常从文件读。 Content::read从当前位置开始解析,传进去之前 reader 要在文件开头,否则报unknown magic。