Rust in Everything
基于 candle 0.11.0

PyTorch 的 .bin / .pt

主线位置:推理主线第一步「读 config 和权重」,权重不是 safetensors,而是 torch.save 存的 .pt / pytorch_model.bin。

版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate)
flowchart TB
  f[".pt 文件"] --> zip{"是 ZIP 吗"}
  zip -->|"旧格式"| e1["invalid Zip archive"]
  zip -->|是| pkl["解析 data.pkl"]
  pkl --> top{"顶层对象"}
  top -->|"state_dict"| ok["张量表"]
  top -->|"嵌套 dict"| key["要给 state_key"]
  top -->|"整个模型"| zero["0 个张量"]
  ok --> dt{"storage 类型"}
  dt -->|"Bool Int"| skip["跳过"]
  dt -->|"Float Half 等"| vb["from_pth"]

实验代码:PyTorch 侧 make_pt.py、make_odd.py、torch_load.py,candle 侧 crate weights-exp 的 bin pth 和 load(release)。权重是本地的 SmolLM2-135M-Instruct。平台 M2 Max、macOS 27、rustc 1.99.0。

还会遇到 .bin 吗

transformers 5.22.0 的 save_pretrained 已经没有 safe_serialization 参数,model.save_pretrained(d, safe_serialization=False) 写出来的目录里只有 model.safetensors。读的一侧还在:from_pretrained 找不到 safetensors 时会找 pytorch_model.bin,用 torch.load(..., weights_only=True) 读。把下面的 sd_zip.pt 改名为 pytorch_model.bin 放进一个目录,from_pretrained 读出来的 logits 和读 safetensors 逐位相同。所以新存的模型基本都是 safetensors;.bin / .pt 来自老模型和自己训练时 torch.save 的检查点。

默认格式的 state_dict:能读

PyTorch 侧存四种常见的文件:

model = AutoModelForCausalLM.from_pretrained(model_dir)  # 默认 dtype,bf16
sd = model.state_dict()
torch.save(sd, f"{out}/sd_zip.pt")
torch.save(sd, f"{out}/sd_legacy.pt", _use_new_zipfile_serialization=False)
torch.save(model, f"{out}/full.pt")
torch.save({"model": sd, "step": 100}, f"{out}/ckpt.pt")

candle 侧,VarBuilder 换成 from_pth,模型代码不变:

let vb = VarBuilder::from_pth(format!("{dir}/sd_zip.pt"), DType::F32, &dev)?;
let model = Llama::load(vb, &cfg)?;
load 258.0 ms
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK

和 transformers f32 比差 2.98e-5(最大 |ref| 20.6,容差 1e-4);和 candle 读 model.safetensors 的 logits 比差 0(容差 0)。两份文件里是同一组 bf16 数,转 F32 无损,之后的计算完全一样。state_dict 里有 273 个键,比 safetensors 多一个 lm_head.weight:它和词嵌入共享存储,torch.save 照样写一个键(数据只存一份);Llama::load 按 tie_word_embeddings 不去要它(键名对不上)。

慢一些,峰值内存反而低

只加载不前向,/usr/bin/time -l 各 5 次取最小值(uptime 负载 6 到 8):

来源dtype加载时间峰值 RSS
from_mmaped_safetensorsF3267.2 ms841.1 MiB
from_pthF32199.9 ms586.0 MiB
from_mmaped_safetensorsBF1637.6 ms522.1 MiB
from_pthBF16171.2 ms266.9 MiB

from_pth 的后端 PthTensors 不 mmap。它的注释写着不存 zip reader,每取一个张量都重新打开文件、重新建一个 ZipArchive,再用 Tensor::from_reader 读进一个新的 CPU 张量:

/// Lazy tensor loader.
pub struct PthTensors {
    tensor_infos: HashMap<String, TensorInfo>,
    path: std::path::PathBuf,
    // We do not store a zip reader as it needs mutable access to extract data. Instead we
    // re-create a zip reader for each tensor.
}

所以 272 次打开文件、解析 ZIP 目录,慢了 130 ms 左右;但进程里没有映射的文件页,BF16 时峰值只有一份权重(267 MiB),比 mmap 的 safetensors 少 255 MiB。张量先读到 CPU,再 to_device,到 Metal 是两次复制(safetensors 与 mmap)。

读不出来的四种

同一组文件,两边的结果:

for f in ["sd_zip.pt", "sd_legacy.pt", "ckpt.pt", "full.pt", "odd.pt", "evil.pt"]:
  try:
    obj = torch.load(f"{d}/{f}")
    keys = list(obj)[:2] if isinstance(obj, dict) else type(obj).__name__
    print(f"{f:13} weights_only=True  ok, {type(obj).__name__}, {keys}")
  except Exception as e:
    print(f"{f:13} weights_only=True  {type(e).__name__}: {str(e).splitlines()[0][:110]}")
for f in ["sd_zip.pt", "sd_legacy.pt", "ckpt.pt", "full.pt"] {
  let path = format!("{dir}/{f}");
  match pickle::read_pth_tensor_info(&path, false, None) {
    Ok(infos) => println!("{f}: read_pth_tensor_info -> {} tensors", infos.len()),
    Err(e) => println!("{f}: read_pth_tensor_info -> Err: {e}"),
  }
  let r = VarBuilder::from_pth(&path, DType::F32, &dev).and_then(|vb| Llama::load(vb, &cfg));
  // ... 打印 ok 或 Err
}

(PyTorch 的输出里有一段加粗用的终端控制符,这里去掉了。)

# PyTorch
sd_zip.pt     weights_only=True  ok, OrderedDict, ['model.embed_tokens.weight', 'model.layers.0.self_attn.q_proj.weight']
sd_legacy.pt  weights_only=True  ok, OrderedDict, ['model.embed_tokens.weight', 'model.layers.0.self_attn.q_proj.weight']
ckpt.pt       weights_only=True  ok, dict, ['model', 'step']
full.pt       weights_only=True  UnpicklingError: Weights only load failed. This file can still be loaded, to do so you have two options, do those steps onl
# candle
sd_zip.pt: read_pth_tensor_info -> 273 tensors
sd_zip.pt: from_pth + Llama::load -> ok
sd_legacy.pt: read_pth_tensor_info -> Err: invalid Zip archive: Could not find EOCD
sd_legacy.pt: from_pth + Llama::load -> Err: invalid Zip archive: Could not find EOCD
ckpt.pt: read_pth_tensor_info -> 0 tensors
ckpt.pt: from_pth + Llama::load -> Err: cannot find tensor model.embed_tokens.weight
full.pt: read_pth_tensor_info -> 0 tensors
full.pt: from_pth + Llama::load -> Err: cannot find tensor model.embed_tokens.weight

旧格式(_use_new_zipfile_serialization=False;torch 2.15.0 的 torch.save 文档写着 1.6 版起才默认改用 ZIP 格式):文件开头是 pickle 协议头和一个魔数(80 02 8a 0a 6c fc 9c 46 ...),不是 ZIP。candle 的 read_pth_tensor_info 第一步就是 zip::ZipArchive::new,找不到 ZIP 的目录结尾记录(EOCD),报错。torch 两种都能读。要在 candle 里用,先在 Python 里 torch.save(torch.load(f)) 存成新格式,或者直接 save_file 成 safetensors。

嵌套的检查点({"model": sd, "step": 100}):不报错,0 个张量,直到模型要第一个权重才 cannot find tensor。read_pth_tensor_info 把顶层对象当成 state_dict,逐项尝试解析成张量,"model" 的值是一个字典、"step" 是整数,都不是张量,被静默丢掉。PyTorch 里写 torch.load(f)["model"],candle 里用 from_pth_with_state 给出这个键:

let vb = VarBuilder::from_pth_with_state(format!("{dir}/ckpt.pt"), DType::F32, "model", &dev)?;
load 209.4 ms
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK

键写错时报 key state_dict not found(这里试的是常见的 "state_dict")。只能取一层:{"model": {"backbone": sd}} 这种要先在 Python 里拆开。

整个模型(torch.save(model)):PyTorch 2.15.0 的 torch.load 默认 weights_only=True,不认 LlamaForCausalLM 这个类,拒绝读:

UnpicklingError: Weights only load failed. This file can still be loaded, to do so you have two options, do those steps only if you trust the source of the checkpoint. 
	(1) In PyTorch 2.6, we changed the default value of the `weights_only` argument in `torch.load` from `False` to `True`. Re-running `torch.load` with `weights_only` set to `False` will likely succeed, but it can result in arbitrary code execution. Do it only if you got the file from a trusted source.
	(2) Alternatively, to load with `weights_only=True` please check the recommended steps in the following error message.
	WeightsUnpickler error: Unsupported global: GLOBAL transformers.models.llama.modeling_llama.LlamaForCausalLM was not an allowed global by default. ...

candle 读出 0 个张量,同样不报错。这个文件的 data.pkl 一开头是 GLOBAL 'transformers.models.llama.modeling_llama LlamaForCausalLM'、NEWOBJ,再 BUILD 一个属性字典:参数藏在 _modules / _parameters 一层层的属性里,不是一个「名字 → 张量」的表。read_pth_tensor_info 只认一种「模块」:

        let obj = match obj {
            Object::Build { callable, args } => match *callable {
                Object::Reduce { callable, args: _ } => match *callable {
                    Object::Class {
                        module_name,
                        class_name,
                    } if module_name == "__torch__" && class_name == "Module" => *args,
                    _ => continue,
                },
                _ => continue,
            },
            obj => obj,
        };

类名不是 __torch__.Module 就 continue,这个文件就被跳过了。这个分支来自 2023 年的 #530(提交标题「Small tweaks to the pickle handling to be able to use libtorch files」)。实测 torch.jit.save(torch.jit.script(nn.Linear(3, 2))) 存的 TorchScript 文件(torch 2.15.0 对这两个函数都打 FutureWarning,说已弃用),类名是 __torch__.torch.nn.modules.linear Linear,也不匹配:

lin_sd.pt: read_all -> ["bias [2]", "weight [2, 3]"]
lin_full.pt: read_all -> []
jit.pt: read_all -> []

同一个 nn.Linear,只有存 state_dict 的那份读得出来。整个模型的文件只能在信得过来源时用 Python torch.load(f, weights_only=False).state_dict() 再存一次。

burn 在这一点上不同:它的 PytorchStore 读得了旧格式和整个模型的文件(导入 PyTorch 权重)。

张量本身:dtype 和布局

一个小的 state_dict,各种 dtype 和几种非连续的视图:

base = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4)
odd = {
  "f32": torch.arange(6, dtype=torch.float32).reshape(2, 3),
  "f64": torch.arange(3, dtype=torch.float64),
  "i64": torch.arange(3, dtype=torch.int64),
  "i32": torch.arange(3, dtype=torch.int32),
  "u8": torch.arange(3, dtype=torch.uint8),
  "bool": torch.tensor([True, False, True]),
  "transposed": torch.arange(6, dtype=torch.float32).reshape(2, 3).t(),
  "sliced": torch.arange(10, dtype=torch.float32)[4:7],
  "permuted": base.permute(1, 0, 2),
}
torch.save(odd, f"{out}/odd.pt")
transposed torch.float32  shape=[3, 2] stride=(1, 3) offset=0 contiguous=False
sliced     torch.float32  shape=[3] stride=(1,) offset=4 contiguous=True
permuted   torch.float32  shape=[3, 2, 4] stride=(4, 12, 1) offset=0 contiguous=False

torch.save 存视图时不整理,存的是底层 storage 加 (offset, shape, stride)。torch.load 读回这 9 个都没问题。candle:

let pth = pickle::PthTensors::new(format!("{dir}/odd.pt"), None)?;
for name in ["f32", "f64", "i64", "i32", "u8", "bool", "transposed", "sliced", "permuted"] {
  match pth.get(name) {
    Ok(Some(t)) => {
      let v: Vec<f64> = t.flatten_all()?.to_dtype(DType::F64)?.to_vec1()?;
      println!("{name:10} {:?} {:?} {v:?}", t.dtype(), t.dims())
    }
    Ok(None) => println!("{name:10} None"),
    Err(e) => println!("{name:10} Err: {e}"),
  }
}
skipping: unsupported storage type BoolStorage
skipping: unsupported storage type IntStorage
tensor infos: ["f32", "f64", "i64", "permuted", "sliced", "transposed", "u8"]
f32        F32 [2, 3] [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
f64        F64 [3] [0.0, 1.0, 2.0]
i64        I64 [3] [0.0, 1.0, 2.0]
i32        None
u8         U8 [3] [0.0, 1.0, 2.0]
bool       None
transposed F32 [3, 2] [0.0, 3.0, 1.0, 4.0, 2.0, 5.0]
sliced     F32 [3] [4.0, 5.0, 6.0]
permuted   Err: cannot retrieve non-contiguous tensors Layout { shape: [3, 2, 4], stride: [4, 12, 1], start_offset: 0 }
  • dtype:rebuild_args 按 storage 的类名认 6 种:FloatStorage、DoubleStorage、HalfStorage、BFloat16Storage、ByteStorage、LongStorage。torch.bool 和 torch.int32 的 storage 不在里面,read_pth_tensor_info 往 stderr 打一行 skipping:,然后当它不存在。程序里拿到的是 None,经过 VarBuilder 就是 cannot find tensor。注意力的 bool mask、int32 的位置下标这类 buffer 要在 Python 里转成别的 dtype 再存。
  • 转置(stride=(1, 3)):列优先连续(Fortran contiguous),candle 按反过来的形状读,再 permute 回来,值和 PyTorch 一样。
  • 切片(offset=4):从 storage 的第 4 个元素开始读,得到 [4, 5, 6]。
  • 其他非连续:permute(1, 0, 2) 既不是行优先也不是列优先,PthTensors::get 直接报错。存之前 .contiguous();state_dict 里的参数通常本来就是连续的,自己拼的字典要留意。
📝NOTE
冷知识:切片的偏移曾经按字节算

torch.save 在 pickle 里记的 storage_offset 是元素个数。PR #2824(「Pickle decoder fix and Long1 opcode addition」,首次进入 0.9.0-alpha.1,正式版 0.9.0)的提交说明是「added Long1 opcode, fixed tensor offset calculation」。diff 里 rebuild_args 原来是 Layout::new(crate::Shape::from(size), stride, offset),改成了 offset * dtype.size_in_bytes();PthTensors::get 把这个数当作要跳过的字节数。照修改前的写法,上面的 sliced 只会跳过 4 个字节,也就是 1 个 f32,读到的是 [1, 2, 3] 而不是 [4, 5, 6](这一点是按 diff 推出来的,本页没有编译 0.8 去跑)。同一个 PR 加了 Long1 操作码,偏移和 storage 大小改用 int_or_long 读:pickle 协议 2 把放不进 32 位有符号整数的数写成 LONG1,在这之前 candle 不认这个操作码,元素个数达到 2^31 的 storage 解析不了。

安全:candle 不执行 pickle

pickle 能让读文件的一方调用任意函数。一个 __reduce__ 返回 os.system 的对象:

class Shout:
  # torch.load(weights_only=False) 时,pickle 会调用 __reduce__ 返回的函数
  def __reduce__(self):
    return (os.system, (f"echo pickle ran code > {out}/pwned.txt",))


torch.save({"w": torch.ones(2), "hook": Shout()}, f"{out}/evil.pt")
evil.pt       weights_only=True  UnpicklingError: Weights only load failed. In PyTorch 2.6, we changed the default value of the `weights_only` argument in `torc
evil.pt weights_only=False: ['w', 'hook'] pwned.txt exists: True

默认的 weights_only=True 拦住了(完整报错里写着 Trying to load unsupported GLOBAL posix.system whose module posix is blocked.),显式写 weights_only=False 就执行了 echo。candle 读同一个文件:

for (n, t) in pickle::read_all(format!("{dir}/evil.pt"))? {
  println!("{n}: {:?} {:?}", t.dims(), t.to_vec1::<f32>()?);
}
println!("pwned.txt exists: {}", std::path::Path::new(&format!("{dir}/pwned.txt")).exists());
w: [2] [1.0, 1.0]
pwned.txt exists: false

candle 的 pickle 解析器只是一个栈机。遇到调用(REDUCE 操作码)时,Stack::reduce 除了把 collections.OrderedDict / defaultdict 换成空字典,其他一律包成一个 Object::Reduce { callable, args } 留在栈上,什么也不调用;张量也是从这种「没执行的调用」里认出 torch._utils._rebuild_tensor_v2 再按参数去 ZIP 里读数据。"hook" 这一项不是张量,被丢掉。所以 candle 读来历不明的 .pt 不会执行代码,和 weights_only=True 的效果相近,但它是「不认识就跳过」,PyTorch 是「不认识就报错」。

小结

  • torch.save(state_dict) 的默认 ZIP 格式能用 VarBuilder::from_pth 读,SmolLM2 的 logits 和读 safetensors 逐位相同;比 mmap 的 safetensors 慢 130 ms 左右,峰值 RSS 少一份文件页。
  • 旧格式(_use_new_zipfile_serialization=False)报 invalid Zip archive: Could not find EOCD。嵌套的检查点、torch.save(model)、TorchScript 都不报错、读出 0 个张量,到模型要权重时才 cannot find tensor;嵌套一层用 from_pth_with_state 给键。
  • 支持 F32、F64、F16、BF16、U8、I64 的 storage;bool 和 int32 打一行 skipping: 后当作不存在。转置和切片能读,其他非连续视图报错。
  • candle 的 pickle 解析不调用任何函数,带恶意 __reduce__ 的文件只读出其中的张量。
  • 新模型优先用 safetensors;transformers 5.22.0 的 save_pretrained 只写 safetensors。

💬 关联讨论

发起讨论
加载中...