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::readpanic。返回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,再按目录核对每个张量的结束位置不超过文件长度。