//! Reading PyTorch checkpoints (`torch.save` of a state dict) without PyTorch: the file is a zip //! archive (stored or deflated entries, zip64 sizes) holding `data.pkl`, a pickle (protocol 2–4) //! whose tensors are `torch._utils._rebuild_tensor_v2(storage, offset, size, stride, …)` calls //! with storages as persistent ids pointing at `data/` entries. Only what state dicts use is //! understood; anything else is an error, never a panic. use std::collections::HashMap; /// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous). #[derive(Clone, Debug, PartialEq)] pub struct Tensor { pub shape: Vec, pub data: Vec, } /// A checkpoint's tensors by name (non-float tensors such as batch-norm counters are skipped). pub type Weights = HashMap; type Result = std::result::Result; fn u16_at(b: &[u8], o: usize) -> Result { b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]]) as usize).ok_or_else(|| "truncated zip".to_string()) } fn u32_at(b: &[u8], o: usize) -> Result { b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64).ok_or_else(|| "truncated zip".to_string()) } fn u64_at(b: &[u8], o: usize) -> Result { b.get(o..o + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes).ok_or_else(|| "truncated zip".to_string()) } /// One archive entry: where its data starts, its sizes and compression. struct Entry { data: usize, size: usize, stored: usize, method: usize, } /// The entries of a zip archive by name. fn zip_entries(b: &[u8]) -> Result> { // End of central directory: the last `PK\x05\x06` (a comment may follow it). let eocd = (0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?; let mut count = u16_at(b, eocd + 10)? as u64; let mut cd = u32_at(b, eocd + 16)?; // Zip64: the locator precedes the record. if (count == 0xffff || cd == 0xffff_ffff) && eocd >= 20 && b.get(eocd - 20..eocd - 16) == Some(b"PK\x06\x07") { let rec = u64_at(b, eocd - 12)? as usize; if b.get(rec..rec + 4) == Some(b"PK\x06\x06") { count = u64_at(b, rec + 32)?; cd = u64_at(b, rec + 48)?; } } let mut out = HashMap::new(); let mut p = cd as usize; for _ in 0..count.min(1 << 20) { if b.get(p..p + 4) != Some(b"PK\x01\x02") { return Err("damaged zip central directory".into()); } let method = u16_at(b, p + 10)?; let mut stored = u32_at(b, p + 20)?; let mut size = u32_at(b, p + 24)?; let (nl, xl, cl) = (u16_at(b, p + 28)?, u16_at(b, p + 30)?, u16_at(b, p + 32)?); let mut local = u32_at(b, p + 42)?; let name = String::from_utf8_lossy(b.get(p + 46..p + 46 + nl).ok_or("truncated zip")?).to_string(); // Zip64 extended information: the 0xffffffff fields, in order. let mut x = p + 46 + nl; let xend = x + xl; while x + 4 <= xend { let (id, len) = (u16_at(b, x)?, u16_at(b, x + 2)?); if id == 1 { let mut f = x + 4; for v in [&mut size, &mut stored, &mut local] { if *v == 0xffff_ffff { *v = u64_at(b, f)?; f += 8; } } } x += 4 + len; } let l = local as usize; if b.get(l..l + 4) != Some(b"PK\x03\x04") { return Err(format!("damaged zip entry {name}")); } let data = l + 30 + u16_at(b, l + 26)? + u16_at(b, l + 28)?; out.insert(name, Entry { data, size: size as usize, stored: stored as usize, method }); p += 46 + nl + xl + cl; } Ok(out) } /// An entry's bytes (stored, or raw-deflated). fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result> { let raw = b.get(e.data..e.data + e.stored).ok_or("truncated zip entry")?; match e.method { 0 => Ok(std::borrow::Cow::Borrowed(raw)), 8 => { miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}")) } m => Err(format!("unsupported zip compression {m}")), } } /// The bytes of the archive entry `name` (model bundles are zip archives too). pub(crate) fn zip_file<'a>(b: &'a [u8], name: &str) -> Result> { let entries = zip_entries(b)?; read_entry(b, entries.get(name).ok_or_else(|| format!("{name} is missing from the archive"))?) } /// Pickle values (what state dicts use). #[derive(Clone, Debug)] enum Obj { None, Bool, Int(i64), Float, Str(String), Tuple(Vec), List(Vec), Dict(Vec<(Obj, Obj)>), Global(String, String), /// A storage: (dtype class name, entry key). Storage(String, String), /// `_rebuild_tensor_v2`: storage, element offset, size, stride. Tensor { dtype: String, key: String, offset: usize, size: Vec, stride: Vec, }, /// A call we don't interpret. Other, Mark, } fn int_list(o: &Obj) -> Option> { match o { Obj::Tuple(v) | Obj::List(v) => v.iter().map(|x| if let Obj::Int(i) = x { usize::try_from(*i).ok() } else { None }).collect(), _ => None, } } /// Run the pickle program and return its result. fn unpickle(p: &[u8]) -> Result { let mut stack: Vec = Vec::new(); let mut memo: HashMap = HashMap::new(); let mut i = 0usize; let take = |i: &mut usize, n: usize| -> Result<&[u8]> { let s = p.get(*i..*i + n).ok_or("truncated pickle")?; *i += n; Ok(s) }; let pop = |stack: &mut Vec| stack.pop().ok_or_else(|| "pickle stack underflow".to_string()); let pop_mark = |stack: &mut Vec| -> Result> { let m = stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or("pickle: no mark")?; let items = stack.split_off(m + 1); stack.pop(); Ok(items) }; let line = |i: &mut usize| -> Result { let rest = p.get(*i..).ok_or("truncated pickle")?; let n = rest.iter().position(|c| *c == b'\n').ok_or("truncated pickle")?; let s = String::from_utf8_lossy(&rest[..n]).to_string(); *i += n + 1; Ok(s) }; loop { let op = *p.get(i).ok_or("truncated pickle")?; i += 1; match op { 0x80 => { take(&mut i, 1)?; } 0x95 => { take(&mut i, 8)?; } b'}' => stack.push(Obj::Dict(vec![])), b']' => stack.push(Obj::List(vec![])), b')' => stack.push(Obj::Tuple(vec![])), b'(' => stack.push(Obj::Mark), b'N' => stack.push(Obj::None), 0x88 | 0x89 => stack.push(Obj::Bool), b'K' => stack.push(Obj::Int(take(&mut i, 1)?[0] as i64)), b'M' => { let s = take(&mut i, 2)?; stack.push(Obj::Int(u16::from_le_bytes([s[0], s[1]]) as i64)); } b'J' => { let s = take(&mut i, 4)?; stack.push(Obj::Int(i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as i64)); } 0x8a => { let n = take(&mut i, 1)?[0] as usize; let s = take(&mut i, n)?; let mut v: i64 = 0; for (k, byte) in s.iter().take(8).enumerate() { v |= (*byte as i64) << (8 * k); } if n > 0 && n < 8 && s[n - 1] & 0x80 != 0 { v -= 1i64 << (8 * n); } stack.push(Obj::Int(v)); } b'G' => { take(&mut i, 8)?; stack.push(Obj::Float); } b'X' => { let s = take(&mut i, 4)?; let n = u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as usize; stack.push(Obj::Str(String::from_utf8_lossy(take(&mut i, n)?).to_string())); } 0x8c => { let n = take(&mut i, 1)?[0] as usize; stack.push(Obj::Str(String::from_utf8_lossy(take(&mut i, n)?).to_string())); } b'C' => { let n = take(&mut i, 1)?[0] as usize; take(&mut i, n)?; stack.push(Obj::Other); } b'B' => { let s = take(&mut i, 4)?; let n = u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as usize; take(&mut i, n)?; stack.push(Obj::Other); } b'c' => { let (m, n) = (line(&mut i)?, line(&mut i)?); stack.push(Obj::Global(m, n)); } 0x93 => { let n = pop(&mut stack)?; let m = pop(&mut stack)?; match (m, n) { (Obj::Str(m), Obj::Str(n)) => stack.push(Obj::Global(m, n)), _ => return Err("pickle: bad STACK_GLOBAL".into()), } } b'q' => { let k = take(&mut i, 1)?[0] as u64; memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); } b'r' => { let s = take(&mut i, 4)?; memo.insert(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, stack.last().cloned().ok_or("pickle stack underflow")?); } 0x94 => { let k = memo.len() as u64; memo.insert(k, stack.last().cloned().ok_or("pickle stack underflow")?); } b'h' => { let k = take(&mut i, 1)?[0] as u64; stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?); } b'j' => { let s = take(&mut i, 4)?; stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?); } b't' => { let items = pop_mark(&mut stack)?; stack.push(Obj::Tuple(items)); } 0x85..=0x87 => { let n = (op - 0x84) as usize; if stack.len() < n { return Err("pickle stack underflow".into()); } let items = stack.split_off(stack.len() - n); stack.push(Obj::Tuple(items)); } b'Q' => { // Persistent id: ('storage', dtype class, key, location, numel). let pid = pop(&mut stack)?; let Obj::Tuple(t) = pid else { return Err("pickle: bad persistent id".into()) }; match (t.get(1), t.get(2)) { (Some(Obj::Global(_, dtype)), Some(Obj::Str(key))) => stack.push(Obj::Storage(dtype.clone(), key.clone())), _ => stack.push(Obj::Other), } } b'R' | 0x81 => { let args = pop(&mut stack)?; let f = pop(&mut stack)?; let args = match args { Obj::Tuple(a) => a, _ => vec![], }; stack.push(match f { Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => { match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) { (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => { Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride } } _ => Obj::Other, } } Obj::Global(m, n) if m == "collections" && n == "OrderedDict" => Obj::Dict(vec![]), _ => Obj::Other, }); } b'b' => { pop(&mut stack)?; } b's' => { let v = pop(&mut stack)?; let k = pop(&mut stack)?; if let Some(Obj::Dict(d)) = stack.last_mut() { d.push((k, v)); } } b'u' => { let items = pop_mark(&mut stack)?; if let Some(Obj::Dict(d)) = stack.last_mut() { let mut it = items.into_iter(); while let (Some(k), Some(v)) = (it.next(), it.next()) { d.push((k, v)); } } } b'a' => { let v = pop(&mut stack)?; if let Some(Obj::List(l)) = stack.last_mut() { l.push(v); } } b'e' => { let items = pop_mark(&mut stack)?; if let Some(Obj::List(l)) = stack.last_mut() { l.extend(items); } } b'.' => return pop(&mut stack), other => return Err(format!("pickle: unsupported opcode 0x{other:02x}")), } } } /// Element bytes and conversion of a storage dtype. fn to_f32(dtype: &str, bytes: &[u8]) -> Option> { match dtype { "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()), "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()), "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()), _ => None, } } pub(crate) fn half_to_f32(h: u16) -> f32 { let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32); let bits = match (e, m) { (0, 0) => s << 31, (0, m) => { // Subnormal: normalise. let mut e = 127 - 15 + 1; let mut m = m; while m & 0x400 == 0 { m <<= 1; e -= 1; } (s << 31) | (e << 23) | ((m & 0x3ff) << 13) } (0x1f, m) => (s << 31) | (0xff << 23) | (m << 13), (e, m) => (s << 31) | ((e + 127 - 15) << 23) | (m << 13), }; f32::from_bits(bits) } /// Read a PyTorch checkpoint's float tensors. A dict holding the state dict under `model` or /// `state_dict` is unwrapped. pub fn read(bytes: &[u8]) -> Result { let entries = zip_entries(bytes)?; let (pkl_name, pkl) = entries.iter().find(|(n, _)| n.ends_with("data.pkl")).ok_or("not a PyTorch checkpoint (no data.pkl)")?; let prefix = pkl_name.trim_end_matches("data.pkl").to_string(); let root = unpickle(&read_entry(bytes, pkl)?)?; let dict = match root { Obj::Dict(d) => d, _ => return Err("the checkpoint is not a state dict".into()), }; let dict = match dict.iter().find(|(k, _)| matches!(k, Obj::Str(s) if s == "model" || s == "state_dict")) { Some((_, Obj::Dict(inner))) => inner.clone(), _ => dict, }; let mut storages: HashMap> = HashMap::new(); let mut out = Weights::new(); for (k, v) in dict { let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue }; if !storages.contains_key(&key) { let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?; storages.insert(key.clone(), read_entry(bytes, e)?); } let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue }; let n: usize = size.iter().product(); // Gather (any strides) into a contiguous row-major tensor. let mut data = Vec::with_capacity(n); let mut idx = vec![0usize; size.len()]; for _ in 0..n { let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::(); data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?); for d in (0..idx.len()).rev() { idx[d] += 1; if idx[d] < size[d] { break; } idx[d] = 0; } } out.insert(name, Tensor { shape: size, data }); } Ok(out) } #[cfg(test)] mod tests { use super::*; /// A zip archive with stored entries (what `torch.save` writes). fn zip(files: &[(&str, &[u8])]) -> Vec { let mut out = vec![]; let mut central = vec![]; for (name, data) in files { let off = out.len() as u32; out.extend_from_slice(b"PK\x03\x04"); out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); out.extend_from_slice(&[0; 4]); // crc (unchecked) out.extend_from_slice(&(data.len() as u32).to_le_bytes()); out.extend_from_slice(&(data.len() as u32).to_le_bytes()); out.extend_from_slice(&(name.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name.as_bytes()); out.extend_from_slice(data); central.extend_from_slice(b"PK\x01\x02"); central.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); central.extend_from_slice(&[0; 4]); central.extend_from_slice(&(data.len() as u32).to_le_bytes()); central.extend_from_slice(&(data.len() as u32).to_le_bytes()); central.extend_from_slice(&(name.len() as u16).to_le_bytes()); central.extend_from_slice(&[0; 12]); central.extend_from_slice(&off.to_le_bytes()); central.extend_from_slice(name.as_bytes()); } let cd = out.len() as u32; out.extend_from_slice(¢ral); out.extend_from_slice(b"PK\x05\x06"); out.extend_from_slice(&[0; 4]); out.extend_from_slice(&(files.len() as u16).to_le_bytes()); out.extend_from_slice(&(files.len() as u16).to_le_bytes()); out.extend_from_slice(&(central.len() as u32).to_le_bytes()); out.extend_from_slice(&cd.to_le_bytes()); out.extend_from_slice(&[0, 0]); out } /// The pickle `torch.save({'w': t.t()})` writes for a 2×3 tensor stored transposed (strides /// (1, 2)), plus an int64 counter that is skipped. fn pickle() -> Vec { let mut p = vec![0x80, 2, b'}', b'q', 0, b'(']; let s = |p: &mut Vec, t: &str| { p.push(b'X'); p.extend_from_slice(&(t.len() as u32).to_le_bytes()); p.extend_from_slice(t.as_bytes()); }; for (name, dtype, key, size, stride) in [("w", "FloatStorage", "0", [2u8, 3], [1u8, 2]), ("n", "LongStorage", "1", [1, 1], [1, 1])] { s(&mut p, name); // The call's argument mark, then the persistent id tuple's. p.extend_from_slice(b"ctorch._utils\n_rebuild_tensor_v2\n(("); s(&mut p, "storage"); p.extend_from_slice(format!("ctorch\n{dtype}\n").as_bytes()); s(&mut p, key); s(&mut p, "cpu"); p.extend_from_slice(&[b'K', 6, b't', b'Q', b'K', 0]); p.extend_from_slice(&[b'K', size[0], b'K', size[1], 0x86, b'K', stride[0], b'K', stride[1], 0x86]); p.extend_from_slice(&[0x89, b'c']); p.extend_from_slice(b"collections\nOrderedDict\n"); p.extend_from_slice(b")RtR"); } p.extend_from_slice(b"u."); p } #[test] fn reads_a_state_dict_with_strides() { let storage: Vec = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0].iter().flat_map(|v| v.to_le_bytes()).collect(); let pkl = pickle(); let file = zip(&[("ckpt/data.pkl", &pkl), ("ckpt/data/0", &storage), ("ckpt/data/1", &7i64.to_le_bytes())]); let w = read(&file).unwrap(); // Strides (1, 2) over [1..6]: rows are [1, 3, 5] and [2, 4, 6]. assert_eq!(w.get("w"), Some(&Tensor { shape: vec![2, 3], data: vec![1.0, 3.0, 5.0, 2.0, 4.0, 6.0] })); assert!(!w.contains_key("n"), "integer tensors are skipped"); } #[test] fn damaged_files_are_errors() { assert!(read(b"not a zip").is_err()); let pkl = pickle(); let file = zip(&[("ckpt/data.pkl", &pkl[..pkl.len() - 5])]); assert!(read(&file).is_err()); // Truncate the storage: the tensor reads out of range. let file = zip(&[("ckpt/data.pkl", &pkl), ("ckpt/data/0", &[0u8; 8]), ("ckpt/data/1", &[0u8; 8])]); assert!(read(&file).is_err()); assert_eq!(half_to_f32(0x3c00), 1.0); assert_eq!(half_to_f32(0xc000), -2.0); } }