Skip to main content

zsfm_checkpoint/
read.rs

1//! Format-agnostic checkpoint loading.
2//!
3//! Supported inputs (detected by extension, with a magic-byte fallback):
4//! - `.safetensors` — single file or several shard files
5//! - `model.safetensors.index.json` — HF sharded-model index (shards resolved
6//!   relative to the index file)
7//! - `.pt` / `.pth` / `.bin` / `.ckpt` — PyTorch pickle checkpoints (zip-based
8//!   `torch.save` format) via candle's pickle reader
9//! - `.npy` / `.npz` — NumPy arrays
10//! - `.gguf` — existing GGUF files (tensors dequantized to F32; metadata carried
11//!   through), which makes dtype re-quantization possible
12//!
13//! Every tensor is returned as raw little-endian bytes plus a [`SrcDtype`]
14//! (F32/F16/BF16 preserved exactly; other dtypes — F64, integers — are cast to
15//! F32 with a warning).
16
17use std::collections::HashSet;
18use std::io::BufReader;
19use std::path::{Path, PathBuf};
20
21use anyhow::{bail, Context, Result};
22use candle_core::quantized::gguf_file;
23use candle_core::{pickle, DType, Device, Tensor};
24use safetensors::{Dtype as StDtype, SafeTensors};
25
26use zsfm_gguf::GGUFMetaValue;
27
28use crate::cast::{f32_to_bytes, SrcDtype};
29
30/// One tensor read from a checkpoint: name, row-major (python-order) shape,
31/// source dtype, and raw little-endian bytes.
32pub struct RawTensor {
33    pub name: String,
34    pub shape: Vec<u64>,
35    pub dtype: SrcDtype,
36    pub data: Vec<u8>,
37}
38
39/// A loaded checkpoint: all tensors plus any metadata carried over from the
40/// source (only GGUF inputs have metadata).
41pub struct Checkpoint {
42    pub tensors: Vec<RawTensor>,
43    pub metadata: Vec<(String, GGUFMetaValue)>,
44}
45
46#[derive(Default)]
47pub struct LoadOptions {
48    /// Descend into this top-level dict before collecting tensors (pickle
49    /// inputs only), e.g. `state_dict` for PyTorch Lightning checkpoints.
50    pub pickle_key: Option<String>,
51    /// Strip this prefix from every tensor name that carries it.
52    pub strip_prefix: Option<String>,
53}
54
55/// Load one or more checkpoint files into a single [`Checkpoint`].
56/// Multiple inputs (e.g. safetensors shards) are merged; duplicate tensor
57/// names across inputs are an error.
58pub fn load_checkpoint(inputs: &[PathBuf], opts: &LoadOptions) -> Result<Checkpoint> {
59    anyhow::ensure!(!inputs.is_empty(), "no input files given");
60
61    let mut tensors: Vec<RawTensor> = Vec::new();
62    let mut metadata: Vec<(String, GGUFMetaValue)> = Vec::new();
63
64    for path in inputs {
65        let ckpt = load_one(path, opts)
66            .with_context(|| format!("load checkpoint {}", path.display()))?;
67        tensors.extend(ckpt.tensors);
68        metadata.extend(ckpt.metadata);
69    }
70
71    if let Some(prefix) = &opts.strip_prefix {
72        for t in &mut tensors {
73            if let Some(rest) = t.name.strip_prefix(prefix.as_str()) {
74                t.name = rest.to_string();
75            }
76        }
77    }
78
79    let mut seen = HashSet::new();
80    for t in &tensors {
81        if !seen.insert(t.name.as_str()) {
82            bail!("duplicate tensor name across inputs: {}", t.name);
83        }
84    }
85
86    Ok(Checkpoint { tensors, metadata })
87}
88
89/// TensorFlow SavedModel / TF-checkpoint weights use the TensorBundle format,
90/// which has no trustworthy pure-Rust reader — point at the bundled exporter
91/// script instead of failing cryptically.
92const TF_BUNDLE_HELP: &str =
93    "TensorFlow SavedModel / checkpoint weights (TensorBundle format) can't be read \
94     natively. Export them to safetensors first with the bundled script (needs \
95     tensorflow + safetensors installed):\n\n    \
96     python zsfm-rs/tools/export-weights.py <saved_model_dir | ckpt_prefix> weights.safetensors\n\n\
97     then run: zsfm convert weights.safetensors -o model.gguf\n\
98     (Keras .h5 / .keras files ARE supported natively — no export needed.)";
99
100fn load_one(path: &Path, opts: &LoadOptions) -> Result<Checkpoint> {
101    let fname = path
102        .file_name()
103        .and_then(|f| f.to_str())
104        .unwrap_or_default()
105        .to_ascii_lowercase();
106    let ext = path
107        .extension()
108        .and_then(|e| e.to_str())
109        .unwrap_or_default()
110        .to_ascii_lowercase();
111
112    if path.is_dir() {
113        if path.join("saved_model.pb").exists() {
114            bail!("{} is a TensorFlow SavedModel directory. {TF_BUNDLE_HELP}", path.display());
115        }
116        bail!(
117            "{} is a directory — pass a checkpoint file (or for sharded safetensors, \
118             the model.safetensors.index.json)",
119            path.display()
120        );
121    }
122    if ext == "index" || fname.contains(".data-00") {
123        bail!("{} looks like a TensorFlow checkpoint file. {TF_BUNDLE_HELP}", path.display());
124    }
125
126    if fname.ends_with(".safetensors.index.json") {
127        return load_safetensors_index(path);
128    }
129    match ext.as_str() {
130        "safetensors" => load_safetensors(path),
131        "pt" | "pth" | "bin" | "ckpt" => load_pickle(path, opts),
132        "npz" => load_npz(path),
133        "npy" => load_npy(path),
134        "gguf" => load_gguf(path),
135        "onnx" => crate::onnx::load_onnx(path),
136        "h5" | "hdf5" => crate::hdf5_keras::load_hdf5(path),
137        "keras" => crate::hdf5_keras::load_keras(path),
138        _ => match sniff_format(path)? {
139            Sniffed::SafeTensors => load_safetensors(path),
140            Sniffed::Gguf => load_gguf(path),
141            Sniffed::Hdf5 => crate::hdf5_keras::load_hdf5(path),
142            Sniffed::Unknown => bail!(
143                "unrecognized checkpoint format for {} — supported: .safetensors, \
144                 model.safetensors.index.json, .pt/.pth/.bin/.ckpt (PyTorch pickle), \
145                 .npy/.npz, .onnx, .h5/.hdf5, .keras, .gguf",
146                path.display()
147            ),
148        },
149    }
150}
151
152enum Sniffed {
153    SafeTensors,
154    Gguf,
155    Hdf5,
156    Unknown,
157}
158
159fn sniff_format(path: &Path) -> Result<Sniffed> {
160    use std::io::Read;
161    let mut head = [0u8; 16];
162    let n = std::fs::File::open(path)
163        .with_context(|| format!("open {}", path.display()))?
164        .read(&mut head)?;
165    if n >= 4 && &head[..4] == b"GGUF" {
166        return Ok(Sniffed::Gguf);
167    }
168    if n >= 8 && &head[..8] == b"\x89HDF\r\n\x1a\n" {
169        return Ok(Sniffed::Hdf5);
170    }
171    // safetensors: u64 LE header length followed by a JSON object.
172    if n >= 9 {
173        let header_len = u64::from_le_bytes(head[..8].try_into().unwrap());
174        let file_len = std::fs::metadata(path)?.len();
175        if header_len > 0 && header_len.saturating_add(8) <= file_len && head[8] == b'{' {
176            return Ok(Sniffed::SafeTensors);
177        }
178    }
179    Ok(Sniffed::Unknown)
180}
181
182// ---------------------------------------------------------------------------
183// safetensors
184// ---------------------------------------------------------------------------
185
186fn load_safetensors(path: &Path) -> Result<Checkpoint> {
187    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
188    let st = SafeTensors::deserialize(&bytes).context("deserialize safetensors")?;
189
190    let mut tensors = Vec::with_capacity(st.len());
191    for (name, view) in st.tensors() {
192        let shape: Vec<u64> = view.shape().iter().map(|&d| d as u64).collect();
193        let (dtype, data) = match view.dtype() {
194            StDtype::F32 => (SrcDtype::F32, view.data().to_vec()),
195            StDtype::F16 => (SrcDtype::F16, view.data().to_vec()),
196            StDtype::BF16 => (SrcDtype::BF16, view.data().to_vec()),
197            other => {
198                eprintln!("note: tensor {name}: casting {other:?} to F32");
199                let n: usize = view.shape().iter().product();
200                let t = safetensors_view_to_f32(&view, other, n)
201                    .with_context(|| format!("tensor {name}: unsupported dtype {other:?}"))?;
202                (SrcDtype::F32, f32_to_bytes(&t))
203            }
204        };
205        tensors.push(RawTensor { name: name.to_string(), shape, dtype, data });
206    }
207    Ok(Checkpoint { tensors, metadata: Vec::new() })
208}
209
210fn safetensors_view_to_f32(
211    view: &safetensors::tensor::TensorView,
212    dtype: StDtype,
213    n_elems: usize,
214) -> Result<Vec<f32>> {
215    let data = view.data();
216    let mut out = Vec::with_capacity(n_elems);
217    match dtype {
218        StDtype::F64 => {
219            for c in data.chunks_exact(8) {
220                out.push(f64::from_le_bytes(c.try_into().unwrap()) as f32);
221            }
222        }
223        StDtype::I64 => {
224            for c in data.chunks_exact(8) {
225                out.push(i64::from_le_bytes(c.try_into().unwrap()) as f32);
226            }
227        }
228        StDtype::I32 => {
229            for c in data.chunks_exact(4) {
230                out.push(i32::from_le_bytes(c.try_into().unwrap()) as f32);
231            }
232        }
233        StDtype::I16 => {
234            for c in data.chunks_exact(2) {
235                out.push(i16::from_le_bytes(c.try_into().unwrap()) as f32);
236            }
237        }
238        StDtype::I8 => out.extend(data.iter().map(|&b| b as i8 as f32)),
239        StDtype::U8 => out.extend(data.iter().map(|&b| b as f32)),
240        StDtype::BOOL => out.extend(data.iter().map(|&b| (b != 0) as u8 as f32)),
241        other => bail!("safetensors dtype {other:?} not supported"),
242    }
243    Ok(out)
244}
245
246fn load_safetensors_index(path: &Path) -> Result<Checkpoint> {
247    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
248    let v: serde_json::Value = serde_json::from_str(&raw).context("parse index json")?;
249    let map = v
250        .get("weight_map")
251        .and_then(|m| m.as_object())
252        .context("index json has no weight_map object")?;
253
254    let mut shard_names: Vec<String> = map
255        .values()
256        .filter_map(|s| s.as_str().map(str::to_string))
257        .collect();
258    shard_names.sort();
259    shard_names.dedup();
260
261    let dir = path.parent().unwrap_or_else(|| Path::new("."));
262    let mut tensors = Vec::new();
263    for name in &shard_names {
264        let shard_path = dir.join(name);
265        let ckpt = load_safetensors(&shard_path)
266            .with_context(|| format!("load shard {}", shard_path.display()))?;
267        tensors.extend(ckpt.tensors);
268    }
269    Ok(Checkpoint { tensors, metadata: Vec::new() })
270}
271
272// ---------------------------------------------------------------------------
273// PyTorch pickle
274// ---------------------------------------------------------------------------
275
276fn load_pickle(path: &Path, opts: &LoadOptions) -> Result<Checkpoint> {
277    let mut named = pickle::read_all_with_key(path, opts.pickle_key.as_deref())
278        .with_context(|| format!("read PyTorch checkpoint {}", path.display()))?;
279
280    // Plain `torch.save(model.state_dict())` checkpoints hold tensors at the top
281    // level, but wrapper checkpoints (PyTorch Lightning, training states) nest
282    // them under "state_dict" — candle finds nothing at the top level for those,
283    // so retry with the conventional key before giving up.
284    if named.is_empty() && opts.pickle_key.is_none() {
285        if let Ok(retried) = pickle::read_all_with_key(path, Some("state_dict")) {
286            if !retried.is_empty() {
287                eprintln!("note: no top-level tensors — using the \"state_dict\" dict instead");
288                named = retried;
289            }
290        }
291    }
292    anyhow::ensure!(
293        !named.is_empty(),
294        "no tensors found in {} (if the checkpoint nests weights under a custom dict, \
295         pass --pickle-key <KEY>)",
296        path.display()
297    );
298
299    let mut tensors = Vec::with_capacity(named.len());
300    for (name, tensor) in &named {
301        tensors.push(tensor_to_raw(name, tensor)?);
302    }
303    Ok(Checkpoint { tensors, metadata: Vec::new() })
304}
305
306// ---------------------------------------------------------------------------
307// NumPy
308// ---------------------------------------------------------------------------
309
310fn load_npz(path: &Path) -> Result<Checkpoint> {
311    let npz = candle_core::npy::NpzTensors::new(path)
312        .with_context(|| format!("read npz {}", path.display()))?;
313    let mut names = npz.names().into_iter().map(String::from).collect::<Vec<_>>();
314    names.sort();
315    let mut tensors = Vec::with_capacity(names.len());
316    for name in &names {
317        let t = npz
318            .get(name)?
319            .with_context(|| format!("npz entry {name} missing"))?;
320        tensors.push(tensor_to_raw(name, &t)?);
321    }
322    Ok(Checkpoint { tensors, metadata: Vec::new() })
323}
324
325fn load_npy(path: &Path) -> Result<Checkpoint> {
326    let t = Tensor::read_npy(path).with_context(|| format!("read npy {}", path.display()))?;
327    let name = path
328        .file_stem()
329        .and_then(|s| s.to_str())
330        .unwrap_or("tensor")
331        .to_string();
332    Ok(Checkpoint { tensors: vec![tensor_to_raw(&name, &t)?], metadata: Vec::new() })
333}
334
335// ---------------------------------------------------------------------------
336// GGUF (enables re-quantization)
337// ---------------------------------------------------------------------------
338
339/// GGUF loading tries the in-crate minimal reader first (covers everything the
340/// zsfm writer emits, including BF16, which candle's reader rejects) and falls
341/// back to candle's reader for foreign quantization formats (Q4_K etc.).
342fn load_gguf(path: &Path) -> Result<Checkpoint> {
343    match load_gguf_minimal(path) {
344        Ok(ckpt) => Ok(ckpt),
345        Err(minimal_err) => load_gguf_candle(path).with_context(|| {
346            format!("minimal GGUF reader failed first with: {minimal_err:#}")
347        }),
348    }
349}
350
351fn load_gguf_minimal(path: &Path) -> Result<Checkpoint> {
352    use zsfm_gguf::GGUFFile;
353    let file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
354    let mut file = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
355    let gguf = GGUFFile::read(&mut file)?;
356
357    let mut tensors = Vec::with_capacity(gguf.tensors.len());
358    for info in &gguf.tensors {
359        // Stored dims are GGUF order (reversed row-major) — flip back.
360        let shape: Vec<u64> = info.shape.iter().rev().copied().collect();
361        let (dtype, data) = match info.dtype {
362            zsfm_gguf::GGMLType::F32 => (SrcDtype::F32, gguf.tensor_bytes(&mut file, info)?),
363            zsfm_gguf::GGMLType::F16 => (SrcDtype::F16, gguf.tensor_bytes(&mut file, info)?),
364            zsfm_gguf::GGMLType::BF16 => (SrcDtype::BF16, gguf.tensor_bytes(&mut file, info)?),
365            zsfm_gguf::GGMLType::Q8_0 => (
366                SrcDtype::F32,
367                f32_to_bytes(&gguf.tensor_f32(&mut file, info)?),
368            ),
369        };
370        tensors.push(RawTensor { name: info.name.clone(), shape, dtype, data });
371    }
372    Ok(Checkpoint { tensors, metadata: gguf.metadata })
373}
374
375fn load_gguf_candle(path: &Path) -> Result<Checkpoint> {
376    let file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
377    let mut file = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
378    let content = gguf_file::Content::read(&mut file).context("read GGUF header")?;
379    let device = Device::Cpu;
380
381    let mut names: Vec<String> = content.tensor_infos.keys().cloned().collect();
382    names.sort();
383
384    let mut tensors = Vec::with_capacity(names.len());
385    for name in &names {
386        let qt = content
387            .tensor(&mut file, name, &device)
388            .with_context(|| format!("read tensor {name}"))?;
389        let t = qt.dequantize(&device)?;
390        tensors.push(tensor_to_raw(name, &t)?);
391    }
392
393    let mut keys: Vec<&String> = content.metadata.keys().collect();
394    keys.sort();
395    let mut metadata = Vec::new();
396    for key in keys {
397        match gguf_value_to_meta(&content.metadata[key]) {
398            Some(v) => metadata.push((key.clone(), v)),
399            None => eprintln!("note: skipping GGUF metadata key {key} (unsupported value type)"),
400        }
401    }
402
403    Ok(Checkpoint { tensors, metadata })
404}
405
406fn gguf_value_to_meta(v: &gguf_file::Value) -> Option<GGUFMetaValue> {
407    use gguf_file::Value as V;
408    Some(match v {
409        V::U8(x) => GGUFMetaValue::Uint8(*x),
410        V::I8(x) => GGUFMetaValue::Int8(*x),
411        V::U16(x) => GGUFMetaValue::Uint16(*x),
412        V::I16(x) => GGUFMetaValue::Int16(*x),
413        V::U32(x) => GGUFMetaValue::Uint32(*x),
414        V::I32(x) => GGUFMetaValue::Int32(*x),
415        V::U64(x) => GGUFMetaValue::Uint64(*x),
416        V::I64(x) => GGUFMetaValue::Int64(*x),
417        V::F32(x) => GGUFMetaValue::Float32(*x),
418        V::F64(x) => GGUFMetaValue::Float64(*x),
419        V::Bool(x) => GGUFMetaValue::Bool(*x),
420        V::String(x) => GGUFMetaValue::String(x.clone()),
421        V::Array(items) => {
422            if items.iter().all(|i| matches!(i, V::U32(_))) {
423                GGUFMetaValue::ArrayUint32(
424                    items.iter().filter_map(|i| i.to_u32().ok()).collect(),
425                )
426            } else if items.iter().all(|i| matches!(i, V::F32(_))) {
427                GGUFMetaValue::ArrayFloat32(
428                    items.iter().filter_map(|i| i.to_f32().ok()).collect(),
429                )
430            } else if items.iter().all(|i| matches!(i, V::String(_))) {
431                GGUFMetaValue::ArrayString(
432                    items
433                        .iter()
434                        .filter_map(|i| i.to_string().ok().cloned())
435                        .collect(),
436                )
437            } else {
438                return None;
439            }
440        }
441    })
442}
443
444// ---------------------------------------------------------------------------
445// candle Tensor → RawTensor
446// ---------------------------------------------------------------------------
447
448fn tensor_to_raw(name: &str, t: &Tensor) -> Result<RawTensor> {
449    let shape: Vec<u64> = t.dims().iter().map(|&d| d as u64).collect();
450    let flat = t.flatten_all()?;
451    let (dtype, data) = match t.dtype() {
452        DType::F32 => (SrcDtype::F32, f32_to_bytes(&flat.to_vec1::<f32>()?)),
453        DType::F16 => {
454            let vals = flat.to_vec1::<half::f16>()?;
455            (
456                SrcDtype::F16,
457                vals.iter().flat_map(|v| v.to_bits().to_le_bytes()).collect(),
458            )
459        }
460        DType::BF16 => {
461            let vals = flat.to_vec1::<half::bf16>()?;
462            (
463                SrcDtype::BF16,
464                vals.iter().flat_map(|v| v.to_bits().to_le_bytes()).collect(),
465            )
466        }
467        other => {
468            eprintln!("note: tensor {name}: casting {other:?} to F32");
469            let vals = flat.to_dtype(DType::F32)?.to_vec1::<f32>()?;
470            (SrcDtype::F32, f32_to_bytes(&vals))
471        }
472    };
473    Ok(RawTensor { name: name.to_string(), shape, dtype, data })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn write_test_safetensors(path: &Path) {
481        let dev = Device::Cpu;
482        let a = Tensor::from_vec(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], (2, 3), &dev).unwrap();
483        let b = Tensor::from_vec((0..32).map(|i| i as f32).collect::<Vec<_>>(), 32, &dev).unwrap();
484        candle_core::safetensors::save(
485            &std::collections::HashMap::from([("a".to_string(), a), ("b".to_string(), b)]),
486            path,
487        )
488        .unwrap();
489    }
490
491    #[test]
492    fn safetensors_roundtrip_and_shape_order() {
493        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
494        std::fs::create_dir_all(&dir).unwrap();
495        let path = dir.join("t.safetensors");
496        write_test_safetensors(&path);
497
498        let ckpt = load_checkpoint(&[path], &LoadOptions::default()).unwrap();
499        assert_eq!(ckpt.tensors.len(), 2);
500        let a = ckpt.tensors.iter().find(|t| t.name == "a").unwrap();
501        assert_eq!(a.shape, vec![2, 3]); // python order
502        assert_eq!(a.dtype, SrcDtype::F32);
503        let vals: Vec<f32> = a
504            .data
505            .chunks_exact(4)
506            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
507            .collect();
508        assert_eq!(vals, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
509    }
510
511    #[test]
512    fn duplicate_names_rejected() {
513        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
514        std::fs::create_dir_all(&dir).unwrap();
515        let path = dir.join("dup.safetensors");
516        write_test_safetensors(&path);
517        let err = match load_checkpoint(&[path.clone(), path], &LoadOptions::default()) {
518            Ok(_) => panic!("expected duplicate-name error"),
519            Err(e) => e,
520        };
521        assert!(err.to_string().contains("duplicate tensor name"));
522    }
523
524    #[test]
525    fn sniffs_safetensors_without_extension() {
526        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
527        std::fs::create_dir_all(&dir).unwrap();
528        let st = dir.join("noext-src.safetensors");
529        write_test_safetensors(&st);
530        let noext = dir.join("noext_checkpoint");
531        std::fs::copy(&st, &noext).unwrap();
532        let ckpt = load_checkpoint(&[noext], &LoadOptions::default()).unwrap();
533        assert_eq!(ckpt.tensors.len(), 2);
534    }
535}