Rust in Everything
基于 candle-core 0.11.0

attempt to divide by zero

主线位置:推理主线的第一步,gguf_file::Content::read 读完张量目录、按 general.alignment 算出张量数据的起点。头部格式和 0.11.0 加的那些分配上限见 GGUF 文件,那一页还用一组手工构造的坏文件逐个试过。

flowchart TB
  hdr["头部 general.alignment"] --> a{"值"}
  a -->|0| div["div_ceil 除以 0"]
  div --> panic["panic 不是 Err"]
  a -->|32| ok["正常"]
  trunc["文件被截断"] --> read["Content::read 通过"]
  read --> late["from_gguf 读张量时才报错"]
  fix["读完头部自己核对"] --> err["变成 Err"]

实验 crate exp-candle/pb-pit,bin ggufpit;坏文件由脚本 pb-pit/scripts/craft.py 从 SmolLM2-135M-Instruct-Q8_0.gguf 的副本生成(原文件不动):

  • align0.gguf:在元数据最前面插入一条 general.alignment = U32 0,元数据计数加 1,张量数据重新按 32 字节对齐放好;
  • align32.gguf:同样插入,值是 32,作对照;
  • trunc.gguf:原文件的前 100 MiB(模拟下载中断)。

release(debug 结果相同),CPU,M2 Max、macOS 27、rustc 1.99.0。

现象

let c = Content::read(&mut BufReader::new(File::open(&path)?))?;
println!("{mode}: Content::read ok, {} tensors, alignment {:?}", c.tensor_infos.len(), c.metadata.get("general.alignment").map(Value::to_u32));
let model = ModelWeights::from_gguf(c, &mut f, &dev)?; // then one forward on "What is Rust?"
--- align32
align32: Content::read ok, 272 tensors, alignment Some(Ok(32))
first generated token 66 "R"
exit=0
--- align0

thread 'main' (3975114) 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
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
--- trunc
trunc: Content::read ok, 272 tensors, alignment None
Error: tensor needs 2304 bytes at offset 144809056, only 0 remaining in file
exit=1
  • 对照组说明插入的这条元数据本身是合法的:值为 32 时加载、生成都正常。
  • 值为 0 时 Content::read panic。返回 Result 的函数里出了 panic,调用方的 ? 接不住;整数除零在 release 下同样 panic(不像溢出那样在 release 里回绕)。
  • 截断的文件 Content::read 照样成功,272 个张量的目录完整。from_gguf 按自己的顺序取张量,第二个取的 output_norm.weight 在文件末尾,这时才报错;错误里有偏移量,没有张量名。

出处

Content::read 的最后:

        let position = reader.stream_position()?;
        let alignment = match metadata.get("general.alignment") {
            Some(Value::U8(v)) => *v as u64,
            Some(Value::U16(v)) => *v as u64,
            Some(Value::U32(v)) => *v as u64,
            Some(Value::I8(v)) if *v >= 0 => *v as u64,
            Some(Value::I16(v)) if *v >= 0 => *v as u64,
            Some(Value::I32(v)) if *v >= 0 => *v as u64,
            _ => DEFAULT_ALIGNMENT,
        };
        let tensor_data_offset = position.div_ceil(alignment) * alignment;

负数被 if *v >= 0 挡掉、回落到默认的 32,0 却原样通过,u64::div_ceil(0) 就是第 560 行的除零。0.11.0 为头部加了一整套上限检查(字符串长度、数组长度、维数、嵌套深度,见 GGUF 文件),这一个漏掉了。对齐为 0 本身没有意义,正确的处理是像负数一样拒绝(或回落到默认值)、返回 Err。这是 0.11.0 的上游 bug;最小复现不需要 145 MB 的文件:GGUF 文件 里手工构造的小文件报的是同一行,动手:mini-quant 的 examples/crafted.rs 用 57 字节的头部也能复现。

截断的情况,Content::read 只解析头部,不知道张量数据有没有;检查在 TensorInfo::read 里,每读一个张量才比较一次「需要多少字节、文件还剩多少」:

        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"
            )
        }

所以一个下载了一半的文件,可能在加载了几十个张量(分配了几十 MB)之后才失败;如果代码只取用文件前部的张量,甚至根本不会发现。

修法

读完头部立刻核对一遍:把 Content::read 的 panic 接住变成错误,再按目录算出每个张量的结束位置,和文件长度比:

/// Reject what Content::read lets through: alignment 0 (it panics) and tensors past the end.
fn checked(path: &str) -> anyhow::Result<Content> {
  let content = std::panic::catch_unwind(|| read_header(path))
    .map_err(|_| anyhow::anyhow!("{path}: Content::read panicked (general.alignment = 0?)"))??;
  let file_len = std::fs::metadata(path)?.len();
  let mut infos: Vec<_> = content.tensor_infos.iter().collect();
  infos.sort_by_key(|(_, info)| info.offset);
  for (name, info) in infos {
    let bytes = (info.shape.elem_count() / info.ggml_dtype.block_size() * info.ggml_dtype.type_size()) as u64;
    let end = content.tensor_data_offset + info.offset + bytes;
    anyhow::ensure!(end <= file_len, "{path}: tensor {name} ends at {end}, file has {file_len} bytes");
  }
  Ok(content)
}

(read_header 是 读 GGUF 头部要几十毫秒 里把头部读进内存再解析的函数。)

q8: ok, first generated token 66 "R"
align32: ok, first generated token 66 "R"

thread 'main' (4003453) 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
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
align0: Err(~/exp-candle/pb-pit/gguf/align0.gguf: Content::read panicked (general.alignment = 0?))
trunc: Err(~/exp-candle/pb-pit/gguf/trunc.gguf: tensor blk.26.ffn_gate.weight ends at 105279328, file has 104857600 bytes)
  • catch_unwind 接住了 panic,程序继续处理下一个文件;默认的 panic hook 仍然把信息打到 stderr。不想看到可以先 std::panic::set_hook,或者在解析之前自己扫一遍元数据(那就等于重写半个 Content::read)。
  • 截断文件在加载任何张量之前就被拒绝,错误里给出第一个越界的张量名(按偏移量排序,结果是确定的)和文件实际长度。
  • catch_unwind 要求闭包是 UnwindSafe;这里闭包只捕获了 &str,没有问题。项目设了 panic = "abort" 时它接不住,只能在解析前自己检查。

怎么确认没中招

  • 加载来自外部的 GGUF(用户上传、网上下载)的代码,都走上面的 checked,不要直接 Content::read + from_gguf。
  • 下载完先比文件长度(或校验和)再加载;checked 的第二步是兜底。
  • 用 craft.py 这样的脚本造几个坏文件放进测试,断言它们得到的是 Err 而不是 panic。

小结

  • general.alignment = 0 让 Content::read 在 div_ceil 里除零 panic,debug 和 release 都是;负数会回落到 32,0 不会。0.11.0 的上游 bug。
  • 截断的文件能通过 Content::read,到读张量时才报 tensor needs N bytes at offset M, only 0 remaining in file,不带张量名。
  • 读完头部用 catch_unwind 接住 panic,再按目录核对每个张量的结束位置不超过文件长度。

💬 关联讨论

发起讨论
加载中...