Skip to main content

oxiphysics_gpu/compute/
cuda_backend.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! CUDA compute backend for the OxiPhysics GPU acceleration layer.
5//!
6//! This module provides [`CudaBackend`] which implements the compute-backend
7//! interface using NVIDIA CUDA via the [`cudarc`](https://crates.io/crates/cudarc)
8//! crate for type-safe PTX / CUDA kernel management.
9//!
10//! ## Feature flag
11//!
12//! This backend is gated behind the `cuda-backend` Cargo feature:
13//!
14//! ```toml
15//! [dependencies]
16//! oxiphysics-gpu = { features = ["cuda-backend"] }
17//! ```
18//!
19//! When the feature is disabled the module compiles to a no-op stub returning
20//! [`CudaInitError::NotAvailable`] from [`CudaBackend::try_new`].
21//!
22//! When the feature is enabled, cudarc uses dynamic-loading (`libloading`) so
23//! the crate compiles on any platform; the CUDA driver is opened at runtime and
24//! an error is returned if it is absent (e.g. macOS, headless Linux without an
25//! NVIDIA driver).
26//!
27//! ## Architecture
28//!
29//! ```text
30//!  CudaBackend
31//!   ├── cudarc::CudaContext                  ← CUDA device context (Arc)
32//!   ├── cudarc::CudaStream                   ← Default stream for kernel dispatch
33//!   ├── cudarc::CudaSlice<u8>                ← Device-resident buffer slices
34//!   ├── Vec<CudaBufferEntry>                 ← Registered buffer metadata
35//!   └── KernelRegistry                       ← Compiled PTX / NVRTC modules
36//!
37//!  Compute pipeline:
38//!    write_buffer [host→device memcpy via stream]
39//!    → launch_kernel(grid, block, args)
40//!    → read_buffer  [device→host memcpy via stream]
41//! ```
42//!
43//! ## Kernels shipped with this backend
44//!
45//! | Source constant | Description |
46//! |---|---|
47//! | [`PTX_SPH_DENSITY`] | SPH density summation (cubic-spline W3), 256 threads/block |
48//! | [`PTX_PARALLEL_SCAN`] | Blelloch exclusive prefix scan, warp-shuffle optimised |
49//! | [`PTX_CONSTRAINT_PGS`] | Block-PGS constraint solver, 64 threads/block |
50//! | [`CUDA_SPH_DENSITY_SRC`] | CUDA C SPH density kernel (compiled at runtime via NVRTC) |
51//!
52//! ## Example (when `cuda-backend` feature enabled)
53//!
54//! ```ignore
55//! use oxiphysics_gpu::compute::cuda_backend::CudaBackend;
56//!
57//! let mut backend = CudaBackend::try_new(0)?;          // device 0
58//! let buf = backend.create_buffer(1024);               // 1024 f64 slots
59//! backend.write_buffer(buf, &vec![1.0_f64; 1024]);
60//! backend.launch("sph_density", &[buf], 16, 256);      // 16 blocks × 256 threads
61//! let result = backend.read_buffer(buf);
62//! ```
63
64// ── CudaBufferHandle ──────────────────────────────────────────────────────────
65
66/// Opaque handle to a CUDA device buffer allocated by [`CudaBackend`].
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct CudaBufferHandle(pub usize);
69
70// ── CudaDeviceInfo ────────────────────────────────────────────────────────────
71
72/// Information about the selected CUDA device.
73#[derive(Debug, Clone, Default)]
74pub struct CudaDeviceInfo {
75    /// CUDA device ordinal (0-indexed).
76    pub ordinal: u32,
77    /// Device name from `cuDeviceGetName`.
78    pub name: String,
79    /// Total global memory in bytes (`cuDeviceTotalMem`).
80    pub total_mem_bytes: u64,
81    /// Compute capability as `(major, minor)`.
82    pub compute_capability: (u32, u32),
83    /// Number of CUDA streaming multiprocessors.
84    pub multiprocessor_count: u32,
85    /// Maximum threads per block.
86    pub max_threads_per_block: u32,
87    /// Warp size (always 32 on current NVIDIA hardware).
88    pub warp_size: u32,
89    /// Whether the device supports unified memory (Compute Capability ≥ 3.0).
90    pub supports_unified_memory: bool,
91    /// Whether the device supports FP64 (`cuDeviceGetAttribute CUDA_DEVICE_ATTRIBUTE_DOUBLE`).
92    pub supports_f64: bool,
93    /// CUDA driver version string.
94    pub driver_version: String,
95}
96
97// ── CudaInitError ─────────────────────────────────────────────────────────────
98
99/// Errors returned by [`CudaBackend::try_new`].
100#[derive(Debug, Clone)]
101pub enum CudaInitError {
102    /// CUDA runtime or driver is not installed on this system.
103    NotAvailable,
104    /// The `cuda-backend` Cargo feature is not enabled in this build.
105    FeatureNotEnabled,
106    /// No CUDA-capable device found (all GPUs are AMD / Intel).
107    NoDevice,
108    /// The requested device ordinal is out of range.
109    DeviceOrdinalOutOfRange(u32),
110    /// cudarc device initialisation returned an error.
111    DeviceError(String),
112    /// NVRTC compilation of a kernel source failed.
113    CompilationError(String),
114}
115
116impl std::fmt::Display for CudaInitError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::NotAvailable => write!(f, "CUDA is not available on this system"),
120            Self::FeatureNotEnabled => write!(f, "`cuda-backend` feature is not enabled"),
121            Self::NoDevice => write!(f, "no CUDA-capable device found"),
122            Self::DeviceOrdinalOutOfRange(n) => write!(f, "device ordinal {n} is out of range"),
123            Self::DeviceError(msg) => write!(f, "CUDA device error: {msg}"),
124            Self::CompilationError(msg) => write!(f, "NVRTC compile error: {msg}"),
125        }
126    }
127}
128
129impl std::error::Error for CudaInitError {}
130
131// ── PTX kernel sources (stub PTX for introspection / documentation) ────────────
132
133/// PTX source for SPH density summation with cubic-spline W3 kernel.
134///
135/// Grid: N/256 blocks. Block: 256 threads.  Each thread computes the density
136/// for one particle by summing contributions from all particles within 2h.
137///
138/// Shared memory is used for tile-based neighbour loading (32 kB per SM).
139///
140/// When the `cuda-backend` feature is active, the real CUDA C source in
141/// [`CUDA_SPH_DENSITY_SRC`] is compiled via NVRTC at runtime.  This constant
142/// is kept as reference documentation and for `register_kernel` calls in the
143/// stub path.
144pub const PTX_SPH_DENSITY: &str = r#"
145// CUDA C source (compiled to PTX via nvcc -arch=sm_70 -ptx)
146// extern "C" __global__ void sph_density(
147//     const float4* __restrict__ pos,   // positions + mass in .w
148//     float*        __restrict__ rho,   // output density
149//     int                        n,     // particle count
150//     float                      h,     // smoothing length
151//     float                      h_inv  // 1/h
152// ) {
153//     int i = blockIdx.x * blockDim.x + threadIdx.x;
154//     if (i >= n) return;
155//
156//     float xi = pos[i].x, yi = pos[i].y, zi = pos[i].z;
157//     float density = 0.0f;
158//     const float coeff = (315.0f / 64.0f) * __fdividef(1.0f, 3.14159265f * h*h*h);
159//
160//     // tile-based neighbour loop (shared memory)
161//     __shared__ float4 tile[256];
162//     for (int t = 0; t < (n + 255) / 256; t++) {
163//         int j = t * 256 + threadIdx.x;
164//         tile[threadIdx.x] = (j < n) ? pos[j] : make_float4(1e30f, 1e30f, 1e30f, 0.0f);
165//         __syncthreads();
166//         for (int k = 0; k < 256; k++) {
167//             float dx = xi - tile[k].x, dy = yi - tile[k].y, dz = zi - tile[k].z;
168//             float r2 = dx*dx + dy*dy + dz*dz;
169//             float h2 = h * h;
170//             if (r2 < h2) {
171//                 float q = 1.0f - r2 * __fdividef(1.0f, h2);
172//                 density += tile[k].w * coeff * q * q * q;
173//             }
174//         }
175//         __syncthreads();
176//     }
177//     rho[i] = density;
178// }
179// --- actual PTX would be here ---
180.version 7.0
181.target sm_70
182.address_size 64
183// (stub — replace with actual nvcc-compiled PTX)
184"#;
185
186/// PTX source for Blelloch exclusive parallel prefix scan.
187///
188/// Grid: 1 block per chunk of 512 elements.  Block: 256 threads.
189/// Uses warp-shuffle primitives (`__shfl_up_sync`) for the intra-warp scan,
190/// then shared memory for the inter-warp reduction.
191pub const PTX_PARALLEL_SCAN: &str = r#"
192// CUDA C source:
193// extern "C" __global__ void exclusive_scan(
194//     const double* __restrict__ in,
195//     double*       __restrict__ out,
196//     int                        n
197// ) {
198//     extern __shared__ double shmem[];
199//     int tid = threadIdx.x;
200//     int gid = blockIdx.x * blockDim.x + tid;
201//
202//     // Load into shared memory
203//     shmem[tid] = (gid < n) ? in[gid] : 0.0;
204//     __syncthreads();
205//
206//     // Blelloch up-sweep
207//     for (int stride = 1; stride < blockDim.x; stride <<= 1) {
208//         int idx = (tid + 1) * stride * 2 - 1;
209//         if (idx < blockDim.x)
210//             shmem[idx] += shmem[idx - stride];
211//         __syncthreads();
212//     }
213//
214//     // Set root to zero
215//     if (tid == blockDim.x - 1) shmem[tid] = 0.0;
216//     __syncthreads();
217//
218//     // Blelloch down-sweep
219//     for (int stride = blockDim.x / 2; stride >= 1; stride >>= 1) {
220//         int idx = (tid + 1) * stride * 2 - 1;
221//         if (idx < blockDim.x) {
222//             double t    = shmem[idx - stride];
223//             shmem[idx - stride] = shmem[idx];
224//             shmem[idx]  = shmem[idx] + t;
225//         }
226//         __syncthreads();
227//     }
228//
229//     if (gid < n) out[gid] = shmem[tid];
230// }
231.version 7.0
232.target sm_70
233.address_size 64
234// (stub — replace with actual nvcc-compiled PTX)
235"#;
236
237/// PTX source for block-PGS constraint solving.
238///
239/// Grid: ⌈N/64⌉ blocks.  Block: 64 threads (1 thread does the sequential inner
240/// loop for guaranteed Gauss-Seidel convergence within the block).
241pub const PTX_CONSTRAINT_PGS: &str = r#"
242// CUDA C source:
243// extern "C" __global__ void constraint_pgs_iter(
244//     const GpuConstraint* __restrict__ constraints,
245//     float* __restrict__              lambda,
246//     float4* __restrict__             vel_lin,   // xyz=vel, w=inv_mass
247//     float4* __restrict__             vel_ang,
248//     int                              n,
249//     float                            omega
250// ) {
251//     int base = blockIdx.x * blockDim.x;
252//     if (threadIdx.x != 0) return;
253//
254//     for (int ci = base; ci < min(base + (int)blockDim.x, n); ci++) {
255//         GpuConstraint c = constraints[ci];
256//         float3 vla = make_float3(0), wla = make_float3(0); float inv_ma = 0;
257//         float3 vlb = make_float3(0), wlb = make_float3(0); float inv_mb = 0;
258//
259//         if (c.body_a != 0xFFFFFFFF) {
260//             float4 vl = vel_lin[c.body_a], vw = vel_ang[c.body_a];
261//             vla = make_float3(vl); wla = make_float3(vw); inv_ma = vl.w;
262//         }
263//         if (c.body_b != 0xFFFFFFFF) {
264//             float4 vl = vel_lin[c.body_b], vw = vel_ang[c.body_b];
265//             vlb = make_float3(vl); wlb = make_float3(vw); inv_mb = vl.w;
266//         }
267//
268//         float3 n3 = make_float3(c.nx, c.ny, c.nz);
269//         float3 va  = vla + cross(wla, make_float3(c.rax, c.ray, c.raz));
270//         float3 vb  = vlb + cross(wlb, make_float3(c.rbx, c.rby, c.rbz));
271//         float  rv  = dot(n3, va - vb);
272//         float  d   = -(rv + c.bias) * c.em * omega;
273//         float  old = lambda[ci];
274//         float  neo = __saturatef((old + d - c.lambda_lo) / (c.lambda_hi - c.lambda_lo))
275//                      * (c.lambda_hi - c.lambda_lo) + c.lambda_lo;
276//         lambda[ci] = neo;
277//         float  dl  = neo - old;
278//
279//         float3 imp = n3 * dl;
280//         if (c.body_a != 0xFFFFFFFF) { /* update vel_lin/ang[body_a] */ }
281//         if (c.body_b != 0xFFFFFFFF) { /* update vel_lin/ang[body_b] */ }
282//     }
283// }
284.version 7.0
285.target sm_70
286.address_size 64
287// (stub — replace with actual nvcc-compiled PTX)
288"#;
289
290/// CUDA C source for SPH density summation kernel, compiled at runtime via NVRTC.
291///
292/// This kernel computes the SPH density for each particle using the cubic-spline
293/// kernel W(r, h) = (315 / 64π h³) (1 − r²/h²)³ for r < h.
294///
295/// Each thread handles one particle (index `i`) and iterates over all `n_particles`
296/// to accumulate density.  The grid-stride is 1 thread per particle; caller must
297/// dispatch `ceil(n_particles / 256)` blocks × 256 threads.
298///
299/// Positions are stored as a flat interleaved array: `positions[3*i]` = x,
300/// `positions[3*i+1]` = y, `positions[3*i+2]` = z.
301pub const CUDA_SPH_DENSITY_SRC: &str = r#"
302extern "C" __global__ void sph_density_kernel(
303    const double* __restrict__ positions,
304    double* __restrict__ densities,
305    int n_particles,
306    double smoothing_length,
307    double particle_mass
308) {
309    int i = blockIdx.x * blockDim.x + threadIdx.x;
310    if (i >= n_particles) return;
311    double px = positions[3*i], py = positions[3*i+1], pz = positions[3*i+2];
312    double rho = 0.0;
313    double h2 = smoothing_length * smoothing_length;
314    double coeff = 315.0 / (64.0 * 3.14159265358979 * smoothing_length
315                            * smoothing_length * smoothing_length);
316    for (int j = 0; j < n_particles; j++) {
317        double dx = px - positions[3*j];
318        double dy = py - positions[3*j+1];
319        double dz = pz - positions[3*j+2];
320        double r2 = dx*dx + dy*dy + dz*dz;
321        if (r2 < h2) {
322            double q = 1.0 - r2 / h2;
323            rho += q * q * q;
324        }
325    }
326    densities[i] = particle_mass * coeff * rho;
327}
328"#;
329
330// ── Internal buffer entry ──────────────────────────────────────────────────────
331
332/// Internal buffer entry: CPU shadow + metadata.
333#[derive(Debug, Clone)]
334struct CudaBufferEntry {
335    /// Number of `f64` elements allocated.
336    len: usize,
337    /// CPU shadow data (mirrors device memory in stub implementation).
338    shadow: Vec<f64>,
339    /// Whether this buffer uses unified memory (UM).
340    _unified: bool,
341}
342
343// ── Real CUDA context (feature-gated) ─────────────────────────────────────────
344
345#[cfg(feature = "cuda-backend")]
346mod real_ctx {
347    use super::CudaInitError;
348    use std::collections::HashMap;
349    use std::sync::Arc;
350
351    use cudarc::driver::{CudaContext, CudaFunction, CudaModule, CudaSlice, CudaStream};
352
353    /// Holds live cudarc objects for the active CUDA device context.
354    pub(super) struct CudaRealContext {
355        /// The CUDA device context.
356        pub ctx: Arc<CudaContext>,
357        /// Default stream used for all memory operations and kernel launches.
358        pub stream: Arc<CudaStream>,
359        /// Device-resident byte buffers, indexed parallel to `CudaBackend::buffers`.
360        pub real_buffers: Vec<CudaSlice<u8>>,
361        /// Loaded modules keyed by name.
362        pub modules: HashMap<String, Arc<CudaModule>>,
363        /// Functions keyed by name.
364        pub functions: HashMap<String, CudaFunction>,
365    }
366
367    impl CudaRealContext {
368        /// Initialise a CUDA device context for the given ordinal.
369        ///
370        /// `default_stream` is infallible in cudarc 0.19 — it simply wraps the
371        /// null-pointer stream which always exists.
372        pub fn new(ordinal: u32) -> Result<Self, CudaInitError> {
373            let ctx = CudaContext::new(ordinal as usize)
374                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
375            // default_stream() returns Arc<CudaStream> directly (not Result).
376            let stream = ctx.default_stream();
377            Ok(Self {
378                ctx,
379                stream,
380                real_buffers: Vec::new(),
381                modules: HashMap::new(),
382                functions: HashMap::new(),
383            })
384        }
385
386        /// Allocate `len` bytes zeroed on the device, returning the buffer index.
387        pub fn alloc_bytes(&mut self, len: usize) -> Result<usize, CudaInitError> {
388            let slice: CudaSlice<u8> = self
389                .stream
390                .alloc_zeros::<u8>(len)
391                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
392            let idx = self.real_buffers.len();
393            self.real_buffers.push(slice);
394            Ok(idx)
395        }
396
397        /// Upload `data` (as raw bytes of f64) to buffer at `idx`.
398        pub fn write_f64_slice(&mut self, idx: usize, data: &[f64]) -> Result<(), CudaInitError> {
399            // Reinterpret f64 slice as u8 slice for the memcpy.
400            let byte_len = std::mem::size_of_val(data);
401            let byte_slice: &[u8] =
402                // SAFETY: f64 is a POD type; we never write through this reference.
403                unsafe { std::slice::from_raw_parts(data.as_ptr().cast::<u8>(), byte_len) };
404
405            let dst = self
406                .real_buffers
407                .get_mut(idx)
408                .ok_or_else(|| CudaInitError::DeviceError("invalid buffer index".to_owned()))?;
409
410            // Only copy as many bytes as fit in the allocated slice.
411            let copy_len = byte_len.min(dst.len());
412            if copy_len == 0 {
413                return Ok(());
414            }
415            let src_trimmed = &byte_slice[..copy_len];
416
417            // memcpy_htod requires dst.len() >= src.len(), so use a sub-view.
418            let mut dst_view = dst
419                .try_slice_mut(..copy_len)
420                .ok_or_else(|| CudaInitError::DeviceError("slice view failed".to_owned()))?;
421
422            self.stream
423                .memcpy_htod(src_trimmed, &mut dst_view)
424                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))
425        }
426
427        /// Download buffer at `idx` into a Vec<f64>.
428        pub fn read_f64_vec(&self, idx: usize) -> Result<Vec<f64>, CudaInitError> {
429            let src = self
430                .real_buffers
431                .get(idx)
432                .ok_or_else(|| CudaInitError::DeviceError("invalid buffer index".to_owned()))?;
433            let bytes: Vec<u8> = self
434                .stream
435                .clone_dtoh(src)
436                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
437            Ok(bytes_to_f64_vec(bytes))
438        }
439
440        /// Register a PTX-source kernel from a raw `.ptx` string via `Ptx::from_src`.
441        pub fn register_ptx(&mut self, name: &str, ptx_src: &str) -> Result<(), CudaInitError> {
442            use cudarc::nvrtc::Ptx;
443            let ptx = Ptx::from_src(ptx_src);
444            let module: Arc<CudaModule> = self
445                .ctx
446                .load_module(ptx)
447                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
448            let func: CudaFunction = module
449                .load_function(name)
450                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
451            self.modules.insert(name.to_owned(), module);
452            self.functions.insert(name.to_owned(), func);
453            Ok(())
454        }
455
456        /// Compile CUDA C source via NVRTC and register the named kernel.
457        pub fn compile_and_register(
458            &mut self,
459            name: &str,
460            cuda_c_src: &str,
461        ) -> Result<(), CudaInitError> {
462            use cudarc::nvrtc::compile_ptx;
463            let ptx = compile_ptx(cuda_c_src)
464                .map_err(|e| CudaInitError::CompilationError(format!("{e:?}")))?;
465            let module: Arc<CudaModule> = self
466                .ctx
467                .load_module(ptx)
468                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
469            let func: CudaFunction = module
470                .load_function(name)
471                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
472            self.modules.insert(name.to_owned(), module);
473            self.functions.insert(name.to_owned(), func);
474            Ok(())
475        }
476
477        /// Synchronise the default stream (block until all work completes).
478        pub fn synchronize(&self) -> Result<(), CudaInitError> {
479            self.stream
480                .synchronize()
481                .map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))
482        }
483    }
484
485    /// Convert raw `Vec<u8>` (little-endian IEEE-754) back to `Vec<f64>`.
486    pub(super) fn bytes_to_f64_vec(bytes: Vec<u8>) -> Vec<f64> {
487        if !bytes.len().is_multiple_of(8) {
488            return Vec::new();
489        }
490        bytes
491            .chunks_exact(8)
492            .filter_map(|c| <[u8; 8]>::try_from(c).ok().map(f64::from_le_bytes))
493            .collect()
494    }
495}
496
497// ── CudaBackend ───────────────────────────────────────────────────────────────
498
499/// CUDA compute backend.
500///
501/// **Without** `cuda-backend` feature: no-op stub; [`Self::try_new`] always returns
502/// [`CudaInitError::FeatureNotEnabled`].  All buffer and kernel methods operate
503/// on CPU shadows so unit tests compile and run on any platform.
504///
505/// **With** `cuda-backend` feature: real cudarc device context; [`Self::try_new`]
506/// calls `CudaContext::new(ordinal)` and returns an error if the CUDA driver is
507/// absent (e.g. on macOS or a Linux machine without an NVIDIA driver).  Buffer
508/// methods perform actual host↔device memcpy via the default stream.
509pub struct CudaBackend {
510    /// Device information (filled from driver attributes when a real context is active).
511    pub device_info: CudaDeviceInfo,
512    /// Whether a real CUDA device context is active.
513    available: bool,
514    /// CPU-side buffer shadows (used by the stub path; metadata only in real path).
515    buffers: Vec<CudaBufferEntry>,
516    /// Registered kernel names (stub path) or names of compiled functions (real path).
517    kernels: Vec<String>,
518    /// Live cudarc context — present only when `cuda-backend` feature is enabled
519    /// **and** device initialisation succeeded.
520    #[cfg(feature = "cuda-backend")]
521    real: Option<real_ctx::CudaRealContext>,
522}
523
524// ── Common constructor helpers ────────────────────────────────────────────────
525
526impl CudaBackend {
527    /// Attempt to create a CUDA backend on device `ordinal`.
528    ///
529    /// - **Without** `cuda-backend` feature: always returns
530    ///   `Err(CudaInitError::FeatureNotEnabled)`.
531    /// - **With** `cuda-backend` feature: calls `CudaContext::new(ordinal)`.
532    ///   Returns `Err(CudaInitError::DeviceError(...))` if the CUDA driver is
533    ///   absent or the ordinal is invalid.
534    pub fn try_new(ordinal: u32) -> Result<Self, CudaInitError> {
535        #[cfg(feature = "cuda-backend")]
536        {
537            Self::try_new_real(ordinal)
538        }
539        #[cfg(not(feature = "cuda-backend"))]
540        {
541            let _ = ordinal;
542            Err(CudaInitError::FeatureNotEnabled)
543        }
544    }
545
546    /// Create a CPU-fallback stub (useful for unit testing without a GPU).
547    pub fn new_stub() -> Self {
548        Self {
549            device_info: CudaDeviceInfo {
550                name: "CPU stub".into(),
551                ..Default::default()
552            },
553            available: false,
554            buffers: Vec::new(),
555            kernels: Vec::new(),
556            #[cfg(feature = "cuda-backend")]
557            real: None,
558        }
559    }
560
561    /// True if a real CUDA device context is active.
562    pub fn is_available(&self) -> bool {
563        self.available
564    }
565
566    /// Device information.
567    pub fn device_info(&self) -> &CudaDeviceInfo {
568        &self.device_info
569    }
570
571    // ── Buffer management ────────────────────────────────────────────────────
572
573    /// Allocate a device buffer that can hold `len` `f64` values.
574    ///
575    /// Real path: calls `CudaStream::alloc_zeros::<u8>(len * 8)` and stores
576    /// the returned `CudaSlice<u8>`.  Falls back to a CPU-shadow buffer when
577    /// no real context is active.
578    pub fn create_buffer(&mut self, len: usize) -> CudaBufferHandle {
579        let handle = CudaBufferHandle(self.buffers.len());
580
581        #[cfg(feature = "cuda-backend")]
582        if let Some(ctx) = self.real.as_mut() {
583            let byte_len = len * std::mem::size_of::<f64>();
584            // If real allocation fails, degrade gracefully to CPU shadow.
585            if ctx.alloc_bytes(byte_len).is_ok() {
586                self.buffers.push(CudaBufferEntry {
587                    len,
588                    shadow: Vec::new(), // no CPU shadow in real path
589                    _unified: false,
590                });
591                return handle;
592            }
593        }
594
595        self.buffers.push(CudaBufferEntry {
596            len,
597            shadow: vec![0.0; len],
598            _unified: false,
599        });
600        handle
601    }
602
603    /// Allocate a **unified memory** buffer (accessible from both CPU and GPU).
604    ///
605    /// In the current implementation unified memory is backed by the same
606    /// `CudaSlice<u8>` path as a regular buffer; true UM page migration would
607    /// require `UnifiedSlice` from cudarc which is gated on additional CUDA
608    /// driver capabilities.  Falls back to a CPU-shadow buffer in the stub.
609    pub fn alloc_unified(&mut self, len: usize) -> CudaBufferHandle {
610        let handle = CudaBufferHandle(self.buffers.len());
611
612        #[cfg(feature = "cuda-backend")]
613        if let Some(ctx) = self.real.as_mut() {
614            let byte_len = len * std::mem::size_of::<f64>();
615            if ctx.alloc_bytes(byte_len).is_ok() {
616                self.buffers.push(CudaBufferEntry {
617                    len,
618                    shadow: Vec::new(),
619                    _unified: true,
620                });
621                return handle;
622            }
623        }
624
625        self.buffers.push(CudaBufferEntry {
626            len,
627            shadow: vec![0.0; len],
628            _unified: true,
629        });
630        handle
631    }
632
633    /// Upload `data` to the device buffer at `handle`.
634    ///
635    /// Real path: `CudaStream::memcpy_htod` — synchronous on the default stream.
636    /// Stub path: copies into the CPU shadow.
637    pub fn write_buffer(&mut self, handle: CudaBufferHandle, data: &[f64]) {
638        #[cfg(feature = "cuda-backend")]
639        if let Some(ctx) = self.real.as_mut() {
640            // Attempt real memcpy; silently degrade on error.
641            let _ = ctx.write_f64_slice(handle.0, data);
642            return;
643        }
644
645        if let Some(entry) = self.buffers.get_mut(handle.0) {
646            let len = data.len().min(entry.len);
647            if entry.shadow.len() < len {
648                entry.shadow.resize(entry.len, 0.0);
649            }
650            entry.shadow[..len].copy_from_slice(&data[..len]);
651        }
652    }
653
654    /// Download data from the device buffer at `handle`.
655    ///
656    /// Real path: `CudaStream::clone_dtoh` — synchronous copy to a new `Vec<f64>`.
657    /// Stub path: returns a clone of the CPU shadow.
658    pub fn read_buffer(&self, handle: CudaBufferHandle) -> Vec<f64> {
659        #[cfg(feature = "cuda-backend")]
660        if let Some(ctx) = self.real.as_ref() {
661            return ctx.read_f64_vec(handle.0).unwrap_or_default();
662        }
663
664        self.buffers
665            .get(handle.0)
666            .map(|e| e.shadow.clone())
667            .unwrap_or_default()
668    }
669
670    // ── Kernel management ────────────────────────────────────────────────────
671
672    /// Register a PTX kernel source and associate it with `name`.
673    ///
674    /// Real path: loads the module via `CudaContext::load_module` and retrieves
675    /// the named function.  Stub path: records the name only.
676    pub fn register_kernel(&mut self, name: &str, ptx_source: &str) {
677        #[cfg(feature = "cuda-backend")]
678        if let Some(ctx) = self.real.as_mut() {
679            let _ = ctx.register_ptx(name, ptx_source);
680        }
681        // In stub path the ptx_source is intentionally not used (no NVRTC).
682        #[cfg(not(feature = "cuda-backend"))]
683        let _ = ptx_source;
684
685        if !self.kernels.contains(&name.to_owned()) {
686            self.kernels.push(name.to_string());
687        }
688    }
689
690    /// Compile a CUDA C kernel at runtime via NVRTC and register it.
691    ///
692    /// Real path: calls `cudarc::nvrtc::compile_ptx` then loads the module.
693    /// Stub path: records the name and returns `Ok(())`.
694    pub fn compile_and_register(
695        &mut self,
696        name: &str,
697        cuda_c_source: &str,
698    ) -> Result<(), CudaInitError> {
699        #[cfg(feature = "cuda-backend")]
700        if let Some(ctx) = self.real.as_mut() {
701            ctx.compile_and_register(name, cuda_c_source)?;
702            if !self.kernels.contains(&name.to_owned()) {
703                self.kernels.push(name.to_string());
704            }
705            return Ok(());
706        }
707
708        // Stub path: record name, suppress unused-var warnings
709        let _ = cuda_c_source;
710        if !self.kernels.contains(&name.to_owned()) {
711            self.kernels.push(name.to_string());
712        }
713        Ok(())
714    }
715
716    // ── Kernel launch ────────────────────────────────────────────────────────
717
718    /// Launch a registered kernel with buffer arguments only.
719    ///
720    /// # Parameters
721    ///
722    /// - `name` — kernel name as passed to [`Self::register_kernel`] or
723    ///   [`Self::compile_and_register`]
724    /// - `buffers` — buffer handles bound as kernel arguments (in order)
725    /// - `grid_x` — number of thread blocks in X dimension
726    /// - `block_x` — number of threads per block in X dimension
727    ///
728    /// For kernels that take scalar arguments (e.g. an integer particle count
729    /// or floating-point smoothing length), use [`Self::launch_with_scalars`]
730    /// instead — calling `launch` against a kernel whose signature includes
731    /// scalar parameters will pass uninitialised registers to those slots and
732    /// is undefined behaviour.
733    ///
734    /// Real path: retrieves the stored `CudaFunction` and dispatches via
735    /// `CudaStream::launch_builder`.  Currently up to two buffer arguments
736    /// are forwarded; extend as needed for higher-arity kernels.
737    ///
738    /// Stub path: no-op.
739    pub fn launch(&mut self, name: &str, buffers: &[CudaBufferHandle], grid_x: u32, block_x: u32) {
740        self.launch_with_scalars(name, buffers, &[], &[], grid_x, block_x);
741    }
742
743    /// Launch a registered kernel passing buffer **and** scalar arguments.
744    ///
745    /// Scalars are appended to the kernel argument list after the buffer
746    /// arguments in the order `i32` scalars then `f64` scalars; the kernel
747    /// signature must match that ordering exactly.
748    ///
749    /// # Parameters
750    ///
751    /// - `name` — kernel name as passed to [`Self::register_kernel`] or
752    ///   [`Self::compile_and_register`]
753    /// - `buffers` — buffer handles bound as the leading kernel arguments
754    /// - `scalars_i32` — `i32` scalars appended after the buffers
755    /// - `scalars_f64` — `f64` scalars appended after the `i32` scalars
756    /// - `grid_x` — number of thread blocks in X dimension
757    /// - `block_x` — number of threads per block in X dimension
758    ///
759    /// Stub path: no-op.
760    pub fn launch_with_scalars(
761        &mut self,
762        name: &str,
763        buffers: &[CudaBufferHandle],
764        scalars_i32: &[i32],
765        scalars_f64: &[f64],
766        grid_x: u32,
767        block_x: u32,
768    ) {
769        #[cfg(feature = "cuda-backend")]
770        if let Some(ctx) = self.real.as_mut() {
771            use cudarc::driver::{LaunchConfig, PushKernelArg};
772            let cfg = LaunchConfig {
773                grid_dim: (grid_x, 1, 1),
774                block_dim: (block_x, 1, 1),
775                shared_mem_bytes: 0,
776            };
777            let Some(func) = ctx.functions.get(name).cloned() else {
778                return;
779            };
780
781            // Current support: up to two buffer arguments.  Validate indices
782            // are in range and pairwise distinct (aliasing breaks the unsafe
783            // split below).
784            if buffers.len() > 2 {
785                return;
786            }
787            for (i, h) in buffers.iter().enumerate() {
788                if h.0 >= ctx.real_buffers.len() {
789                    return;
790                }
791                for h2 in &buffers[i + 1..] {
792                    if h.0 == h2.0 {
793                        return;
794                    }
795                }
796            }
797
798            // Materialise the buffer references first (raw-pointer split),
799            // then borrow ctx.stream immutably to build the launch.  The
800            // raw-pointer derived references and ctx.stream live in disjoint
801            // fields of ctx; the borrow checker cannot see this through the
802            // pointer cast, so we rely on the manual validation above.
803            let real_ptr = ctx.real_buffers.as_mut_ptr();
804            // SAFETY: indices validated above; lifetimes do not outlive this
805            // function and we do not call any &mut ctx.real_buffers method
806            // between here and `.launch(cfg)`.
807            let buf0 = buffers.first().map(|h| unsafe { &mut *real_ptr.add(h.0) });
808            let buf1 = buffers.get(1).map(|h| unsafe { &mut *real_ptr.add(h.0) });
809
810            let mut builder = ctx.stream.launch_builder(&func);
811            if let Some(b) = buf0 {
812                builder.arg(b);
813            }
814            if let Some(b) = buf1 {
815                builder.arg(b);
816            }
817            for v in scalars_i32 {
818                builder.arg(v);
819            }
820            for v in scalars_f64 {
821                builder.arg(v);
822            }
823            let _ = unsafe { builder.launch(cfg) };
824            return;
825        }
826        // Stub: no-op
827        let _ = (name, buffers, scalars_i32, scalars_f64, grid_x, block_x);
828    }
829
830    /// Synchronise the device (blocks until all submitted work completes).
831    ///
832    /// Real path: `CudaStream::synchronize()`.
833    /// Stub path: immediate return.
834    pub fn synchronize(&mut self) {
835        #[cfg(feature = "cuda-backend")]
836        if let Some(ctx) = self.real.as_ref() {
837            let _ = ctx.synchronize();
838        }
839    }
840
841    // ── Device query ─────────────────────────────────────────────────────────
842
843    /// Return the number of CUDA devices available on this system.
844    ///
845    /// Real path: calls `cudarc::driver::result::device::get_count()`.
846    /// Stub path: always returns `0`.
847    pub fn device_count() -> u32 {
848        #[cfg(feature = "cuda-backend")]
849        {
850            // cudarc panics on dlopen failure with dynamic-loading; catch it.
851            let count = std::panic::catch_unwind(|| {
852                cudarc::driver::result::init()
853                    .ok()
854                    .and_then(|()| cudarc::driver::result::device::get_count().ok())
855                    .map(|n| n as u32)
856                    .unwrap_or(0)
857            });
858            count.unwrap_or(0)
859        }
860        #[cfg(not(feature = "cuda-backend"))]
861        {
862            0
863        }
864    }
865
866    /// Query device attributes for device `ordinal` without creating a backend.
867    ///
868    /// Stub path: always returns `Err(CudaInitError::NotAvailable)`.
869    /// Real path: returns basic info derived from the driver (name, total mem, CC).
870    pub fn query_device_info(ordinal: u32) -> Result<CudaDeviceInfo, CudaInitError> {
871        #[cfg(feature = "cuda-backend")]
872        {
873            use cudarc::driver::result;
874            result::init().map_err(|e| CudaInitError::DeviceError(format!("{e:?}")))?;
875            let dev = result::device::get(ordinal as i32)
876                .map_err(|_| CudaInitError::DeviceOrdinalOutOfRange(ordinal))?;
877            let name = result::device::get_name(dev).unwrap_or_else(|_| "unknown".to_owned());
878            let total_mem = unsafe { result::device::total_mem(dev) }.unwrap_or(0);
879            Ok(CudaDeviceInfo {
880                ordinal,
881                name,
882                total_mem_bytes: total_mem as u64,
883                ..Default::default()
884            })
885        }
886        #[cfg(not(feature = "cuda-backend"))]
887        {
888            let _ = ordinal;
889            Err(CudaInitError::NotAvailable)
890        }
891    }
892}
893
894// ── Real-path constructor (feature-gated) ─────────────────────────────────────
895
896#[cfg(feature = "cuda-backend")]
897impl CudaBackend {
898    /// Initialise a real CUDA backend on device `ordinal` using cudarc 0.19.
899    ///
900    /// Called by [`try_new`] when the `cuda-backend` feature is active.
901    fn try_new_real(ordinal: u32) -> Result<Self, CudaInitError> {
902        use cudarc::driver::result;
903
904        // cudarc with `dynamic-loading` **panics** at the dlopen stage when no
905        // CUDA shared library is found on the system (e.g. on macOS or a
906        // machine without an NVIDIA driver).  Catch that panic and convert it
907        // into a clean `Err(DeviceError(...))` so callers can handle it without
908        // unwinding the test process.
909        let init_result = std::panic::catch_unwind(result::init);
910        match init_result {
911            Ok(Ok(())) => {}
912            Ok(Err(e)) => {
913                return Err(CudaInitError::DeviceError(format!("{e:?}")));
914            }
915            Err(_payload) => {
916                // cudarc panicked during dlopen — CUDA driver not present.
917                return Err(CudaInitError::NotAvailable);
918            }
919        }
920
921        let dev = result::device::get(ordinal as i32)
922            .map_err(|_| CudaInitError::DeviceOrdinalOutOfRange(ordinal))?;
923
924        // Query basic device info before acquiring the context.
925        let name = result::device::get_name(dev).unwrap_or_else(|_| "unknown".to_owned());
926        // SAFETY: `dev` was returned by `result::device::get`, fulfilling the contract.
927        let total_mem = unsafe { result::device::total_mem(dev) }.unwrap_or(0);
928
929        let real = real_ctx::CudaRealContext::new(ordinal)?;
930
931        Ok(Self {
932            device_info: CudaDeviceInfo {
933                ordinal,
934                name,
935                total_mem_bytes: total_mem as u64,
936                ..Default::default()
937            },
938            available: true,
939            buffers: Vec::new(),
940            kernels: Vec::new(),
941            real: Some(real),
942        })
943    }
944}
945
946// ── Debug impl ────────────────────────────────────────────────────────────────
947
948impl std::fmt::Debug for CudaBackend {
949    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
950        f.debug_struct("CudaBackend")
951            .field("device", &self.device_info.name)
952            .field("available", &self.available)
953            .field("buffers", &self.buffers.len())
954            .field("kernels", &self.kernels.len())
955            .finish()
956    }
957}
958
959// ── tests ─────────────────────────────────────────────────────────────────────
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn test_try_new_behaviour() {
967        // Without the `cuda-backend` feature, `try_new` must fail with
968        // `FeatureNotEnabled`.
969        //
970        // With the `cuda-backend` feature on a machine without a CUDA driver,
971        // it must fail with `NotAvailable` / `DeviceError` (and not panic).
972        //
973        // With the `cuda-backend` feature on a machine *with* a working CUDA
974        // driver and at least one device, it returns `Ok` and the backend
975        // must report itself as available.  All three outcomes are valid;
976        // the contract is "no panic and outcome consistent with environment".
977        let result = CudaBackend::try_new(0);
978        #[cfg(not(feature = "cuda-backend"))]
979        {
980            assert!(matches!(result, Err(CudaInitError::FeatureNotEnabled)));
981        }
982        #[cfg(feature = "cuda-backend")]
983        {
984            match result {
985                Ok(b) => assert!(b.is_available()),
986                Err(_) => { /* no CUDA driver / device on this machine — OK */ }
987            }
988        }
989    }
990
991    #[test]
992    fn test_stub_backend_buffer_roundtrip() {
993        let mut b = CudaBackend::new_stub();
994        let h = b.create_buffer(8);
995        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0_f64];
996        b.write_buffer(h, &data);
997        let out = b.read_buffer(h);
998        assert_eq!(out, data);
999    }
1000
1001    #[test]
1002    fn test_stub_kernel_registration() {
1003        let mut b = CudaBackend::new_stub();
1004        b.register_kernel("sph_density", PTX_SPH_DENSITY);
1005        assert_eq!(b.kernels.len(), 1);
1006        assert_eq!(b.kernels[0], "sph_density");
1007    }
1008
1009    #[test]
1010    fn test_stub_unified_alloc() {
1011        let mut b = CudaBackend::new_stub();
1012        let h = b.alloc_unified(16);
1013        b.write_buffer(h, &[std::f64::consts::PI; 16]);
1014        let out = b.read_buffer(h);
1015        assert!((out[0] - std::f64::consts::PI).abs() < 1e-10);
1016        // Verify the entry is marked as unified
1017        assert!(b.buffers[h.0]._unified);
1018    }
1019
1020    #[test]
1021    fn test_device_count_environment_consistent() {
1022        // Without the `cuda-backend` feature the count is always 0.
1023        // With the feature the count reflects the host: 0 on machines without
1024        // a CUDA driver, >=1 on machines with one or more CUDA devices.  In
1025        // either case the call must not panic.
1026        let count = CudaBackend::device_count();
1027        #[cfg(not(feature = "cuda-backend"))]
1028        {
1029            assert_eq!(count, 0);
1030        }
1031        #[cfg(feature = "cuda-backend")]
1032        {
1033            // Just exercise the path — any non-panicking result is acceptable.
1034            let _ = count;
1035        }
1036    }
1037
1038    #[test]
1039    fn test_compile_and_register() {
1040        let mut b = CudaBackend::new_stub();
1041        let result = b.compile_and_register("scan", PTX_PARALLEL_SCAN);
1042        assert!(result.is_ok());
1043        assert_eq!(b.kernels[0], "scan");
1044    }
1045
1046    #[test]
1047    fn test_error_display() {
1048        let e = CudaInitError::CompilationError("undefined symbol 'foo'".into());
1049        let s = format!("{e}");
1050        assert!(s.contains("foo"));
1051    }
1052
1053    #[test]
1054    fn test_cuda_sph_density_src_not_empty() {
1055        assert!(!CUDA_SPH_DENSITY_SRC.is_empty());
1056        assert!(CUDA_SPH_DENSITY_SRC.contains("sph_density_kernel"));
1057    }
1058
1059    #[test]
1060    fn test_try_new_no_panic() {
1061        // Regardless of feature flags or hardware, try_new(0) must not panic.
1062        let _ = CudaBackend::try_new(0);
1063    }
1064}