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 is aligned ([`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.
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        _ => return None,
48    })
49}
50
51fn name_of(dtype: DType) -> &'static str {
52    match dtype {
53        DType::Bool => "BOOL",
54        DType::U8 => "U8",
55        DType::U16 => "U16",
56        DType::U32 => "U32",
57        DType::U64 => "U64",
58        DType::I8 => "I8",
59        DType::I16 => "I16",
60        DType::I32 => "I32",
61        DType::I64 => "I64",
62        DType::F16 => "F16",
63        DType::BF16 => "BF16",
64        DType::F32 => "F32",
65        DType::F64 => "F64",
66        _ => "C64",
67    }
68}
69
70fn usize_of(v: &Json, what: &str) -> Result<usize> {
71    match v {
72        Json::Int(n) if *n >= 0 => Ok(*n as usize),
73        _ => Err(invalid(format!("{what} must be a non-negative integer"))),
74    }
75}
76
77/// The reference implementation's limit on the header size.
78const MAX_HEADER: usize = 100_000_000;
79
80/// A parsed and validated header: the metadata and the tensors' byte ranges (sorted, tiling
81/// the data section from `start` to the end of the file).
82struct Header {
83    metadata: BTreeMap<String, String>,
84    /// `(begin, end, name, dtype, shape)`, relative to the data section.
85    ranges: Vec<(usize, usize, String, DType, Vec<usize>)>,
86    /// Where the data section starts (8 + the header's length).
87    start: usize,
88}
89
90impl Header {
91    /// Whether every tensor is aligned to its element size in the file (else the loader
92    /// reads the tensors instead of mapping the file).
93    fn aligned(&self) -> bool {
94        self.ranges
95            .iter()
96            .all(|(begin, _, _, dtype, _)| (self.start + begin).is_multiple_of(dtype.size()))
97    }
98
99    /// Where the tensors `keep` accepts go in one buffer read from the file: in file order,
100    /// each continuing the previous kept tensor's file range if it follows it in the file
101    /// and lands aligned to its element size, else starting a new range at the next multiple
102    /// of 16. MLX-written files are aligned relative to their data section, so their kept
103    /// tensors take one range per gap left by rejected ones.
104    fn plan(&self, keep: &dyn Fn(&str) -> bool) -> ReadPlan {
105        let mut plan = ReadPlan::default();
106        // The file end of the current range, if any.
107        let mut file_end = None;
108        for (index, (begin, end, name, dtype, _)) in self.ranges.iter().enumerate() {
109            if !keep(name) {
110                continue;
111            }
112            let at = (self.start + begin) as u64;
113            let n = end - begin;
114            let offset = if file_end == Some(at) && plan.len.is_multiple_of(dtype.size()) {
115                let last = plan.ranges.last_mut().expect("an open range");
116                last.2 += n;
117                plan.len
118            } else {
119                let offset = plan.len.next_multiple_of(16);
120                plan.ranges.push((at, offset, n));
121                offset
122            };
123            plan.tensors.push((index, offset));
124            plan.len = offset + n;
125            file_end = Some(at + n as u64);
126        }
127        plan
128    }
129}
130
131/// The reads loading a misaligned file makes ([`Header::plan`]).
132#[derive(Default)]
133struct ReadPlan {
134    /// `(file offset, buffer offset, length)`, increasing and disjoint in the buffer.
135    ranges: Vec<(u64, usize, usize)>,
136    /// `(index into Header::ranges, buffer offset)` of each kept tensor.
137    tensors: Vec<(usize, usize)>,
138    /// The buffer's length.
139    len: usize,
140}
141
142/// The data section's start from the 8-byte header size, for a file of `file_len` bytes.
143fn header_end(size: [u8; 8], file_len: usize) -> Result<usize> {
144    usize::try_from(u64::from_le_bytes(size))
145        .ok()
146        .filter(|&n| n <= MAX_HEADER)
147        .and_then(|n| n.checked_add(8))
148        .filter(|&end| end <= file_len)
149        .ok_or_else(|| invalid("header larger than the file or 100 MB"))
150}
151
152/// Parses and validates the JSON header (`json`, the bytes after the size) of a file of
153/// `file_len` bytes whose data starts at `start`.
154fn parse_header(json_bytes: &[u8], start: usize, file_len: usize) -> Result<Header> {
155    let Json::Object(entries) = json::parse(json_bytes)? else {
156        return Err(invalid("the header must be a JSON object"));
157    };
158    let mut metadata = BTreeMap::new();
159    let mut ranges = Vec::new();
160    let mut names = BTreeSet::new();
161    for (name, entry) in entries {
162        if name == "__metadata__" {
163            match entry {
164                Json::Null => {}
165                Json::Object(fields) => {
166                    for (k, v) in fields {
167                        let Json::Str(s) = v else {
168                            return Err(invalid("metadata values must be strings"));
169                        };
170                        metadata.insert(k, s);
171                    }
172                }
173                _ => return Err(invalid("`__metadata__` must be an object")),
174            }
175            continue;
176        }
177        // A repeated name would tile the data and then silently replace the first tensor.
178        if !names.insert(name.clone()) {
179            return Err(invalid(format!("duplicate tensor `{name}`")));
180        }
181        let Json::Object(fields) = entry else {
182            return Err(invalid(format!("entry `{name}` must be an object")));
183        };
184        let field = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v);
185        let dtype = match field("dtype") {
186            Some(Json::Str(d)) => {
187                dtype_of(d).ok_or_else(|| invalid(format!("`{name}`: unsupported dtype {d}")))?
188            }
189            _ => return Err(invalid(format!("`{name}`: missing dtype"))),
190        };
191        let shape = match field("shape") {
192            Some(Json::Array(dims)) => dims
193                .iter()
194                .map(|d| usize_of(d, "a dimension"))
195                .collect::<Result<Vec<_>>>()?,
196            _ => return Err(invalid(format!("`{name}`: missing shape"))),
197        };
198        let (begin, end) = match field("data_offsets") {
199            Some(Json::Array(o)) if o.len() == 2 => {
200                (usize_of(&o[0], "an offset")?, usize_of(&o[1], "an offset")?)
201            }
202            _ => return Err(invalid(format!("`{name}`: missing data_offsets"))),
203        };
204        let expected = shape
205            .iter()
206            .try_fold(dtype.size(), |n, &d| n.checked_mul(d))
207            .ok_or_else(|| invalid(format!("`{name}`: size overflows")))?;
208        if end.checked_sub(begin) != Some(expected) {
209            return Err(invalid(format!(
210                "`{name}`: data_offsets [{begin}, {end}] do not hold {expected} bytes"
211            )));
212        }
213        ranges.push((begin, end, name, dtype, shape));
214    }
215    // The ranges must tile the data exactly.
216    ranges.sort_by_key(|r| (r.0, r.1));
217    let mut at = 0;
218    for (begin, end, name, ..) in &ranges {
219        if *begin != at {
220            return Err(invalid(format!(
221                "`{name}`: data_offsets overlap or leave a gap"
222            )));
223        }
224        at = *end;
225    }
226    if start.checked_add(at) != Some(file_len) {
227        return Err(invalid(
228            "the tensors do not cover the data to the end of the file",
229        ));
230    }
231    Ok(Header {
232        metadata,
233        ranges,
234        start,
235    })
236}
237
238/// Reads and validates a file's header with ordinary reads (nothing is mapped), returning
239/// it and the file's length.
240fn read_header(path: &Path) -> Result<(Header, usize)> {
241    use std::io::Read;
242    let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
243    let mut file = std::fs::File::open(path).map_err(io)?;
244    let file_len = usize::try_from(file.metadata().map_err(io)?.len())
245        .map_err(|_| Error::Io(format!("{}: too large", path.display())))?;
246    let mut size = [0u8; 8];
247    file.read_exact(&mut size).map_err(|_| {
248        invalid(format!(
249            "{}: file shorter than its header size",
250            path.display()
251        ))
252    })?;
253    let start = header_end(size, file_len).map_err(|e| named(path, e))?;
254    let mut json_bytes = vec![0u8; start - 8];
255    file.read_exact(&mut json_bytes).map_err(io)?;
256    let header = parse_header(&json_bytes, start, file_len).map_err(|e| named(path, e))?;
257    Ok((header, file_len))
258}
259
260/// `e` with the file's name in front of its message.
261fn named(path: &Path, e: Error) -> Error {
262    match e {
263        Error::InvalidArgument(m) => Error::InvalidArgument(format!("{}: {m}", path.display())),
264        Error::Io(m) => Error::Io(format!("{}: {m}", path.display())),
265        e => e,
266    }
267}
268
269/// What loading safetensors checkpoints holds in the GPU allocator, in bytes (see
270/// [`safetensors_footprint`]). Compare with
271/// `nuvec_gpu::MemoryStats`'s `mapped`, `active` and `peak_with_mapped`.
272#[non_exhaustive]
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
274pub struct Footprint {
275    /// Kept mapped: the page-rounded whole files (header included) of aligned files.
276    pub mapped: u64,
277    /// Read into allocator memory: the size-class-rounded tensors of files whose tensors are
278    /// not aligned (as MLX writes them; mlx-community checkpoints). Such files are not
279    /// mapped.
280    pub copied: u64,
281    /// The peak of `mapped + copied` while loading: `mapped + copied` (no file is mapped
282    /// and copied at once).
283    pub transient: u64,
284}
285
286impl Footprint {
287    /// The footprint of one file of `file_len` bytes with this header.
288    fn of(header: &Header, file_len: usize) -> Self {
289        if header.aligned() {
290            let mapping = nuvec_core::MappedFile::mapping_size(file_len as u64);
291            Footprint {
292                mapped: mapping,
293                copied: 0,
294                transient: mapping,
295            }
296        } else {
297            let copied = nuvec_core::MappedFile::copy_size(header.plan(&|_| true).len as u64);
298            Footprint {
299                mapped: 0,
300                copied,
301                transient: copied,
302            }
303        }
304    }
305}
306
307/// What loading a safetensors file, or every `*.safetensors` file of a directory, will
308/// hold, from their headers only (ordinary reads; nothing is mapped, copied or wired):
309/// the bytes kept mapped, the bytes copied, and the transient peak of the load.
310///
311/// After [`load_safetensors`] (file by file for a directory) the GPU allocator's `mapped`
312/// grows by `mapped`, its `active` by `copied`, and `peak_with_mapped` rises by at most
313/// `transient`, which is `mapped + copied`: misaligned files are read, not mapped.
314/// [`load_safetensors_filtered`] copies at most `copied`.
315///
316/// ```
317/// # fn main() -> nuvec_core::Result<()> {
318/// # if cfg!(miri) { return Ok(()); }
319/// use nuvec_core::array;
320///
321/// let path = std::env::temp_dir().join("nuvec-footprint-doc.safetensors");
322/// nuvec_io::save_safetensors(&path, &[("w", &array![1.0, 2.0])], &[])?;
323/// let fp = nuvec_io::safetensors_footprint(&path)?;
324/// assert_eq!(fp.copied, 0); // nuvec writes aligned files: mapped, not copied
325/// assert!(fp.mapped >= std::fs::metadata(&path).unwrap().len());
326/// # Ok(())
327/// # }
328/// ```
329pub fn safetensors_footprint(path: impl AsRef<Path>) -> Result<Footprint> {
330    let path = path.as_ref();
331    let files = if path.is_dir() {
332        let mut files: Vec<_> = std::fs::read_dir(path)
333            .map_err(|e| Error::Io(format!("{}: {e}", path.display())))?
334            .filter_map(|e| e.ok().map(|e| e.path()))
335            .filter(|p| p.extension().is_some_and(|x| x == "safetensors"))
336            .collect();
337        files.sort();
338        files
339    } else {
340        vec![path.to_path_buf()]
341    };
342    let mut total = Footprint::default();
343    for file in &files {
344        let (header, len) = read_header(file)?;
345        let one = Footprint::of(&header, len);
346        total.mapped += one.mapped;
347        total.copied += one.copied;
348    }
349    total.transient = total.mapped + total.copied;
350    Ok(total)
351}
352
353/// Loads a safetensors file. The tensors are views of the memory-mapped file or, if they
354/// are not aligned to their element sizes (MLX writes headers without padding), of one
355/// buffer they are read into with parallel positioned reads (the file is then not mapped).
356///
357/// Validation follows the reference implementation: a header of at most 100 MB, an
358/// optional (or `null`) `__metadata__` of strings, and tensors whose byte ranges, sorted
359/// by offset, tile the data from its start to the end of the file.
360///
361/// Before mapping or reading, the file's load transient ([`safetensors_footprint`]) is
362/// checked against the GPU's memory limit: a load that would pass it fails with an error
363/// naming the file, with nothing mapped or allocated.
364pub fn load_safetensors(path: impl AsRef<Path>) -> Result<SafeTensors> {
365    load_safetensors_filtered(path, |_| true)
366}
367
368/// [`load_safetensors`], returning only the tensors whose names `keep` accepts. In files
369/// that are read rather than mapped (misaligned ones, as MLX writes them), the others are
370/// not read at all: loading a language model from a multimodal checkpoint, say, skips the
371/// vision weights.
372///
373/// ```
374/// # fn main() -> nuvec_core::Result<()> {
375/// # if cfg!(miri) { return Ok(()); }
376/// use nuvec_core::array;
377///
378/// let path = std::env::temp_dir().join("nuvec-filtered-doc.safetensors");
379/// nuvec_io::save_safetensors(&path, &[("a", &array![1.0]), ("b", &array![2.0])], &[])?;
380/// let loaded = nuvec_io::load_safetensors_filtered(&path, |name| name == "b")?;
381/// assert_eq!(loaded.tensors.keys().collect::<Vec<_>>(), ["b"]);
382/// # Ok(())
383/// # }
384/// ```
385pub fn load_safetensors_filtered(
386    path: impl AsRef<Path>,
387    keep: impl Fn(&str) -> bool,
388) -> Result<SafeTensors> {
389    let path = path.as_ref();
390    let (header, len) = read_header(path)?;
391    let aligned = header.aligned();
392    let plan = (!aligned).then(|| header.plan(&keep));
393    let need = match &plan {
394        Some(plan) => nuvec_core::MappedFile::copy_size(plan.len as u64),
395        None => Footprint::of(&header, len).transient,
396    };
397    if let Some(room) = nuvec_core::MappedFile::headroom()
398        && need > room
399    {
400        return Err(Error::Io(format!(
401            "{}: out of memory: loading needs {need} bytes (mapped or read), {room} \
402             are left under the memory limit",
403            path.display()
404        )));
405    }
406    let oom = |e| match e {
407        Error::OutOfMemory(n) => Error::Io(format!(
408            "{}: out of memory: {n} bytes pass the memory limit",
409            path.display()
410        )),
411        e => e,
412    };
413    let mut out = SafeTensors {
414        metadata: header.metadata,
415        ..SafeTensors::default()
416    };
417    match plan {
418        // Read (not mapped): a mapping besides the copy would hold the checkpoint twice.
419        Some(plan) => {
420            let data = MappedFile::read_ranges(path, &plan.ranges, plan.len).map_err(oom)?;
421            let mut ranges: Vec<_> = header.ranges.into_iter().map(Some).collect();
422            for (index, offset) in plan.tensors {
423                let (_, _, name, dtype, shape) = ranges[index].take().expect("kept once");
424                out.tensors.insert(name, data.array(offset, dtype, &shape)?);
425            }
426        }
427        None => {
428            let file = MappedFile::open(path).map_err(oom)?;
429            // The file is the one whose header was read; it must not change while it is
430            // mapped (see `MappedFile`), so a different length is an error.
431            if file.len() != len {
432                return Err(Error::Io(format!(
433                    "{}: the file changed while it was loaded",
434                    path.display()
435                )));
436            }
437            for (begin, _, name, dtype, shape) in header.ranges {
438                if keep(&name) {
439                    let tensor = file.array(header.start + begin, dtype, &shape)?;
440                    out.tensors.insert(name, tensor);
441                }
442            }
443        }
444    }
445    Ok(out)
446}
447
448/// The array's elements as little-endian bytes, row-major.
449pub(crate) fn array_bytes(a: &Array) -> Result<Vec<u8>> {
450    macro_rules! le {
451        ($t:ty) => {
452            a.to_vec::<$t>()?
453                .iter()
454                .flat_map(|v| v.to_le_bytes())
455                .collect()
456        };
457    }
458    Ok(match a.dtype() {
459        DType::Bool => a.to_vec::<bool>()?.iter().map(|&b| u8::from(b)).collect(),
460        DType::U8 => a.to_vec::<u8>()?,
461        DType::U16 => le!(u16),
462        DType::U32 => le!(u32),
463        DType::U64 => le!(u64),
464        DType::I8 => le!(i8),
465        DType::I16 => le!(i16),
466        DType::I32 => le!(i32),
467        DType::I64 => le!(i64),
468        DType::F16 => le!(nuvec_core::f16),
469        DType::BF16 => le!(nuvec_core::bf16),
470        DType::F32 => le!(f32),
471        DType::F64 => le!(f64),
472        _ => a
473            .to_vec::<nuvec_core::Complex64>()?
474            .iter()
475            .flat_map(|z| [z.re.to_le_bytes(), z.im.to_le_bytes()].concat())
476            .collect(),
477    })
478}
479
480/// The file's bytes for `tensors` (evaluating them) and `metadata`.
481fn encode(tensors: &[(&str, &Array)], metadata: &[(&str, &str)]) -> Result<Vec<u8>> {
482    let mut sorted: Vec<&(&str, &Array)> = tensors.iter().collect();
483    sorted.sort_by(|(ln, la), (rn, ra)| ra.dtype().size().cmp(&la.dtype().size()).then(ln.cmp(rn)));
484    let mut header = Vec::new();
485    if !metadata.is_empty() {
486        let map: BTreeMap<String, String> = metadata
487            .iter()
488            .map(|(k, v)| (k.to_string(), v.to_string()))
489            .collect();
490        header.push(format!("\"__metadata__\":{}", json::object(&map)));
491    }
492    let mut data = Vec::new();
493    for (name, a) in sorted {
494        let bytes = array_bytes(a)?;
495        let shape: Vec<String> = a.shape().iter().map(i32::to_string).collect();
496        header.push(format!(
497            "{}:{{\"dtype\":\"{}\",\"shape\":[{}],\"data_offsets\":[{},{}]}}",
498            json::quote(name),
499            name_of(a.dtype()),
500            shape.join(","),
501            data.len(),
502            data.len() + bytes.len()
503        ));
504        data.extend(bytes);
505    }
506    let mut header = format!("{{{}}}", header.join(",")).into_bytes();
507    header.resize(header.len().next_multiple_of(8), b' ');
508    let mut out = Vec::with_capacity(8 + header.len() + data.len());
509    out.extend((header.len() as u64).to_le_bytes());
510    out.extend(header);
511    out.extend(data);
512    Ok(out)
513}
514
515/// Writes `bytes` to `path` atomically: to `path.tmp`, renamed over `path` on success.
516fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
517    let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display()));
518    let mut tmp = path.as_os_str().to_owned();
519    tmp.push(".tmp");
520    let tmp = std::path::PathBuf::from(tmp);
521    if let Err(e) = std::fs::write(&tmp, bytes) {
522        let _ = std::fs::remove_file(&tmp);
523        return Err(io(e));
524    }
525    std::fs::rename(&tmp, path).map_err(|e| {
526        let _ = std::fs::remove_file(&tmp);
527        io(e)
528    })
529}
530
531/// Saves `tensors` (evaluating them) and `metadata` as a safetensors file, written to
532/// `path.tmp` and renamed into place.
533///
534/// As the reference implementation, tensors are laid out by decreasing element size (then
535/// by name) after a header padded to 8 bytes, so every tensor is aligned to its element
536/// size and loads without a copy.
537pub fn save_safetensors(
538    path: impl AsRef<Path>,
539    tensors: &[(&str, &Array)],
540    metadata: &[(&str, &str)],
541) -> Result<()> {
542    write_atomically(path.as_ref(), &encode(tensors, metadata)?)
543}
544
545/// A background safetensors write ([`save_safetensors_async`]). [`SaveHandle::wait`]
546/// returns its result; dropping the handle waits for the write to finish.
547#[must_use = "wait() returns the write's result; dropping the handle only waits"]
548#[derive(Debug)]
549pub struct SaveHandle {
550    thread: Option<std::thread::JoinHandle<Result<()>>>,
551}
552
553impl SaveHandle {
554    /// Waits for the write and returns its result.
555    pub fn wait(mut self) -> Result<()> {
556        self.join()
557    }
558
559    fn join(&mut self) -> Result<()> {
560        match self.thread.take() {
561            Some(t) => t
562                .join()
563                .unwrap_or_else(|_| Err(Error::Io("the safetensors writer panicked".into()))),
564            None => Ok(()),
565        }
566    }
567}
568
569impl Drop for SaveHandle {
570    fn drop(&mut self) {
571        let _ = self.join();
572    }
573}
574
575/// [`save_safetensors`] in the background, for snapshots taken while work continues (a
576/// KV cache or recurrent state copied with `copy`, say): the arrays are submitted for
577/// evaluation ([`nuvec_core::async_eval`]) and returned at once with a handle; a
578/// background thread waits for them and writes `path.tmp`, renamed into place on success.
579/// The file is byte-identical to [`save_safetensors`]'s. The handle holds the arrays until
580/// the write is done, so an in-place update of one of them copies instead of writing into
581/// memory being saved.
582///
583/// ```
584/// # fn main() -> nuvec_core::Result<()> {
585/// # if cfg!(miri) { return Ok(()); }
586/// use nuvec_core::array;
587///
588/// let path = std::env::temp_dir().join("nuvec-async-save-doc.safetensors");
589/// let state = array![1.0, 2.0, 3.0];
590/// let handle = nuvec_io::save_safetensors_async(&path, &[("state", &state.copy())], &[]);
591/// // ... more work here ...
592/// handle.wait()?;
593/// assert_eq!(nuvec_io::load_safetensors(&path)?.tensors["state"].to_vec::<f32>()?, [1.0, 2.0, 3.0]);
594/// # Ok(())
595/// # }
596/// ```
597pub fn save_safetensors_async(
598    path: impl AsRef<Path>,
599    tensors: &[(&str, &Array)],
600    metadata: &[(&str, &str)],
601) -> SaveHandle {
602    let path = path.as_ref().to_path_buf();
603    let tensors: Vec<(String, Array)> = tensors
604        .iter()
605        .map(|(n, a)| ((*n).to_string(), (*a).clone()))
606        .collect();
607    let metadata: Vec<(String, String)> = metadata
608        .iter()
609        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
610        .collect();
611    let submitted = nuvec_core::async_eval(&tensors.iter().map(|(_, a)| a).collect::<Vec<_>>());
612    let thread = std::thread::spawn(move || {
613        submitted?;
614        let tensors: Vec<(&str, &Array)> = tensors.iter().map(|(n, a)| (n.as_str(), a)).collect();
615        let metadata: Vec<(&str, &str)> = metadata
616            .iter()
617            .map(|(k, v)| (k.as_str(), v.as_str()))
618            .collect();
619        write_atomically(&path, &encode(&tensors, &metadata)?)
620    });
621    SaveHandle {
622        thread: Some(thread),
623    }
624}