Skip to main content

readcon_core/
array.rs

1//! Opaque numeric storage for builders and frames (metatensor v0.2.x shape).
2//!
3//! **Design:** internal data is DLPack-shaped (`shape` / `dtype` / `device` /
4//! `as_dlpack(device, stream, max_version)`), not “AoS structs with a DLPack
5//! export bolt-on.” Callers **query** storage dtype/device, then `as_dlpack`
6//! with a **requested device** (and stream / max version)—same contract as
7//! metatensor `mts_array_t`. Choosing f32 vs f64 is choosing **storage**
8//! (or a future typed builder), not passing a cast-target on every export.
9//!
10//! `ConFrame` keeps row-major `ArcArray` blocks for positions and optional
11//! sections; `atom_data` is the CON-text AoS projection for the writer.
12//!
13//! Implementors can swap dtypes (f32 / f64 / u64 / bool), devices (CPU /
14//! future GPU), and ownership (`ArrayD`, `Arc<RwLock<...>>`, …) without
15//! changing the public surface.
16//!
17//! The default backing is `Arc<RwLock<ndarray::ArrayD<T>>>` --
18//!   * `Arc`     : multiple DLPack views can share the same buffer
19//!     across threads / FFI consumers.
20//!   * `RwLock`  : enforces aliasing soundness; concurrent reads
21//!     are non-blocking, concurrent writes contend.
22//!   * `ndarray::ArrayD<T>` : type-erased dimension, generic dtype,
23//!     ndarray's allocator (8-byte aligned, fine for f64; future
24//!     SIMD-aligned variants implement this trait separately).
25//!
26//! See `docs/orgmode/spec.org` §17 for the public contract.
27
28use std::sync::{Arc, RwLock, TryLockError};
29
30use dlpk::sys::{DLDataType, DLDevice, DLPackVersion};
31use dlpk::{DLPackPointerCast, DLPackTensor, GetDLPackDataType};
32use ndarray::ArrayD;
33
34use crate::error::ParseError;
35
36/// Storage hook for one per-atom field of a ConFrameBuilder.
37///
38/// Implementors hold the raw bytes for a single field (e.g. all atom
39/// positions as a `(N, 3) f64` block) and expose them via DLPack so
40/// downstream consumers can map a numpy / Eigen / torch view onto
41/// the same memory zero-copy.
42pub trait Array: std::any::Any + Send + Sync {
43    /// `&dyn Any` access for downcast (mirrors metatensor's pattern).
44    fn as_any(&self) -> &dyn std::any::Any;
45
46    /// `&mut dyn Any` access for downcast.
47    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
48
49    /// Shape of the underlying tensor.
50    fn shape(&self) -> Vec<usize>;
51
52    /// DLPack dtype of the elements.
53    fn dtype(&self) -> DLDataType;
54
55    /// Device residency of the storage.
56    fn device(&self) -> DLDevice;
57
58    /// Export a DLPack-managed tensor view of this array.
59    ///
60    /// `device` requests the consumer's preferred device; CPU
61    /// implementors return `Err(ParseError::ValidationError(...))`
62    /// when asked for a non-CPU device they cannot service.
63    /// `stream` is the consumer's stream (CUDA / ROCm / SYCL); CPU
64    /// backings ignore it.
65    fn as_dlpack(
66        &self,
67        device: DLDevice,
68        stream: Option<i64>,
69        max_version: DLPackVersion,
70    ) -> Result<DLPackTensor, ParseError>;
71
72    /// Deep-copy this array (used by ConFrameBuilder::clone +
73    /// `move_data`-style ops). Default impl just `clone`s through
74    /// the implementor's natural mechanism.
75    fn copy(&self) -> Box<dyn Array>;
76}
77
78/// Default Rust backing for the Array trait: shared, lockable,
79/// dynamic-rank ndarray. Matches metatensor v2's
80/// `Arc<RwLock<ArrayD<T>>>` choice and inherits its DLPack +
81/// concurrency semantics.
82impl<T> Array for Arc<RwLock<ArrayD<T>>>
83where
84    T: 'static + Send + Sync + Clone + Default + GetDLPackDataType + DLPackPointerCast,
85{
86    fn as_any(&self) -> &dyn std::any::Any {
87        self
88    }
89
90    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
91        self
92    }
93
94    fn shape(&self) -> Vec<usize> {
95        match self.try_read() {
96            Ok(lock) => lock.shape().to_vec(),
97            Err(TryLockError::Poisoned(_)) => panic!("readcon-core array lock is poisoned"),
98            Err(TryLockError::WouldBlock) => panic!("readcon-core array is already locked"),
99        }
100    }
101
102    fn dtype(&self) -> DLDataType {
103        T::get_dlpack_data_type()
104    }
105
106    fn device(&self) -> DLDevice {
107        DLDevice::cpu()
108    }
109
110    fn as_dlpack(
111        &self,
112        device: DLDevice,
113        _stream: Option<i64>,
114        _max_version: DLPackVersion,
115    ) -> Result<DLPackTensor, ParseError> {
116        if device != DLDevice::cpu() {
117            return Err(ParseError::ValidationError(format!(
118                "Arc<RwLock<ArrayD>> is CPU-only; requested device {device:?} unsupported"
119            )));
120        }
121        // Borrow the inner ArrayD<T> read-only and convert to DLPack
122        // through dlpk's ndarray feature. The resulting DLPackTensor
123        // owns a clone of the Arc, so the lifetime is decoupled from
124        // the borrow above.
125        let lock = match self.try_read() {
126            Ok(lock) => lock,
127            Err(TryLockError::Poisoned(_)) => {
128                return Err(ParseError::ValidationError(
129                    "readcon-core array lock is poisoned".into(),
130                ));
131            }
132            Err(TryLockError::WouldBlock) => {
133                return Err(ParseError::ValidationError(
134                    "readcon-core array is already locked".into(),
135                ));
136            }
137        };
138        // Clone the ArrayD<T> contents into an owned ndarray, then
139        // hand it to dlpk's TryFrom<ArrayD<T>> -> DLPackTensor (this
140        // takes ownership, so the resulting DLPackTensor has its own
141        // backing storage independent of the Arc<RwLock<...>>; future
142        // optimisation: build a custom Array impl that exposes the
143        // Arc-shared storage directly via dlpk's manager_ctx).
144        let owned: ArrayD<T> = lock.to_owned();
145        DLPackTensor::try_from(owned).map_err(|e| {
146            ParseError::ValidationError(format!("dlpk ArrayD conversion failed: {e}"))
147        })
148    }
149
150    fn copy(&self) -> Box<dyn Array> {
151        // Cheap Arc clone, NOT a deep copy of the data buffer. If a
152        // caller needs a true deep copy, materialize via
153        // `Arc::new(RwLock::new(ArrayD::clone(&*lock)))`.
154        Box::new(Arc::clone(self))
155    }
156}
157
158/// Convenience constructor for the default backing.
159pub fn array_from_shape<T>(shape: &[usize]) -> Box<dyn Array>
160where
161    T: 'static + Send + Sync + Clone + Default + GetDLPackDataType + DLPackPointerCast,
162{
163    let arr: ArrayD<T> = ArrayD::default(ndarray::IxDyn(shape));
164    Box::new(Arc::new(RwLock::new(arr)))
165}
166
167/// Allocate a new zeroed array **on** `device`.
168///
169/// Default builds only allocate on CPU. Non-CPU allocation fails with a clear
170/// error (not silent reinterpret as CPU). Callers that already own device
171/// memory should use [`array_from_host_f64_on_device`] / [`from_dlpack_f64`] to
172/// **preserve** device identity without allocating on the GPU in this process.
173pub fn allocate_array_on_device(
174    shape: &[usize],
175    device: DLDevice,
176) -> Result<Box<dyn Array>, ParseError> {
177    if device == DLDevice::cpu() {
178        return Ok(array_from_shape::<f64>(shape));
179    }
180    #[cfg(feature = "cuda")]
181    {
182        use dlpk::sys::DLDeviceType;
183        if device.device_type == DLDeviceType::kDLCUDA {
184            return crate::cuda_array::allocate_cuda_f64(shape, device.device_id);
185        }
186    }
187    Err(ParseError::ValidationError(format!(
188        "no device allocator in this build for {device:?}; use caller-supplied device buffers via from_dlpack / array_from_host_f64_on_device, or build with `--features cuda` for CUDA devices"
189    )))
190}
191
192/// Host-resident `f64` buffer tagged with a DLPack `device` (CPU or non-CPU).
193///
194/// Used for **device-preserving interchange**: callers (or tests) supply
195/// buffers that logically live on CUDA/ROCm/etc. without requiring a CUDA
196/// driver in the default build. [`Array::device`] and matching
197/// [`Array::as_dlpack`] requests preserve the tag; mismatched device requests
198/// fail without `--features cuda`; with that feature, CUDA allocate uses real
199/// device memory (see [`crate::cuda_array`]).
200pub struct DeviceTaggedF64Array {
201    shape: Vec<usize>,
202    device: DLDevice,
203    /// Row-major elements (host-visible for contract tests / zero-copy tag).
204    data: Arc<Vec<f64>>,
205}
206
207impl DeviceTaggedF64Array {
208    /// Build from host `f64` values with an explicit DLPack device tag.
209    pub fn new(shape: &[usize], data: Vec<f64>, device: DLDevice) -> Result<Self, ParseError> {
210        let n: usize = shape.iter().product();
211        if data.len() != n {
212            return Err(ParseError::ValidationError(format!(
213                "device-tagged array: expected {n} f64 values for shape {shape:?}, got {}",
214                data.len()
215            )));
216        }
217        Ok(Self {
218            shape: shape.to_vec(),
219            device,
220            data: Arc::new(data),
221        })
222    }
223}
224
225/// Install a device-tagged f64 array (caller-supplied buffer / logical device).
226pub fn array_from_host_f64_on_device(
227    shape: &[usize],
228    data: Vec<f64>,
229    device: DLDevice,
230) -> Result<Box<dyn Array>, ParseError> {
231    Ok(Box::new(DeviceTaggedF64Array::new(shape, data, device)?))
232}
233
234/// Ingest from a DLPack tensor: preserve `tensor.device()` and copy f64 host
235/// elements when the tensor is CPU-addressable; for non-CPU tensors in this
236/// build we still preserve the **device tag** using host staging only when the
237/// tensor reports f64 data that is readable (host tests tag CUDA with host
238/// bytes). Pure allocate-on-GPU without a backend is [`allocate_array_on_device`].
239pub fn from_dlpack_f64(tensor: &DLPackTensor) -> Result<Box<dyn Array>, ParseError> {
240    let device = tensor.device();
241    let shape: Vec<usize> = tensor.shape().iter().map(|&d| d as usize).collect();
242    let n: usize = shape.iter().product();
243    let dtype = tensor.dtype();
244    if dtype.code != dlpk::sys::DLDataTypeCode::kDLFloat || dtype.bits != 64 {
245        return Err(ParseError::ValidationError(format!(
246            "from_dlpack_f64: expected f64, got dtype code={:?} bits={}",
247            dtype.code, dtype.bits
248        )));
249    }
250    // Read elements via data pointer (host-backed tensors and host-staged
251    // device-tagged tests). Non-readable device memory would fail here.
252    let ptr = tensor
253        .data_ptr::<f64>()
254        .map_err(|e| ParseError::ValidationError(format!("from_dlpack_f64 data_ptr: {e}")))?;
255    let mut data = vec![0.0f64; n];
256    if n > 0 {
257        unsafe {
258            std::ptr::copy_nonoverlapping(ptr, data.as_mut_ptr(), n);
259        }
260    }
261    array_from_host_f64_on_device(&shape, data, device)
262}
263
264struct DeviceTaggedManager {
265    data: Arc<Vec<f64>>,
266    shape: Vec<i64>,
267}
268
269unsafe extern "C" fn device_tagged_deleter(managed: *mut dlpk::sys::DLManagedTensorVersioned) {
270    if managed.is_null() {
271        return;
272    }
273    // Only free our manager_ctx. When constructed via `DLPackTensor::from_raw`,
274    // the outer dlpk deleter restores `manager_ctx`/`deleter` then calls us and
275    // finally frees the managed tensor allocation — we must not free `managed`.
276    unsafe {
277        let ctx = (*managed).manager_ctx;
278        if !ctx.is_null() {
279            let _ = Box::from_raw(ctx as *mut DeviceTaggedManager);
280            (*managed).manager_ctx = std::ptr::null_mut();
281        }
282    }
283}
284
285impl Array for DeviceTaggedF64Array {
286    fn as_any(&self) -> &dyn std::any::Any {
287        self
288    }
289
290    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
291        self
292    }
293
294    fn shape(&self) -> Vec<usize> {
295        self.shape.clone()
296    }
297
298    fn dtype(&self) -> DLDataType {
299        f64::get_dlpack_data_type()
300    }
301
302    fn device(&self) -> DLDevice {
303        self.device
304    }
305
306    fn as_dlpack(
307        &self,
308        device: DLDevice,
309        _stream: Option<i64>,
310        _max_version: DLPackVersion,
311    ) -> Result<DLPackTensor, ParseError> {
312        if device != self.device {
313            return Err(ParseError::ValidationError(format!(
314                "device mismatch: array is on {:?}, requested {:?}",
315                self.device, device
316            )));
317        }
318        // Build a managed tensor that reports `self.device` while holding an
319        // Arc to host-resident f64 storage (device-tag preserving interchange
320        // without a CUDA allocator in the default build).
321        let manager = Box::new(DeviceTaggedManager {
322            data: Arc::clone(&self.data),
323            shape: self.shape.iter().map(|&d| d as i64).collect(),
324        });
325        let data_ptr = manager.data.as_ptr() as *mut std::ffi::c_void;
326        let shape_ptr = manager.shape.as_ptr() as *mut i64;
327        let mut managed = dlpk::sys::DLManagedTensorVersioned {
328            version: dlpk::sys::DLPackVersion {
329                major: dlpk::sys::DLPACK_MAJOR_VERSION,
330                minor: dlpk::sys::DLPACK_MINOR_VERSION,
331            },
332            manager_ctx: std::ptr::null_mut(),
333            deleter: Some(device_tagged_deleter),
334            dl_tensor: dlpk::sys::DLTensor {
335                data: data_ptr,
336                device: self.device,
337                ndim: self.shape.len() as i32,
338                dtype: f64::get_dlpack_data_type(),
339                shape: shape_ptr,
340                strides: std::ptr::null_mut(),
341                byte_offset: 0,
342            },
343            flags: 0,
344        };
345        managed.manager_ctx = Box::into_raw(manager) as *mut std::ffi::c_void;
346        // Safety: valid DLManagedTensorVersioned; deleter frees DeviceTaggedManager
347        // (Arc + shape storage). from_raw re-wraps with a Rust manager that
348        // still invokes our deleter.
349        Ok(unsafe { DLPackTensor::from_raw(managed) })
350    }
351
352    fn copy(&self) -> Box<dyn Array> {
353        Box::new(Self {
354            shape: self.shape.clone(),
355            device: self.device,
356            data: Arc::clone(&self.data),
357        })
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn array_from_shape_reports_shape_and_dtype() {
367        let a: Box<dyn Array> = array_from_shape::<f64>(&[5, 3]);
368        assert_eq!(a.shape(), vec![5, 3]);
369        let dt = a.dtype();
370        assert_eq!(dt.code, dlpk::sys::DLDataTypeCode::kDLFloat);
371        assert_eq!(dt.bits, 64);
372        assert_eq!(dt.lanes, 1);
373        assert_eq!(a.device(), DLDevice::cpu());
374    }
375
376    #[test]
377    fn array_copy_shares_storage_via_arc() {
378        let a = array_from_shape::<f64>(&[2, 3]);
379        let b = a.copy();
380        // shapes match
381        assert_eq!(a.shape(), b.shape());
382    }
383
384    #[test]
385    fn array_dlpack_export_round_trip() {
386        let a = array_from_shape::<f64>(&[4, 3]);
387        let tensor = a
388            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
389            .expect("DLPack export should succeed for CPU array");
390        assert_eq!(tensor.shape(), &[4, 3]);
391    }
392
393    /// Without `--features cuda`, non-CPU allocate must fail with a clear error
394    /// (never panic inside a missing driver loader).
395    #[cfg(not(feature = "cuda"))]
396    #[test]
397    fn allocate_non_cpu_fails_clearly() {
398        match allocate_array_on_device(&[2, 3], DLDevice::cuda(0)) {
399            Ok(_) => panic!("non-CPU allocate must fail without --features cuda"),
400            Err(err) => {
401                let msg = format!("{err:?}");
402                assert!(
403                    msg.contains("no device allocator") || msg.contains("allocator"),
404                    "{msg}"
405                );
406            }
407        }
408        let cpu = allocate_array_on_device(&[2, 3], DLDevice::cpu()).unwrap();
409        assert_eq!(cpu.device(), DLDevice::cpu());
410    }
411
412    /// With `--features cuda` and a working driver, allocate on CUDA must succeed
413    /// and report `kDLCUDA` (real device memory path).
414    #[cfg(feature = "cuda")]
415    #[test]
416    fn allocate_cuda_succeeds_with_feature() {
417        let a = allocate_array_on_device(&[2, 3], DLDevice::cuda(0))
418            .expect("CUDA allocate must succeed with --features cuda and a driver");
419        assert_eq!(a.device(), DLDevice::cuda(0));
420        assert_eq!(
421            a.device().device_type,
422            dlpk::sys::DLDeviceType::kDLCUDA
423        );
424        let t = a
425            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
426            .expect("matching as_dlpack");
427        assert_eq!(t.device().device_type, dlpk::sys::DLDeviceType::kDLCUDA);
428        let cpu = allocate_array_on_device(&[2, 3], DLDevice::cpu()).unwrap();
429        assert_eq!(cpu.device(), DLDevice::cpu());
430    }
431
432    #[test]
433    fn cuda_tagged_preserves_device_and_matching_as_dlpack() {
434        let data: Vec<f64> = (0..6).map(|i| i as f64).collect();
435        let a = array_from_host_f64_on_device(&[2, 3], data.clone(), DLDevice::cuda(0)).unwrap();
436        assert_eq!(a.device(), DLDevice::cuda(0));
437        assert_eq!(a.shape(), vec![2, 3]);
438
439        let mismatch = a
440            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
441            .unwrap_err();
442        assert!(
443            format!("{mismatch:?}").contains("device mismatch"),
444            "{mismatch:?}"
445        );
446
447        let tensor = a
448            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
449            .expect("matching CUDA device export");
450        assert_eq!(tensor.device(), DLDevice::cuda(0));
451        assert_eq!(tensor.shape(), &[2, 3]);
452
453        // from_dlpack preserves device tag
454        let back = from_dlpack_f64(&tensor).unwrap();
455        assert_eq!(back.device(), DLDevice::cuda(0));
456        assert_eq!(back.shape(), vec![2, 3]);
457        let again = back
458            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
459            .unwrap();
460        assert_eq!(again.device(), DLDevice::cuda(0));
461    }
462
463    #[test]
464    fn cpu_tagged_path_unchanged() {
465        let a = array_from_host_f64_on_device(&[1, 3], vec![1.0, 2.0, 3.0], DLDevice::cpu()).unwrap();
466        assert_eq!(a.device(), DLDevice::cpu());
467        let t = a
468            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
469            .unwrap();
470        assert_eq!(t.device(), DLDevice::cpu());
471        let back = from_dlpack_f64(&t).unwrap();
472        assert_eq!(back.device(), DLDevice::cpu());
473    }
474}