Rust in Everything
基于 candle-core 0.11.0

读 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。

💬 关联讨论

发起讨论
加载中...