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}