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,
4};
5use super::tensor::Tensor;
6use alloc::collections::BTreeMap;
7use alloc::format;
8use alloc::string::{String, ToString};
9use alloc::vec;
10use alloc::vec::Vec;
11use burn_std::Bytes;
12
13#[cfg(feature = "std")]
14use std::fs::File;
15#[cfg(feature = "std")]
16use std::io::{Read, Write};
17#[cfg(feature = "std")]
18use std::path::Path;
19
20/// Align an offset to the specified alignment boundary.
21///
22/// Returns the smallest value >= `offset` that is a multiple of `alignment`.
23#[inline]
24const fn align_offset(offset: u64, alignment: u64) -> u64 {
25    offset.div_ceil(alignment) * alignment
26}
27
28/// Maximum number of bytes materialized from a single tensor at a time while
29/// streaming its data into a [`Sink`].
30///
31/// Large device-resident tensors are read back to host memory lazily, one
32/// [`Bytes::view`] window at a time, instead of all at once. This keeps the
33/// transient (often pinned) host staging buffer bounded by this size regardless
34/// of how large the tensor is. The value is a multiple of [`TENSOR_ALIGNMENT`]
35/// so each window starts on an aligned device offset.
36const WRITE_CHUNK_SIZE: usize = 8 * 1024 * 1024;
37
38/// Writer for creating Burnpack files
39pub struct Writer {
40    /// Tensors to write
41    pub(crate) tensors: Vec<Tensor>,
42    /// Metadata key-value pairs
43    pub(crate) metadata: BTreeMap<String, String>,
44    /// Typed scalars keyed by name
45    pub(crate) scalars: BTreeMap<String, Scalar>,
46}
47
48impl Writer {
49    /// Create a new writer
50    pub fn new(tensors: Vec<Tensor>) -> Self {
51        Self {
52            tensors,
53            metadata: BTreeMap::new(),
54            scalars: BTreeMap::new(),
55        }
56    }
57
58    /// Builder pattern: add metadata and return self
59    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
60        self.metadata.insert(key.to_string(), value.to_string());
61        self
62    }
63
64    /// Builder pattern: add a typed scalar and return self.
65    pub fn with_scalar(mut self, key: &str, value: Scalar) -> Self {
66        self.scalars.insert(key.to_string(), value);
67        self
68    }
69
70    /// Calculate the total size needed for the burnpack data.
71    ///
72    /// This is useful when you want to pre-allocate a buffer for `write_into()`.
73    /// The size includes padding bytes for both metadata alignment and tensor alignment.
74    pub fn size(&self) -> Result<usize, Error> {
75        Ok(self.plan()?.total_size())
76    }
77
78    /// Write burnpack data into a caller-provided buffer.
79    ///
80    /// The buffer must be large enough to hold all data. Use `size()` to determine
81    /// the required buffer size. If the buffer is too small, this will return an error.
82    ///
83    /// This allows the caller to control buffer allocation, enabling optimizations like:
84    /// - Buffer reuse across multiple writes
85    /// - Custom allocators
86    /// - Pinned memory for GPU transfers
87    ///
88    /// # Arguments
89    ///
90    /// * `buffer` - Mutable slice to write data into. Must be at least `size()` bytes.
91    pub fn write_into(self, buffer: &mut [u8]) -> Result<(), Error> {
92        let layout = self.plan()?;
93        let total_size = layout.total_size();
94
95        if buffer.len() < total_size {
96            return Err(Error::IoError(format!(
97                "Buffer too small: need {} bytes, got {} bytes",
98                total_size,
99                buffer.len()
100            )));
101        }
102
103        let mut sink = BufferSink { buffer, offset: 0 };
104        self.write_container(&layout, &mut sink)
105    }
106
107    /// Write to a byte buffer (convenience method).
108    ///
109    /// This allocates a buffer internally and writes the burnpack data.
110    /// For more control over buffer allocation, use `size()` + `write_into()`.
111    pub fn into_bytes(self) -> Result<Bytes, Error> {
112        let layout = self.plan()?;
113        let mut buffer = vec![0u8; layout.total_size()];
114
115        let mut sink = BufferSink {
116            buffer: &mut buffer,
117            offset: 0,
118        };
119        self.write_container(&layout, &mut sink)?;
120
121        Ok(Bytes::from_bytes_vec(buffer))
122    }
123
124    /// Write directly to a file (more memory efficient for large models).
125    ///
126    /// If `path` has no extension, the canonical [`crate::EXTENSION`] (`.bpk`) is appended.
127    #[cfg(feature = "std")]
128    pub fn write_to_file<P: AsRef<Path>>(self, path: P) -> Result<(), Error> {
129        let path = path.as_ref();
130        let path = if path.extension().is_none() {
131            path.with_extension(crate::EXTENSION)
132        } else {
133            path.to_path_buf()
134        };
135
136        let layout = self.plan()?;
137        let file = File::create(path).map_err(|e| Error::IoError(e.to_string()))?;
138
139        let mut sink = FileSink { file };
140        self.write_container(&layout, &mut sink)?;
141
142        sink.file.flush().map_err(|e| Error::IoError(e.to_string()))
143    }
144
145    /// Build the complete on-disk layout: header, serialized metadata, and the
146    /// position and size of the (aligned) tensor data section.
147    fn plan(&self) -> Result<Layout, Error> {
148        let (metadata, metadata_bytes, data_size) = self.build_metadata()?;
149
150        let metadata_size: u32 = metadata_bytes.len().try_into().map_err(|_| {
151            Error::IoError(format!(
152                "Metadata size {} exceeds maximum of {} bytes",
153                metadata_bytes.len(),
154                u32::MAX
155            ))
156        })?;
157
158        let header = Header {
159            magic: MAGIC_NUMBER,
160            version: FORMAT_VERSION,
161            metadata_size,
162        };
163
164        let data_section_start = aligned_data_section_start(metadata_bytes.len());
165
166        Ok(Layout {
167            metadata,
168            metadata_bytes,
169            header,
170            data_section_start,
171            data_size,
172        })
173    }
174
175    /// Serialize the metadata structure (tensor descriptors + key-value pairs) to CBOR.
176    ///
177    /// Also returns the size of the tensor data section, computed while assigning offsets.
178    fn build_metadata(&self) -> Result<(Metadata, Vec<u8>, usize), Error> {
179        let (tensors, data_size) = self.build_descriptors()?;
180        let metadata = Metadata {
181            tensors,
182            metadata: self.metadata.clone(),
183            scalars: self.scalars.clone(),
184        };
185
186        let mut metadata_bytes = Vec::new();
187        ciborium::ser::into_writer(&metadata, &mut metadata_bytes)
188            .map_err(|e| Error::MetadataSerializationError(e.to_string()))?;
189
190        Ok((metadata, metadata_bytes, data_size))
191    }
192
193    /// Build tensor descriptors, assigning each tensor an aligned offset within
194    /// the data section so that absolute file positions are mmap-friendly.
195    ///
196    /// Returns the descriptors plus the total data-section size — the running offset after the
197    /// last tensor. Offsets only grow, so this is also the highest descriptor end offset.
198    fn build_descriptors(&self) -> Result<(BTreeMap<String, TensorDescriptor>, usize), Error> {
199        let mut tensors = BTreeMap::new();
200        let mut current_offset = 0u64;
201
202        for tensor in &self.tensors {
203            let data_len = tensor.bytes.len() as u64;
204
205            // Align the start offset for mmap zero-copy support.
206            let aligned_start = align_offset(current_offset, TENSOR_ALIGNMENT);
207            let end = aligned_start.checked_add(data_len).ok_or_else(|| {
208                Error::IoError(format!(
209                    "Tensor offset overflow: {} + {} exceeds maximum",
210                    aligned_start, data_len
211                ))
212            })?;
213
214            // Descriptors are keyed by name, but the tensor data is written from the
215            // (ordered) `self.tensors` list. A duplicate name would collapse to a single
216            // descriptor while still writing two data blocks, corrupting the container.
217            if tensors
218                .insert(
219                    tensor.name.clone(),
220                    TensorDescriptor {
221                        dtype: tensor.dtype,
222                        shape: tensor.shape.iter().map(|&s| s as u64).collect(),
223                        data_offsets: (aligned_start, end),
224                        param_id: tensor.param_id,
225                    },
226                )
227                .is_some()
228            {
229                return Err(Error::ValidationError(format!(
230                    "Duplicate tensor name '{}'",
231                    tensor.name
232                )));
233            }
234
235            current_offset = end;
236        }
237
238        Ok((tensors, current_offset as usize))
239    }
240
241    /// Emit the full container — header, metadata, alignment padding, then tensor data
242    /// — into `sink`, which decides where the bytes ultimately land.
243    fn write_container(self, layout: &Layout, sink: &mut impl Sink) -> Result<(), Error> {
244        sink.write(&layout.header.into_bytes())?;
245        sink.write(&layout.metadata_bytes)?;
246
247        // Pad so the data section starts at its aligned position.
248        let unaligned_data_start = HEADER_SIZE + layout.metadata_bytes.len();
249        if layout.data_section_start > unaligned_data_start {
250            sink.pad(layout.data_section_start - unaligned_data_start)?;
251        }
252
253        self.write_tensors(&layout.metadata, sink)
254    }
255
256    /// Write each tensor's data into `sink`, inserting alignment padding between
257    /// tensors so every tensor lands at its descriptor's aligned offset.
258    fn write_tensors(self, metadata: &Metadata, sink: &mut impl Sink) -> Result<(), Error> {
259        // Position within the data section (relative to its aligned start).
260        let mut data_offset = 0usize;
261
262        for tensor in self.tensors.into_iter() {
263            let (aligned_offset, data) = Self::resolve_tensor(tensor, metadata)?;
264
265            if aligned_offset > data_offset {
266                sink.pad(aligned_offset - data_offset)?;
267                data_offset = aligned_offset;
268            }
269
270            Self::write_tensor_data(&data, sink)?;
271            data_offset += data.len();
272        }
273
274        Ok(())
275    }
276
277    /// Stream a single tensor's bytes into `sink`, materializing at most
278    /// [`WRITE_CHUNK_SIZE`] bytes at a time.
279    ///
280    /// When the backing supports zero-copy windows — device-resident
281    /// ([lazy](burn_std::Bytes) device readback), file, or shared buffers — each
282    /// chunk is taken as a [`Bytes::view`] and read just-in-time, then dropped
283    /// before the next one. A large device tensor is therefore copied to host in
284    /// bounded pieces rather than through one big (pinned) staging buffer, so the
285    /// whole tensor never has to be resident at once.
286    ///
287    /// Backings without a zero-copy window (e.g. a plain heap `Vec`) are already
288    /// host-resident, so [`Bytes::view`] reports it can't window them and the
289    /// remaining bytes are written in a single pass.
290    fn write_tensor_data(data: &Bytes, sink: &mut impl Sink) -> Result<(), Error> {
291        let len = data.len();
292        let mut offset = 0;
293
294        while offset < len {
295            let end = (offset + WRITE_CHUNK_SIZE).min(len);
296            match data.view(offset, end) {
297                Ok(chunk) => {
298                    sink.write(&chunk)?;
299                    offset = end;
300                }
301                // No zero-copy window available (already host-resident): write
302                // whatever remains in one shot. View support is a property of the
303                // backing, so this only ever happens on the first iteration.
304                Err(_) => {
305                    sink.write(&data[offset..])?;
306                    break;
307                }
308            }
309        }
310
311        Ok(())
312    }
313
314    /// Look up a tensor's aligned offset from the metadata and validate that its
315    /// bytes match the length the descriptor reserved for it.
316    fn resolve_tensor(tensor: Tensor, metadata: &Metadata) -> Result<(usize, Bytes), Error> {
317        let descriptor = metadata.tensors.get(&tensor.name).ok_or_else(|| {
318            Error::IoError(format!(
319                "Internal error: tensor '{}' not found in metadata",
320                tensor.name
321            ))
322        })?;
323
324        let (start, end) = descriptor.data_offsets;
325        let declared_len = (end - start) as usize;
326        let actual_len = tensor.bytes.len();
327        if actual_len != declared_len {
328            return Err(Error::TensorBytesSizeMismatch(format!(
329                "tensor '{}' has inconsistent length (expected {}, got {})",
330                tensor.name, declared_len, actual_len
331            )));
332        }
333
334        Ok((start as usize, tensor.bytes))
335    }
336}
337
338/// The computed on-disk layout of a burnpack container.
339///
340/// Captures everything needed to emit the bytes: the serialized metadata, the
341/// header, where the aligned data section begins, and how large it is. Built once
342/// via [`Writer::plan`] and shared by `size`, `write_into`, `to_bytes`, and
343/// `write_to_file`.
344struct Layout {
345    metadata: Metadata,
346    metadata_bytes: Vec<u8>,
347    header: Header,
348    data_section_start: usize,
349    data_size: usize,
350}
351
352impl Layout {
353    /// Total number of bytes the container occupies.
354    fn total_size(&self) -> usize {
355        self.data_section_start + self.data_size
356    }
357}
358
359/// A sequential destination for the bytes of a burnpack container.
360///
361/// Padding and data are written in order; each implementation advances its own
362/// cursor, letting the writer stay agnostic about whether bytes land in a buffer
363/// or a file.
364trait Sink {
365    /// Write `count` zero bytes of alignment padding.
366    fn pad(&mut self, count: usize) -> Result<(), Error>;
367    /// Write `data` verbatim.
368    fn write(&mut self, data: &[u8]) -> Result<(), Error>;
369}
370
371/// Sink that copies into a caller-provided buffer.
372struct BufferSink<'a> {
373    buffer: &'a mut [u8],
374    offset: usize,
375}
376
377impl Sink for BufferSink<'_> {
378    fn pad(&mut self, count: usize) -> Result<(), Error> {
379        self.buffer[self.offset..self.offset + count].fill(0);
380        self.offset += count;
381        Ok(())
382    }
383
384    fn write(&mut self, data: &[u8]) -> Result<(), Error> {
385        self.buffer[self.offset..self.offset + data.len()].copy_from_slice(data);
386        self.offset += data.len();
387        Ok(())
388    }
389}
390
391/// Sink that streams directly to a file.
392#[cfg(feature = "std")]
393struct FileSink {
394    file: File,
395}
396
397#[cfg(feature = "std")]
398impl Sink for FileSink {
399    fn pad(&mut self, count: usize) -> Result<(), Error> {
400        // Stream zeros without allocating a `count`-sized buffer per call.
401        std::io::copy(&mut std::io::repeat(0).take(count as u64), &mut self.file)
402            .map(|_| ())
403            .map_err(|e| Error::IoError(e.to_string()))
404    }
405
406    fn write(&mut self, data: &[u8]) -> Result<(), Error> {
407        self.file
408            .write_all(data)
409            .map_err(|e| Error::IoError(e.to_string()))
410    }
411}