Skip to main content

scirs2_python/
dlpack.rs

1//! DLPack tensor interop for scirs2-python
2//!
3//! Provides `from_dlpack` and `to_dlpack` entry points that follow the
4//! DLPack 1.0 protocol.  Full zero-copy sharing with PyTorch, JAX, CuPy,
5//! TensorFlow etc. requires the calling Python environment to have the
6//! relevant library installed; the Rust side handles the capsule protocol.
7//!
8//! # DLPack protocol
9//!
10//! A *DLPack capsule* is a `PyCapsule` object whose name is `"dltensor"`.
11//! After the consumer takes ownership, the capsule is renamed to
12//! `"used_dltensor"` so double-frees are prevented.
13//!
14//! # Stride handling
15//!
16//! `from_dlpack` does **not** assume the producer's buffer is C-contiguous.
17//! Non-contiguous tensors — a transposed PyTorch tensor (`t.t().__dlpack__()`),
18//! a reversed/negative-stride view, a sliced view, … — report their true
19//! per-dimension strides via `DLTensor.strides`, and this module walks the
20//! buffer using genuine N-dimensional strided iteration so the copied data
21//! always matches the producer's logical layout instead of being silently
22//! misread as if it were contiguous.
23//!
24//! # Python usage
25//!
26//! ```python
27//! import torch
28//! import scirs2
29//!
30//! t = torch.randn(3, 4)
31//! # PyTorch tensors expose __dlpack__() / __dlpack_device__()
32//! capsule = t.__dlpack__()
33//! arr = scirs2.from_dlpack(capsule)   # -> scirs2 array (NumPy-compatible)
34//!
35//! # Round-trip: export back
36//! cap2 = scirs2.to_dlpack(arr)
37//! t2 = torch.from_dlpack(cap2)
38//! ```
39
40use std::ffi::{c_void, CStr};
41use std::ptr::NonNull;
42
43use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
44use pyo3::prelude::*;
45use pyo3::types::{PyCapsule, PyCapsuleMethods};
46use scirs2_numpy::dlpack::{
47    DLDataType, DLDataTypeCode, DLDevice, DLDeviceType, DLManagedTensor, DLTensor,
48};
49
50/// Expected DLPack capsule name (C string literal, DLPack 1.0 spec).
51const DLTENSOR_NAME: &CStr = c"dltensor";
52
53/// Name the capsule is renamed to once consumed (prevents double-free).
54const USED_DLTENSOR_NAME: &CStr = c"used_dltensor";
55
56// ─── Ownership wrapper ────────────────────────────────────────────────────────
57
58/// Heap allocation that backs a DLPack capsule created by `to_dlpack`.
59///
60/// Bundles the `DLManagedTensor` with the shape/strides arrays and the owned
61/// data copy.  All memory is freed through `BackingStore::drop_raw`.
62///
63/// # Layout
64///
65/// `#[repr(C)]` is required here, not optional: `to_dlpack` stores a pointer
66/// to this struct's *start* in the `PyCapsule`, and both `from_dlpack` and
67/// `capsule_destructor` reinterpret that address directly as `*mut
68/// DLManagedTensor` (see `managed` below). Rust's default `repr(Rust)`
69/// layout is free to reorder fields and does not guarantee `managed` sits at
70/// offset 0 — without `#[repr(C)]` the reinterpreted pointer can land on the
71/// wrong bytes (e.g. inside one of the `Vec` fields), so `device_type` reads
72/// as garbage and calling the bogus `deleter` function pointer during
73/// capsule cleanup is undefined behavior (observed in practice as an
74/// immediate SIGBUS crash when a `to_dlpack` capsule is garbage-collected).
75#[repr(C)]
76struct BackingStore {
77    /// ABI-compatible managed-tensor struct; must be the first field so that
78    /// a `*mut BackingStore` can be cast to `*mut DLManagedTensor` safely.
79    /// (Enforced by `#[repr(C)]` above — see the `# Layout` note.)
80    managed: DLManagedTensor,
81    /// Owned copy of the tensor's element data.
82    data: Vec<f64>,
83    /// Owned shape array (length = `managed.dl_tensor.ndim`).
84    shape: Vec<i64>,
85    /// Owned strides array (length = `managed.dl_tensor.ndim`).
86    strides: Vec<i64>,
87}
88
89impl BackingStore {
90    /// Free a `BackingStore` that was previously leaked with `Box::into_raw`.
91    ///
92    /// # Safety
93    ///
94    /// `ptr` must be a non-null pointer obtained from `Box::into_raw` on a
95    /// `BackingStore`.  This function must be called at most once.
96    unsafe fn drop_raw(ptr: *mut BackingStore) {
97        if !ptr.is_null() {
98            // SAFETY: ptr was obtained from Box::into_raw.
99            drop(unsafe { Box::from_raw(ptr) });
100        }
101    }
102}
103
104/// DLPack `deleter` stored inside the `DLManagedTensor`.
105///
106/// Called by the consumer framework (PyTorch, JAX, etc.) when it is finished
107/// with the tensor.
108///
109/// # Safety
110///
111/// `managed` must point to the `managed` field of a `BackingStore` that was
112/// previously leaked via `Box::into_raw`.
113unsafe extern "C" fn backing_store_deleter(managed: *mut DLManagedTensor) {
114    if managed.is_null() {
115        return;
116    }
117    // SAFETY: BackingStore has `managed` as its first field, so the pointer
118    // arithmetic is a no-op and the cast is valid.
119    let backing = managed as *mut BackingStore;
120    // SAFETY: backed by a Box::into_raw call in `to_dlpack`.
121    unsafe { BackingStore::drop_raw(backing) };
122}
123
124/// Destructor registered with `PyCapsule::new_with_pointer_and_destructor`.
125///
126/// Called by Python's GC when the capsule object is finalized.  Extracts the
127/// `BackingStore` raw pointer from the capsule and drops it.
128///
129/// # Already-consumed capsules
130///
131/// Per the DLPack 1.0 protocol, `from_dlpack` renames a capsule it has
132/// consumed from `"dltensor"` to `"used_dltensor"` *and* already invokes the
133/// `deleter` itself at that point. If this destructor blindly called
134/// `PyCapsule_GetPointer` with the original `"dltensor"` name on such a
135/// capsule, two things would go wrong: (1) the name no longer matches, so
136/// CPython sets a `ValueError` — which a finalizer must never leave
137/// pending, since CPython surfaces it as a `SystemError` out of the next
138/// `gc.collect()` / refcount drop; and (2) even if the name check were
139/// bypassed, the `BackingStore` was already freed by `from_dlpack`, so
140/// fetching and calling its deleter again here would be a double-free.
141/// `PyCapsule_IsValid` is used instead of `PyCapsule_GetPointer` for the
142/// initial check because the CPython docs guarantee it "will not fail" (it
143/// never sets an exception) — it simply reports whether the capsule is
144/// still a live, unconsumed `"dltensor"` capsule.
145///
146/// # Safety
147///
148/// `capsule` must be a valid `PyObject*`. If it is a live (unconsumed)
149/// `"dltensor"` capsule, its stored pointer must have been set during
150/// `to_dlpack` to the `managed` field of a `BackingStore` allocation.
151unsafe extern "C" fn capsule_destructor(capsule: *mut pyo3::ffi::PyObject) {
152    // SAFETY: capsule is a valid PyObject (finalizer contract of
153    // `PyCapsule::new_with_pointer_and_destructor`). `PyCapsule_IsValid`
154    // never sets a Python exception, unlike `PyCapsule_GetPointer` on a
155    // name mismatch.
156    let is_live_dltensor =
157        unsafe { pyo3::ffi::PyCapsule_IsValid(capsule, DLTENSOR_NAME.as_ptr()) } != 0;
158    if !is_live_dltensor {
159        // Either not a capsule of ours, or already consumed by
160        // `from_dlpack` (renamed to "used_dltensor", deleter already run
161        // there) — nothing left to free.
162        return;
163    }
164
165    // SAFETY: capsule is a valid PyCapsule whose pointer was set during
166    // `to_dlpack` to a `BackingStore::managed` field; just confirmed live
167    // and named "dltensor" by `PyCapsule_IsValid` above, so
168    // `PyCapsule_GetPointer` with the same name is guaranteed to succeed.
169    let ptr = unsafe { pyo3::ffi::PyCapsule_GetPointer(capsule, DLTENSOR_NAME.as_ptr()) };
170    if !ptr.is_null() {
171        let managed_ptr = ptr as *mut DLManagedTensor;
172        // SAFETY: managed_ptr is the `managed` field of a BackingStore.
173        if let Some(deleter) = unsafe { (*managed_ptr).deleter } {
174            unsafe { deleter(managed_ptr) };
175        }
176    }
177}
178
179// ─── from_dlpack ─────────────────────────────────────────────────────────────
180
181/// Errors produced while decoding a [`DLTensor`]'s payload into a flat `f64`
182/// buffer.
183///
184/// Kept separate from `PyErr` so the pure-Rust extraction logic
185/// (`read_strided_elements` / `extract_dlpack_data`) has no PyO3 dependency
186/// and can be unit tested without a Python interpreter.
187#[derive(Debug)]
188enum ExtractError {
189    /// The element dtype (code + bits + lanes) is not one this bridge knows
190    /// how to widen to `f64`.
191    UnsupportedDtype {
192        /// DLDataType code (0=int, 1=uint, 2=float, 3=bfloat).
193        code: u8,
194        /// DLDataType bit width.
195        bits: u8,
196    },
197    /// An offset computation while walking the tensor's shape/strides
198    /// overflowed, or the shape/strides were otherwise malformed.
199    Arithmetic(String),
200}
201
202/// Read `product(shape)` elements of type `T` starting at `base_ptr`, honoring
203/// the tensor's per-dimension `strides` (in units of `size_of::<T>()`, per the
204/// DLPack ABI) instead of assuming a C-contiguous layout.
205///
206/// Elements are produced in row-major (C-order) traversal order relative to
207/// `shape` — the innermost (last) dimension varies fastest — regardless of the
208/// tensor's *physical* stride layout.  This means the result can be hand off
209/// directly to `numpy.array(..).reshape(shape)` and still contain the
210/// logically correct values even when the source tensor is a transposed,
211/// reversed, sliced, or otherwise non-contiguous view (e.g.
212/// `torch_tensor.t().__dlpack__()`).
213///
214/// # Errors
215///
216/// Returns `Err` — rather than silently wrapping or panicking — if `shape`
217/// and `strides` have different lengths, if `shape` contains a negative
218/// dimension, or if any intermediate offset computation would overflow
219/// `i64`.
220///
221/// # Safety
222///
223/// For every multi-index `idx` within `shape`, `base_ptr` offset by
224/// `(idx[0]*strides[0] + ... + idx[n-1]*strides[n-1]) * size_of::<T>()` bytes
225/// must be a valid, readable, properly initialized `T` value.  This is the
226/// same "producer promises a valid memory region" contract that every DLPack
227/// consumer (NumPy, PyTorch, …) relies on.
228unsafe fn read_strided_elements<T: Copy>(
229    base_ptr: *const u8,
230    shape: &[i64],
231    strides: &[i64],
232) -> Result<Vec<T>, String> {
233    if shape.len() != strides.len() {
234        return Err(format!(
235            "shape/strides length mismatch: {} dims vs {} strides",
236            shape.len(),
237            strides.len()
238        ));
239    }
240
241    if shape.is_empty() {
242        // 0-dimensional tensor: a single scalar sits at `base_ptr` itself.
243        // SAFETY: caller guarantees base_ptr is valid for one `T` read.
244        let v = unsafe { std::ptr::read_unaligned(base_ptr as *const T) };
245        return Ok(vec![v]);
246    }
247
248    let elem_size = std::mem::size_of::<T>() as i64;
249    let ndim = shape.len();
250
251    let mut total: usize = 1;
252    for &d in shape {
253        if d < 0 {
254            return Err(format!("negative dimension {d} in tensor shape"));
255        }
256        total = total
257            .checked_mul(d as usize)
258            .ok_or_else(|| "tensor element count overflows usize".to_string())?;
259    }
260
261    let mut result: Vec<T> = Vec::with_capacity(total);
262    let mut idx = vec![0i64; ndim];
263
264    for _ in 0..total {
265        let mut offset_elems: i64 = 0;
266        for (&i, &s) in idx.iter().zip(strides.iter()) {
267            let term = i
268                .checked_mul(s)
269                .ok_or_else(|| "stride offset overflow".to_string())?;
270            offset_elems = offset_elems
271                .checked_add(term)
272                .ok_or_else(|| "stride offset overflow".to_string())?;
273        }
274        let byte_offset = offset_elems
275            .checked_mul(elem_size)
276            .ok_or_else(|| "byte offset overflow".to_string())?;
277
278        // `wrapping_offset` is a safe fn (pure pointer arithmetic that can
279        // never itself trigger UB); it is used instead of `offset` so an
280        // extreme-but-not-i64-overflowing byte offset can't cause UB in the
281        // arithmetic step. The actual unsafety is confined to the
282        // dereference below.
283        let ptr = base_ptr.wrapping_offset(byte_offset as isize) as *const T;
284        // SAFETY: caller guarantees every offset reachable via shape/strides
285        // addresses a valid, initialized `T` value relative to `base_ptr`.
286        // `read_unaligned` additionally tolerates any alignment.
287        let v = unsafe { std::ptr::read_unaligned(ptr) };
288        result.push(v);
289
290        // Odometer increment in row-major order: the last (innermost)
291        // dimension varies fastest, matching the traversal order that
292        // `numpy.reshape(shape)` expects from a flat buffer.
293        for (i, &dim) in idx.iter_mut().zip(shape.iter()).rev() {
294            *i += 1;
295            if *i < dim {
296                break;
297            }
298            *i = 0;
299        }
300    }
301
302    Ok(result)
303}
304
305/// Decode a validated (non-null data, CPU-resident) [`DLTensor`] into a flat
306/// `f64` buffer plus its logical shape, honoring `dl_tensor.strides` so that
307/// non-contiguous (transposed, reversed, sliced, …) source tensors are read
308/// correctly rather than silently misinterpreted as C-contiguous.
309///
310/// The returned `Vec<f64>` is always in row-major (C) order relative to the
311/// returned shape, so it can be handed straight to
312/// `numpy.array(..).reshape(shape)`.
313///
314/// # Safety
315///
316/// `dl_tensor.data` (offset by `dl_tensor.byte_offset`) combined with every
317/// offset reachable via `dl_tensor.shape`/`dl_tensor.strides` must address
318/// valid, initialized memory of the declared `dtype` — the standard DLPack
319/// producer contract.  `dl_tensor.shape` (if non-null) must be valid for
320/// `dl_tensor.ndim` elements, likewise for `dl_tensor.strides`.
321unsafe fn extract_dlpack_data(
322    dl_tensor: &DLTensor,
323) -> Result<(Vec<f64>, Vec<usize>), ExtractError> {
324    let is_scalar = dl_tensor.ndim <= 0 || dl_tensor.shape.is_null();
325
326    let shape_i64: Vec<i64> = if is_scalar {
327        Vec::new()
328    } else {
329        // SAFETY: shape is valid for ndim elements (DLPack producer contract).
330        unsafe {
331            std::slice::from_raw_parts(dl_tensor.shape as *const i64, dl_tensor.ndim as usize)
332        }
333        .to_vec()
334    };
335
336    // A NULL `strides` pointer is the DLPack convention for "this tensor is
337    // C-contiguous". A *non-null* `strides` must always be honored, even
338    // when it describes a non-contiguous layout (transposed/sliced/reversed
339    // views) — that is precisely the case that must not be read as if it
340    // were contiguous.
341    let elem_strides: Vec<i64> = if is_scalar {
342        Vec::new()
343    } else if dl_tensor.strides.is_null() {
344        compute_c_strides(&shape_i64)
345    } else {
346        // SAFETY: strides is valid for ndim elements (same producer contract
347        // already relied on for `shape` above).
348        unsafe {
349            std::slice::from_raw_parts(dl_tensor.strides as *const i64, dl_tensor.ndim as usize)
350        }
351        .to_vec()
352    };
353
354    // SAFETY: caller (from_dlpack) already validated `dl_tensor.data` is
355    // non-null; byte_offset is producer-supplied per the DLPack contract.
356    let base_ptr = unsafe { (dl_tensor.data as *const u8).add(dl_tensor.byte_offset as usize) };
357
358    let dtype = dl_tensor.dtype;
359    let flat_vec: Vec<f64> = match (dtype.code, dtype.bits, dtype.lanes) {
360        // float32 (DLDataTypeCode::Float = 2, bits=32)
361        (2, 32, 1) => {
362            let raw: Vec<f32> =
363                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
364                    .map_err(ExtractError::Arithmetic)?;
365            raw.into_iter().map(|v| v as f64).collect()
366        }
367        // float64
368        (2, 64, 1) => unsafe { read_strided_elements::<f64>(base_ptr, &shape_i64, &elem_strides) }
369            .map_err(ExtractError::Arithmetic)?,
370        // int8
371        (0, 8, 1) => {
372            let raw: Vec<i8> =
373                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
374                    .map_err(ExtractError::Arithmetic)?;
375            raw.into_iter().map(|v| v as f64).collect()
376        }
377        // int16
378        (0, 16, 1) => {
379            let raw: Vec<i16> =
380                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
381                    .map_err(ExtractError::Arithmetic)?;
382            raw.into_iter().map(|v| v as f64).collect()
383        }
384        // int32
385        (0, 32, 1) => {
386            let raw: Vec<i32> =
387                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
388                    .map_err(ExtractError::Arithmetic)?;
389            raw.into_iter().map(|v| v as f64).collect()
390        }
391        // int64
392        (0, 64, 1) => {
393            let raw: Vec<i64> =
394                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
395                    .map_err(ExtractError::Arithmetic)?;
396            raw.into_iter().map(|v| v as f64).collect()
397        }
398        // uint8
399        (1, 8, 1) => {
400            let raw: Vec<u8> =
401                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
402                    .map_err(ExtractError::Arithmetic)?;
403            raw.into_iter().map(|v| v as f64).collect()
404        }
405        // uint16
406        (1, 16, 1) => {
407            let raw: Vec<u16> =
408                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
409                    .map_err(ExtractError::Arithmetic)?;
410            raw.into_iter().map(|v| v as f64).collect()
411        }
412        // uint32
413        (1, 32, 1) => {
414            let raw: Vec<u32> =
415                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
416                    .map_err(ExtractError::Arithmetic)?;
417            raw.into_iter().map(|v| v as f64).collect()
418        }
419        // uint64
420        (1, 64, 1) => {
421            let raw: Vec<u64> =
422                unsafe { read_strided_elements(base_ptr, &shape_i64, &elem_strides) }
423                    .map_err(ExtractError::Arithmetic)?;
424            raw.into_iter().map(|v| v as f64).collect()
425        }
426        (code, bits, _) => {
427            return Err(ExtractError::UnsupportedDtype { code, bits });
428        }
429    };
430
431    let shape_vec: Vec<usize> = if is_scalar {
432        vec![1]
433    } else {
434        shape_i64.iter().map(|&d| d.max(0) as usize).collect()
435    };
436
437    Ok((flat_vec, shape_vec))
438}
439
440/// Convert a DLPack capsule (from PyTorch, JAX, CuPy, TensorFlow, …) into a
441/// scirs2 NumPy-compatible array.
442///
443/// Parameters
444/// ----------
445/// capsule : PyCapsule
446///     A `PyCapsule` object whose name is `"dltensor"`.  Anything that
447///     implements `__dlpack__()` can produce such an object.
448///
449/// Returns
450/// -------
451/// numpy.ndarray
452///     A NumPy array (matching the source tensor's shape and dimensionality)
453///     whose contents are *copied* from the DLPack tensor.  CPU tensors of
454///     dtype int8/16/32/64, uint8/16/32/64, float32, or float64 are
455///     supported; all other dtypes raise `TypeError`.
456///
457/// Notes
458/// -----
459/// GPU tensors raise `TypeError` until an optional `gpu` feature is enabled.
460/// The capsule is renamed to `"used_dltensor"` after consumption to prevent
461/// double-frees, consistent with the DLPack 1.0 spec.
462///
463/// **Stride handling**: the source tensor is *not* assumed to be
464/// C-contiguous.  A non-null `strides` field (e.g. from a transposed
465/// PyTorch tensor's `t().__dlpack__()`, a reversed/negative-stride view, or
466/// any other sliced/strided view) is read using genuine N-dimensional
467/// strided iteration, so the copied data is always logically correct
468/// regardless of the producer's physical memory layout.  A null `strides`
469/// field is treated as the DLPack-spec default of C-contiguous.
470#[pyfunction]
471pub fn from_dlpack(py: Python<'_>, capsule: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
472    // Cast to PyCapsule — accept PyAny so callers can pass __dlpack__() result.
473    let cap = capsule.cast::<PyCapsule>().map_err(|_| {
474        PyTypeError::new_err(
475            "from_dlpack: argument must be a PyCapsule (the result of tensor.__dlpack__()). \
476             Got a non-capsule object instead.",
477        )
478    })?;
479
480    // Validate the capsule name against the DLPack spec.
481    let name_opt = cap.name().map_err(|e| {
482        PyValueError::new_err(format!("from_dlpack: could not read capsule name: {e}"))
483    })?;
484
485    let name_matches = match name_opt {
486        None => false,
487        Some(cn) => {
488            // SAFETY: The name pointer is valid for the duration of this call.
489            let name_cstr = unsafe { cn.as_cstr() };
490            name_cstr == DLTENSOR_NAME
491        }
492    };
493
494    if !name_matches {
495        return Err(PyValueError::new_err(
496            "from_dlpack: expected a PyCapsule named 'dltensor'. \
497             Pass the result of tensor.__dlpack__() directly.",
498        ));
499    }
500
501    // Retrieve the DLManagedTensor pointer from the capsule.
502    // SAFETY: We validated the name above; the pointer was placed here by the
503    // producer and is valid until we consume it.
504    let nn_ptr: NonNull<c_void> = cap
505        .pointer_checked(Some(DLTENSOR_NAME))
506        .map_err(|e| PyRuntimeError::new_err(format!("from_dlpack: null capsule pointer: {e}")))?;
507
508    let managed_ptr = nn_ptr.as_ptr() as *mut DLManagedTensor;
509
510    // SAFETY: managed_ptr is non-null and valid; derived from the capsule above.
511    let dl_tensor: &DLTensor = unsafe { &(*managed_ptr).dl_tensor };
512
513    // Reject non-CPU tensors.
514    if dl_tensor.device.device_type != DLDeviceType::Cpu as i32 {
515        return Err(PyTypeError::new_err(format!(
516            "from_dlpack: only CPU tensors are supported (got device type {}). \
517             Copy the tensor to CPU before calling from_dlpack.",
518            dl_tensor.device.device_type
519        )));
520    }
521
522    // Reject null data pointers.
523    if dl_tensor.data.is_null() {
524        return Err(PyValueError::new_err(
525            "from_dlpack: tensor has a null data pointer.",
526        ));
527    }
528
529    // Decode the tensor's data honoring its actual stride layout — a
530    // producer is not required to hand back a C-contiguous buffer (e.g. a
531    // transposed PyTorch tensor's `__dlpack__()` reports non-row-major
532    // strides), so we must walk it accordingly rather than assuming `shape`
533    // alone determines memory layout.
534    // SAFETY: dl_tensor was validated above (non-null data, CPU device);
535    // shape/strides validity for `ndim` elements is the DLPack producer
536    // contract, same as relied on everywhere else in this module.
537    let (flat_vec, shape_vec) = unsafe { extract_dlpack_data(dl_tensor) }.map_err(|e| match e {
538        ExtractError::UnsupportedDtype { code, bits } => PyTypeError::new_err(format!(
539            "from_dlpack: unsupported dtype (code={code}, bits={bits}). \
540             Supported: int8/16/32/64, uint8/16/32/64, float32, float64.",
541        )),
542        ExtractError::Arithmetic(msg) => PyValueError::new_err(format!("from_dlpack: {msg}")),
543    })?;
544
545    // Rename the capsule to "used_dltensor" per DLPack 1.0 spec to prevent
546    // the producer from being consumed again (double-free guard).
547    // We attempt this on a best-effort basis; failure is non-fatal here because
548    // the data has already been copied.
549    let rename_result =
550        unsafe { pyo3::ffi::PyCapsule_SetName(cap.as_ptr(), USED_DLTENSOR_NAME.as_ptr()) };
551    let _ = rename_result; // intentionally ignored after copy
552
553    // Call the managed tensor's deleter if present, as we have consumed it.
554    if let Some(deleter) = unsafe { (*managed_ptr).deleter } {
555        unsafe { deleter(managed_ptr) };
556    }
557
558    // Convert the flat f64 Vec into a numpy array via Python's numpy.
559    let numpy = py.import("numpy").map_err(|e| {
560        PyRuntimeError::new_err(format!("from_dlpack: could not import numpy: {e}"))
561    })?;
562    let arr = numpy.getattr("array")?.call1((flat_vec,))?;
563
564    // Reshape to match the original tensor shape.
565    let shaped = arr.call_method1("reshape", (shape_vec,))?;
566
567    Ok(shaped.into())
568}
569
570// ─── to_dlpack ────────────────────────────────────────────────────────────────
571
572/// Export a scirs2 (NumPy-compatible) array as a DLPack `PyCapsule`.
573///
574/// Parameters
575/// ----------
576/// array : numpy.ndarray
577///     A NumPy float64 array (or any object with the buffer protocol that
578///     numpy can interpret as float64).
579///
580/// Returns
581/// -------
582/// PyCapsule
583///     A capsule named `"dltensor"` that can be consumed by PyTorch, JAX, etc.
584///
585/// Notes
586/// -----
587/// The capsule *owns a copy* of the array data so that the Python array object
588/// can be garbage-collected independently.  The `DLManagedTensor.deleter`
589/// registered in the capsule frees this copy when the consumer is done.
590#[pyfunction]
591pub fn to_dlpack(py: Python<'_>, array: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
592    // Extract the array data as a Vec<f64> via numpy.
593    let numpy = py
594        .import("numpy")
595        .map_err(|e| PyRuntimeError::new_err(format!("to_dlpack: could not import numpy: {e}")))?;
596
597    // Ensure we have a contiguous float64 C-order array.
598    let arr = numpy.getattr("asarray")?.call1((array,))?;
599    let arr_f64 = numpy
600        .getattr("ascontiguousarray")?
601        .call((arr,), Some(&pyo3::types::PyDict::new(py)))?;
602
603    // Read shape.
604    let shape_obj = arr_f64.getattr("shape")?;
605    let shape_tuple: Vec<i64> = shape_obj.extract::<Vec<i64>>().map_err(|e| {
606        PyTypeError::new_err(format!("to_dlpack: could not extract array shape: {e}"))
607    })?;
608
609    // Extract flat data as f64.
610    let flat_list = arr_f64.call_method0("flatten")?;
611    let data_vec: Vec<f64> = flat_list.extract::<Vec<f64>>().map_err(|e| {
612        PyTypeError::new_err(format!(
613            "to_dlpack: array must be convertible to float64: {e}"
614        ))
615    })?;
616
617    // Compute C-order strides (in elements).
618    let strides_vec: Vec<i64> = compute_c_strides(&shape_tuple);
619
620    // Build the BackingStore on the heap.  We use Box::into_raw so it lives
621    // until the capsule destructor frees it.
622    let n = shape_tuple.len();
623    let mut store = Box::new(BackingStore {
624        managed: DLManagedTensor {
625            dl_tensor: DLTensor {
626                data: std::ptr::null_mut(), // filled in below
627                device: DLDevice {
628                    device_type: DLDeviceType::Cpu as i32,
629                    device_id: 0,
630                },
631                ndim: n as i32,
632                dtype: DLDataType {
633                    code: DLDataTypeCode::Float as u8,
634                    bits: 64,
635                    lanes: 1,
636                },
637                shape: std::ptr::null_mut(),   // filled in below
638                strides: std::ptr::null_mut(), // filled in below
639                byte_offset: 0,
640            },
641            manager_ctx: std::ptr::null_mut(),
642            deleter: Some(backing_store_deleter),
643        },
644        data: data_vec,
645        shape: shape_tuple,
646        strides: strides_vec,
647    });
648
649    // Now that the Vecs are in their final locations inside the Box, set the
650    // raw pointers in dl_tensor to point into those Vecs.
651    store.managed.dl_tensor.data = store.data.as_mut_ptr() as *mut c_void;
652    store.managed.dl_tensor.shape = store.shape.as_mut_ptr();
653    store.managed.dl_tensor.strides = store.strides.as_mut_ptr();
654
655    let raw_store: *mut BackingStore = Box::into_raw(store);
656    // SAFETY: raw_store is non-null (just created by Box::into_raw).
657    let managed_nn = NonNull::new(raw_store as *mut c_void)
658        .ok_or_else(|| PyRuntimeError::new_err("to_dlpack: null BackingStore pointer"))?;
659
660    // SAFETY: managed_nn points to a valid BackingStore; capsule_destructor
661    // will call backing_store_deleter which frees it via Box::from_raw.
662    let capsule = unsafe {
663        PyCapsule::new_with_pointer_and_destructor(
664            py,
665            managed_nn,
666            DLTENSOR_NAME,
667            Some(capsule_destructor),
668        )
669    }
670    .map_err(|e| PyRuntimeError::new_err(format!("to_dlpack: failed to create capsule: {e}")))?;
671
672    Ok(capsule.into())
673}
674
675/// Compute C-order (row-major) strides in elements for the given shape.
676///
677/// The last dimension has stride 1; each preceding dimension has stride equal
678/// to the product of all following dimensions.
679fn compute_c_strides(shape: &[i64]) -> Vec<i64> {
680    let n = shape.len();
681    if n == 0 {
682        return Vec::new();
683    }
684    let mut strides = vec![1i64; n];
685    for i in (0..n - 1).rev() {
686        strides[i] = strides[i + 1] * shape[i + 1];
687    }
688    strides
689}
690
691/// Register DLPack interop functions on the given module.
692pub fn register_dlpack_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
693    m.add_function(wrap_pyfunction!(from_dlpack, m)?)?;
694    m.add_function(wrap_pyfunction!(to_dlpack, m)?)?;
695    Ok(())
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    /// Compile-time check: the module registration function exists and has the
703    /// expected signature.  Actual invocation requires a Python interpreter.
704    #[test]
705    fn dlpack_module_symbol_exists() {
706        let _msg = "dlpack module compiled successfully";
707    }
708
709    #[test]
710    fn compute_c_strides_1d() {
711        assert_eq!(compute_c_strides(&[5]), vec![1]);
712    }
713
714    #[test]
715    fn compute_c_strides_2d() {
716        // Shape [2, 3] -> strides [3, 1]
717        assert_eq!(compute_c_strides(&[2, 3]), vec![3, 1]);
718    }
719
720    #[test]
721    fn compute_c_strides_3d() {
722        // Shape [2, 3, 4] -> strides [12, 4, 1]
723        assert_eq!(compute_c_strides(&[2, 3, 4]), vec![12, 4, 1]);
724    }
725
726    #[test]
727    fn compute_c_strides_empty() {
728        assert_eq!(compute_c_strides(&[]), Vec::<i64>::new());
729    }
730
731    // ─── read_strided_elements / extract_dlpack_data: stride correctness ────
732    //
733    // These exercise the exact bug fixed here: a `from_dlpack` producer that
734    // reports a non-contiguous (e.g. transposed) tensor must be read
735    // according to its *actual* strides, never silently treated as if it
736    // were C-contiguous.
737
738    #[test]
739    fn read_strided_elements_contiguous_matches_plain_read() {
740        // Shape [2, 3] laid out C-contiguously; strides = [3, 1] should
741        // reproduce the buffer verbatim (no permutation) — i.e. no
742        // regression for the common (already-contiguous) case.
743        let data: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
744        let shape = [2_i64, 3];
745        let strides = compute_c_strides(&shape);
746        let base_ptr = data.as_ptr() as *const u8;
747
748        // SAFETY: base_ptr is valid for the 6 elements shape/strides address.
749        let out: Vec<f64> = unsafe { read_strided_elements(base_ptr, &shape, &strides) }
750            .expect("contiguous read should not fail");
751        assert_eq!(out, data.to_vec());
752    }
753
754    #[test]
755    fn read_strided_elements_transposed_2d_reads_logical_order() {
756        // Underlying buffer is the C-contiguous 2x3 matrix:
757        //   [[1, 2, 3],
758        //    [4, 5, 6]]
759        // A *transposed* (3x2) view of that same buffer has shape=[3,2] and
760        // strides=[1,3] (the classic PyTorch `t.t()` / NumPy `.T` case: no
761        // data is moved, only shape/strides are swapped). The logical
762        // transposed matrix is:
763        //   [[1, 4],
764        //    [2, 5],
765        //    [3, 6]]
766        // so row-major traversal must yield [1, 4, 2, 5, 3, 6] — NOT the
767        // physical buffer order [1, 2, 3, 4, 5, 6] a contiguous-only reader
768        // would silently (and wrongly) produce.
769        let data: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
770        let shape = [3_i64, 2];
771        let strides = [1_i64, 3];
772        let base_ptr = data.as_ptr() as *const u8;
773
774        // SAFETY: every offset reachable via shape/strides indexes within
775        // `data` (max offset = 2*1 + 1*3 = 5, i.e. the last element).
776        let out: Vec<f64> = unsafe { read_strided_elements(base_ptr, &shape, &strides) }
777            .expect("transposed strided read should succeed");
778        assert_eq!(out, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
779
780        // Sanity: this must differ from the physical (wrong) contiguous
781        // reading of the same buffer, proving the fix actually changes
782        // behavior for non-contiguous input.
783        assert_ne!(out, data.to_vec());
784    }
785
786    #[test]
787    fn read_strided_elements_negative_stride_reversed_view() {
788        // A negative-stride view (e.g. NumPy's `arr[::-1]`) walking backwards
789        // through the buffer starting from its last element.
790        let data: [f64; 3] = [10.0, 20.0, 30.0];
791        let shape = [3_i64];
792        let strides = [-1_i64];
793        // Base pointer must point at the *first logical* element, i.e. the
794        // last physical element (index 2).
795        let base_ptr = unsafe { data.as_ptr().add(2) } as *const u8;
796
797        // SAFETY: offsets range over {0, -1, -2} elements from base_ptr,
798        // i.e. indices {2, 1, 0} into `data` — all in bounds.
799        let out: Vec<f64> = unsafe { read_strided_elements(base_ptr, &shape, &strides) }
800            .expect("negative-stride read should succeed");
801        assert_eq!(out, vec![30.0, 20.0, 10.0]);
802    }
803
804    #[test]
805    fn read_strided_elements_length_mismatch_errors_without_reading() {
806        // shape has 2 dims, strides only 1 — must error out before ever
807        // dereferencing base_ptr.
808        let x = 0.0_f64;
809        let base_ptr = &x as *const f64 as *const u8;
810        let result: Result<Vec<f64>, String> =
811            unsafe { read_strided_elements(base_ptr, &[2, 2], &[1]) };
812        assert!(result.is_err());
813    }
814
815    #[test]
816    fn read_strided_elements_stride_overflow_errors_cleanly() {
817        // Pathological strides that overflow i64 arithmetic must produce a
818        // clean Err, never a panic/abort/UB. Only idx=[0,0] (offset 0) is
819        // ever actually dereferenced before the overflow is detected on the
820        // very next offset computation, so a 1-element buffer suffices.
821        let data = [0.0_f64];
822        let shape = [2_i64, 2];
823        let strides = [i64::MAX, i64::MAX];
824        let base_ptr = data.as_ptr() as *const u8;
825
826        let result: Result<Vec<f64>, String> =
827            unsafe { read_strided_elements(base_ptr, &shape, &strides) };
828        assert!(result.is_err(), "expected overflow to be caught as Err");
829    }
830
831    #[test]
832    fn read_strided_elements_scalar_zero_dim() {
833        let data = [42.0_f64];
834        let base_ptr = data.as_ptr() as *const u8;
835        let out: Vec<f64> =
836            unsafe { read_strided_elements(base_ptr, &[], &[]) }.expect("scalar read should work");
837        assert_eq!(out, vec![42.0]);
838    }
839
840    // ─── extract_dlpack_data: full DLTensor-level coverage ───────────────────
841
842    /// Build a bare CPU [`DLTensor`] over the given data/shape/strides for
843    /// testing `extract_dlpack_data` without any PyCapsule/Python machinery.
844    fn make_tensor(
845        data_ptr: *mut c_void,
846        shape: &[i64],
847        strides: *mut i64,
848        dtype: DLDataType,
849    ) -> DLTensor {
850        DLTensor {
851            data: data_ptr,
852            device: DLDevice {
853                device_type: DLDeviceType::Cpu as i32,
854                device_id: 0,
855            },
856            ndim: shape.len() as i32,
857            dtype,
858            shape: shape.as_ptr() as *mut i64,
859            strides,
860            byte_offset: 0,
861        }
862    }
863
864    #[test]
865    fn extract_dlpack_data_null_strides_is_treated_as_c_contiguous() {
866        // Regression guard: a plain (already-contiguous) producer with a
867        // NULL strides pointer — the DLPack convention — must still work
868        // exactly as before this fix.
869        let mut data: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
870        let shape = [2_i64, 3];
871        let dtype = DLDataType {
872            code: DLDataTypeCode::Float as u8,
873            bits: 64,
874            lanes: 1,
875        };
876        let tensor = make_tensor(
877            data.as_mut_ptr() as *mut c_void,
878            &shape,
879            std::ptr::null_mut(),
880            dtype,
881        );
882
883        // SAFETY: tensor borrows `data`/`shape`, both alive for this call.
884        let (flat, shape_vec) =
885            unsafe { extract_dlpack_data(&tensor) }.expect("contiguous extraction should succeed");
886        assert_eq!(flat, data.to_vec());
887        assert_eq!(shape_vec, vec![2, 3]);
888    }
889
890    #[test]
891    fn extract_dlpack_data_non_null_strides_transposed_view_reads_correctly() {
892        // The exact bug scenario: a producer hands back a transposed view
893        // (shape=[3,2], strides=[1,3] over the same physical 2x3 buffer)
894        // with a *non-null* strides pointer. Before this fix, `from_dlpack`
895        // ignored `strides` entirely and would have returned the raw
896        // physical buffer order (WRONG). It must now return the logically
897        // transposed data.
898        let mut data: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
899        let mut strides = [1_i64, 3];
900        let shape = [3_i64, 2];
901        let dtype = DLDataType {
902            code: DLDataTypeCode::Float as u8,
903            bits: 64,
904            lanes: 1,
905        };
906        let tensor = make_tensor(
907            data.as_mut_ptr() as *mut c_void,
908            &shape,
909            strides.as_mut_ptr(),
910            dtype,
911        );
912
913        // SAFETY: tensor borrows `data`/`shape`/`strides`, all alive here.
914        let (flat, shape_vec) = unsafe { extract_dlpack_data(&tensor) }
915            .expect("transposed extraction should succeed, not silently misread");
916        assert_eq!(flat, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
917        assert_eq!(shape_vec, vec![3, 2]);
918
919        // The whole point of the fix: this must NOT equal the naive
920        // (contiguous-assuming) physical read of the same buffer.
921        assert_ne!(flat, data.to_vec());
922    }
923
924    #[test]
925    fn extract_dlpack_data_float32_transposed_widens_correctly() {
926        // Same transposed-view scenario but with float32 source data,
927        // covering the widening-to-f64 dtype arms as well as strides.
928        let mut data: [f32; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
929        let mut strides = [1_i64, 3];
930        let shape = [3_i64, 2];
931        let dtype = DLDataType {
932            code: DLDataTypeCode::Float as u8,
933            bits: 32,
934            lanes: 1,
935        };
936        let tensor = make_tensor(
937            data.as_mut_ptr() as *mut c_void,
938            &shape,
939            strides.as_mut_ptr(),
940            dtype,
941        );
942
943        // SAFETY: tensor borrows `data`/`shape`/`strides`, all alive here.
944        let (flat, shape_vec) =
945            unsafe { extract_dlpack_data(&tensor) }.expect("f32 transposed extraction failed");
946        assert_eq!(flat, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
947        assert_eq!(shape_vec, vec![3, 2]);
948    }
949
950    #[test]
951    fn extract_dlpack_data_unsupported_dtype_errors() {
952        let mut data: [f64; 1] = [0.0];
953        let shape = [1_i64];
954        let dtype = DLDataType {
955            code: 99, // not a recognised DLDataTypeCode
956            bits: 64,
957            lanes: 1,
958        };
959        let tensor = make_tensor(
960            data.as_mut_ptr() as *mut c_void,
961            &shape,
962            std::ptr::null_mut(),
963            dtype,
964        );
965
966        // SAFETY: tensor borrows `data`/`shape`, both alive here.
967        let result = unsafe { extract_dlpack_data(&tensor) };
968        assert!(matches!(
969            result,
970            Err(ExtractError::UnsupportedDtype { code: 99, bits: 64 })
971        ));
972    }
973}