Skip to main content

burn_store/
bridge.rs

1//! Bridge between burn-core tensors and the tensor-agnostic burnpack format entries.
2//!
3//! [`burn_pack::Tensor`] carries the format-level facts about a tensor (name, dtype, shape,
4//! optional param id) and a source for its raw little-endian bytes, which may not exist yet.
5//! burn-store uses it as its single tensor-transport type: what a [`Collector`](crate::Collector)
6//! produces, what an [`Applier`](crate::Applier) consumes, and what the stores read and write.
7//!
8//! This module is the seam where burn-core's [`TensorData`] meets those raw bytes. The lazy
9//! paths are the load-bearing ones: a tensor built by [`from_tensor`] holds the
10//! (reference-counted) device tensor and reads it back only when the bytes are finally drawn,
11//! [`map_data`] composes a transform onto a byte source without materializing it, and
12//! `from_pytorch` (with the `pytorch` feature) leaves a checkpoint tensor's bytes unread.
13
14use alloc::format;
15use alloc::string::{String, ToString};
16
17use burn_pack::{Error as PackError, Tensor as PackTensor};
18
19use burn_core::tensor::kind::Basic;
20use burn_core::tensor::quantization::quantized_data_len;
21use burn_core::tensor::{Bytes, DType, Shape, Tensor, TensorData};
22
23/// Number of bytes a tensor of this dtype and shape serializes to.
24///
25/// [`Writer`](burn_pack::Writer) lays out the whole container from these lengths before any
26/// data is produced, which is what lets a save's peak memory be bounded by the largest single
27/// tensor rather than by the model. Quantized data packs its values and appends scales inline,
28/// so its length is not a product of the shape and dtype and gets its own accounting.
29pub fn data_len(dtype: DType, shape: &Shape) -> usize {
30    match dtype {
31        DType::QFloat(scheme) => quantized_data_len(&scheme, shape),
32        _ => shape.iter().product::<usize>() * dtype.size(),
33    }
34}
35
36/// Run a data-producing closure, turning a panic into an error.
37///
38/// A backend can panic reading a tensor back from its device, and that panic would otherwise
39/// unwind through [`Writer`](burn_pack::Writer) partway through a container. burn-pack cannot
40/// guard against it: `catch_unwind` needs `std`, which that crate deliberately does not
41/// require. So the guard lives here, wrapped around every provider burn-store builds.
42///
43/// Classified as a validation failure rather than an I/O one on purpose. It arrives mid-write,
44/// alongside the writer's own file errors, and calling it I/O would send the reader looking at
45/// their disk.
46#[cfg(feature = "std")]
47fn guarded(f: impl Fn() -> Result<TensorData, PackError>) -> Result<TensorData, PackError> {
48    // AssertUnwindSafe: the shared closure is not UnwindSafe, and the only state that could be
49    // observed after a panic is the tensor being read, which is dropped either way.
50    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|payload| {
51        // Carry the panic's own message through. Everything from a device OOM to a bug in an
52        // adapter's transform arrives here, and without the payload they are indistinguishable
53        // to anything handling the error rather than reading stderr.
54        let cause = payload
55            .downcast_ref::<&str>()
56            .map(|s| (*s).to_string())
57            .or_else(|| payload.downcast_ref::<String>().cloned())
58            .unwrap_or_else(|| "unknown panic payload".to_string());
59
60        Err(PackError::ValidationError(format!(
61            "panic while producing tensor data: {cause}"
62        )))
63    })
64}
65
66/// Run a data-producing closure.
67///
68/// See the `std` variant. `catch_unwind` is unavailable here, so a panic propagates.
69#[cfg(not(feature = "std"))]
70fn guarded(f: impl Fn() -> Result<TensorData, PackError>) -> Result<TensorData, PackError> {
71    f()
72}
73
74/// The `Send + Sync` bound a data closure carries on a target with pointer-width atomics, and
75/// nothing on one without.
76///
77/// [`PackTensor::deferred`] asks for `Send + Sync` providers only where `alloc::sync` exists so
78/// deferred tensors preserve the thread-safety of resident ones. Without pointer-width atomics,
79/// it uses an `Rc`-backed provider and cannot itself be `Send` or `Sync`, regardless of the
80/// closure's bounds. Naming that difference once keeps every constructor below from having to
81/// be written twice.
82///
83/// Implemented for every type that satisfies it; the blanket impl makes a manual
84/// implementation impossible.
85#[cfg(target_has_atomic = "ptr")]
86pub trait MaybeSendSync: Send + Sync {}
87#[cfg(target_has_atomic = "ptr")]
88impl<T: Send + Sync> MaybeSendSync for T {}
89
90/// Empty on a target without pointer-width atomics, where an `Rc`-backed [`PackTensor`] cannot
91/// be `Send` or `Sync` regardless of the provider's own bounds.
92#[cfg(not(target_has_atomic = "ptr"))]
93pub trait MaybeSendSync {}
94#[cfg(not(target_has_atomic = "ptr"))]
95impl<T> MaybeSendSync for T {}
96
97/// Build a tensor whose data is produced on demand by `data_fn`.
98///
99/// `dtype` and `shape` describe what `data_fn` will return, and fix the byte length the writer
100/// reserves before the closure ever runs, so a transform that changes either must be declared
101/// here rather than left for the closure to reveal. A provider that hands back something else
102/// is rejected when the data is drawn, so the failure is an error rather than a misplaced or
103/// misread tensor.
104pub fn deferred(
105    name: String,
106    dtype: DType,
107    shape: Shape,
108    param_id: Option<u64>,
109    data_fn: impl Fn() -> Result<TensorData, PackError> + MaybeSendSync + 'static,
110) -> PackTensor {
111    let byte_len = data_len(dtype, &shape);
112    let declared = shape.clone();
113
114    PackTensor::deferred(name, dtype, shape, param_id, byte_len, move || {
115        let data = guarded(&data_fn)?;
116
117        // The byte length is checked downstream, but two dtypes of the same width (F32 and
118        // I32, say) produce identical lengths, and the declared one is what a reader will
119        // reinterpret the bytes as. Nothing else catches that, so check it here.
120        if data.dtype() != dtype || *data.shape() != declared {
121            return Err(PackError::ValidationError(format!(
122                "provider produced {:?} {:?}, but the tensor declared {:?} {:?}",
123                data.dtype(),
124                data.shape(),
125                dtype,
126                declared
127            )));
128        }
129
130        Ok(data.into_bytes())
131    })
132}
133
134/// Snapshot a module parameter without reading it back from its device.
135///
136/// The tensor is cloned (cheap, reference-counted) and read only when the bytes are drawn, so
137/// a whole module can be traversed for its metadata alone. One function covers float, int and
138/// bool parameters: [`Basic`] is what supplies `dtype`, `shape` and `to_data` for all three.
139pub fn from_tensor<const D: usize, K: Basic + 'static>(
140    tensor: &Tensor<D, K>,
141    name: String,
142    param_id: Option<u64>,
143) -> PackTensor {
144    let (dtype, shape) = (tensor.dtype(), tensor.shape());
145    let tensor = tensor.clone();
146
147    deferred(name, dtype, shape, param_id, move || Ok(tensor.to_data()))
148}
149
150/// Build a tensor from data already in hand.
151pub fn from_data(data: TensorData, name: String, param_id: Option<u64>) -> PackTensor {
152    let (bytes, shape, dtype) = data.into_parts();
153    PackTensor::new(name, dtype, shape, param_id, bytes)
154}
155
156/// Read a tensor's bytes back as [`TensorData`], leaving it intact.
157///
158/// Costs whatever the source costs: a deferred tensor re-runs its provider, since nothing is
159/// cached, and a resident one copies its bytes unless they can be shared. Prefer [`into_data`]
160/// where the tensor is not needed afterwards.
161pub fn to_data(tensor: &PackTensor) -> Result<TensorData, PackError> {
162    tensor_data(tensor.to_bytes()?, tensor.shape.clone(), tensor.dtype)
163}
164
165/// Take a tensor's bytes as [`TensorData`], producing them if deferred.
166pub fn into_data(tensor: PackTensor) -> Result<TensorData, PackError> {
167    let (_, dtype, shape, _, bytes) = tensor.into_parts()?;
168
169    tensor_data(bytes, shape, dtype)
170}
171
172/// Build [`TensorData`] from raw bytes, reporting a length that disagrees with the shape and
173/// dtype as an error rather than a panic.
174pub(crate) fn tensor_data(
175    bytes: Bytes,
176    shape: Shape,
177    dtype: DType,
178) -> Result<TensorData, PackError> {
179    TensorData::try_from_bytes(bytes, shape, dtype)
180        .map_err(|err| PackError::TensorBytesSizeMismatch(err.to_string()))
181}
182
183/// Wrap a tensor's data in a transform, keeping it deferred.
184///
185/// How adapters chain. Each declares the `dtype` and `shape` its transform produces, because
186/// the writer needs the resulting byte length up front, while the transform itself runs only
187/// once the bytes are finally drawn. `name` is passed explicitly because some adapters rename
188/// as they go.
189///
190/// Takes the tensor by value: the closure holds it for the adapted tensor's whole lifetime, so
191/// borrowing here would only mean cloning it into the closure anyway.
192pub fn map_data(
193    tensor: PackTensor,
194    name: String,
195    dtype: DType,
196    shape: Shape,
197    f: impl Fn(TensorData) -> TensorData + MaybeSendSync + 'static,
198) -> PackTensor {
199    let param_id = tensor.param_id;
200
201    deferred(name, dtype, shape, param_id, move || {
202        to_data(&tensor).map(&f)
203    })
204}
205
206/// Wrap a PyTorch checkpoint tensor, leaving its bytes unread until they are drawn.
207///
208/// PyTorch carries no parameter identity, so `param_id` stays `None`. Takes the tensor by
209/// value for the same reason [`map_data`] does.
210#[cfg(feature = "pytorch")]
211pub fn from_pytorch(tensor: pytorch_reader::Tensor) -> PackTensor {
212    use std::io::ErrorKind;
213
214    let (name, dtype, shape, read) = tensor.into_parts();
215    let dtype = pytorch_dtype(dtype);
216    let shape = Shape::from(shape);
217    let declared = shape.clone();
218
219    deferred(name, dtype, shape, None, move || {
220        // `Tensor::read` documents the split: these two kinds mean the file disagrees with
221        // itself, any other that the operating system could not read it.
222        let bytes = read().map_err(|err| match err.kind() {
223            ErrorKind::InvalidData | ErrorKind::UnexpectedEof => {
224                PackError::ValidationError(err.to_string())
225            }
226            _ => PackError::IoError(err.to_string()),
227        })?;
228        tensor_data(Bytes::from_bytes_vec(bytes), declared.clone(), dtype)
229    })
230}
231
232#[cfg(feature = "pytorch")]
233fn pytorch_dtype(dtype: pytorch_reader::DType) -> DType {
234    use burn_core::tensor::BoolStore;
235    use pytorch_reader::DType as Pt;
236
237    match dtype {
238        Pt::F64 => DType::F64,
239        Pt::F32 => DType::F32,
240        Pt::F16 => DType::F16,
241        Pt::BF16 => DType::BF16,
242        Pt::I64 => DType::I64,
243        Pt::I32 => DType::I32,
244        Pt::I16 => DType::I16,
245        Pt::I8 => DType::I8,
246        Pt::U64 => DType::U64,
247        Pt::U32 => DType::U32,
248        Pt::U16 => DType::U16,
249        Pt::U8 => DType::U8,
250        Pt::Bool => DType::Bool(BoolStore::Native),
251    }
252}
253
254#[cfg(all(test, feature = "std"))]
255mod tests {
256    use super::*;
257    use alloc::format;
258    use alloc::string::ToString;
259    use alloc::vec;
260    use burn_core::tensor::quantization::{QuantScheme, QuantStore, QuantValue, ScaleDtype};
261    use burn_core::tensor::{Bool, Device, Distribution, Int, shape};
262
263    /// `byte_len` is computed from the declared shape and dtype rather than observed, and the
264    /// burnpack writer reserves exactly that many bytes before ever running the provider. A
265    /// disagreement would silently misplace everything written after this tensor.
266    ///
267    /// The length is also checked inside the materialization path, so a mismatch surfaces as
268    /// an error rather than a wrong number; report both the same way.
269    fn assert_byte_len_matches(tensor: PackTensor) {
270        let (declared, name) = (tensor.byte_len(), tensor.name.clone());
271
272        match to_data(&tensor) {
273            Ok(data) => assert_eq!(
274                declared,
275                data.bytes().len(),
276                "byte_len disagrees with the materialized bytes for {name}"
277            ),
278            Err(e) => panic!("byte_len disagrees with the materialized bytes for {name}: {e}"),
279        }
280    }
281
282    #[test]
283    fn data_len_covers_every_dtype_family() {
284        let device = Device::default();
285
286        let floats = Tensor::<2>::from_data([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], &device);
287        let ints = Tensor::<2, Int>::from_data([[1, 2], [3, 4]], &device);
288        let bools = Tensor::<2, Bool>::from_data([[true, false], [false, true]], &device);
289
290        assert_byte_len_matches(from_tensor(&floats, "float".to_string(), None));
291        assert_byte_len_matches(from_tensor(&ints, "int".to_string(), None));
292        assert_byte_len_matches(from_tensor(&bools, "bool".to_string(), None));
293    }
294
295    /// Quantized is the one family where [`data_len`] reconstructs the layout instead of
296    /// deriving it from an element count, so it is where the two drift apart.
297    ///
298    /// The shapes and schemes here hit the two ways that reconstruction has been wrong: a
299    /// value-byte count that is not a multiple of the 4-byte scale alignment (which a spurious
300    /// round-up inflates), and a sub-byte `QuantValue` under `QuantStore::Native` (whose stored
301    /// width rounds down to zero bytes if divided rather than div_ceil'd). A 32x32 Q8 tensor
302    /// passes either way, so it cannot stand in for these.
303    ///
304    /// Both axes rely on the test backend storing quantized values natively, one `i8` per
305    /// value: `quantize_dynamic` rewrites the scheme's store to `QuantStore::Native` even
306    /// though `QuantScheme::default()` starts as `PackedU32`. A backend honoring `PackedU32`
307    /// would produce 4-byte-exact value counts and exercise neither axis.
308    #[test]
309    fn data_len_matches_materialized_bytes_when_quantized() {
310        let device = Device::default();
311
312        let schemes = [
313            ("q8", QuantScheme::default()),
314            ("q4", QuantScheme::default().with_value(QuantValue::Q4S)),
315            ("q2", QuantScheme::default().with_value(QuantValue::Q2S)),
316        ];
317        // Element counts that are and are not multiples of the 4-byte scale alignment.
318        let shapes = [shape![32, 32], shape![3, 3], shape![5, 5], shape![2, 3]];
319
320        for (name, scheme) in schemes {
321            for shape in &shapes {
322                let tensor = Tensor::<2>::random(shape.clone(), Distribution::Default, &device)
323                    .quantize_dynamic(&scheme);
324                assert_byte_len_matches(from_tensor(&tensor, format!("{name}-{shape:?}"), None));
325            }
326        }
327    }
328
329    /// The same agreement against what `TensorData::quantized` writes, over block sizes rather
330    /// than through a backend, so the per-block and per-tensor scale arithmetic is pinned
331    /// independently of what any backend happens to produce.
332    #[test]
333    fn data_len_matches_quantized_bytes() {
334        let base = QuantScheme::default()
335            .with_value(QuantValue::Q8S)
336            .with_store(QuantStore::Native);
337
338        // 8 values in blocks of 4 lands on `QPARAM_ALIGN`, which would hide a padding
339        // assumption; 6 in blocks of 3 and 10 in blocks of 5 do not.
340        for (values, block) in [(8usize, 4usize), (6, 3), (10, 5)] {
341            let scales = vec![0.5f32; values / block];
342            let one_level = base.per_block([block as u8], ScaleDtype::UE4M3);
343            let two_level = base
344                .per_block([block as u8], ScaleDtype::UE4M3)
345                .per_tensor(ScaleDtype::F32);
346
347            for (scheme, global) in [(one_level, None), (two_level, Some(3.0f32))] {
348                let data =
349                    TensorData::quantized(vec![0i8; values], [values], scheme, &scales, global);
350
351                assert_eq!(
352                    data_len(data.dtype(), data.shape()),
353                    data.bytes().len(),
354                    "predicted size disagrees with the written bytes for {values} values \
355                     in blocks of {block}, {scheme:?}"
356                );
357            }
358        }
359    }
360
361    /// Packed quantized storage divides only the packed dimension, so a non-divisible extent
362    /// pads once per line rather than once over the flattened tensor. No current backend can
363    /// materialize such a tensor, so this pins the formula against the storage shape the
364    /// allocation would use (`CubeTensor::quantized_storage`) rather than against real bytes.
365    #[test]
366    fn data_len_packs_per_line_for_packed_stores() {
367        // Q4 in u32 words: 8 values per storage element, packed along the last dimension.
368        let scheme = QuantScheme::default().with_value(QuantValue::Q4S);
369        let packed = |shape: Shape| data_len(DType::QFloat(scheme), &shape);
370
371        // 3 lines x ceil(3 / 8) = 3 u32 words = 12 value bytes, plus one tensor-level f32
372        // scale. Flattening first would say ceil(9 / 8) * 4 = 8.
373        assert_eq!(packed(shape![3, 3]), 12 + 4);
374
375        // The same over more than two dimensions: 2 * 5 lines x ceil(9 / 8) = 20 words.
376        assert_eq!(packed(shape![2, 5, 9]), 20 * 4 + 4);
377
378        // A divisible extent is unaffected, and agrees with the flattened count.
379        assert_eq!(packed(shape![4, 8]), 4 * 4 + 4);
380    }
381
382    /// A backend can panic reading a tensor back from its device. That panic must not unwind
383    /// through the burnpack writer partway through a container, so every provider built here
384    /// is wrapped: the panic becomes an ordinary error the writer can report and abort on.
385    #[test]
386    fn a_panicking_provider_becomes_an_error() {
387        let tensor = deferred("weight".to_string(), DType::F32, shape![2, 2], None, || {
388            panic!("device readback panicked")
389        });
390
391        let err = to_data(&tensor).expect_err("a panicking provider must not unwind");
392        assert!(
393            matches!(&err, PackError::ValidationError(m) if m.contains("panic")),
394            "expected a validation error naming the panic, got {err:?}"
395        );
396    }
397
398    /// A materialization failure must reach the caller through the writer with its class and
399    /// its tensor intact: a panic is a validation failure, not an I/O one, and it arrives
400    /// mid-write alongside the writer's real file errors. Reverting the classification, or
401    /// dropping the writer's `in_tensor` annotation, fails here.
402    #[test]
403    fn a_write_time_failure_keeps_its_class_and_names_its_tensor() {
404        let tensor = deferred(
405            "encoder.weight".to_string(),
406            DType::F32,
407            shape![1],
408            None,
409            || panic!("device readback panicked"),
410        );
411
412        let err = burn_pack::Writer::new(vec![tensor])
413            .into_bytes()
414            .expect_err("a panicking provider must fail the write");
415
416        assert!(
417            matches!(&err, PackError::ValidationError(m)
418                if m.contains("tensor 'encoder.weight'") && m.contains("device readback panicked")),
419            "expected a named ValidationError carrying the panic message, got {err:?}"
420        );
421    }
422
423    /// A provider's own error is passed through untouched; only panics are translated.
424    #[test]
425    fn a_provider_error_passes_through() {
426        let tensor = deferred("weight".to_string(), DType::F32, shape![2, 2], None, || {
427            Err(PackError::IoError("simulated IO error".to_string()))
428        });
429
430        let err = to_data(&tensor).unwrap_err();
431        assert!(
432            matches!(&err, PackError::IoError(m) if m == "simulated IO error"),
433            "expected the provider's own error, got {err:?}"
434        );
435    }
436
437    /// A deferred tensor must not run its provider until the bytes are asked for. This is what
438    /// bounds a save's peak host memory by the largest single tensor rather than by the whole
439    /// model, and it is invisible in the metadata, so pin it on the call count.
440    #[test]
441    fn deferred_runs_its_provider_only_when_asked() {
442        use core::sync::atomic::{AtomicUsize, Ordering};
443        // Tests only build for std targets, so the atomic pointer is always available here.
444        use alloc::sync::Arc;
445
446        let calls = Arc::new(AtomicUsize::new(0));
447        let counter = calls.clone();
448        let data = TensorData::from([1.0f32, 2.0, 3.0, 4.0]);
449
450        let tensor = deferred(
451            "weight".to_string(),
452            DType::F32,
453            shape![4],
454            None,
455            move || {
456                counter.fetch_add(1, Ordering::Relaxed);
457                Ok(data.clone())
458            },
459        );
460
461        // Metadata alone costs nothing.
462        assert_eq!(tensor.byte_len(), 16);
463        assert_eq!(tensor.shape, shape![4]);
464        assert_eq!(calls.load(Ordering::Relaxed), 0);
465
466        to_data(&tensor).unwrap();
467        assert_eq!(calls.load(Ordering::Relaxed), 1);
468    }
469
470    /// One generic constructor has to cover all three parameter kinds, since `Basic` is what
471    /// supplies `dtype`, `shape` and `to_data` for each.
472    #[test]
473    fn from_tensor_covers_every_kind() {
474        let device = Device::default();
475
476        let floats = from_tensor(
477            &Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device),
478            "float".to_string(),
479            Some(7),
480        );
481        assert_eq!(floats.name, "float");
482        assert_eq!(floats.shape, shape![2, 2]);
483        assert_eq!(floats.param_id, Some(7));
484        assert_eq!(*to_data(&floats).unwrap().shape(), shape![2, 2]);
485
486        let ints = from_tensor(
487            &Tensor::<2, Int>::from_data([[1, 2], [3, 4]], &device),
488            "int".to_string(),
489            None,
490        );
491        assert_eq!(ints.dtype, device.settings().int_dtype.into());
492
493        let bools = from_tensor(
494            &Tensor::<2, Bool>::from_data([[true, false], [false, true]], &device),
495            "bool".to_string(),
496            None,
497        );
498        assert_eq!(*to_data(&bools).unwrap().shape(), shape![2, 2]);
499    }
500
501    /// A provider that returns a different dtype than the tensor declared must be refused.
502    /// Two dtypes of the same width give identical byte lengths, so the length check cannot
503    /// see this, and a reader would reinterpret the bytes as the declared type.
504    #[test]
505    fn a_provider_contradicting_its_declared_dtype_is_refused() {
506        let tensor = deferred("weight".to_string(), DType::F32, shape![4], None, || {
507            Ok(TensorData::from([1i32, 2, 3, 4]))
508        });
509
510        let err = to_data(&tensor).unwrap_err();
511        assert!(
512            matches!(&err, PackError::ValidationError(m) if m.contains("I32") && m.contains("F32")),
513            "expected a validation error naming both dtypes, got {err:?}"
514        );
515    }
516
517    /// The same for a shape that permutes to the same element count.
518    #[test]
519    fn a_provider_contradicting_its_declared_shape_is_refused() {
520        let tensor = deferred("weight".to_string(), DType::F32, shape![2, 3], None, || {
521            Ok(TensorData::new(vec![0.0f32; 6], shape![3, 2]))
522        });
523
524        assert!(matches!(
525            to_data(&tensor).unwrap_err(),
526            PackError::ValidationError(_)
527        ));
528    }
529
530    /// `into_data` yields what `to_data` does, taking the tensor rather than borrowing it.
531    #[test]
532    fn into_data_matches_to_data() {
533        let device = Device::default();
534        let tensor = from_tensor(
535            &Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device),
536            "weight".to_string(),
537            None,
538        );
539
540        let borrowed = to_data(&tensor).unwrap();
541        let taken = into_data(tensor).unwrap();
542        assert_eq!(borrowed.shape(), taken.shape());
543        assert_eq!(borrowed.dtype(), taken.dtype());
544        assert_eq!(borrowed.as_bytes(), taken.as_bytes());
545    }
546
547    /// `map_data` has to declare the length its transform will produce, not the one it
548    /// consumed. A cast that shrinks the data while still claiming the source's length would
549    /// be caught only once the writer had already committed its offsets.
550    #[test]
551    fn map_data_declares_the_transformed_length() {
552        let device = Device::default();
553        let source = from_tensor(
554            &Tensor::<2>::from_data([[1.0, 2.0], [3.0, 4.0]], &device),
555            "weight".to_string(),
556            None,
557        );
558        assert_eq!(source.byte_len(), 16);
559
560        let shape = source.shape.clone();
561        let cast = map_data(source, "weight".to_string(), DType::F16, shape, |data| {
562            data.convert_dtype(DType::F16)
563        });
564
565        assert_eq!(cast.byte_len(), 8);
566        assert_byte_len_matches(cast);
567    }
568
569    /// A checkpoint tensor crosses over with its identity intact and its bytes reinterpreted
570    /// under the mapped dtype. Bool is the variant with no direct counterpart, and the one
571    /// whose bytes must arrive as valid `bool`s.
572    #[cfg(feature = "pytorch")]
573    #[test]
574    fn from_pytorch_maps_dtype_and_reads_bytes() {
575        use burn_core::tensor::BoolStore;
576
577        let path = concat!(
578            env!("CARGO_MANIFEST_DIR"),
579            "/pytorch-tests/tests/boolean/boolean.pt"
580        );
581        let mut tensors = pytorch_reader::PytorchReader::new(path)
582            .unwrap()
583            .into_tensors();
584        let tensor = from_pytorch(tensors.remove("buffer").unwrap());
585
586        assert_eq!(tensor.name, "buffer");
587        assert_eq!(tensor.dtype, DType::Bool(BoolStore::Native));
588        assert_eq!(tensor.shape, shape![3]);
589        assert_byte_len_matches(tensor.clone());
590
591        let data = into_data(tensor).unwrap();
592        assert_eq!(data.as_slice::<bool>().unwrap(), &[true, false, true]);
593    }
594
595    /// Every checkpoint dtype maps to the burn dtype of the same name and width. The two
596    /// sides of a swapped arm (`I16` to `U16`, say) would agree with each other everywhere
597    /// downstream, so only a check against the names catches it.
598    #[cfg(feature = "pytorch")]
599    #[test]
600    fn pytorch_dtype_maps_every_variant_by_name() {
601        use pytorch_reader::DType as Pt;
602
603        let all = [
604            Pt::F64,
605            Pt::F32,
606            Pt::F16,
607            Pt::BF16,
608            Pt::I64,
609            Pt::I32,
610            Pt::I16,
611            Pt::I8,
612            Pt::U64,
613            Pt::U32,
614            Pt::U16,
615            Pt::U8,
616            Pt::Bool,
617        ];
618        for pt in all {
619            let mapped = pytorch_dtype(pt);
620            assert_eq!(mapped.size(), pt.size(), "{pt:?}");
621            let expected = match pt {
622                Pt::Bool => "Bool(Native)".to_string(),
623                other => format!("{other:?}"),
624            };
625            assert_eq!(format!("{mapped:?}"), expected);
626        }
627    }
628}