1use 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
30pub struct RawTensor {
33 pub name: String,
34 pub shape: Vec<u64>,
35 pub dtype: SrcDtype,
36 pub data: Vec<u8>,
37}
38
39pub struct Checkpoint {
42 pub tensors: Vec<RawTensor>,
43 pub metadata: Vec<(String, GGUFMetaValue)>,
44}
45
46#[derive(Default)]
47pub struct LoadOptions {
48 pub pickle_key: Option<String>,
51 pub strip_prefix: Option<String>,
53}
54
55pub 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
89const 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 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
182fn 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
272fn 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 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
306fn 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
335fn 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 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
444fn 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]); 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}