1use anyhow::{Context, Result, bail};
35use candle_core::pickle::PthTensors;
36use candle_core::{DType, Device};
37use candle_nn::VarBuilder;
38use hf_hub::{HFClientSync, HFError, HFRepositorySync, RepoTypeModel};
39use std::path::{Path, PathBuf};
40use std::time::{Duration, Instant};
41
42const CACHE_LOCK_RETRY_BUDGET: Duration = Duration::from_secs(600);
51
52const CACHE_LOCK_RETRY_PAUSE: Duration = Duration::from_secs(2);
54
55#[derive(Debug, Clone)]
61pub struct LoadOptions {
62 pub dtype: DType,
64 pub device: Device,
66}
67
68impl LoadOptions {
69 pub fn new(device: Device) -> Self {
71 Self {
72 dtype: DType::F32,
73 device,
74 }
75 }
76
77 pub fn with_dtype(mut self, dtype: DType) -> Self {
79 self.dtype = dtype;
80 self
81 }
82
83 pub fn validate(&self) -> Result<()> {
98 if self.dtype == DType::BF16 && self.device.is_cpu() {
99 bail!(
100 "BF16 is not supported on the CPU backend: candle has no BF16 matmul there, \
101 so loading or the first forward pass would fail with a bare \
102 'unsupported dtype BF16 for op matmul'. Use F16 for half precision on CPU, \
103 or run on Metal/CUDA."
104 );
105 }
106 Ok(())
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Format {
115 Safetensors,
117 Pth {
119 root_key: Option<&'static str>,
122 },
123}
124
125#[derive(Debug, Clone, Copy)]
136pub struct WeightSource {
137 pub repo_id: &'static str,
139 pub revision: Option<&'static str>,
141 pub format: Format,
143}
144
145impl WeightSource {
146 pub const fn safetensors(repo_id: &'static str) -> Self {
148 Self {
149 repo_id,
150 revision: None,
151 format: Format::Safetensors,
152 }
153 }
154
155 pub const fn pth(repo_id: &'static str, root_key: Option<&'static str>) -> Self {
160 Self {
161 repo_id,
162 revision: None,
163 format: Format::Pth { root_key },
164 }
165 }
166
167 pub const fn at_revision(mut self, revision: &'static str) -> Self {
169 self.revision = Some(revision);
170 self
171 }
172
173 fn owner_and_name(&self) -> Result<(&'static str, &'static str)> {
179 let (owner, name) = self.repo_id.split_once('/').ok_or_else(|| {
180 anyhow::anyhow!(
181 "malformed HuggingFace repo id {:?}: expected 'owner/name'",
182 self.repo_id
183 )
184 })?;
185 if owner.is_empty() || name.is_empty() || name.contains('/') {
186 bail!(
187 "malformed HuggingFace repo id {:?}: expected 'owner/name' with both parts non-empty",
188 self.repo_id
189 );
190 }
191 Ok((owner, name))
192 }
193
194 fn repo(&self) -> Result<HFRepositorySync<RepoTypeModel>> {
195 let (owner, name) = self.owner_and_name()?;
196 let client = HFClientSync::new().with_context(|| {
197 format!(
198 "failed to initialise the HuggingFace client while loading {}",
199 self.repo_id
200 )
201 })?;
202 Ok(client.model(owner, name))
203 }
204
205 pub fn fetch(&self, filename: &str) -> Result<PathBuf> {
210 let repo = self.repo()?;
211 let deadline = Instant::now() + CACHE_LOCK_RETRY_BUDGET;
212
213 loop {
214 let attempt = repo
215 .download_file()
216 .filename(filename.to_string())
217 .maybe_revision(self.revision.map(str::to_string))
218 .send();
219
220 match attempt {
221 Ok(path) => return Ok(path),
222
223 Err(HFError::CacheLockTimeout { .. }) if Instant::now() < deadline => {
228 eprintln!(
229 "{}: waiting on another download of {filename} (cache lock held)…",
230 self.repo_id
231 );
232 std::thread::sleep(CACHE_LOCK_RETRY_PAUSE);
233 }
234
235 Err(e) => {
236 return Err(anyhow::Error::new(e)).with_context(|| {
237 format!("failed to download {filename} from {}", self.repo_id)
238 });
239 }
240 }
241 }
242 }
243
244 pub fn fetch_optional(&self, filename: &str) -> Option<PathBuf> {
249 self.fetch(filename).ok()
250 }
251
252 const SHARD_INDEX_SUFFIX: &'static str = ".index.json";
258
259 pub fn var_builder(&self, filename: &str, opts: &LoadOptions) -> Result<VarBuilder<'static>> {
260 if filename.ends_with(Self::SHARD_INDEX_SUFFIX) {
261 return self.var_builder_sharded(filename, opts);
262 }
263 let path = self.fetch(filename)?;
264 self.var_builder_from_path(&path, opts)
265 }
266
267 pub fn var_builder_sharded(
279 &self,
280 index_filename: &str,
281 opts: &LoadOptions,
282 ) -> Result<VarBuilder<'static>> {
283 opts.validate()?;
284 if !matches!(self.format, Format::Safetensors) {
285 bail!(
286 "{}: sharded loading is only defined for safetensors, but {index_filename} \
287 was requested for a {:?} source",
288 self.repo_id,
289 self.format
290 );
291 }
292
293 let index_path = self.fetch(index_filename)?;
294 let shards = Self::shard_names(&index_path)?;
295
296 let paths = shards
297 .iter()
298 .map(|shard| self.fetch(shard))
299 .collect::<Result<Vec<_>>>()?;
300
301 let vb = unsafe {
304 VarBuilder::from_mmaped_safetensors(&paths, opts.dtype, &opts.device).with_context(
305 || {
306 format!(
307 "failed to mmap {} shards from {}",
308 paths.len(),
309 self.repo_id
310 )
311 },
312 )?
313 };
314 Ok(vb)
315 }
316
317 fn shard_names(index_path: &Path) -> Result<Vec<String>> {
320 #[derive(serde::Deserialize)]
321 struct ShardIndex {
322 weight_map: std::collections::BTreeMap<String, String>,
323 }
324
325 let raw = std::fs::read_to_string(index_path)
326 .with_context(|| format!("failed to read shard index {}", index_path.display()))?;
327 let index: ShardIndex = serde_json::from_str(&raw).with_context(|| {
328 format!(
329 "shard index {} is not a HuggingFace weight_map",
330 index_path.display()
331 )
332 })?;
333
334 if index.weight_map.is_empty() {
335 bail!(
336 "shard index {} has an empty weight_map",
337 index_path.display()
338 );
339 }
340
341 let mut shards: Vec<String> = index.weight_map.into_values().collect();
342 shards.sort_unstable();
343 shards.dedup();
344 Ok(shards)
345 }
346
347 pub fn var_builder_from_path(
352 &self,
353 path: &Path,
354 opts: &LoadOptions,
355 ) -> Result<VarBuilder<'static>> {
356 var_builder_from_path(path, self.format, opts)
357 }
358}
359
360pub fn var_builder_from_path(
365 path: &Path,
366 format: Format,
367 opts: &LoadOptions,
368) -> Result<VarBuilder<'static>> {
369 opts.validate()?;
370 match format {
371 Format::Safetensors => {
372 let vb = unsafe {
376 VarBuilder::from_mmaped_safetensors(&[path], opts.dtype, &opts.device)
377 .with_context(|| format!("failed to mmap {}", path.display()))?
378 };
379 Ok(vb)
380 }
381 Format::Pth { root_key } => {
382 let pth = PthTensors::new(path, root_key)
383 .with_context(|| format!("failed to parse {}", path.display()))?;
384 Ok(VarBuilder::from_backend(
385 Box::new(pth),
386 opts.dtype,
387 opts.device.clone(),
388 ))
389 }
390 }
391}
392
393pub fn optional_prefix<'a>(vb: VarBuilder<'a>, prefix: &str, probe: &str) -> VarBuilder<'a> {
411 if vb.contains_tensor(&format!("{prefix}.{probe}")) {
412 vb.pp(prefix)
413 } else {
414 vb
415 }
416}
417
418#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_owner_and_name_splits_valid_id() {
426 let src = WeightSource::safetensors("chandar-lab/AMPLIFY_120M");
427 assert_eq!(
428 src.owner_and_name().unwrap(),
429 ("chandar-lab", "AMPLIFY_120M")
430 );
431 }
432
433 #[test]
436 fn test_owner_and_name_rejects_malformed_ids() {
437 for bad in ["no-slash", "/leading", "trailing/", "a/b/c", ""] {
438 let err = WeightSource::safetensors(bad)
439 .owner_and_name()
440 .expect_err("{bad} should be rejected");
441 assert!(
442 err.to_string().contains("malformed HuggingFace repo id"),
443 "error should name the malformed id for {bad:?}; got: {err}"
444 );
445 }
446 }
447
448 #[test]
449 fn test_revision_defaults_to_none_and_is_pinnable() {
450 let src = WeightSource::safetensors("owner/name");
451 assert_eq!(src.revision, None);
452 assert_eq!(src.at_revision("v2").revision, Some("v2"));
453 }
454
455 #[test]
456 fn test_format_constructors() {
457 assert_eq!(WeightSource::safetensors("o/n").format, Format::Safetensors);
458 assert_eq!(
459 WeightSource::pth("o/n", Some("model_state_dict")).format,
460 Format::Pth {
461 root_key: Some("model_state_dict")
462 }
463 );
464 }
465
466 #[test]
472 fn test_cache_lock_retry_budget_outlasts_a_large_download() {
473 assert!(
474 CACHE_LOCK_RETRY_BUDGET >= Duration::from_secs(300),
475 "budget must cover a multi-hundred-MB download; hf-hub's own lock \
476 timeout is only 10s and is not configurable"
477 );
478 assert!(
479 CACHE_LOCK_RETRY_PAUSE < CACHE_LOCK_RETRY_BUDGET,
480 "pause must be shorter than the budget or only one retry happens"
481 );
482 }
483
484 #[test]
489 fn test_shard_names_dedupes_and_sorts() {
490 let dir = std::env::temp_dir().join("ferritin-shard-index-test");
491 std::fs::create_dir_all(&dir).unwrap();
492 let path = dir.join("model.safetensors.index.json");
493 std::fs::write(
494 &path,
495 r#"{
496 "metadata": {"total_size": 123},
497 "weight_map": {
498 "b.weight": "model-00002-of-00002.safetensors",
499 "a.weight": "model-00001-of-00002.safetensors",
500 "a.bias": "model-00001-of-00002.safetensors"
501 }
502 }"#,
503 )
504 .unwrap();
505
506 let shards = WeightSource::shard_names(&path).unwrap();
507 assert_eq!(
508 shards,
509 [
510 "model-00001-of-00002.safetensors",
511 "model-00002-of-00002.safetensors"
512 ],
513 "three tensors across two shards should yield two unique files"
514 );
515 }
516
517 #[test]
518 fn test_shard_names_rejects_a_non_index() {
519 let dir = std::env::temp_dir().join("ferritin-shard-index-test");
520 std::fs::create_dir_all(&dir).unwrap();
521 let path = dir.join("not-an-index.json");
522 std::fs::write(&path, r#"{"hello": "world"}"#).unwrap();
523
524 let err = WeightSource::shard_names(&path)
525 .map(|_| ())
526 .expect_err("a file without weight_map is not a shard index");
527 assert!(
528 err.to_string().contains("weight_map"),
529 "error should say what was expected; got: {err}"
530 );
531 }
532
533 #[test]
534 fn test_shard_names_rejects_an_empty_weight_map() {
535 let dir = std::env::temp_dir().join("ferritin-shard-index-test");
536 std::fs::create_dir_all(&dir).unwrap();
537 let path = dir.join("empty-index.json");
538 std::fs::write(&path, r#"{"weight_map": {}}"#).unwrap();
539
540 let err = WeightSource::shard_names(&path)
541 .map(|_| ())
542 .expect_err("an empty weight_map names no shards");
543 assert!(err.to_string().contains("empty weight_map"));
544 }
545
546 #[test]
553 fn test_var_builder_resolves_tensors_across_shards() {
554 use candle_core::Tensor;
555 use std::collections::HashMap;
556
557 let dir = std::env::temp_dir().join("ferritin-shard-vb-test");
558 std::fs::create_dir_all(&dir).unwrap();
559 let device = Device::Cpu;
560
561 let shard_a = dir.join("model-00001-of-00002.safetensors");
562 let shard_b = dir.join("model-00002-of-00002.safetensors");
563
564 let mut a = HashMap::new();
565 a.insert(
566 "block.0.weight".to_string(),
567 Tensor::zeros((2, 3), DType::F32, &device).unwrap(),
568 );
569 candle_core::safetensors::save(&a, &shard_a).unwrap();
570
571 let mut b = HashMap::new();
572 b.insert(
573 "block.1.weight".to_string(),
574 Tensor::ones((4, 5), DType::F32, &device).unwrap(),
575 );
576 candle_core::safetensors::save(&b, &shard_b).unwrap();
577
578 let vb = unsafe {
580 VarBuilder::from_mmaped_safetensors(&[shard_a, shard_b], DType::F32, &device).unwrap()
581 };
582
583 assert!(
584 vb.contains_tensor("block.0.weight"),
585 "a tensor from the first shard should resolve"
586 );
587 assert!(
588 vb.contains_tensor("block.1.weight"),
589 "a tensor from the second shard should resolve"
590 );
591 assert_eq!(vb.get((2, 3), "block.0.weight").unwrap().dims(), &[2, 3]);
592 assert_eq!(vb.get((4, 5), "block.1.weight").unwrap().dims(), &[4, 5]);
593 }
594
595 #[test]
598 fn test_sharded_loading_is_refused_for_pth() {
599 let err = WeightSource::pth("owner/name", None)
600 .var_builder_sharded(
601 "model.safetensors.index.json",
602 &LoadOptions::new(Device::Cpu),
603 )
604 .map(|_| ())
605 .expect_err("pth sources cannot be sharded");
606 assert!(
607 err.to_string().contains("only defined for safetensors"),
608 "got: {err}"
609 );
610 }
611
612 #[test]
613 fn test_load_options_rejects_bf16_on_cpu() {
614 let err = LoadOptions::new(Device::Cpu)
615 .with_dtype(DType::BF16)
616 .validate()
617 .expect_err("BF16 on CPU must be refused");
618 assert!(
619 err.to_string().contains("not supported on the CPU backend"),
620 "error should explain the CPU limitation; got: {err}"
621 );
622 }
623
624 #[test]
625 fn test_load_options_accepts_f16_and_f32_on_cpu() {
626 for dtype in [DType::F32, DType::F16] {
627 LoadOptions::new(Device::Cpu)
628 .with_dtype(dtype)
629 .validate()
630 .unwrap_or_else(|e| panic!("{dtype:?} should be allowed on CPU: {e}"));
631 }
632 }
633
634 #[test]
635 fn test_load_options_defaults_to_f32() {
636 let opts = LoadOptions::new(Device::Cpu);
637 assert_eq!(opts.dtype, DType::F32);
638 assert_eq!(opts.with_dtype(DType::F16).dtype, DType::F16);
639 }
640
641 #[test]
643 fn test_optional_prefix_falls_back_when_absent() {
644 use candle_core::Tensor;
645 use std::collections::HashMap;
646
647 let device = Device::Cpu;
648 let mut flat = HashMap::new();
649 flat.insert(
650 "embed.weight".to_string(),
651 Tensor::zeros((2, 2), DType::F32, &device).unwrap(),
652 );
653 let vb = VarBuilder::from_tensors(flat, DType::F32, &device);
654 let root = optional_prefix(vb, "esmc", "embed.weight");
656 assert!(root.contains_tensor("embed.weight"));
657 }
658
659 #[test]
660 fn test_optional_prefix_descends_when_present() {
661 use candle_core::Tensor;
662 use std::collections::HashMap;
663
664 let device = Device::Cpu;
665 let mut wrapped = HashMap::new();
666 wrapped.insert(
667 "esmc.embed.weight".to_string(),
668 Tensor::zeros((2, 2), DType::F32, &device).unwrap(),
669 );
670 let vb = VarBuilder::from_tensors(wrapped, DType::F32, &device);
671 let root = optional_prefix(vb, "esmc", "embed.weight");
672 assert!(
673 root.contains_tensor("embed.weight"),
674 "after descending into 'esmc' the leaf should resolve unprefixed"
675 );
676 }
677}