Skip to main content

burn_pack/
tensor.rs

1//! Tensor-library-agnostic tensor entry for the burnpack format.
2//!
3//! [`Tensor`] carries only the format-level information (name, dtype, shape, optional param
4//! id) plus the raw little-endian [`Bytes`]. Keeping the bytes as [`Bytes`] (rather than a
5//! custom buffer type) integrates with the rest of the Burn ecosystem: a reader can hand out
6//! file-backed bytes ([`Bytes::from_file`]) for fast, lazy file-to-GPU transfers.
7//!
8//! A tensor's bytes need not exist yet. [`Tensor::deferred`] describes one by its length and
9//! a provider that yields the data on demand, which is what lets [`Writer`](crate::Writer)
10//! save a model without holding all of it in memory.
11
12use alloc::format;
13use alloc::string::String;
14
15#[cfg(target_has_atomic = "ptr")]
16use alloc::sync::Arc;
17// `alloc::sync` needs atomic CAS. Use `Rc` on targets without it, where `Arc` is unavailable;
18// the `Rc`-backed source means `Tensor` cannot be `Send` or `Sync` on those targets.
19#[cfg(not(target_has_atomic = "ptr"))]
20use alloc::rc::Rc as Arc;
21
22use burn_std::{Bytes, DType, Shape};
23
24use crate::base::Error;
25
26/// Shared handle to a deferred tensor's byte provider, kept behind a pointer so [`Tensor`]
27/// stays [`Clone`].
28///
29/// On targets with pointer atomics, the provider is `Send + Sync` so a deferred source
30/// preserves the same thread-safety properties as a resident one. `Sync` is also required
31/// for a shared provider to be `Send` (`Arc<T>: Send` requires `T: Send + Sync`).
32#[cfg(target_has_atomic = "ptr")]
33type Provider = Arc<dyn Fn() -> Result<Bytes, Error> + Send + Sync>;
34#[cfg(not(target_has_atomic = "ptr"))]
35type Provider = Arc<dyn Fn() -> Result<Bytes, Error>>;
36
37/// Where a tensor's bytes come from.
38///
39/// The distinction is invisible to readers of the container: both variants describe the same
40/// `byte_len` bytes at the same offset. It matters only to the writer, which plans the whole
41/// container from lengths alone and then draws the data one tensor at a time.
42///
43/// [`Bytes`] is itself already lazy about the *copy* - a file-backed or device-backed buffer
44/// reports its length without materializing and only reads on access - so `Resident` bytes
45/// are not necessarily in host memory. `Deferred` defers a step earlier: the call that would
46/// produce the `Bytes` at all. Converting a whole model eagerly would submit every device
47/// readback up front even if each returned a lazy buffer, which is what this avoids.
48#[derive(Clone)]
49enum Source {
50    /// Bytes already in hand.
51    Resident(Bytes),
52    /// Bytes not yet produced, but whose length is already known.
53    Deferred { len: usize, provider: Provider },
54}
55
56impl Source {
57    /// Number of bytes described, without producing them.
58    fn len(&self) -> usize {
59        match self {
60            Self::Resident(bytes) => bytes.len(),
61            Self::Deferred { len, .. } => *len,
62        }
63    }
64
65    fn to_bytes(&self) -> Result<Bytes, Error> {
66        match self {
67            Self::Resident(bytes) => Ok(bytes.clone()),
68            Self::Deferred { len, provider } => Self::checked(*len, provider()?),
69        }
70    }
71
72    fn into_bytes(self) -> Result<Bytes, Error> {
73        match self {
74            Self::Resident(bytes) => Ok(bytes),
75            Self::Deferred { len, provider } => Self::checked(len, provider()?),
76        }
77    }
78
79    /// Reject a provider that hands back a different length than it declared.
80    ///
81    /// Every materialization path goes through here, so the length a caller reads from
82    /// [`Tensor::byte_len`] is the length they will get. `Writer` commits its offset table
83    /// from that number before any provider runs, and a disagreement would misplace every
84    /// tensor written after this one.
85    fn checked(expected: usize, bytes: Bytes) -> Result<Bytes, Error> {
86        if bytes.len() != expected {
87            return Err(Error::TensorBytesSizeMismatch(format!(
88                "deferred source has inconsistent length (expected {}, got {})",
89                expected,
90                bytes.len()
91            )));
92        }
93        Ok(bytes)
94    }
95}
96
97/// A single tensor in a burnpack container, decoupled from any tensor library.
98///
99/// The bytes are in little-endian layout, and are reached with [`to_bytes`](Self::to_bytes),
100/// [`into_bytes`](Self::into_bytes), or [`into_parts`](Self::into_parts). Their length is
101/// available up front via
102/// [`byte_len`](Self::byte_len) whether or not they exist yet. For every dtype but
103/// [`DType::QFloat`] that length is the element count implied by [`shape`](Self::shape) and
104/// [`dtype`](Self::dtype); quantized data packs its values and appends scales inline, so it
105/// is not a product of the two.
106///
107/// Construct one with [`new`](Self::new) when the data is already in memory, or
108/// [`deferred`](Self::deferred) when it is not. A [`Reader`](crate::Reader) produces resident
109/// tensors, though for a file-backed source the bytes are still only read from disk on
110/// access.
111///
112/// # Thread safety
113///
114/// On targets with pointer-width atomics, `Tensor` is `Send + Sync` whether its bytes are
115/// resident or deferred. Accordingly, [`deferred`](Self::deferred) requires its provider to
116/// be `Send + Sync` on those targets. Without pointer-width atomics, deferred providers are
117/// held in an `Rc`, so `Tensor` implements neither trait.
118#[derive(Clone)]
119pub struct Tensor {
120    /// Fully-qualified tensor name (e.g. `"encoder.layer1.weight"`).
121    pub name: String,
122    /// Data type of the tensor.
123    pub dtype: DType,
124    /// Tensor shape.
125    pub shape: Shape,
126    /// Optional parameter id, used to preserve identities for stateful training.
127    pub param_id: Option<u64>,
128    /// Where the raw little-endian bytes come from.
129    source: Source,
130}
131
132impl Tensor {
133    /// Create a tensor from its metadata and raw bytes.
134    pub fn new(
135        name: String,
136        dtype: DType,
137        shape: impl Into<Shape>,
138        param_id: Option<u64>,
139        bytes: Bytes,
140    ) -> Self {
141        Self {
142            name,
143            dtype,
144            shape: shape.into(),
145            param_id,
146            source: Source::Resident(bytes),
147        }
148    }
149
150    /// Create a tensor whose bytes are produced on demand.
151    ///
152    /// `byte_len` must equal the length of the [`Bytes`] `provider` eventually returns, since
153    /// [`Writer`](crate::Writer) reserves exactly that much space in the data section before
154    /// calling it. Getting it wrong would misplace every later tensor, so it is checked twice:
155    /// while planning, against `shape` and `dtype`, and while writing, against the bytes
156    /// actually produced. Quantized data is exempt from the first, its length being packed
157    /// values plus inline scales rather than a product of the two, so there the write-time
158    /// check is the only guard.
159    ///
160    /// `provider` runs at most once per write, when the writer reaches this tensor, and its
161    /// result is dropped before the next tensor's is requested. Producing the data there -
162    /// reading a parameter back from a device, say - is what bounds a save's peak host memory
163    /// by the largest single tensor rather than by the whole model.
164    #[cfg(target_has_atomic = "ptr")]
165    pub fn deferred(
166        name: String,
167        dtype: DType,
168        shape: impl Into<Shape>,
169        param_id: Option<u64>,
170        byte_len: usize,
171        provider: impl Fn() -> Result<Bytes, Error> + Send + Sync + 'static,
172    ) -> Self {
173        Self::with_provider(name, dtype, shape, param_id, byte_len, Arc::new(provider))
174    }
175
176    /// Create a tensor whose bytes are produced on demand.
177    ///
178    /// See the `target_has_atomic = "ptr"` variant for the contract. This one drops the
179    /// `Send + Sync` bound because the resulting `Rc`-backed tensor cannot implement either
180    /// trait regardless of the provider's own bounds.
181    #[cfg(not(target_has_atomic = "ptr"))]
182    pub fn deferred(
183        name: String,
184        dtype: DType,
185        shape: impl Into<Shape>,
186        param_id: Option<u64>,
187        byte_len: usize,
188        provider: impl Fn() -> Result<Bytes, Error> + 'static,
189    ) -> Self {
190        Self::with_provider(name, dtype, shape, param_id, byte_len, Arc::new(provider))
191    }
192
193    /// The half of [`deferred`](Self::deferred) that does not vary by target.
194    fn with_provider(
195        name: String,
196        dtype: DType,
197        shape: impl Into<Shape>,
198        param_id: Option<u64>,
199        byte_len: usize,
200        provider: Provider,
201    ) -> Self {
202        Self {
203            name,
204            dtype,
205            shape: shape.into(),
206            param_id,
207            source: Source::Deferred {
208                len: byte_len,
209                provider,
210            },
211        }
212    }
213
214    /// Number of raw bytes the tensor occupies, known without producing them.
215    pub fn byte_len(&self) -> usize {
216        self.source.len()
217    }
218
219    /// The tensor's raw little-endian bytes, leaving it intact.
220    ///
221    /// Named `to_` rather than `bytes` because it can cost the whole tensor: a `Deferred` one
222    /// re-runs its provider (nothing is cached), and a `Resident` one is deep-copied unless
223    /// its [`Bytes`] can share their backing. Prefer [`into_bytes`](Self::into_bytes), or
224    /// [`into_parts`](Self::into_parts) when the metadata is wanted too.
225    pub fn to_bytes(&self) -> Result<Bytes, Error> {
226        self.source.to_bytes()
227    }
228
229    /// Take the tensor's raw little-endian bytes, producing them if deferred.
230    ///
231    /// Infallible in practice for a tensor from a [`Reader`](crate::Reader), which only
232    /// produces resident tensors; the [`Result`] is there for deferred ones, whose provider
233    /// can fail.
234    pub fn into_bytes(self) -> Result<Bytes, Error> {
235        self.source.into_bytes()
236    }
237
238    /// Split into `(name, dtype, shape, param_id, bytes)`, producing the bytes if deferred.
239    ///
240    /// The fields are public, but taking the bytes consumes the tensor, so reading both
241    /// otherwise means cloning the metadata first. This hands over everything in one move.
242    pub fn into_parts(self) -> Result<(String, DType, Shape, Option<u64>, Bytes), Error> {
243        let Self {
244            name,
245            dtype,
246            shape,
247            param_id,
248            source,
249        } = self;
250        Ok((name, dtype, shape, param_id, source.into_bytes()?))
251    }
252}
253
254/// Shows the format-level metadata and the byte length, but never draws the bytes: a
255/// `Deferred` tensor would have to run its provider, which is exactly what a debug print must
256/// not trigger.
257impl core::fmt::Debug for Tensor {
258    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
259        f.debug_struct("Tensor")
260            .field("name", &self.name)
261            .field("dtype", &self.dtype)
262            .field("shape", &self.shape)
263            .field("param_id", &self.param_id)
264            .field("byte_len", &self.byte_len())
265            .finish()
266    }
267}
268
269// The counting tests below need `fetch_add`, so the whole module wants atomic CAS. Tests are
270// only ever run on a host anyway; this keeps `--tests` compiling for embedded targets.
271#[cfg(all(test, target_has_atomic = "ptr"))]
272mod tests {
273    use super::*;
274    use alloc::string::ToString;
275    use alloc::vec;
276    use core::sync::atomic::{AtomicUsize, Ordering};
277
278    /// `to_bytes` borrows rather than consuming, so the tensor can still be drawn from
279    /// afterwards. That is the whole reason it exists next to `into_bytes`.
280    #[test]
281    fn to_bytes_leaves_a_resident_tensor_intact() {
282        let tensor = Tensor::new(
283            "w".to_string(),
284            DType::U8,
285            vec![4],
286            None,
287            Bytes::from_bytes_vec(vec![1, 2, 3, 4]),
288        );
289
290        assert_eq!(&tensor.to_bytes().unwrap()[..], &[1, 2, 3, 4]);
291        assert_eq!(&tensor.into_bytes().unwrap()[..], &[1, 2, 3, 4]);
292    }
293
294    /// Nothing is cached, so each call runs the provider again. That is why the record load
295    /// paths use `into_parts` instead.
296    #[test]
297    fn to_bytes_reruns_a_deferred_provider_on_every_call() {
298        let calls = Arc::new(AtomicUsize::new(0));
299        let counter = calls.clone();
300
301        let tensor = Tensor::deferred("w".to_string(), DType::U8, vec![2], None, 2, move || {
302            counter.fetch_add(1, Ordering::Relaxed);
303            Ok(Bytes::from_bytes_vec(vec![7, 8]))
304        });
305
306        assert_eq!(&tensor.to_bytes().unwrap()[..], &[7, 8]);
307        assert_eq!(&tensor.to_bytes().unwrap()[..], &[7, 8]);
308        assert_eq!(calls.load(Ordering::Relaxed), 2);
309
310        // Still usable, and `into_bytes` draws once more.
311        assert_eq!(tensor.byte_len(), 2);
312        assert_eq!(&tensor.into_bytes().unwrap()[..], &[7, 8]);
313        assert_eq!(calls.load(Ordering::Relaxed), 3);
314    }
315
316    /// A provider failure surfaces verbatim. Naming the tensor is `Writer::materialize`'s
317    /// job, not this method's.
318    #[test]
319    fn to_bytes_surfaces_a_provider_failure_unannotated() {
320        let tensor = Tensor::deferred("w".to_string(), DType::U8, vec![1], None, 1, || {
321            Err(Error::IoError("device read failed".to_string()))
322        });
323
324        let err = tensor.to_bytes().unwrap_err();
325        assert!(
326            matches!(&err, Error::IoError(m) if m == "device read failed"),
327            "expected the provider's own error, got {err:?}"
328        );
329    }
330
331    /// Hands over metadata and bytes in one move, which is what lets the record load paths
332    /// avoid cloning the name just to reach the bytes.
333    #[test]
334    fn into_parts_yields_metadata_and_bytes_together() {
335        let tensor = Tensor::deferred(
336            "layer.w".to_string(),
337            DType::U8,
338            vec![2],
339            Some(7),
340            2,
341            || Ok(Bytes::from_bytes_vec(vec![7, 8])),
342        );
343
344        let (name, dtype, shape, param_id, bytes) = tensor.into_parts().unwrap();
345        assert_eq!(name, "layer.w");
346        assert_eq!(dtype, DType::U8);
347        assert_eq!(shape.to_vec(), vec![2]);
348        assert_eq!(param_id, Some(7));
349        assert_eq!(&bytes[..], &[7, 8]);
350    }
351
352    /// A deferred source must preserve the thread-safety properties of a resident tensor.
353    #[test]
354    fn tensor_is_send_and_sync() {
355        fn assert_send_sync<T: Send + Sync>() {}
356        assert_send_sync::<Tensor>();
357    }
358}