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}