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)
146            .map_err(|e| ParseError::ValidationError(format!("dlpk ArrayD conversion failed: {e}")))
147    }
148
149    fn copy(&self) -> Box<dyn Array> {
150        // Cheap Arc clone, NOT a deep copy of the data buffer. If a
151        // caller needs a true deep copy, materialize via
152        // `Arc::new(RwLock::new(ArrayD::clone(&*lock)))`.
153        Box::new(Arc::clone(self))
154    }
155}
156
157/// Convenience constructor for the default backing.
158pub fn array_from_shape<T>(shape: &[usize]) -> Box<dyn Array>
159where
160    T: 'static + Send + Sync + Clone + Default + GetDLPackDataType + DLPackPointerCast,
161{
162    let arr: ArrayD<T> = ArrayD::default(ndarray::IxDyn(shape));
163    Box::new(Arc::new(RwLock::new(arr)))
164}
165
166/// Allocate a new zeroed array **on** `device`.
167///
168/// Default builds only allocate on CPU. Non-CPU allocation fails with a clear
169/// error (not silent reinterpret as CPU). Callers that already own device
170/// memory should use [`array_from_host_f64_on_device`] / [`from_dlpack_f64`] to
171/// **preserve** device identity without allocating on the GPU in this process.
172pub fn allocate_array_on_device(
173    shape: &[usize],
174    device: DLDevice,
175) -> Result<Box<dyn Array>, ParseError> {
176    if device == DLDevice::cpu() {
177        return Ok(array_from_shape::<f64>(shape));
178    }
179    #[cfg(feature = "cuda")]
180    {
181        use dlpk::sys::DLDeviceType;
182        if device.device_type == DLDeviceType::kDLCUDA {
183            return crate::cuda_array::allocate_cuda_f64(shape, device.device_id);
184        }
185    }
186    Err(ParseError::ValidationError(format!(
187        "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"
188    )))
189}
190
191/// Host-resident `f64` buffer tagged with a DLPack `device` (CPU or non-CPU).
192///
193/// Used for **device-preserving interchange**: callers (or tests) supply
194/// buffers that logically live on CUDA/ROCm/etc. without requiring a CUDA
195/// driver in the default build. [`Array::device`] and matching
196/// [`Array::as_dlpack`] requests preserve the tag; mismatched device requests
197/// fail without `--features cuda`; with that feature, CUDA allocate uses real
198/// device memory (see [`crate::cuda_array`]).
199pub struct DeviceTaggedF64Array {
200    shape: Vec<usize>,
201    device: DLDevice,
202    /// Row-major elements (host-visible for contract tests / zero-copy tag).
203    data: Arc<Vec<f64>>,
204}
205
206impl DeviceTaggedF64Array {
207    /// Build from host `f64` values with an explicit DLPack device tag.
208    pub fn new(shape: &[usize], data: Vec<f64>, device: DLDevice) -> Result<Self, ParseError> {
209        let n: usize = shape.iter().product();
210        if data.len() != n {
211            return Err(ParseError::ValidationError(format!(
212                "device-tagged array: expected {n} f64 values for shape {shape:?}, got {}",
213                data.len()
214            )));
215        }
216        Ok(Self {
217            shape: shape.to_vec(),
218            device,
219            data: Arc::new(data),
220        })
221    }
222}
223
224/// Install a device-tagged f64 array (caller-supplied buffer / logical device).
225pub fn array_from_host_f64_on_device(
226    shape: &[usize],
227    data: Vec<f64>,
228    device: DLDevice,
229) -> Result<Box<dyn Array>, ParseError> {
230    Ok(Box::new(DeviceTaggedF64Array::new(shape, data, device)?))
231}
232
233/// Ingest from a DLPack tensor: preserve `tensor.device()` and copy f64 host
234/// elements when the tensor is CPU-addressable; for non-CPU tensors in this
235/// build we still preserve the **device tag** using host staging only when the
236/// tensor reports f64 data that is readable (host tests tag CUDA with host
237/// bytes). Pure allocate-on-GPU without a backend is [`allocate_array_on_device`].
238pub fn from_dlpack_f64(tensor: &DLPackTensor) -> Result<Box<dyn Array>, ParseError> {
239    let device = tensor.device();
240    let shape: Vec<usize> = tensor.shape().iter().map(|&d| d as usize).collect();
241    let n: usize = shape.iter().product();
242    let dtype = tensor.dtype();
243    if dtype.code != dlpk::sys::DLDataTypeCode::kDLFloat || dtype.bits != 64 {
244        return Err(ParseError::ValidationError(format!(
245            "from_dlpack_f64: expected f64, got dtype code={:?} bits={}",
246            dtype.code, dtype.bits
247        )));
248    }
249    // Read elements via data pointer (host-backed tensors and host-staged
250    // device-tagged tests). Non-readable device memory would fail here.
251    let ptr = tensor
252        .data_ptr::<f64>()
253        .map_err(|e| ParseError::ValidationError(format!("from_dlpack_f64 data_ptr: {e}")))?;
254    let mut data = vec![0.0f64; n];
255    if n > 0 {
256        unsafe {
257            std::ptr::copy_nonoverlapping(ptr, data.as_mut_ptr(), n);
258        }
259    }
260    array_from_host_f64_on_device(&shape, data, device)
261}
262
263struct DeviceTaggedManager {
264    data: Arc<Vec<f64>>,
265    shape: Vec<i64>,
266}
267
268unsafe extern "C" fn device_tagged_deleter(managed: *mut dlpk::sys::DLManagedTensorVersioned) {
269    if managed.is_null() {
270        return;
271    }
272    // Only free our manager_ctx. When constructed via `DLPackTensor::from_raw`,
273    // the outer dlpk deleter restores `manager_ctx`/`deleter` then calls us and
274    // finally frees the managed tensor allocation — we must not free `managed`.
275    unsafe {
276        let ctx = (*managed).manager_ctx;
277        if !ctx.is_null() {
278            let _ = Box::from_raw(ctx as *mut DeviceTaggedManager);
279            (*managed).manager_ctx = std::ptr::null_mut();
280        }
281    }
282}
283
284impl Array for DeviceTaggedF64Array {
285    fn as_any(&self) -> &dyn std::any::Any {
286        self
287    }
288
289    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
290        self
291    }
292
293    fn shape(&self) -> Vec<usize> {
294        self.shape.clone()
295    }
296
297    fn dtype(&self) -> DLDataType {
298        f64::get_dlpack_data_type()
299    }
300
301    fn device(&self) -> DLDevice {
302        self.device
303    }
304
305    fn as_dlpack(
306        &self,
307        device: DLDevice,
308        _stream: Option<i64>,
309        _max_version: DLPackVersion,
310    ) -> Result<DLPackTensor, ParseError> {
311        if device != self.device {
312            return Err(ParseError::ValidationError(format!(
313                "device mismatch: array is on {:?}, requested {:?}",
314                self.device, device
315            )));
316        }
317        // Build a managed tensor that reports `self.device` while holding an
318        // Arc to host-resident f64 storage (device-tag preserving interchange
319        // without a CUDA allocator in the default build).
320        let manager = Box::new(DeviceTaggedManager {
321            data: Arc::clone(&self.data),
322            shape: self.shape.iter().map(|&d| d as i64).collect(),
323        });
324        let data_ptr = manager.data.as_ptr() as *mut std::ffi::c_void;
325        let shape_ptr = manager.shape.as_ptr() as *mut i64;
326        let mut managed = dlpk::sys::DLManagedTensorVersioned {
327            version: dlpk::sys::DLPackVersion {
328                major: dlpk::sys::DLPACK_MAJOR_VERSION,
329                minor: dlpk::sys::DLPACK_MINOR_VERSION,
330            },
331            manager_ctx: std::ptr::null_mut(),
332            deleter: Some(device_tagged_deleter),
333            dl_tensor: dlpk::sys::DLTensor {
334                data: data_ptr,
335                device: self.device,
336                ndim: self.shape.len() as i32,
337                dtype: f64::get_dlpack_data_type(),
338                shape: shape_ptr,
339                strides: std::ptr::null_mut(),
340                byte_offset: 0,
341            },
342            flags: dlpk::sys::DLPACK_FLAG_BITMASK_READ_ONLY,
343        };
344        managed.manager_ctx = Box::into_raw(manager) as *mut std::ffi::c_void;
345        // Safety: valid DLManagedTensorVersioned; deleter frees DeviceTaggedManager
346        // (Arc + shape storage). from_raw re-wraps with a Rust manager that
347        // still invokes our deleter.
348        Ok(unsafe { DLPackTensor::from_raw(managed) })
349    }
350
351    fn copy(&self) -> Box<dyn Array> {
352        Box::new(Self {
353            shape: self.shape.clone(),
354            device: self.device,
355            data: Arc::clone(&self.data),
356        })
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn array_from_shape_reports_shape_and_dtype() {
366        let a: Box<dyn Array> = array_from_shape::<f64>(&[5, 3]);
367        assert_eq!(a.shape(), vec![5, 3]);
368        let dt = a.dtype();
369        assert_eq!(dt.code, dlpk::sys::DLDataTypeCode::kDLFloat);
370        assert_eq!(dt.bits, 64);
371        assert_eq!(dt.lanes, 1);
372        assert_eq!(a.device(), DLDevice::cpu());
373    }
374
375    #[test]
376    fn array_copy_shares_storage_via_arc() {
377        let a = array_from_shape::<f64>(&[2, 3]);
378        let b = a.copy();
379        // shapes match
380        assert_eq!(a.shape(), b.shape());
381    }
382
383    #[test]
384    fn array_dlpack_export_round_trip() {
385        let a = array_from_shape::<f64>(&[4, 3]);
386        let tensor = a
387            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
388            .expect("DLPack export should succeed for CPU array");
389        assert_eq!(tensor.shape(), &[4, 3]);
390    }
391
392    /// Without `--features cuda`, non-CPU allocate must fail with a clear error
393    /// (never panic inside a missing driver loader).
394    #[cfg(not(feature = "cuda"))]
395    #[test]
396    fn allocate_non_cpu_fails_clearly() {
397        match allocate_array_on_device(&[2, 3], DLDevice::cuda(0)) {
398            Ok(_) => panic!("non-CPU allocate must fail without --features cuda"),
399            Err(err) => {
400                let msg = format!("{err:?}");
401                assert!(
402                    msg.contains("no device allocator") || msg.contains("allocator"),
403                    "{msg}"
404                );
405            }
406        }
407        let cpu = allocate_array_on_device(&[2, 3], DLDevice::cpu()).unwrap();
408        assert_eq!(cpu.device(), DLDevice::cpu());
409    }
410
411    /// With `--features cuda` and a working driver, allocate on CUDA must succeed
412    /// and report `kDLCUDA` (real device memory path).
413    #[cfg(feature = "cuda")]
414    #[test]
415    fn allocate_cuda_succeeds_with_feature() {
416        let a = allocate_array_on_device(&[2, 3], DLDevice::cuda(0))
417            .expect("CUDA allocate must succeed with --features cuda and a driver");
418        assert_eq!(a.device(), DLDevice::cuda(0));
419        assert_eq!(a.device().device_type, dlpk::sys::DLDeviceType::kDLCUDA);
420        let t = a
421            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
422            .expect("matching as_dlpack");
423        assert_eq!(t.device().device_type, dlpk::sys::DLDeviceType::kDLCUDA);
424        let cpu = allocate_array_on_device(&[2, 3], DLDevice::cpu()).unwrap();
425        assert_eq!(cpu.device(), DLDevice::cpu());
426    }
427
428    #[test]
429    fn cuda_tagged_preserves_device_and_matching_as_dlpack() {
430        let data: Vec<f64> = (0..6).map(|i| i as f64).collect();
431        let a = array_from_host_f64_on_device(&[2, 3], data.clone(), DLDevice::cuda(0)).unwrap();
432        assert_eq!(a.device(), DLDevice::cuda(0));
433        assert_eq!(a.shape(), vec![2, 3]);
434
435        let mismatch = a
436            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
437            .unwrap_err();
438        assert!(
439            format!("{mismatch:?}").contains("device mismatch"),
440            "{mismatch:?}"
441        );
442
443        let tensor = a
444            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
445            .expect("matching CUDA device export");
446        assert_eq!(tensor.device(), DLDevice::cuda(0));
447        assert_eq!(tensor.shape(), &[2, 3]);
448
449        // from_dlpack preserves device tag
450        let back = from_dlpack_f64(&tensor).unwrap();
451        assert_eq!(back.device(), DLDevice::cuda(0));
452        assert_eq!(back.shape(), vec![2, 3]);
453        let again = back
454            .as_dlpack(DLDevice::cuda(0), None, DLPackVersion::current())
455            .unwrap();
456        assert_eq!(again.device(), DLDevice::cuda(0));
457    }
458
459    #[test]
460    fn cpu_tagged_path_unchanged() {
461        let a =
462            array_from_host_f64_on_device(&[1, 3], vec![1.0, 2.0, 3.0], DLDevice::cpu()).unwrap();
463        assert_eq!(a.device(), DLDevice::cpu());
464        let t = a
465            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
466            .unwrap();
467        assert_eq!(t.device(), DLDevice::cpu());
468        let back = from_dlpack_f64(&t).unwrap();
469        assert_eq!(back.device(), DLDevice::cpu());
470    }
471}