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}