Skip to main content

xlog_cuda/
dlpack.rs

1//! DLPack interop for zero-copy GPU exchange.
2//!
3//! This module provides a minimal DLPack implementation for exporting XLOG GPU
4//! buffers to other ecosystems (e.g., Python cuDF) without device↔host copies.
5
6use std::ffi::c_void;
7use std::sync::Arc;
8
9use xlog_core::{Result, ScalarType, Schema, XlogError};
10
11use crate::device_runtime::Access;
12use crate::memory::{
13    validate_logical_row_count, with_memory_access, CudaBuffer, CudaColumn, DeviceRead,
14};
15use crate::provider::CudaKernelProvider;
16use crate::CudaDevice;
17
18pub type DLDeviceType = i32;
19
20pub const K_DLCPU: DLDeviceType = 1;
21pub const K_DLCUDA: DLDeviceType = 2;
22
23pub type DLDataTypeCode = u8;
24pub const K_DLINT: DLDataTypeCode = 0;
25pub const K_DLUINT: DLDataTypeCode = 1;
26pub const K_DLFLOAT: DLDataTypeCode = 2;
27pub const K_DLBOOL: DLDataTypeCode = 6;
28
29#[repr(C)]
30#[derive(Debug, Clone, Copy)]
31pub struct DLDevice {
32    pub device_type: DLDeviceType,
33    pub device_id: i32,
34}
35
36#[repr(C)]
37#[derive(Debug, Clone, Copy)]
38pub struct DLDataType {
39    pub code: DLDataTypeCode,
40    pub bits: u8,
41    pub lanes: u16,
42}
43
44#[repr(C)]
45#[derive(Debug)]
46pub struct DLTensor {
47    pub data: *mut c_void,
48    pub device: DLDevice,
49    pub ndim: i32,
50    pub dtype: DLDataType,
51    pub shape: *mut i64,
52    pub strides: *mut i64,
53    pub byte_offset: u64,
54}
55
56pub type DLDeleter = Option<unsafe extern "C" fn(*mut DLManagedTensor)>;
57
58#[repr(C)]
59#[derive(Debug)]
60pub struct DLManagedTensor {
61    pub dl_tensor: DLTensor,
62    pub manager_ctx: *mut c_void,
63    pub deleter: DLDeleter,
64}
65
66struct DlpackCtx {
67    _buffer: Arc<CudaBuffer>,
68    shape: Box<[i64]>,
69}
70
71unsafe extern "C" fn dlpack_deleter(ptr: *mut DLManagedTensor) {
72    if ptr.is_null() {
73        return;
74    }
75    // SAFETY: ptr is non-null (checked above); manager_ctx was set from a Box<DlpackCtx> raw pointer
76    let ctx_ptr = unsafe { (*ptr).manager_ctx as *mut DlpackCtx };
77    if !ctx_ptr.is_null() {
78        // SAFETY: ctx_ptr was originally created via Box::into_raw; we are the sole owner
79        unsafe {
80            drop(Box::from_raw(ctx_ptr));
81        }
82    }
83    // SAFETY: ptr was originally created via Box::into_raw in DlpackManagedTensor::into_raw; we are the sole owner
84    unsafe {
85        drop(Box::from_raw(ptr));
86    }
87}
88
89fn scalar_to_dl_dtype(ty: ScalarType) -> DLDataType {
90    match ty {
91        ScalarType::U32 | ScalarType::Symbol => DLDataType {
92            code: K_DLUINT,
93            bits: 32,
94            lanes: 1,
95        },
96        ScalarType::U64 => DLDataType {
97            code: K_DLUINT,
98            bits: 64,
99            lanes: 1,
100        },
101        ScalarType::I32 => DLDataType {
102            code: K_DLINT,
103            bits: 32,
104            lanes: 1,
105        },
106        ScalarType::I64 => DLDataType {
107            code: K_DLINT,
108            bits: 64,
109            lanes: 1,
110        },
111        ScalarType::F32 => DLDataType {
112            code: K_DLFLOAT,
113            bits: 32,
114            lanes: 1,
115        },
116        ScalarType::F64 => DLDataType {
117            code: K_DLFLOAT,
118            bits: 64,
119            lanes: 1,
120        },
121        ScalarType::Bool => DLDataType {
122            code: K_DLBOOL,
123            bits: 8,
124            lanes: 1,
125        },
126    }
127}
128
129fn dl_dtype_to_scalar(dtype: DLDataType) -> Result<ScalarType> {
130    if dtype.lanes != 1 {
131        return Err(XlogError::Kernel(format!(
132            "Unsupported DLPack dtype lanes {} (expected 1)",
133            dtype.lanes
134        )));
135    }
136    match (dtype.code, dtype.bits) {
137        (K_DLUINT, 32) => Ok(ScalarType::U32),
138        (K_DLUINT, 64) => Ok(ScalarType::U64),
139        (K_DLINT, 32) => Ok(ScalarType::I32),
140        (K_DLINT, 64) => Ok(ScalarType::I64),
141        (K_DLFLOAT, 32) => Ok(ScalarType::F32),
142        (K_DLFLOAT, 64) => Ok(ScalarType::F64),
143        // XLOG represents bool as one byte per row today (not bitpacked).
144        (K_DLBOOL, 8) => Ok(ScalarType::Bool),
145        _ => Err(XlogError::Kernel(format!(
146            "Unsupported DLPack dtype code={} bits={} lanes={}",
147            dtype.code, dtype.bits, dtype.lanes
148        ))),
149    }
150}
151
152/// Owned DLPack tensor handle.
153///
154/// Dropping this value will call the DLPack deleter and free the underlying GPU memory.
155pub struct DlpackManagedTensor {
156    ptr: *mut DLManagedTensor,
157}
158
159// SAFETY: DLPack tensors are GPU device pointers with a deleter callback.
160// GPU memory is accessible from any CPU thread, and the deleter is a plain
161// function pointer with no thread affinity. The pointer is never dereferenced
162// concurrently — it is only read during column access and freed on drop.
163unsafe impl Send for DlpackManagedTensor {}
164unsafe impl Sync for DlpackManagedTensor {}
165
166impl DlpackManagedTensor {
167    /// Construct an owned DLPack tensor from a raw pointer.
168    ///
169    /// # Safety
170    /// `ptr` must be a valid `DLManagedTensor*` obtained from a DLPack producer, and ownership
171    /// must be transferred to the caller (the returned value will call the DLPack deleter on drop).
172    /// All tensor data must be live and producer-ready at handoff. The producer
173    /// must coordinate subsequent non-XLOG accesses for the imported lifetime.
174    /// Metadata and the deleter must support use from any host thread.
175    pub unsafe fn from_raw(ptr: *mut DLManagedTensor) -> Self {
176        Self { ptr }
177    }
178
179    pub fn as_ptr(&self) -> *mut DLManagedTensor {
180        self.ptr
181    }
182
183    pub fn into_raw(self) -> *mut DLManagedTensor {
184        let ptr = self.ptr;
185        std::mem::forget(self);
186        ptr
187    }
188}
189
190impl Drop for DlpackManagedTensor {
191    fn drop(&mut self) {
192        // SAFETY: ptr is non-null (checked), was created by the DLPack producer; deleter is the registered cleanup function
193        unsafe {
194            if !self.ptr.is_null() {
195                if let Some(deleter) = (*self.ptr).deleter {
196                    deleter(self.ptr);
197                }
198            }
199        }
200    }
201}
202
203unsafe fn dlpack_tensor_info(
204    provider: &CudaKernelProvider,
205    tensor: &DlpackManagedTensor,
206) -> Result<(u64, ScalarType, cudarc::driver::sys::CUdeviceptr, usize)> {
207    let ptr = tensor.as_ptr();
208    if ptr.is_null() {
209        return Err(XlogError::Kernel(
210            "Null DLManagedTensor pointer".to_string(),
211        ));
212    }
213
214    // SAFETY: ptr is non-null (checked above); DlpackManagedTensor holds a valid DLManagedTensor for its lifetime
215    let dl = unsafe { &(*ptr).dl_tensor };
216
217    // SAFETY: the genuine managed owner retains the original descriptor arrays.
218    unsafe { dlpack_tensor_metadata(provider.device().ordinal() as i32, dl) }
219}
220
221/// Inspect the canonical rank-one DLPack metadata without reading device cells.
222///
223/// # Safety
224/// Non-null shape and stride pointers must address their original descriptor
225/// arrays for the duration of this call. No allocation ownership is transferred.
226pub(crate) unsafe fn dlpack_tensor_metadata(
227    device_id: i32,
228    dl: &DLTensor,
229) -> Result<(u64, ScalarType, cudarc::driver::sys::CUdeviceptr, usize)> {
230    if dl.device.device_type != K_DLCUDA {
231        return Err(XlogError::Kernel(format!(
232            "Unsupported DLPack device type {} (expected CUDA)",
233            dl.device.device_type
234        )));
235    }
236    if dl.device.device_id != device_id {
237        return Err(XlogError::Kernel(format!(
238            "DLPack tensor device_id {} does not match provider device_id {}",
239            dl.device.device_id, device_id
240        )));
241    }
242
243    if dl.ndim != 1 {
244        return Err(XlogError::Kernel(format!(
245            "Unsupported DLPack ndim {} (expected 1)",
246            dl.ndim
247        )));
248    }
249    if dl.shape.is_null() {
250        return Err(XlogError::Kernel("DLPack tensor shape is null".to_string()));
251    }
252    if !dl.strides.is_null() {
253        // SAFETY: dl.strides is non-null (checked above); points to a valid C array of ndim elements
254        let stride0 = unsafe { *dl.strides };
255        if stride0 != 1 {
256            return Err(XlogError::Kernel(format!(
257                "Non-contiguous DLPack tensor stride {} (expected 1)",
258                stride0
259            )));
260        }
261    }
262
263    // SAFETY: dl.shape is non-null (checked above); points to a valid C array of ndim elements
264    let shape0 = unsafe { *dl.shape };
265    if shape0 < 0 {
266        return Err(XlogError::Kernel(format!(
267            "Negative DLPack tensor shape {}",
268            shape0
269        )));
270    }
271    let num_rows = shape0 as u64;
272
273    let scalar = dl_dtype_to_scalar(dl.dtype)?;
274    let elem_size = scalar.size_bytes();
275    if !dl.byte_offset.is_multiple_of(elem_size as u64) {
276        return Err(XlogError::Kernel(format!(
277            "DLPack byte_offset {} is not aligned to element size {}",
278            dl.byte_offset, elem_size
279        )));
280    }
281
282    if dl.data.is_null() && num_rows > 0 {
283        return Err(XlogError::Kernel(
284            "DLPack tensor data pointer is null".to_string(),
285        ));
286    }
287
288    let base = dl.data as usize;
289    let ptr_with_offset = base
290        .checked_add(dl.byte_offset as usize)
291        .ok_or_else(|| XlogError::Kernel("DLPack data pointer overflow".to_string()))?;
292
293    if ptr_with_offset % elem_size != 0 {
294        return Err(XlogError::Kernel(
295            "DLPack tensor data is not properly aligned".to_string(),
296        ));
297    }
298
299    let len_bytes = usize::try_from(num_rows)
300        .ok()
301        .and_then(|n| n.checked_mul(elem_size))
302        .ok_or_else(|| XlogError::Kernel("DLPack tensor length overflow".to_string()))?;
303
304    Ok((num_rows, scalar, ptr_with_offset as u64, len_bytes))
305}
306
307fn dlpack_logical_row_count(device: &Arc<CudaDevice>, buffer: &CudaBuffer) -> Result<usize> {
308    if let Some(cached_rows) = buffer.cached_row_count() {
309        return validate_logical_row_count(buffer.num_rows(), cached_rows as usize);
310    }
311
312    let mut host_rows = [0u32];
313    device
314        .inner()
315        .dtoh_sync_copy_into(buffer.num_rows_device(), &mut host_rows)
316        .map_err(|e| XlogError::Kernel(format!("Failed to read row count: {}", e)))?;
317    buffer.set_cached_row_count_if_unset(host_rows[0]);
318    validate_logical_row_count(buffer.num_rows(), host_rows[0] as usize)
319}
320
321/// A table-like wrapper that can export individual columns as DLPack tensors without copies.
322///
323/// The underlying `CudaBuffer` is reference-counted so multiple DLPack exports can share it.
324pub struct DlpackTable {
325    buffer: Arc<CudaBuffer>,
326    cuda_device: Arc<CudaDevice>,
327    device: DLDevice,
328}
329
330impl DlpackTable {
331    /// Export a column after waiting for its recorded producer uses. This legacy
332    /// managed-tensor interface has no consumer-stream argument or sync event.
333    pub fn column(&self, col_idx: usize) -> Result<DlpackManagedTensor> {
334        let logical_rows = dlpack_logical_row_count(&self.cuda_device, &self.buffer)?;
335        let dtype =
336            self.buffer.schema().column_type(col_idx).ok_or_else(|| {
337                XlogError::Kernel(format!("Column index {} out of bounds", col_idx))
338            })?;
339
340        let col = self
341            .buffer
342            .columns
343            .get(col_idx)
344            .ok_or_else(|| XlogError::Kernel(format!("Column {} not found", col_idx)))?;
345
346        let stream = col.stream();
347        let _ordinary = crate::cuda_graph::reserve_uncaptured_stream(stream).map_err(|error| {
348            XlogError::Kernel(format!("DLPack export stream admission: {error}"))
349        })?;
350        let view = col.device_view();
351        with_memory_access(
352            Arc::clone(stream),
353            vec![view
354                .access(Access::Read)
355                .map_err(|error| XlogError::Kernel(error.to_string()))?],
356            |_| Ok(stream.synchronize()?),
357        )
358        .map_err(|error| XlogError::Kernel(format!("DLPack producer readiness: {error}")))?;
359        let device_ptr = *col.device_ptr() as usize as *mut c_void;
360
361        let mut ctx = Box::new(DlpackCtx {
362            _buffer: self.buffer.clone(),
363            shape: vec![logical_rows as i64].into_boxed_slice(),
364        });
365        let shape_ptr = ctx.shape.as_mut_ptr();
366
367        let dl_tensor = DLTensor {
368            data: device_ptr,
369            device: self.device,
370            ndim: 1,
371            dtype: scalar_to_dl_dtype(dtype),
372            shape: shape_ptr,
373            strides: std::ptr::null_mut(),
374            byte_offset: 0,
375        };
376
377        let managed = Box::new(DLManagedTensor {
378            dl_tensor,
379            manager_ctx: Box::into_raw(ctx) as *mut c_void,
380            deleter: Some(dlpack_deleter),
381        });
382
383        Ok(DlpackManagedTensor {
384            ptr: Box::into_raw(managed),
385        })
386    }
387}
388
389impl CudaKernelProvider {
390    /// Convert a `CudaBuffer` into a DLPack-exportable table without device↔host copies.
391    ///
392    /// Export each column with `DlpackTable::column(...)`.
393    pub fn to_dlpack_table(&self, buffer: CudaBuffer) -> DlpackTable {
394        DlpackTable {
395            buffer: Arc::new(buffer),
396            cuda_device: Arc::clone(self.device()),
397            device: DLDevice {
398                device_type: K_DLCUDA,
399                device_id: self.device().ordinal() as i32,
400            },
401        }
402    }
403
404    /// Import one DLPack tensor per column as a zero-copy `CudaBuffer`.
405    ///
406    /// The returned buffer owns the DLPack tensors and will call their deleters on drop.
407    pub fn from_dlpack_tensors(&self, tensors: Vec<DlpackManagedTensor>) -> Result<CudaBuffer> {
408        if tensors.is_empty() {
409            return self.create_empty_buffer(Schema::new(vec![]));
410        }
411
412        let mut columns = Vec::with_capacity(tensors.len());
413        let mut schema_cols = Vec::with_capacity(tensors.len());
414        let mut num_rows: Option<u64> = None;
415
416        for (i, tensor) in tensors.into_iter().enumerate() {
417            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
418            let (rows, ty, ptr, len_bytes) = unsafe { dlpack_tensor_info(self, &tensor)? };
419            if let Some(n) = num_rows {
420                if rows != n {
421                    return Err(XlogError::Kernel(
422                        "DLPack column row counts do not match".to_string(),
423                    ));
424                }
425            } else {
426                num_rows = Some(rows);
427            }
428
429            schema_cols.push((format!("col_{}", i), ty));
430            // SAFETY: tensor_info checked this range; tensor retains the
431            // producer allocation under from_raw's completed-handoff contract.
432            columns.push(unsafe {
433                CudaColumn::dlpack(
434                    ptr,
435                    len_bytes,
436                    self.device().inner().stream().clone(),
437                    tensor,
438                )
439            });
440        }
441
442        let schema = xlog_core::Schema::new(schema_cols);
443        self.buffer_from_columns(columns, num_rows.unwrap_or(0), schema)
444    }
445
446    /// Import DLPack column tensors with an explicit schema (type-checked).
447    pub fn from_dlpack_tensors_with_schema(
448        &self,
449        schema: xlog_core::Schema,
450        tensors: Vec<DlpackManagedTensor>,
451    ) -> Result<CudaBuffer> {
452        if schema.arity() != tensors.len() {
453            return Err(XlogError::Kernel(format!(
454                "Schema arity {} does not match tensor count {}",
455                schema.arity(),
456                tensors.len()
457            )));
458        }
459
460        if tensors.is_empty() {
461            return self.create_empty_buffer(schema);
462        }
463
464        let mut columns = Vec::with_capacity(tensors.len());
465        let mut num_rows: Option<u64> = None;
466
467        for (i, tensor) in tensors.into_iter().enumerate() {
468            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
469            let (rows, ty, ptr, len_bytes) = unsafe { dlpack_tensor_info(self, &tensor)? };
470            let expected = schema.column_type(i).ok_or_else(|| {
471                XlogError::Kernel(format!("Missing schema type for column {}", i))
472            })?;
473            if !expected.dlpack_compatible(ty) {
474                return Err(XlogError::Kernel(format!(
475                    "DLPack column {} dtype {:?} does not match schema {:?}",
476                    i, ty, expected
477                )));
478            }
479
480            if let Some(n) = num_rows {
481                if rows != n {
482                    return Err(XlogError::Kernel(
483                        "DLPack column row counts do not match".to_string(),
484                    ));
485                }
486            } else {
487                num_rows = Some(rows);
488            }
489
490            // SAFETY: tensor_info checked this range and the owned tensor
491            // supplies the completed, thread-safe producer handoff.
492            columns.push(unsafe {
493                CudaColumn::dlpack(
494                    ptr,
495                    len_bytes,
496                    self.device().inner().stream().clone(),
497                    tensor,
498                )
499            });
500        }
501
502        self.buffer_from_columns(columns, num_rows.unwrap_or(0), schema)
503    }
504}
505
506/// Manager context for a slice-backed export: keeps the runtime-backed
507/// allocation alive until the consumer invokes the deleter.
508struct SliceExportCtx {
509    _slice: Arc<crate::memory::TrackedCudaSlice<u8>>,
510    shape: Box<[i64; 2]>,
511}
512
513unsafe extern "C" fn slice_export_deleter(ptr: *mut DLManagedTensor) {
514    if ptr.is_null() {
515        return;
516    }
517    // SAFETY: ptr is non-null (checked above); manager_ctx was set from a Box<SliceExportCtx> raw pointer
518    let ctx_ptr = unsafe { (*ptr).manager_ctx as *mut SliceExportCtx };
519    if !ctx_ptr.is_null() {
520        // SAFETY: ctx_ptr was originally created via Box::into_raw; we are the sole owner
521        unsafe {
522            drop(Box::from_raw(ctx_ptr));
523        }
524    }
525    // SAFETY: ptr was originally created via Box::into_raw; we are the sole owner
526    unsafe {
527        drop(Box::from_raw(ptr));
528    }
529}
530
531/// Export an xlog-owned runtime-backed slice as a 2-D DLPack managed
532/// tensor without copies. The manager context holds the slice's `Arc`,
533/// so the export shares the exact producer allocation and the memory
534/// stays alive until the consumer's deleter runs; xlog remains the
535/// owner throughout. The declared `rows x cols x dtype` view must
536/// cover the allocation exactly — a mismatched shape is a typed error,
537/// never a truncated or padded view.
538/// Recorded producer uses complete before return because this legacy managed
539/// tensor has no consumer-stream synchronization field. Registering future
540/// external producer/consumer work remains the caller's interop responsibility.
541pub fn export_slice_managed_tensor(
542    slice: Arc<crate::memory::TrackedCudaSlice<u8>>,
543    device_id: i32,
544    dtype: DLDataType,
545    rows: usize,
546    cols: usize,
547) -> Result<DlpackManagedTensor> {
548    let elem_bytes = (dtype.bits as usize / 8) * dtype.lanes as usize;
549    let needed = rows
550        .checked_mul(cols)
551        .and_then(|cells| cells.checked_mul(elem_bytes))
552        .ok_or_else(|| XlogError::Kernel("export shape overflows".to_string()))?;
553    if needed != slice.len() {
554        return Err(XlogError::Kernel(format!(
555            "export view {rows}x{cols}x{elem_bytes}B = {needed} bytes does not \
556             cover the {}-byte allocation exactly",
557            slice.len()
558        )));
559    }
560
561    let stream = slice.stream();
562    let _ordinary = crate::cuda_graph::reserve_uncaptured_stream(stream)
563        .map_err(|error| XlogError::Kernel(format!("DLPack export stream admission: {error}")))?;
564    let view = slice.view();
565    with_memory_access(
566        Arc::clone(stream),
567        vec![view
568            .access(Access::Read)
569            .map_err(|error| XlogError::Kernel(error.to_string()))?],
570        |_| Ok(stream.synchronize()?),
571    )
572    .map_err(|error| XlogError::Kernel(format!("DLPack producer readiness: {error}")))?;
573    let data = *slice.device_ptr() as usize as *mut c_void;
574    let mut ctx = Box::new(SliceExportCtx {
575        _slice: slice,
576        shape: Box::new([rows as i64, cols as i64]),
577    });
578    let shape_ptr = ctx.shape.as_mut_ptr();
579
580    let dl_tensor = DLTensor {
581        data,
582        device: DLDevice {
583            device_type: K_DLCUDA,
584            device_id,
585        },
586        ndim: 2,
587        dtype,
588        shape: shape_ptr,
589        strides: std::ptr::null_mut(),
590        byte_offset: 0,
591    };
592
593    let managed = Box::new(DLManagedTensor {
594        dl_tensor,
595        manager_ctx: Box::into_raw(ctx) as *mut c_void,
596        deleter: Some(slice_export_deleter),
597    });
598
599    Ok(DlpackManagedTensor {
600        ptr: Box::into_raw(managed),
601    })
602}