Skip to main content

nuvec_io/
safetensors.rs

1//! The safetensors format: an 8-byte little-endian header size, a JSON header mapping
2//! tensor names to their dtype, shape and byte range, then the tensors' bytes.
3//!
4//! Loading maps the file without a copy (see [`MappedFile`]): every tensor is a view of the
5//! mapped bytes, read from disk (or the page cache) when first used. Files whose tensors are
6//! not aligned to their element sizes (MLX writes headers without padding) are not mapped:
7//! their tensors are read once, with parallel positioned reads, into one buffer per file
8//! where each starts at a multiple of 256 bytes ([`MappedFile::read_ranges`]).
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13use nuvec_core::{Array, DType, Error, MappedFile, Result};
14
15use crate::json::{self, Json};
16
17fn invalid(msg: impl Into<String>) -> Error {
18    Error::InvalidArgument(format!("safetensors: {}", msg.into()))
19}
20
21/// The tensors and metadata of a safetensors file.
22#[derive(Debug, Clone, Default)]
23pub struct SafeTensors {
24    /// The tensors by name.
25    pub tensors: BTreeMap<String, Array>,
26    /// The `__metadata__` strings.
27    pub metadata: BTreeMap<String, String>,
28}
29
30/// safetensors' dtype names, with MLX's `C64` for complex64 and fp8 tensors read as bytes.
31fn dtype_of(name: &str) -> Option<DType> {
32    Some(match name {
33        "BOOL" => DType::Bool,
34        "U8" => DType::U8,
35        "U16" => DType::U16,
36        "U32" => DType::U32,
37        "U64" => DType::U64,
38        "I8" => DType::I8,
39        "I16" => DType::I16,
40        "I32" => DType::I32,
41        "I64" => DType::I64,
42        "F16" => DType::F16,
43        "BF16" => DType::BF16,
44        "F32" => DType::F32,
45        "F64" => DType::F64,
46        "C64" => DType::Complex64,
47        // MLX's reading of fp8 tensors: bytes (`to_fp8`/`from_fp8`, quantization scales);
48        // `F8_E5M2` has no reading (MLX rejects it too).
49        "F8_E4M3" | "F8_E8M0" => DType::U8,
50        _ => return None,
51    })
52}
53
54fn name_of(dtype: DType) -> &'static str {
55    match dtype {
56        DType::Bool => "BOOL",
57        DType::U8 => "U8",
58        DType::U16 => "U16",
59        DType::U32 => "U32",
60        DType::U64 => "U64",
61        DType::I8 => "I8",
62        DType::I16 => "I16",
63        DType::I32 => "I32",
64        DType::I64 => "I64",
65        DType::F16 => "F16",
66        DType::BF16 => "BF16",
67        DType::F32 => "F32",
68        DType::F64 => "F64",
69        _ => "C64",
70    }
71}
72
73fn usize_of(v: &Json, what: &str) -> Result<usize> {
74    match v {
75        Json::Int(n) if *n >= 0 => Ok(*n as usize),
76        _ => Err(invalid(format!("{what} must be a non-negative integer"))),
77    }
78}
79
80/// The reference implementation's limit on the header size.
81const MAX_HEADER: usize = 100_000_000;
82
83/// Where tensors read from a misaligned file start in their buffer: multiples of 256 bytes
84/// (decode at 0.99 → 1.00× mlx-lm on Gemma 4 E4B; 1 KiB and 16 KiB pages measured the same).
85const TENSOR_ALIGN: usize = 256;
86
87/// A parsed and validated header: the metadata and the tensors' byte ranges (sorted, tiling
88/// the data section from `start` to the end of the file).
89struct Header {
90    metadata: BTreeMap<String, String>,
91    /// `(begin, end, name, dtype, shape)`, relative to the data section.
92    ranges: Vec<(usize, usize, String, DType, Vec<usize>)>,
93    /// Where the data section starts (8 + the header's length).
94    start: usize,
95}
96
97impl Header {
98    /// Whether every tensor is aligned to its element size in the file (else the loader
99    /// reads the tensors instead of mapping the file).
100    fn aligned(&self) -> bool {
101        self.ranges
102            .iter()
103            .all(|(begin, _, _, dtype, _)| (self.start + begin).is_multiple_of(dtype.size()))
104    }
105
106    /// Where the tensors `keep` accepts go in one buffer read from the file: in file order,
107    /// each continuing the previous kept tensor's file range if it follows it in the file
108    /// and lands at a multiple of [`TENSOR_ALIGN`], else starting a new range at the next
109    /// one. (MLX's tensors are separate allocations; at the file's own offsets, a
110    /// quantized product ran up to 4% slower, depending on where its weights fell relative
111    /// to its scales.)
112    fn plan(&self, keep: &dyn Fn(&str) -> bool) -> ReadPlan {
113        let mut plan = ReadPlan::default();
114        // The file end of the current range, if any.
115        let mut file_end = None;
116        for (index, (begin, end, name, _, _)) in self.ranges.iter().enumerate() {
117            if !keep(name) {
118                continue;
119            }
120            let at = (self.start + begin) as u64;
121            let n = end - begin;
122            let offset = if file_end == Some(at) && plan.len.is_multiple_of(TENSOR_ALIGN) {
123                let last = plan.ranges.last_mut().expect("an open range");
124                last.2 += n;
125                plan.len
126            } else {
127                let offset = plan.len.next_multiple_of(TENSOR_ALIGN);
128                plan.ranges.push((at, offset, n));
129                offset
130            };
131            plan.tensors.push((index, offset));
132            plan.len = offset + n;
133            file_end = Some(at + n as u64);
134        }
135        plan
136    }
137}
138
139impl Header {
140    /// The byte ranges of the file a filtered load of an aligned file maps: each kept
141    /// tensor's range rounded out to whole pages of `page` bytes, merged where they touch,
142    /// as `(offset, length)` in file order. Keeping every tensor maps the whole file
143    /// (header included) as one range. Empty tensors take no range.
144    fn runs(&self, file_len: usize, page: u64, keep: &dyn Fn(&str) -> bool) -> Vec<(u64, u64)> {
145        let up = |x: u64| x.div_ceil(page) * page;
146        if self.ranges.iter().all(|(_, _, name, _, _)| keep(name)) {
147            return vec![(0, up(file_len as u64))];
148        }
149        let mut runs: Vec<(u64, u64)> = Vec::new();
150        for (begin, end, name, _, _) in &self.ranges {
151            if begin == end || !keep(name) {
152                continue;
153            }
154            let lo = (self.start + begin) as u64 / page * page;
155            let hi = up((self.start + end) as u64);
156            match runs.last_mut() {
157                Some((at, len)) if lo <= *at + *len => *len = hi.max(*at + *len) - *at,
158                _ => runs.push((lo, hi - lo)),
159            }
160        }
161        runs
162    }
163}
164
165/// The reads loading a misaligned file makes ([`Header::plan`]).
166#[derive(Default)]
167struct ReadPlan {
168    /// `(file offset, buffer offset, length)`, increasing and disjoint in the buffer.
169    ranges: Vec<(u64, usize, usize)>,
170    /// `(index into Header::ranges, buffer offset)` of each kept tensor.
171    tensors: Vec<(usize, usize)>,
172    /// The buffer's length.
173    len: usize,
174}
175
176/// The data section's start from the 8-byte header size, for a file of `file_len` bytes.
177fn header_end(size: [u8; 8], file_len: usize) -> Result<usize> {
178    usize::try_from(u64::from_le_bytes(size))
179        .ok()
180        .filter(|&n| n <= MAX_HEADER)
181        .and_then(|n| n.checked_add(8))
182        .filter(|&end| end <= file_len)
183        .ok_or_else(|| invalid("header larger than the file or 100 MB"))
184}
185
186/// Parses and validates the JSON header (`json`, the bytes after the size) of a file of
187/// `file_len` bytes whose data starts at `start`.
188fn parse_header(json_bytes: &[u8], start: usize, file_len: usize) -> Result<Header> {
189    let Json::Object(entries) = json::parse(json_bytes)? else {
190        return Err(invalid("the header must be a JSON object"));
191    };
192    let mut metadata = BTreeMap::new();
193    let mut ranges = Vec::new();
194    let mut names = BTreeSet::new();
195    for (name, entry) in entries {
196        if name == "__metadata__" {
197            match entry {
198                Json::Null => {}
199                Json::Object(fields) => {
200                    for (k, v) in fields {
201                        let Json::Str(s) = v else {
202                            return Err(invalid("metadata values must be strings"));
203                        };
204                        metadata.insert(k, s);
205                    }
206                }
207                _ => return Err(invalid("`__metadata__` must be an object")),
208            }
209            continue;
210        }
211        // A repeated name would tile the data and then silently replace the first tensor.
212        if !names.insert(name.clone()) {
213            return Err(invalid(format!("duplicate tensor `{name}`")));
214        }
215        let Json::Object(fields) = entry else {
216            return Err(invalid(format!("entry `{name}` must be an object")));
217        };
218        let field = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v);
219        let dtype = match field("dtype") {
220            Some(Json::Str(d)) => {
221                dtype_of(d).ok_or_else(|| invalid(format!("`{name}`: unsupported dtype {d}")))?
222            }
223            _ => return Err(invalid(format!("`{name}`: missing dtype"))),
224        };
225        let shape = match field("shape") {
226            Some(Json::Array(dims)) => dims
227                .iter()
228                .map(|d| usize_of(d, "a dimension"))
229                .collect::<Result<Vec<_>>>()?,
230            _ => return Err(invalid(format!("`{name}`: missing shape"))),
231        };
232        let (begin, end) = match field("data_offsets") {
233            Some(Json::Array(o)) if o.len() == 2 => {
234                (usize_of(&o[0], "an offset")?, usize_of(&o[1], "an offset")?)
235            }
236            _ => return Err(invalid(format!("`{name}`: missing data_offsets"))),
237        };
238        let expected = shape
239            .iter()
240            .try_fold(dtype.size(), |n, &d| n.checked_mul(d))
241            .ok_or_else(|| invalid(format!("`{name}`: size overflows")))?;
242        if end.checked_sub(begin) != Some(expected) {
243            return Err(invalid(format!(
244                "`{name}`: data_offsets [{begin}, {end}] do not hold {expected} bytes"
245            )));
246        }
247        ranges.push((begin, end, name, dtype, shape));
248    }
249    // The ranges must tile the data exactly.
250    ranges.sort_by_key(|r| (r.0, r.1));
251    let mut at = 0;
252    for (begin, end, name, ..) in &ranges {
253        if *begin != at {
254            return Err(invalid(format!(
255                "`{name}`: data_offsets overlap or leave a gap"
256            )));
257        }
258        at = *end;
259    }
260    if start.checked_add(at) != Some(file_len) {
261        return Err(invalid(
262            "the tensors do not cover the data to the end of the file",
263        ));
264    }
265    Ok(Header {
266        metadata,
267        ranges,
268        start,
269    })
270}
271
272/// Reads and validates a file's header with ordinary reads (nothing is mapped), returning
273/// it and the file's length.
274fn read_header(path: &Path) -> Result<(Header, usize)> {
275    use std::io::Read;
276    let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
277    let mut file = std::fs::File::open(path).map_err(io)?;
278    let file_len = usize::try_from(file.metadata().map_err(io)?.len())
279        .map_err(|_| Error::Io(format!("{}: too large", path.display())))?;
280    let mut size = [0u8; 8];
281    file.read_exact(&mut size).map_err(|_| {
282        invalid(format!(
283            "{}: file shorter than its header size",
284            path.display()
285        ))
286    })?;
287    let start = header_end(size, file_len).map_err(|e| named(path, e))?;
288    let mut json_bytes = vec![0u8; start - 8];
289    file.read_exact(&mut json_bytes).map_err(io)?;
290    let header = parse_header(&json_bytes, start, file_len).map_err(|e| named(path, e))?;
291    Ok((header, file_len))
292}
293
294/// `e` with the file's name in front of its message.
295fn named(path: &Path, e: Error) -> Error {
296    match e {
297        Error::InvalidArgument(m) => Error::InvalidArgument(format!("{}: {m}", path.display())),
298        Error::Io(m) => Error::Io(format!("{}: {m}", path.display())),
299        e => e,
300    }
301}
302
303/// What loading safetensors checkpoints holds in the GPU allocator, in bytes (see
304/// [`safetensors_footprint`]). Compare with
305/// `nuvec_gpu::MemoryStats`'s `mapped`, `active` and `peak_with_mapped`.
306#[non_exhaustive]
307#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
308pub struct Footprint {
309    /// Kept mapped: of aligned files, the page-rounded ranges of the kept tensors (the whole
310    /// file, header included, when every tensor is kept).
311    pub mapped: u64,
312    /// Read into allocator memory: the size-class-rounded tensors of files whose tensors are
313    /// not aligned (as MLX writes them; mlx-community checkpoints). Such files are not
314    /// mapped.
315    pub copied: u64,
316    /// The peak of `mapped + copied` while loading: `mapped + copied` (no file is mapped
317    /// and copied at once).
318    pub transient: u64,
319}
320
321impl Footprint {
322    /// The footprint of one file of `file_len` bytes with this header, loading the tensors
323    /// `keep` accepts.
324    fn of(header: &Header, file_len: usize, keep: &dyn Fn(&str) -> bool) -> Self {
325        if header.aligned() {
326            let page = nuvec_core::MappedRanges::page_size() as u64;
327            let mapping = header.runs(file_len, page, keep).iter().map(|r| r.1).sum();
328            Footprint {
329                mapped: mapping,
330                copied: 0,
331                transient: mapping,
332            }
333        } else {
334            let copied = nuvec_core::MappedFile::copy_size(header.plan(keep).len as u64);
335            Footprint {
336                mapped: 0,
337                copied,
338                transient: copied,
339            }
340        }
341    }
342}
343
344/// What loading a safetensors file, or every `*.safetensors` file of a directory, will
345/// hold, from their headers only (ordinary reads; nothing is mapped, copied or wired):
346/// the bytes kept mapped, the bytes copied, and the transient peak of the load.
347///
348/// After [`load_safetensors`] (file by file for a directory) the GPU allocator's `mapped`
349/// grows by `mapped`, its `active` by `copied`, and `peak_with_mapped` rises by at most
350/// `transient`, which is `mapped + copied`: misaligned files are read, not mapped.
351/// [`load_safetensors_filtered`] copies at most `copied`.
352///
353/// ```
354/// # fn main() -> nuvec_core::Result<()> {
355/// # if cfg!(miri) { return Ok(()); }
356/// use nuvec_core::array;
357///
358/// let path = std::env::temp_dir().join("nuvec-footprint-doc.safetensors");
359/// nuvec_io::save_safetensors(&path, &[("w", &array![1.0, 2.0])], &[])?;
360/// let fp = nuvec_io::safetensors_footprint(&path)?;
361/// assert_eq!(fp.copied, 0); // nuvec writes aligned files: mapped, not copied
362/// assert!(fp.mapped >= std::fs::metadata(&path).unwrap().len());
363/// # Ok(())
364/// # }
365/// ```
366pub fn safetensors_footprint(path: impl AsRef<Path>) -> Result<Footprint> {
367    safetensors_footprint_filtered(path, |_| true)
368}
369
370/// What [`load_safetensors_filtered`] with `keep` will hold, as [`safetensors_footprint`]
371/// (a file, or every `*.safetensors` file of a directory): `copied` counts only the kept
372/// tensors of misaligned files, and only the kept tensors' pages of aligned files.
373///
374/// ```
375/// # fn main() -> nuvec_core::Result<()> {
376/// # if cfg!(miri) { return Ok(()); }
377/// use nuvec_core::array;
378///
379/// let path = std::env::temp_dir().join("nuvec-footprint-filtered-doc.safetensors");
380/// nuvec_io::save_safetensors(&path, &[("a", &array![1.0]), ("b", &array![2.0])], &[])?;
381/// let all = nuvec_io::safetensors_footprint(&path)?;
382/// let some = nuvec_io::safetensors_footprint_filtered(&path, |name| name == "b")?;
383/// assert_eq!(some, all); // a tiny file: both tensors share its one page
384/// # Ok(())
385/// # }
386/// ```
387pub fn safetensors_footprint_filtered(
388    path: impl AsRef<Path>,
389    keep: impl Fn(&str) -> bool,
390) -> Result<Footprint> {
391    let keep = |name: &str| !name.starts_with(PAD) && keep(name);
392    let path = path.as_ref();
393    let files = if path.is_dir() {
394        let mut files: Vec<_> = std::fs::read_dir(path)
395            .map_err(|e| Error::Io(format!("{}: {e}", path.display())))?
396            .filter_map(|e| e.ok().map(|e| e.path()))
397            .filter(|p| p.extension().is_some_and(|x| x == "safetensors"))
398            .collect();
399        files.sort();
400        files
401    } else {
402        vec![path.to_path_buf()]
403    };
404    let mut total = Footprint::default();
405    for file in &files {
406        let (header, len) = read_header(file)?;
407        let one = Footprint::of(&header, len, &keep);
408        total.mapped += one.mapped;
409        total.copied += one.copied;
410    }
411    total.transient = total.mapped + total.copied;
412    Ok(total)
413}
414
415/// A tensor of a safetensors header ([`safetensors_tensors`]).
416#[derive(Debug, Clone, PartialEq, Eq)]
417#[non_exhaustive]
418pub struct TensorInfo {
419    /// The tensor's name.
420    pub name: String,
421    /// Its dtype.
422    pub dtype: DType,
423    /// Its shape.
424    pub shape: Vec<usize>,
425    /// Its bytes in the file.
426    pub bytes: usize,
427}
428
429/// The tensors of a safetensors file, from its header alone (no tensor data is read or
430/// mapped), in file order.
431///
432/// ```
433/// # fn main() -> nuvec_core::Result<()> {
434/// # if cfg!(miri) { return Ok(()); }
435/// use nuvec_core::array;
436///
437/// let path = std::env::temp_dir().join("nuvec-tensors-doc.safetensors");
438/// nuvec_io::save_safetensors(&path, &[("w", &array![[1.0, 2.0]])], &[])?;
439/// let t = nuvec_io::safetensors_tensors(&path)?;
440/// assert_eq!((t[0].name.as_str(), t[0].shape.as_slice(), t[0].bytes), ("w", &[1, 2][..], 8));
441/// # Ok(())
442/// # }
443/// ```
444pub fn safetensors_tensors(path: impl AsRef<Path>) -> Result<Vec<TensorInfo>> {
445    let path = path.as_ref();
446    let (header, _) = read_header(path)?;
447    Ok(header
448        .ranges
449        .into_iter()
450        .filter(|(_, _, name, _, _)| !name.starts_with(PAD))
451        .map(|(begin, end, name, dtype, shape)| TensorInfo {
452            name,
453            dtype,
454            shape,
455            bytes: end - begin,
456        })
457        .collect())
458}
459
460/// Loads a safetensors file. The tensors are views of the memory-mapped file or, if they
461/// are not aligned to their element sizes (MLX writes headers without padding), of one
462/// buffer they are read into with parallel positioned reads (the file is then not mapped).
463///
464/// Validation follows the reference implementation: a header of at most 100 MB, an
465/// optional (or `null`) `__metadata__` of strings, and tensors whose byte ranges, sorted
466/// by offset, tile the data from its start to the end of the file.
467///
468/// Before mapping or reading, the file's load transient ([`safetensors_footprint`]) is
469/// checked against the GPU's memory limit: a load that would pass it fails with an error
470/// naming the file, with nothing mapped or allocated.
471pub fn load_safetensors(path: impl AsRef<Path>) -> Result<SafeTensors> {
472    load_safetensors_filtered(path, |_| true)
473}
474
475/// [`load_safetensors`], returning only the tensors whose names `keep` accepts. In files
476/// that are read rather than mapped (misaligned ones, as MLX writes them), the others are
477/// not read at all: loading a language model from a multimodal checkpoint, say, skips the
478/// vision weights.
479///
480/// ```
481/// # fn main() -> nuvec_core::Result<()> {
482/// # if cfg!(miri) { return Ok(()); }
483/// use nuvec_core::array;
484///
485/// let path = std::env::temp_dir().join("nuvec-filtered-doc.safetensors");
486/// nuvec_io::save_safetensors(&path, &[("a", &array![1.0]), ("b", &array![2.0])], &[])?;
487/// let loaded = nuvec_io::load_safetensors_filtered(&path, |name| name == "b")?;
488/// assert_eq!(loaded.tensors.keys().collect::<Vec<_>>(), ["b"]);
489/// # Ok(())
490/// # }
491/// ```
492pub fn load_safetensors_filtered(
493    path: impl AsRef<Path>,
494    keep: impl Fn(&str) -> bool,
495) -> Result<SafeTensors> {
496    let keep = |name: &str| !name.starts_with(PAD) && keep(name);
497    let path = path.as_ref();
498    let (header, len) = read_header(path)?;
499    let aligned = header.aligned();
500    let plan = (!aligned).then(|| header.plan(&keep));
501    let page = nuvec_core::MappedRanges::page_size() as u64;
502    let runs = header.runs(len, page, &keep);
503    let need = match &plan {
504        Some(plan) => nuvec_core::MappedFile::copy_size(plan.len as u64),
505        None => runs.iter().map(|r| r.1).sum(),
506    };
507    if let Some(room) = nuvec_core::MappedFile::headroom()
508        && need > room
509    {
510        return Err(Error::Io(format!(
511            "{}: out of memory: loading needs {need} bytes (mapped or read), {room} \
512             are left under the memory limit",
513            path.display()
514        )));
515    }
516    let oom = |e| match e {
517        Error::OutOfMemory(n) => Error::Io(format!(
518            "{}: out of memory: {n} bytes pass the memory limit",
519            path.display()
520        )),
521        e => e,
522    };
523    let mut out = SafeTensors {
524        metadata: header.metadata,
525        ..SafeTensors::default()
526    };
527    match plan {
528        // Read (not mapped): a mapping besides the copy would hold the checkpoint twice.
529        Some(plan) => {
530            let data = MappedFile::read_ranges(path, &plan.ranges, plan.len).map_err(oom)?;
531            let mut ranges: Vec<_> = header.ranges.into_iter().map(Some).collect();
532            for (index, offset) in plan.tensors {
533                let (_, _, name, dtype, shape) = ranges[index].take().expect("kept once");
534                out.tensors.insert(name, data.array(offset, dtype, &shape)?);
535            }
536        }
537        // Some tensors dropped: only the kept tensors' pages are mapped.
538        None if runs != [(0, len.div_ceil(page as usize) as u64 * page)] => {
539            // Within page-rounded ranges of a file of `len` bytes: the last range may end
540            // past it, inside its last page; map it to the file's end.
541            let ranges: Vec<(u64, u64)> = runs
542                .iter()
543                .map(|&(o, n)| (o, n.min(len as u64 - o)))
544                .collect();
545            let file = nuvec_core::MappedRanges::open(path, &ranges).map_err(oom)?;
546            for (begin, end, name, dtype, shape) in header.ranges {
547                if keep(&name) {
548                    let tensor = if begin == end {
549                        nuvec_core::ops::zeros(
550                            shape.iter().map(|&d| d as i32).collect::<Vec<_>>(),
551                            dtype,
552                        )
553                    } else {
554                        file.array(header.start + begin, dtype, &shape)?
555                    };
556                    out.tensors.insert(name, tensor);
557                }
558            }
559        }
560        None => {
561            let file = MappedFile::open(path).map_err(oom)?;
562            // The file is the one whose header was read; it must not change while it is
563            // mapped (see `MappedFile`), so a different length is an error.
564            if file.len() != len {
565                return Err(Error::Io(format!(
566                    "{}: the file changed while it was loaded",
567                    path.display()
568                )));
569            }
570            for (begin, _, name, dtype, shape) in header.ranges {
571                if keep(&name) {
572                    let tensor = file.array(header.start + begin, dtype, &shape)?;
573                    out.tensors.insert(name, tensor);
574                }
575            }
576        }
577    }
578    Ok(out)
579}
580
581/// The array's elements as little-endian bytes, row-major.
582pub(crate) fn array_bytes(a: &Array) -> Result<Vec<u8>> {
583    macro_rules! le {
584        ($t:ty) => {
585            a.to_vec::<$t>()?
586                .iter()
587                .flat_map(|v| v.to_le_bytes())
588                .collect()
589        };
590    }
591    Ok(match a.dtype() {
592        DType::Bool => a.to_vec::<bool>()?.iter().map(|&b| u8::from(b)).collect(),
593        DType::U8 => a.to_vec::<u8>()?,
594        DType::U16 => le!(u16),
595        DType::U32 => le!(u32),
596        DType::U64 => le!(u64),
597        DType::I8 => le!(i8),
598        DType::I16 => le!(i16),
599        DType::I32 => le!(i32),
600        DType::I64 => le!(i64),
601        DType::F16 => le!(nuvec_core::f16),
602        DType::BF16 => le!(nuvec_core::bf16),
603        DType::F32 => le!(f32),
604        DType::F64 => le!(f64),
605        _ => a
606            .to_vec::<nuvec_core::Complex64>()?
607            .iter()
608            .flat_map(|z| [z.re.to_le_bytes(), z.im.to_le_bytes()].concat())
609            .collect(),
610    })
611}
612
613/// The file's bytes for `tensors` (evaluating them) and `metadata`.
614fn encode(tensors: &[(&str, &Array)], metadata: &[(&str, &str)]) -> Result<Vec<u8>> {
615    let mut sorted: Vec<&(&str, &Array)> = tensors.iter().collect();
616    sorted.sort_by(|(ln, la), (rn, ra)| ra.dtype().size().cmp(&la.dtype().size()).then(ln.cmp(rn)));
617    let mut header = Vec::new();
618    if !metadata.is_empty() {
619        let map: BTreeMap<String, String> = metadata
620            .iter()
621            .map(|(k, v)| (k.to_string(), v.to_string()))
622            .collect();
623        header.push(format!("\"__metadata__\":{}", json::object(&map)));
624    }
625    let mut data = Vec::new();
626    for (name, a) in sorted {
627        let bytes = array_bytes(a)?;
628        let shape: Vec<String> = a.shape().iter().map(i32::to_string).collect();
629        header.push(format!(
630            "{}:{{\"dtype\":\"{}\",\"shape\":[{}],\"data_offsets\":[{},{}]}}",
631            json::quote(name),
632            name_of(a.dtype()),
633            shape.join(","),
634            data.len(),
635            data.len() + bytes.len()
636        ));
637        data.extend(bytes);
638    }
639    let mut header = format!("{{{}}}", header.join(",")).into_bytes();
640    header.resize(header.len().next_multiple_of(8), b' ');
641    let mut out = Vec::with_capacity(8 + header.len() + data.len());
642    out.extend((header.len() as u64).to_le_bytes());
643    out.extend(header);
644    out.extend(data);
645    Ok(out)
646}
647
648/// The name prefix of the padding tensors [`align_safetensors`] writes between tensors
649/// (safetensors allows no gaps): never loaded or listed.
650const PAD: &str = "__nuvec_pad_";
651
652/// Writes a copy of the safetensors file `src` at `dst` in which every tensor starts at a
653/// multiple of 256 bytes of the file (the fastest placement measured), with the same
654/// tensors, bit for bit, in the same order, and the same metadata. Gaps are filled with
655/// padding tensors that nuvec never loads, so `dst` is a valid safetensors file that nuvec
656/// maps without a copy. Tensors are streamed with positioned reads (no whole-file copy in
657/// memory); `dst` is written through a temporary file and a rename.
658pub fn align_safetensors(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
659    use std::io::Write;
660    let (src, dst) = (src.as_ref(), dst.as_ref());
661    let (header, _) = read_header(src)?;
662    let mut entries = Vec::new();
663    if !header.metadata.is_empty() {
664        entries.push(format!(
665            "\"__metadata__\":{}",
666            json::object(&header.metadata)
667        ));
668    }
669    // (file offset in src, length, offset in dst's data section) of each tensor.
670    let mut copies = Vec::new();
671    let mut at = 0usize;
672    for (i, (begin, end, name, dtype, shape)) in header.ranges.iter().enumerate() {
673        let pad = at.next_multiple_of(TENSOR_ALIGN) - at;
674        if pad > 0 {
675            entries.push(format!(
676                "\"{PAD}{i}\":{{\"dtype\":\"U8\",\"shape\":[{pad}],\"data_offsets\":[{at},{}]}}",
677                at + pad
678            ));
679            at += pad;
680        }
681        let n = end - begin;
682        let dims: Vec<String> = shape.iter().map(usize::to_string).collect();
683        entries.push(format!(
684            "{}:{{\"dtype\":\"{}\",\"shape\":[{}],\"data_offsets\":[{at},{}]}}",
685            json::quote(name),
686            name_of(*dtype),
687            dims.join(","),
688            at + n
689        ));
690        copies.push(((header.start + begin) as u64, n, at));
691        at += n;
692    }
693    // The data section starts at a multiple of 256 too.
694    let mut json = format!("{{{}}}", entries.join(",")).into_bytes();
695    json.resize((8 + json.len()).next_multiple_of(TENSOR_ALIGN) - 8, b' ');
696    fn io(p: &Path) -> impl Fn(std::io::Error) -> Error + '_ {
697        move |e| Error::Io(format!("{}: {e}", p.display()))
698    }
699    let tmp = dst.with_extension("safetensors.tmp");
700    let input = std::fs::File::open(src).map_err(io(src))?;
701    let mut out =
702        std::io::BufWriter::with_capacity(16 << 20, std::fs::File::create(&tmp).map_err(io(&tmp))?);
703    out.write_all(&(json.len() as u64).to_le_bytes())
704        .map_err(io(&tmp))?;
705    out.write_all(&json).map_err(io(&tmp))?;
706    let mut written = 0usize;
707    let mut chunk = vec![0u8; 16 << 20];
708    for (from, n, to) in copies {
709        out.write_all(&vec![0u8; to - written]).map_err(io(&tmp))?;
710        let mut done = 0;
711        while done < n {
712            let k = (n - done).min(chunk.len());
713            #[cfg(unix)]
714            std::os::unix::fs::FileExt::read_exact_at(&input, &mut chunk[..k], from + done as u64)
715                .map_err(io(src))?;
716            #[cfg(not(unix))]
717            {
718                use std::io::{Read, Seek, SeekFrom};
719                let mut f = &input;
720                f.seek(SeekFrom::Start(from + done as u64))
721                    .map_err(io(src))?;
722                f.read_exact(&mut chunk[..k]).map_err(io(src))?;
723            }
724            out.write_all(&chunk[..k]).map_err(io(&tmp))?;
725            done += k;
726        }
727        written = to + n;
728    }
729    out.into_inner()
730        .map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?
731        .sync_all()
732        .map_err(io(&tmp))?;
733    std::fs::rename(&tmp, dst).map_err(io(dst))
734}
735
736/// [`load_safetensors_filtered`] through an aligned copy: an aligned file is loaded (mapped)
737/// as it is; a misaligned one (mlx-community checkpoints) is loaded from an aligned copy in
738/// `cache_dir` ([`align_safetensors`]), written on first use and rewritten when the source's
739/// size or modification time changes, so later loads map it without a copy. If the copy
740/// cannot be written (no room, no permission), the file is read as usual.
741pub fn load_safetensors_cached(
742    path: impl AsRef<Path>,
743    cache_dir: impl AsRef<Path>,
744    keep: impl Fn(&str) -> bool,
745) -> Result<SafeTensors> {
746    use std::hash::{Hash, Hasher};
747    let (path, cache_dir) = (path.as_ref(), cache_dir.as_ref());
748    let (header, len) = read_header(path)?;
749    if header.aligned() {
750        return load_safetensors_filtered(path, keep);
751    }
752    let meta =
753        std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
754    let mtime = meta
755        .modified()
756        .ok()
757        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
758        .map_or(0, |d| d.as_nanos());
759    let mut h = std::collections::hash_map::DefaultHasher::new();
760    std::fs::canonicalize(path)
761        .unwrap_or_else(|_| path.to_path_buf())
762        .hash(&mut h);
763    let stem = path
764        .file_stem()
765        .and_then(|s| s.to_str())
766        .unwrap_or("tensors");
767    let copy = cache_dir.join(format!(
768        "{stem}-{:016x}-{len}-{mtime}.safetensors",
769        h.finish()
770    ));
771    if !copy.exists() {
772        let made = std::fs::create_dir_all(cache_dir)
773            .map_err(|e| Error::Io(format!("{}: {e}", cache_dir.display())))
774            .and_then(|()| align_safetensors(path, &copy));
775        if let Err(e) = made {
776            static WARNED: std::sync::Once = std::sync::Once::new();
777            WARNED.call_once(|| {
778                eprintln!("nuvec: no aligned copy ({e}); reading {}", path.display())
779            });
780            return load_safetensors_filtered(path, keep);
781        }
782    }
783    load_safetensors_filtered(&copy, keep)
784}
785
786/// Writes `bytes` to `path` atomically: to `path.tmp`, renamed over `path` on success.
787fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
788    let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
789    let mut tmp = path.as_os_str().to_owned();
790    tmp.push(".tmp");
791    let tmp = std::path::PathBuf::from(tmp);
792    if let Err(e) = std::fs::write(&tmp, bytes) {
793        let _ = std::fs::remove_file(&tmp);
794        return Err(io(e));
795    }
796    std::fs::rename(&tmp, path).map_err(|e| {
797        let _ = std::fs::remove_file(&tmp);
798        io(e)
799    })
800}
801
802/// Saves `tensors` (evaluating them) and `metadata` as a safetensors file, written to
803/// `path.tmp` and renamed into place.
804///
805/// As the reference implementation, tensors are laid out by decreasing element size (then
806/// by name) after a header padded to 8 bytes, so every tensor is aligned to its element
807/// size and loads without a copy.
808pub fn save_safetensors(
809    path: impl AsRef<Path>,
810    tensors: &[(&str, &Array)],
811    metadata: &[(&str, &str)],
812) -> Result<()> {
813    write_atomically(path.as_ref(), &encode(tensors, metadata)?)
814}
815
816/// A background safetensors write ([`save_safetensors_async`]). [`SaveHandle::wait`]
817/// returns its result; dropping the handle waits for the write to finish.
818#[must_use = "wait() returns the write's result; dropping the handle only waits"]
819#[derive(Debug)]
820pub struct SaveHandle {
821    thread: Option<std::thread::JoinHandle<Result<()>>>,
822}
823
824impl SaveHandle {
825    /// Waits for the write and returns its result.
826    pub fn wait(mut self) -> Result<()> {
827        self.join()
828    }
829
830    fn join(&mut self) -> Result<()> {
831        match self.thread.take() {
832            Some(t) => t
833                .join()
834                .unwrap_or_else(|_| Err(Error::Io("the safetensors writer panicked".into()))),
835            None => Ok(()),
836        }
837    }
838}
839
840impl Drop for SaveHandle {
841    fn drop(&mut self) {
842        let _ = self.join();
843    }
844}
845
846/// [`save_safetensors`] in the background, for snapshots taken while work continues (a
847/// KV cache or recurrent state copied with `copy`, say): the arrays are submitted for
848/// evaluation ([`nuvec_core::async_eval`]) and returned at once with a handle; a
849/// background thread waits for them and writes `path.tmp`, renamed into place on success.
850/// The file is byte-identical to [`save_safetensors`]'s. The handle holds the arrays until
851/// the write is done, so an in-place update of one of them copies instead of writing into
852/// memory being saved.
853///
854/// ```
855/// # fn main() -> nuvec_core::Result<()> {
856/// # if cfg!(miri) { return Ok(()); }
857/// use nuvec_core::array;
858///
859/// let path = std::env::temp_dir().join("nuvec-async-save-doc.safetensors");
860/// let state = array![1.0, 2.0, 3.0];
861/// let handle = nuvec_io::save_safetensors_async(&path, &[("state", &state.copy())], &[]);
862/// // ... more work here ...
863/// handle.wait()?;
864/// assert_eq!(nuvec_io::load_safetensors(&path)?.tensors["state"].to_vec::<f32>()?, [1.0, 2.0, 3.0]);
865/// # Ok(())
866/// # }
867/// ```
868pub fn save_safetensors_async(
869    path: impl AsRef<Path>,
870    tensors: &[(&str, &Array)],
871    metadata: &[(&str, &str)],
872) -> SaveHandle {
873    let path = path.as_ref().to_path_buf();
874    let tensors: Vec<(String, Array)> = tensors
875        .iter()
876        .map(|(n, a)| ((*n).to_string(), (*a).clone()))
877        .collect();
878    let metadata: Vec<(String, String)> = metadata
879        .iter()
880        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
881        .collect();
882    let submitted = nuvec_core::async_eval(&tensors.iter().map(|(_, a)| a).collect::<Vec<_>>());
883    let thread = std::thread::spawn(move || {
884        submitted?;
885        let tensors: Vec<(&str, &Array)> = tensors.iter().map(|(n, a)| (n.as_str(), a)).collect();
886        let metadata: Vec<(&str, &str)> = metadata
887            .iter()
888            .map(|(k, v)| (k.as_str(), v.as_str()))
889            .collect();
890        write_atomically(&path, &encode(&tensors, &metadata)?)
891    });
892    SaveHandle {
893        thread: Some(thread),
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    #[test]
902    fn read_tensors_start_at_multiples_of_tensor_align() {
903        // As MLX writes them: an unpadded header (data at an odd offset), tensors back to
904        // back. `a` (300 bytes) ends off the alignment; `b` follows it; `skip` is rejected;
905        // `c` (256 bytes) is followed by `d`, which then lands aligned and continues `c`.
906        let json = br#"{"a":{"dtype":"U32","shape":[75],"data_offsets":[0,300]},
907            "b":{"dtype":"BF16","shape":[5],"data_offsets":[300,310]},
908            "skip":{"dtype":"U8","shape":[2],"data_offsets":[310,312]},
909            "c":{"dtype":"U32","shape":[64],"data_offsets":[312,568]},
910            "d":{"dtype":"U32","shape":[1],"data_offsets":[568,572]}}"#;
911        let start = 8 + json.len();
912        let header = parse_header(json, start, start + 572).unwrap();
913        let plan = header.plan(&|name| name != "skip");
914        let offsets: Vec<usize> = plan.tensors.iter().map(|&(_, at)| at).collect();
915        assert_eq!(offsets, [0, 512, 768, 1024]);
916        assert!(offsets.iter().all(|o| o.is_multiple_of(TENSOR_ALIGN)));
917        let s = start as u64;
918        assert_eq!(
919            plan.ranges,
920            [(s, 0, 300), (s + 300, 512, 10), (s + 312, 768, 260)]
921        );
922        assert_eq!(plan.len, 1028);
923    }
924
925    #[test]
926    fn filtered_aligned_loads_map_page_runs_of_the_kept_tensors() {
927        // Pages of 64 bytes for the test. Data starts at 8 + 376 = 384 (a padded header).
928        let json = format!(
929            "{:<376}",
930            r#"{"a":{"dtype":"F32","shape":[8],"data_offsets":[0,32]},"#.to_string()
931                + r#""b":{"dtype":"F32","shape":[8],"data_offsets":[32,64]},"#
932                + r#""big":{"dtype":"F32","shape":[64],"data_offsets":[64,320]},"#
933                + r#""e":{"dtype":"F32","shape":[0],"data_offsets":[320,320]},"#
934                + r#""z":{"dtype":"F32","shape":[8],"data_offsets":[320,352]}}"#
935        );
936        assert_eq!(json.len(), 376);
937        let start = 8 + json.len();
938        let len = start + 352;
939        let header = parse_header(json.as_bytes(), start, len).unwrap();
940        // Everything: the whole file, one run.
941        assert_eq!(header.runs(len, 64, &|_| true), [(0, 768)]);
942        // `a` and `b` share their pages: one run; `big` dropped; `z` its own pages.
943        let some = header.runs(len, 64, &|n| n != "big");
944        assert_eq!(some, [(384, 64), (704, 64)]);
945        // Touching runs merge.
946        assert_eq!(header.runs(len, 64, &|n| n != "z"), [(384, 320)]);
947        // An empty tensor takes nothing; nothing kept, nothing mapped.
948        assert_eq!(header.runs(len, 64, &|n| n == "e"), []);
949    }
950}