Skip to main content

burn_pack/
writer.rs

1use super::base::{
2    Error, FORMAT_VERSION, HEADER_SIZE, Header, MAGIC_NUMBER, Metadata, Scalar, TENSOR_ALIGNMENT,
3    TensorDescriptor, aligned_data_section_start, validate_tensor_byte_len,
4};
5use super::tensor::Tensor;
6#[cfg(feature = "std")]
7use crate::atomic::AtomicFile;
8use alloc::collections::BTreeMap;
9use alloc::format;
10use alloc::string::{String, ToString};
11use alloc::vec;
12use alloc::vec::Vec;
13use burn_std::Bytes;
14
15#[cfg(feature = "std")]
16use std::fs::File;
17#[cfg(feature = "std")]
18use std::io::{Read, Write};
19#[cfg(feature = "std")]
20use std::path::Path;
21
22/// Align an offset to the specified alignment boundary.
23///
24/// Returns the smallest value >= `offset` that is a multiple of `alignment`.
25#[inline]
26const fn align_offset(offset: u64, alignment: u64) -> u64 {
27    offset.div_ceil(alignment) * alignment
28}
29
30/// Maximum number of bytes materialized from a single tensor at a time while
31/// streaming its data into a [`Sink`].
32///
33/// Large device-resident tensors are read back to host memory lazily, one
34/// [`Bytes::view`] window at a time, instead of all at once. This keeps the
35/// transient (often pinned) host staging buffer bounded by this size regardless
36/// of how large the tensor is. The value is a multiple of [`TENSOR_ALIGNMENT`]
37/// so each window starts on an aligned device offset.
38const WRITE_CHUNK_SIZE: usize = 8 * 1024 * 1024;
39
40/// What [`Writer::build_descriptors`] produces: descriptors keyed by name for the metadata
41/// blob, each tensor's [`Placement`] in write order, and the total size of the data section.
42type Descriptors = (BTreeMap<String, TensorDescriptor>, Vec<Placement>, usize);
43
44/// Writer for creating Burnpack files.
45///
46/// Takes the tensors to write, each carrying bytes that are either already resident or
47/// produced on demand ([`Tensor::deferred`]). Deferred tensors are drawn one at a time
48/// during the write, so a model need not fit in memory to be saved.
49pub struct Writer {
50    /// Tensors to write
51    pub(crate) tensors: Vec<Tensor>,
52    /// Metadata key-value pairs
53    pub(crate) metadata: BTreeMap<String, String>,
54    /// Typed scalars keyed by name
55    pub(crate) scalars: BTreeMap<String, Scalar>,
56    /// Automatically append the canonical extension to extensionless file paths.
57    #[cfg(feature = "std")]
58    auto_extension: bool,
59    /// Replace an existing file at the destination on file writes.
60    #[cfg(feature = "std")]
61    overwrite: bool,
62}
63
64impl Writer {
65    /// Create a new writer
66    pub fn new(tensors: Vec<Tensor>) -> Self {
67        Self {
68            tensors,
69            metadata: BTreeMap::new(),
70            scalars: BTreeMap::new(),
71            #[cfg(feature = "std")]
72            auto_extension: true,
73            #[cfg(feature = "std")]
74            overwrite: true,
75        }
76    }
77
78    /// Builder pattern: add metadata and return self
79    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
80        self.metadata.insert(key.to_string(), value.to_string());
81        self
82    }
83
84    /// Builder pattern: add a typed scalar and return self.
85    pub fn with_scalar(mut self, key: &str, value: Scalar) -> Self {
86        self.scalars.insert(key.to_string(), value);
87        self
88    }
89
90    /// Enable or disable automatic extension appending for file writes.
91    ///
92    /// When enabled (the default), [`write_to_file`](Self::write_to_file) and
93    /// [`write_to_file_in_place`](Self::write_to_file_in_place) append the canonical
94    /// [`crate::EXTENSION`] when the requested path has no extension. When disabled,
95    /// both methods use the requested path exactly as provided.
96    #[cfg(feature = "std")]
97    pub fn auto_extension(mut self, enable: bool) -> Self {
98        self.auto_extension = enable;
99        self
100    }
101
102    /// Allow or refuse replacing an existing file on file writes.
103    ///
104    /// When enabled (the default), [`write_to_file`](Self::write_to_file) and
105    /// [`write_to_file_in_place`](Self::write_to_file_in_place) replace whatever is at the
106    /// destination. When disabled, both return [`Error::AlreadyExists`] instead. The check is
107    /// not a separate step that another writer could slip past: `write_to_file_in_place`
108    /// makes it when it creates the file, and `write_to_file` when it publishes the finished
109    /// one (see [`AtomicFile::commit_new`] for the one exception, filesystems without hard
110    /// links).
111    #[cfg(feature = "std")]
112    pub fn overwrite(mut self, enable: bool) -> Self {
113        self.overwrite = enable;
114        self
115    }
116
117    /// Calculate the total size needed for the burnpack data.
118    ///
119    /// This is useful when you want to pre-allocate a buffer for `write_into()`.
120    /// The size includes padding bytes for both metadata alignment and tensor alignment.
121    pub fn size(&self) -> Result<usize, Error> {
122        Ok(self.plan()?.total_size())
123    }
124
125    /// Write burnpack data into a caller-provided buffer.
126    ///
127    /// The buffer must be large enough to hold all data. Use `size()` to determine
128    /// the required buffer size. If the buffer is too small, this will return an error.
129    ///
130    /// This allows the caller to control buffer allocation, enabling optimizations like:
131    /// - Buffer reuse across multiple writes
132    /// - Custom allocators
133    /// - Pinned memory for GPU transfers
134    ///
135    /// On failure the buffer's contents are unspecified: a deferred [`Tensor`] produces its bytes
136    /// during the write, so an entry that fails partway leaves everything before it already
137    /// copied in. Callers reusing a buffer across writes cannot treat an error as "nothing
138    /// happened". [`write_to_file`](Self::write_to_file) has no such caveat;
139    /// [`write_to_file_in_place`](Self::write_to_file_in_place) has the same one, on the
140    /// destination itself.
141    ///
142    /// # Arguments
143    ///
144    /// * `buffer` - Mutable slice to write data into. Must be at least `size()` bytes.
145    pub fn write_into(self, buffer: &mut [u8]) -> Result<(), Error> {
146        let layout = self.plan()?;
147        let total_size = layout.total_size();
148
149        if buffer.len() < total_size {
150            return Err(Error::IoError(format!(
151                "Buffer too small: need {} bytes, got {} bytes",
152                total_size,
153                buffer.len()
154            )));
155        }
156
157        let mut sink = BufferSink { buffer, offset: 0 };
158        self.write_container(&layout, &mut sink)
159    }
160
161    /// Write to a byte buffer (convenience method).
162    ///
163    /// This allocates a buffer internally and writes the burnpack data.
164    /// For more control over buffer allocation, use `size()` + `write_into()`.
165    pub fn into_bytes(self) -> Result<Bytes, Error> {
166        let layout = self.plan()?;
167        let mut buffer = vec![0u8; layout.total_size()];
168
169        let mut sink = BufferSink {
170            buffer: &mut buffer,
171            offset: 0,
172        };
173        self.write_container(&layout, &mut sink)?;
174
175        Ok(Bytes::from_bytes_vec(buffer))
176    }
177
178    /// Write to a file without ever leaving a partial one at `path`.
179    ///
180    /// By default, the canonical [`crate::EXTENSION`] (`.bpk`) is appended when `path` has no
181    /// extension. Use [`auto_extension(false)`](Self::auto_extension) to preserve the path.
182    ///
183    /// The container is built in a scratch sibling of `path` and renamed into place only once
184    /// every byte is on disk, so `path` either ends up holding a complete container or is left
185    /// exactly as it was. A write can fail partway for reasons that have nothing to do with the
186    /// caller: the disk fills up, a quota is hit, the device reports an I/O error. It can also
187    /// fail because a [`deferred`](Tensor::deferred) tensor's provider, which runs during the
188    /// write, returns an error or hands back a different length than it declared. None of these
189    /// may leave a truncated file where a valid one used to be.
190    ///
191    /// That much holds everywhere, for failure at the process level: a returned error, a panic,
192    /// the process being killed. The rename is a single call, so it either took effect or it
193    /// did not, and neither outcome is a partial file.
194    ///
195    /// Power loss is narrower, and Unix-only. There the data is synced before the rename and
196    /// the parent directory after it, so a crash mid-save leaves the old container and a crash
197    /// after `Ok` leaves the new one - never a mixture, and never a lost save. Elsewhere both
198    /// halves are missing: the directory sync is unavailable, and the replace carries no
199    /// documented atomicity guarantee (Windows `MoveFileEx` is not specified as a single
200    /// metadata transaction when it replaces an existing file). After power loss the
201    /// destination may hold the old container or the new one, and the finished scratch file may
202    /// still be beside it.
203    ///
204    /// With [`overwrite(false)`](Self::overwrite) the finished file is hard-linked into place
205    /// instead of renamed, and [`Error::AlreadyExists`] is returned if anything is at `path`
206    /// by then; see [`AtomicFile::commit_new`].
207    ///
208    /// Building alongside the destination has four consequences.
209    /// [`write_to_file_in_place`](Self::write_to_file_in_place) avoids them, at the cost of the
210    /// guarantee above:
211    ///
212    /// - The data is fsynced before the rename, so the call does not return until the bytes are
213    ///   durable rather than merely handed to the page cache.
214    /// - Overwriting needs room for a second copy. Re-saving a model over itself transiently
215    ///   occupies twice its size, since the old file keeps its blocks until the rename.
216    /// - The destination is replaced rather than truncated, so its ownership and hard links do
217    ///   not carry over. Its permission bits do: on Unix an existing regular file's mode is
218    ///   copied onto the new file before the rename, so a `0600` container stays `0600` rather
219    ///   than reappearing at the process umask. (Windows has no mode to copy.) A symlink at
220    ///   `path` is replaced by a regular file rather than followed.
221    /// - A hard kill (SIGKILL, OOM) skips the cleanup and strands the scratch file. Scratch
222    ///   names are `<file_name>.<pid>-<n>.tmp` siblings of the resolved path (after any
223    ///   extension is appended), so leftovers are identifiable and safe to delete once no
224    ///   writer is running.
225    #[cfg(feature = "std")]
226    pub fn write_to_file<P: AsRef<Path>>(self, path: P) -> Result<(), Error> {
227        let path = self.resolve_path(path.as_ref());
228        let layout = self.plan()?;
229        let overwrite = self.overwrite;
230        let (scratch, file) = AtomicFile::create(&path)?;
231        let mut sink = FileSink {
232            file,
233            path: scratch.path().to_path_buf(),
234        };
235
236        self.write_container(&layout, &mut sink)?;
237
238        scratch.persist(sink, overwrite)
239    }
240
241    /// Write directly to a file, replacing its contents in place.
242    ///
243    /// By default, the canonical [`crate::EXTENSION`] (`.bpk`) is appended when `path` has no
244    /// extension. Use [`auto_extension(false)`](Self::auto_extension) to preserve the path.
245    /// With [`overwrite(false)`](Self::overwrite), an existing file is refused rather than
246    /// replaced.
247    ///
248    /// The file is truncated as soon as writing starts, so a failure partway through (a full
249    /// disk, an I/O error, a [`deferred`](Tensor::deferred) tensor's provider failing) leaves
250    /// it truncated, destroying whatever container was there before. Under `overwrite(false)`
251    /// that partial file is new, and it blocks a retry until removed.
252    ///
253    /// In exchange it skips the costs of [`write_to_file`](Self::write_to_file): no fsync, no
254    /// transient second copy, and an existing file keeps its inode, so its ownership and hard
255    /// links survive and a symlink at `path` is followed rather than replaced. Use it when
256    /// those matter more than keeping the previous container on failure.
257    #[cfg(feature = "std")]
258    pub fn write_to_file_in_place<P: AsRef<Path>>(self, path: P) -> Result<(), Error> {
259        let path = self.resolve_path(path.as_ref());
260        let layout = self.plan()?;
261
262        let file = File::options()
263            .write(true)
264            .truncate(true)
265            .create(self.overwrite)
266            .create_new(!self.overwrite)
267            .open(&path)
268            .map_err(|e| match e.kind() {
269                std::io::ErrorKind::AlreadyExists => {
270                    Error::AlreadyExists(path.display().to_string())
271                }
272                _ => Error::IoError(format!("cannot create '{}': {e}", path.display())),
273            })?;
274        let mut sink = FileSink { file, path };
275
276        self.write_container(&layout, &mut sink)
277    }
278
279    /// Apply the configured extension policy to a requested path.
280    #[cfg(feature = "std")]
281    fn resolve_path(&self, path: &Path) -> std::path::PathBuf {
282        if self.auto_extension && path.extension().is_none() {
283            path.with_extension(crate::EXTENSION)
284        } else {
285            path.to_path_buf()
286        }
287    }
288
289    /// Build the complete on-disk layout: header, serialized metadata, and the
290    /// position and size of the (aligned) tensor data section.
291    fn plan(&self) -> Result<Layout, Error> {
292        let (tensors, placements, data_size) = self.build_descriptors()?;
293        let metadata = Metadata {
294            tensors,
295            metadata: self.metadata.clone(),
296            scalars: self.scalars.clone(),
297        };
298
299        let mut metadata_bytes = Vec::new();
300        ciborium::ser::into_writer(&metadata, &mut metadata_bytes)
301            .map_err(|e| Error::MetadataSerializationError(e.to_string()))?;
302
303        let metadata_size: u32 = metadata_bytes.len().try_into().map_err(|_| {
304            Error::IoError(format!(
305                "Metadata size {} exceeds maximum of {} bytes",
306                metadata_bytes.len(),
307                u32::MAX
308            ))
309        })?;
310
311        let header = Header {
312            magic: MAGIC_NUMBER,
313            version: FORMAT_VERSION,
314            metadata_size,
315        };
316
317        let data_section_start = aligned_data_section_start(metadata_bytes.len());
318
319        Ok(Layout {
320            metadata_bytes,
321            placements,
322            header,
323            data_section_start,
324            data_size,
325        })
326    }
327
328    /// Build tensor descriptors, assigning each tensor an aligned offset within
329    /// the data section so that absolute file positions are mmap-friendly.
330    ///
331    /// Returns the descriptors keyed by name (for the metadata blob), the [`Placement`] of
332    /// each tensor in `self.tensors` order (for the write pass), and the total data-section
333    /// size (the running offset after the last tensor). Offsets only grow, so this is also
334    /// the highest descriptor end offset.
335    fn build_descriptors(&self) -> Result<Descriptors, Error> {
336        let mut tensors = BTreeMap::new();
337        let mut placements = Vec::with_capacity(self.tensors.len());
338        let mut current_offset = 0u64;
339
340        for tensor in &self.tensors {
341            let name = &tensor.name;
342            let dtype = tensor.dtype;
343            let shape: Vec<u64> = tensor.shape.iter().map(|&size| size as u64).collect();
344            let data_len = tensor.byte_len() as u64;
345
346            validate_tensor_byte_len(name, dtype, &shape, data_len)?;
347
348            // Align the start offset for mmap zero-copy support.
349            let aligned_start = align_offset(current_offset, TENSOR_ALIGNMENT);
350            let end = aligned_start.checked_add(data_len).ok_or_else(|| {
351                Error::IoError(format!(
352                    "Tensor offset overflow: {} + {} exceeds maximum",
353                    aligned_start, data_len
354                ))
355            })?;
356
357            // Descriptors are keyed by name, but the tensor data is written from the
358            // (ordered) `self.tensors` list. A duplicate name would collapse to a single
359            // descriptor while still writing two data blocks, corrupting the container.
360            if tensors
361                .insert(
362                    name.clone(),
363                    TensorDescriptor {
364                        dtype,
365                        shape,
366                        data_offsets: (aligned_start, end),
367                        param_id: tensor.param_id,
368                    },
369                )
370                .is_some()
371            {
372                return Err(Error::ValidationError(format!(
373                    "Duplicate tensor name '{}'",
374                    name
375                )));
376            }
377
378            placements.push(Placement {
379                name: name.clone(),
380                offset: aligned_start as usize,
381            });
382            current_offset = end;
383        }
384
385        Ok((tensors, placements, current_offset as usize))
386    }
387
388    /// Emit the full container — header, metadata, alignment padding, then tensor data
389    /// — into `sink`, which decides where the bytes ultimately land.
390    fn write_container(self, layout: &Layout, sink: &mut impl Sink) -> Result<(), Error> {
391        sink.write(&layout.header.into_bytes())?;
392        sink.write(&layout.metadata_bytes)?;
393
394        // Pad so the data section starts at its aligned position.
395        let unaligned_data_start = HEADER_SIZE + layout.metadata_bytes.len();
396        if layout.data_section_start > unaligned_data_start {
397            sink.pad(layout.data_section_start - unaligned_data_start)?;
398        }
399
400        self.write_tensors(&layout.placements, sink)
401    }
402
403    /// Write each tensor's data into `sink`, inserting alignment padding between
404    /// tensors so every tensor lands at its planned offset.
405    ///
406    /// `placements` was built by walking `self.tensors` in this same order, so zipping the
407    /// two pairs each tensor with its own offset by construction, with no lookup to get out
408    /// of step.
409    fn write_tensors(self, placements: &[Placement], sink: &mut impl Sink) -> Result<(), Error> {
410        // The zip below would silently drop tensors if the two ever diverged in length, and
411        // that is a corrupt container; one integer comparison per write buys a loud abort
412        // (which the scratch-file guard turns into a clean one) in release builds too.
413        assert_eq!(placements.len(), self.tensors.len());
414
415        // Position within the data section (relative to its aligned start).
416        let mut data_offset = 0usize;
417
418        for (tensor, placement) in self.tensors.into_iter().zip(placements) {
419            if placement.offset > data_offset {
420                sink.pad(placement.offset - data_offset)?;
421                data_offset = placement.offset;
422            }
423
424            let data = Self::materialize(tensor, placement)?;
425            write_tensor_data(&data, sink)?;
426            data_offset += data.len();
427        }
428
429        Ok(())
430    }
431
432    /// Materialize one tensor's bytes and check they fill exactly the space reserved for it.
433    ///
434    /// The length check is what keeps a lazy entry honest: the offset table was committed
435    /// from [`Tensor::byte_len`] long before these bytes existed, so a provider that
436    /// reports one size and produces another would misplace every tensor after it.
437    fn materialize(tensor: Tensor, placement: &Placement) -> Result<Bytes, Error> {
438        // Name the tensor on the way out. `into_bytes` runs mid-write, so its failures arrive
439        // interleaved with the writer's own disk errors; without this, a device readback that
440        // fails on one tensor of eight hundred is indistinguishable from a full disk.
441        tensor
442            .into_bytes()
443            .map_err(|e| e.in_tensor(&placement.name))
444    }
445}
446
447/// Where one tensor's bytes belong in the data section, computed during planning and
448/// carried forward so the write pass needs nothing from the tensor but its bytes.
449struct Placement {
450    /// The tensor's name, for error messages after the tensor has been consumed.
451    name: String,
452    /// Aligned start, relative to the beginning of the data section.
453    offset: usize,
454}
455
456/// Stream a single tensor's bytes into `sink`, materializing at most
457/// [`WRITE_CHUNK_SIZE`] bytes at a time.
458///
459/// When the backing supports zero-copy windows (device-resident
460/// [lazy](burn_std::Bytes) device readback, file, or shared buffers), each
461/// chunk is taken as a [`Bytes::view`] and read just-in-time, then dropped
462/// before the next one. A large device tensor is therefore copied to host in
463/// bounded pieces rather than through one big (pinned) staging buffer, so the
464/// whole tensor never has to be resident at once.
465///
466/// Backings without a zero-copy window (e.g. a plain heap `Vec`) are already
467/// host-resident, so [`Bytes::view`] reports it can't window them and the
468/// remaining bytes are written in a single pass.
469fn write_tensor_data(data: &Bytes, sink: &mut impl Sink) -> Result<(), Error> {
470    let len = data.len();
471    let mut offset = 0;
472
473    while offset < len {
474        let end = (offset + WRITE_CHUNK_SIZE).min(len);
475        match data.view(offset, end) {
476            Ok(chunk) => {
477                sink.write(&chunk)?;
478                offset = end;
479            }
480            // No zero-copy window available (already host-resident): write
481            // whatever remains in one shot. View support is a property of the
482            // backing, so this only ever happens on the first iteration.
483            Err(_) => {
484                sink.write(&data[offset..])?;
485                break;
486            }
487        }
488    }
489
490    Ok(())
491}
492
493/// The computed on-disk layout of a burnpack container.
494///
495/// Captures everything needed to emit the bytes: the serialized metadata, the
496/// header, where the aligned data section begins, and how large it is. Built once
497/// via [`Writer::plan`] and shared by `size`, `write_into`, `to_bytes`, `write_to_file` and
498/// `write_to_file_in_place`.
499struct Layout {
500    metadata_bytes: Vec<u8>,
501    /// Where each tensor's bytes go, in `Writer::tensors` order.
502    placements: Vec<Placement>,
503    header: Header,
504    data_section_start: usize,
505    data_size: usize,
506}
507
508impl Layout {
509    /// Total number of bytes the container occupies.
510    fn total_size(&self) -> usize {
511        self.data_section_start + self.data_size
512    }
513}
514
515/// A sequential destination for the bytes of a burnpack container.
516///
517/// Padding and data are written in order; each implementation advances its own
518/// cursor, letting the writer stay agnostic about whether bytes land in a buffer
519/// or a file.
520trait Sink {
521    /// Write `count` zero bytes of alignment padding.
522    fn pad(&mut self, count: usize) -> Result<(), Error>;
523    /// Write `data` verbatim.
524    fn write(&mut self, data: &[u8]) -> Result<(), Error>;
525}
526
527/// Sink that copies into a caller-provided buffer.
528struct BufferSink<'a> {
529    buffer: &'a mut [u8],
530    offset: usize,
531}
532
533impl Sink for BufferSink<'_> {
534    fn pad(&mut self, count: usize) -> Result<(), Error> {
535        self.buffer[self.offset..self.offset + count].fill(0);
536        self.offset += count;
537        Ok(())
538    }
539
540    fn write(&mut self, data: &[u8]) -> Result<(), Error> {
541        self.buffer[self.offset..self.offset + data.len()].copy_from_slice(data);
542        self.offset += data.len();
543        Ok(())
544    }
545}
546
547/// Flushing a sink and publishing its scratch file at the destination.
548///
549/// Lives here rather than on [`AtomicFile`] itself because it is what enforces the ordering
550/// the all-or-nothing guarantee rests on: persisting without first surrendering the file
551/// handle is unrepresentable, so the deferred-write-error check in [`FileSink::finish`]
552/// cannot be skipped and the handle is closed before publishing.
553#[cfg(feature = "std")]
554impl AtomicFile {
555    fn persist(mut self, sink: FileSink, overwrite: bool) -> Result<(), Error> {
556        sink.finish()?;
557        if overwrite {
558            self.rename_onto()
559        } else {
560            self.link_onto()
561        }
562    }
563}
564
565/// Sink that streams directly to a file.
566///
567/// Carries the path so its errors can name the file. The writer works on a scratch file
568/// whose name the caller never chose, so "No space left on device" with nothing attached
569/// would leave them with no idea which file the writer was even touching.
570#[cfg(feature = "std")]
571struct FileSink {
572    file: File,
573    path: std::path::PathBuf,
574}
575
576#[cfg(feature = "std")]
577impl FileSink {
578    /// Flush the container to the device and close the handle.
579    ///
580    /// This is the only place a deferred write error can still be caught. `File::flush` is a
581    /// no-op because the handle is unbuffered, and dropping it discards whatever `close`
582    /// reports, yet filesystems that allocate lazily (NFS over quota, a failing disk) report
583    /// exactly there. Without this, such a write would be published over a good container
584    /// while `write_to_file` returned `Ok`.
585    ///
586    /// It also orders durability: the data is on disk before it is published, so a crash
587    /// cannot leave the destination pointing at data that never reached the platter.
588    /// (Durability of the publish itself is [`AtomicFile`]'s job.)
589    fn finish(self) -> Result<(), Error> {
590        self.file.sync_all().map_err(|e| {
591            Error::IoError(format!(
592                "cannot flush '{}' to disk: {e}",
593                self.path.display()
594            ))
595        })
596    }
597}
598
599#[cfg(feature = "std")]
600impl Sink for FileSink {
601    fn pad(&mut self, count: usize) -> Result<(), Error> {
602        // Stream zeros without allocating a `count`-sized buffer per call.
603        std::io::copy(&mut std::io::repeat(0).take(count as u64), &mut self.file)
604            .map(|_| ())
605            .map_err(|e| Error::IoError(format!("cannot write to '{}': {e}", self.path.display())))
606    }
607
608    fn write(&mut self, data: &[u8]) -> Result<(), Error> {
609        self.file
610            .write_all(data)
611            .map_err(|e| Error::IoError(format!("cannot write to '{}': {e}", self.path.display())))
612    }
613}
614
615// Checks the peak-memory guarantee at the loop that provides it: `write_tensors` drops each
616// deferred tensor's bytes before it asks the next provider for its own. Every other test would
617// still pass if the writer collected all the bytes first and wrote them afterwards, so the
618// backing of each tensor logs when it is freed and each provider checks that log.
619#[cfg(all(test, feature = "std"))]
620mod tests {
621    use super::*;
622    use burn_std::{AllocationProperty, DType};
623    use std::sync::{Arc, Mutex};
624
625    const PAYLOAD: usize = 1024 * 1024;
626    const COUNT: usize = 4;
627
628    type FreedLog = Arc<Mutex<Vec<usize>>>;
629
630    /// Writes a container through one of the writer's sinks and returns its size in bytes.
631    type WriteFn = fn(Writer, &Path) -> usize;
632
633    /// The backing of one tensor's bytes. Records its index in the log when it is freed.
634    struct Tracked {
635        data: Vec<u8>,
636        index: usize,
637        freed: FreedLog,
638    }
639
640    impl AsRef<[u8]> for Tracked {
641        fn as_ref(&self) -> &[u8] {
642            &self.data
643        }
644    }
645
646    impl Drop for Tracked {
647        fn drop(&mut self) {
648            self.freed.lock().unwrap().push(self.index);
649        }
650    }
651
652    /// Deferred tensors whose providers assert that every earlier tensor's bytes are already
653    /// freed, in write order, before they produce their own.
654    fn tracked_tensors(freed: &FreedLog) -> Vec<Tensor> {
655        (0..COUNT)
656            .map(|index| {
657                let freed = freed.clone();
658                Tensor::deferred(
659                    format!("t{index}"),
660                    DType::U8,
661                    vec![PAYLOAD],
662                    None,
663                    PAYLOAD,
664                    move || {
665                        let already_freed = freed.lock().unwrap().clone();
666                        assert_eq!(
667                            already_freed,
668                            (0..index).collect::<Vec<_>>(),
669                            "tensor t{index} was requested while an earlier tensor's bytes were \
670                             still live"
671                        );
672                        let backing = Tracked {
673                            data: vec![index as u8; PAYLOAD],
674                            index,
675                            freed: freed.clone(),
676                        };
677                        Ok(Bytes::from_shared(
678                            bytes::Bytes::from_owner(backing),
679                            AllocationProperty::Native,
680                        ))
681                    },
682                )
683            })
684            .collect()
685    }
686
687    #[test]
688    fn each_tensor_is_freed_before_the_next_is_produced() {
689        let dir = tempfile::tempdir().unwrap();
690        let path = dir.path().join("model.bpk");
691
692        // Every write path goes through `write_tensors`, with a different sink.
693        let writes: [(&str, WriteFn); 3] = [
694            ("write_to_file", |writer, path| {
695                writer.write_to_file(path).unwrap();
696                std::fs::metadata(path).unwrap().len() as usize
697            }),
698            ("write_to_file_in_place", |writer, path| {
699                writer.write_to_file_in_place(path).unwrap();
700                std::fs::metadata(path).unwrap().len() as usize
701            }),
702            ("into_bytes", |writer, _| writer.into_bytes().unwrap().len()),
703        ];
704
705        for (name, write) in writes {
706            let freed = FreedLog::default();
707            let written = write(Writer::new(tracked_tensors(&freed)), &path);
708
709            // Also guards against a vacuous pass: a backing is only logged once its provider
710            // has run.
711            assert_eq!(
712                *freed.lock().unwrap(),
713                (0..COUNT).collect::<Vec<_>>(),
714                "{name}: every tensor's bytes should be freed by the end of the write"
715            );
716            assert!(
717                written >= COUNT * PAYLOAD,
718                "{name}: all tensors should have been written"
719            );
720        }
721    }
722}