Skip to main content

qualia_core_db/wgsl_forge/execute/
cuda.rs

1//! Native CUDA execution backend for the WGSL Forge differential oracle.
2//!
3//! This bridges PTX modules emitted by [`crate::wgsl_forge::emit::ptx`] onto the
4//! NVIDIA driver API via `cudarc` 0.19. It implements the same stateless
5//! [`QualiaCompute`] contract as the wgpu backend: a single persistent device
6//! slab is pre-allocated, and dispatches receive lightweight byte offsets
7//! ([`BufferView`]) rather than allocating in the hot loop.
8//!
9//! cudarc 0.19 (driver redesign): allocation, copies, module loading and launch
10//! all hang off a [`CudaContext`]/[`CudaStream`] pair. With the default
11//! `fallback-dynamic-loading` feature the crate compiles without the CUDA
12//! toolkit present and resolves libraries at runtime, so an absent toolkit
13//! degrades to a runtime [`ForgeError::GpuUnavailable`] rather than a build
14//! failure.
15
16#[cfg(feature = "cuda")]
17use std::sync::{Arc, Mutex, OnceLock};
18
19#[cfg(feature = "cuda")]
20use super::compute::QualiaCompute;
21#[cfg(feature = "cuda")]
22use super::memory::{BindingUsage, BufferView, MemoryTopology, QualiaSlabAllocator};
23#[cfg(feature = "cuda")]
24use super::oracle_ctx::OracleContext;
25#[cfg(feature = "cuda")]
26use crate::wgsl_forge::{
27    emit_shader, AdapterConstraints, AdapterIdentity, BufferAccess, BufferElement, BufferSpec,
28    ForgeError, KernelSpec, ScalarType, Schedule, TargetBackend,
29};
30#[cfg(feature = "cuda")]
31use cudarc::driver::{
32    CudaContext, CudaFunction, CudaGraph, CudaModule, CudaSlice, CudaStream, DevicePtr, DeviceRepr,
33    LaunchConfig, PushKernelArg,
34};
35
36/// 16-byte affine uniform block passed to the kernel by value.
37///
38/// The PTX emitter declares the uniform as `.param .align 4 .b8 params[16]`, so
39/// we forward the raw 16 bytes read back from the slab verbatim. `DeviceRepr`'s
40/// default `as_kernel_param` copies these bytes straight into the kernel's
41/// parameter space.
42#[cfg(feature = "cuda")]
43#[repr(C)]
44#[derive(Clone, Copy)]
45struct AffineParamsRaw {
46    bytes: [u8; 16],
47}
48
49#[cfg(feature = "cuda")]
50unsafe impl DeviceRepr for AffineParamsRaw {}
51
52#[cfg(feature = "cuda")]
53pub struct CudaComputeContext {
54    pub ctx: Arc<CudaContext>,
55    pub stream: Arc<CudaStream>,
56    /// Secondary stream for overlapping H2D parameter writes with compute.
57    /// Lazily created on first `write_view_prefetch` call to avoid overhead
58    /// when double-buffering is not used.
59    pub prefetch_stream: Option<Arc<CudaStream>>,
60    /// Cache of loaded CUDA functions keyed by (source_hash, entry_point).
61    /// Avoids redundant `load_module` JIT on every `compile_pipe!` call —
62    /// the PTX text is already cached in `NVRTC_PTX_CACHE`, but the driver
63    /// module load is a separate JIT step that was repeated per token.
64    pub module_cache:
65        Mutex<std::collections::HashMap<u64, (CudaFunction, Arc<CudaModule>, Arc<KernelSpec>)>>,
66    pub adapter: AdapterIdentity,
67    pub constraints: AdapterConstraints,
68    pub allocator: QualiaSlabAllocator,
69    pub slab: CudaSlice<u8>,
70}
71
72/// Instantiated CUDA graph owned by a prepared runtime.
73///
74/// CUDA graph objects require external serialization. Qualia stores this wrapper only inside
75/// the `MultiWeightDevice` mutex, so moving it with that device is safe while concurrent access
76/// remains impossible.
77#[cfg(feature = "cuda")]
78pub struct CapturedCudaGraph {
79    graph: CudaGraph,
80}
81
82#[cfg(feature = "cuda")]
83impl CapturedCudaGraph {
84    /// Exact number of nodes retained by the captured CUDA graph.
85    pub fn node_count(&self) -> Result<usize, ForgeError> {
86        let mut count = 0usize;
87        let status = unsafe {
88            cudarc::driver::sys::cuGraphGetNodes(
89                self.graph.cu_graph(),
90                core::ptr::null_mut(),
91                &mut count,
92            )
93        };
94        if status == cudarc::driver::sys::CUresult::CUDA_SUCCESS {
95            Ok(count)
96        } else {
97            Err(ForgeError::GpuValidation(format!(
98                "CUDA graph node query failed: {status:?}"
99            )))
100        }
101    }
102}
103
104// SAFETY: all access is serialized by `multi_weight_device()`'s mutex; cudarc's graph retains
105// the stream and context that own the captured operations.
106#[cfg(feature = "cuda")]
107unsafe impl Send for CapturedCudaGraph {}
108
109#[cfg(feature = "cuda")]
110impl CudaComputeContext {
111    pub fn new(capacity_bytes: usize) -> Result<Self, ForgeError> {
112        let ctx = CudaContext::new(0)
113            .map_err(|e| ForgeError::GpuUnavailable(format!("CUDA init failed: {:?}", e)))?;
114        // Forge owns stream ordering explicitly (`join_prefetch` before every dependent launch).
115        // cudarc's cross-stream event injection is therefore redundant and cannot be introduced
116        // while a CUDA stream is being captured.
117        //
118        // SAFETY: the slab outlives both streams; every prefetch write is joined before compute;
119        // final readback synchronizes the compute stream before the context can be dropped.
120        unsafe {
121            ctx.disable_event_tracking();
122        }
123        // CUDA graph capture is unsupported on the legacy default stream. A dedicated
124        // non-blocking stream also makes ordering ownership explicit for prepared inference.
125        let stream = ctx
126            .new_stream()
127            .map_err(|e| ForgeError::GpuUnavailable(format!("CUDA stream init failed: {e:?}")))?;
128
129        let adapter = AdapterIdentity {
130            name: "CUDA Device".to_string(),
131            vendor: 4318, // NVIDIA
132            device: 0,
133            device_type: "DiscreteGpu".to_string(),
134            backend: "CUDA".to_string(),
135            driver: "cudarc".to_string(),
136            driver_info: "0.19".to_string(),
137        };
138
139        // Conservative, honest constraints. cudarc abstracts the wgpu limit
140        // surface, so we declare the NVIDIA block ceiling and warp presence and
141        // leave cooperative-matrix (tensor-core) detection to the capability
142        // probe rather than assuming it here.
143        let constraints = AdapterConstraints {
144            max_workgroup_size_x: 1024,
145            max_invocations_per_workgroup: 1024,
146            max_workgroups_per_dimension: 65_535,
147            supports_subgroups: true,
148            // Tensor/RT-core presence depends on the specific NVIDIA part; leave
149            // false until a real compute-capability probe is wired.
150            supports_coopmat: false,
151            supports_rt_cores: false,
152            warp_size: 32, // NVIDIA
153        };
154
155        let topology = MemoryTopology::Discrete {
156            staging_required: true,
157        };
158        let allocator = QualiaSlabAllocator::new(topology, capacity_bytes);
159
160        // Pre-allocate the persistent device slab. All transient buffers are
161        // sub-ranges of this allocation, addressed by offset.
162        let slab = stream.alloc_zeros::<u8>(capacity_bytes).map_err(|e| {
163            ForgeError::GpuUnavailable(format!("Failed to allocate CUDA slab: {:?}", e))
164        })?;
165
166        Ok(Self {
167            ctx,
168            stream,
169            prefetch_stream: None,
170            module_cache: Mutex::new(std::collections::HashMap::new()),
171            adapter,
172            constraints,
173            allocator,
174            slab,
175        })
176    }
177
178    /// Begin thread-local capture on the prepared compute stream.
179    pub fn begin_graph_capture(&self) -> Result<(), ForgeError> {
180        // Capture may not inherit event-tracked dependencies from setup work. Drain all cold
181        // uploads/module preparation before establishing the graph boundary.
182        self.stream.synchronize().map_err(|e| {
183            ForgeError::GpuValidation(format!("CUDA graph pre-capture sync: {e:?}"))
184        })?;
185        self.stream
186            .begin_capture(
187                cudarc::driver::sys::CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_THREAD_LOCAL,
188            )
189            .map_err(|e| ForgeError::GpuValidation(format!("CUDA graph begin capture: {e:?}")))
190    }
191
192    /// Finish, instantiate and upload the current compute-stream capture.
193    pub fn end_graph_capture(&self) -> Result<CapturedCudaGraph, ForgeError> {
194        let graph = self
195            .stream
196            .end_capture(
197                cudarc::driver::sys::CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_USE_NODE_PRIORITY,
198            )
199            .map_err(|e| ForgeError::GpuValidation(format!("CUDA graph end capture: {e:?}")))?
200            .ok_or_else(|| ForgeError::GpuValidation("CUDA capture produced no graph".into()))?;
201        Ok(CapturedCudaGraph { graph })
202    }
203
204    /// Enqueue one replay on the graph's retained compute stream.
205    pub fn launch_graph(&self, graph: &CapturedCudaGraph) -> Result<(), ForgeError> {
206        graph
207            .graph
208            .launch()
209            .map_err(|e| ForgeError::GpuValidation(format!("CUDA graph launch: {e:?}")))
210    }
211
212    pub fn allocate_and_write(
213        &mut self,
214        data: &[u8],
215        binding: u32,
216        group: u32,
217    ) -> Result<BufferView, ForgeError> {
218        // CUDA addresses everything through one slab via raw pointers, so the
219        // wgpu usage class is not load-bearing here.
220        let view = self.allocator.allocate_transient(
221            data.len(),
222            binding,
223            group,
224            BindingUsage::StorageReadWrite,
225        )?;
226        if !data.is_empty() {
227            let mut dst = self
228                .slab
229                .slice_mut(view.offset..view.offset + view.length_bytes);
230            self.stream
231                .memcpy_htod(data, &mut dst)
232                .map_err(|e| ForgeError::GpuValidation(format!("H2D transfer failed: {:?}", e)))?;
233        }
234        Ok(view)
235    }
236
237    /// Lazily create the prefetch stream if it doesn't exist yet.
238    fn ensure_prefetch_stream(&mut self) -> Result<&Arc<CudaStream>, ForgeError> {
239        if self.prefetch_stream.is_none() {
240            let s = self
241                .ctx
242                .new_stream()
243                .map_err(|e| ForgeError::GpuUnavailable(format!("prefetch stream: {:?}", e)))?;
244            self.prefetch_stream = Some(s);
245        }
246        Ok(self.prefetch_stream.as_ref().unwrap())
247    }
248
249    /// Overwrite a device view with host bytes on the **prefetch stream**,
250    /// overlapping with compute on the primary stream. Caller must invoke
251    /// [`join_prefetch`] before launching a kernel that reads this data.
252    pub fn write_view_prefetch(
253        &mut self,
254        view: &BufferView,
255        data: &[u8],
256    ) -> Result<(), ForgeError> {
257        if data.len() > view.length_bytes {
258            return Err(ForgeError::GpuValidation(format!(
259                "write_view_prefetch overflow: {} > {}",
260                data.len(),
261                view.length_bytes
262            )));
263        }
264        if data.is_empty() {
265            return Ok(());
266        }
267        let pf_stream = self.ensure_prefetch_stream()?.clone();
268        let mut dst = self.slab.slice_mut(view.offset..view.offset + data.len());
269        pf_stream
270            .memcpy_htod(data, &mut dst)
271            .map_err(|e| ForgeError::GpuValidation(format!("prefetch H2D: {:?}", e)))?;
272        Ok(())
273    }
274
275    /// Make the compute stream wait for all outstanding prefetch-stream work.
276    /// Call this before launching a kernel that depends on prefetched data.
277    pub fn join_prefetch(&self) -> Result<(), ForgeError> {
278        if let Some(ref pf) = self.prefetch_stream {
279            self.stream
280                .join(pf)
281                .map_err(|e| ForgeError::GpuValidation(format!("join_prefetch: {:?}", e)))?;
282        }
283        Ok(())
284    }
285
286    /// Overwrite an existing device view with host bytes (no new allocation).
287    /// `data.len()` must be ≤ `view.length_bytes`.
288    pub fn write_view(&mut self, view: &BufferView, data: &[u8]) -> Result<(), ForgeError> {
289        if data.len() > view.length_bytes {
290            return Err(ForgeError::GpuValidation(format!(
291                "write_view overflow: {} > {}",
292                data.len(),
293                view.length_bytes
294            )));
295        }
296        if data.is_empty() {
297            return Ok(());
298        }
299        let mut dst = self.slab.slice_mut(view.offset..view.offset + data.len());
300        self.stream
301            .memcpy_htod(data, &mut dst)
302            .map_err(|e| ForgeError::GpuValidation(format!("H2D write_view failed: {:?}", e)))?;
303        Ok(())
304    }
305
306    pub fn allocate_transient(
307        &mut self,
308        size_bytes: usize,
309        binding: u32,
310        group: u32,
311    ) -> Result<BufferView, ForgeError> {
312        self.allocator.allocate_transient(
313            size_bytes,
314            binding,
315            group,
316            BindingUsage::StorageReadWrite,
317        )
318    }
319
320    pub fn advance_read_head(&mut self, offset: usize) {
321        self.allocator.advance_read_head(offset);
322    }
323
324    pub fn clear_transient_allocations(&mut self) {
325        self.allocator.clear();
326    }
327
328    /// See [`QualiaSlabAllocator::write_checkpoint`].
329    pub fn write_checkpoint(&self) -> u64 {
330        self.allocator.write_checkpoint()
331    }
332
333    /// See [`QualiaSlabAllocator::restore_checkpoint`].
334    pub fn restore_checkpoint(&mut self, write_count: u64) {
335        self.allocator.restore_checkpoint(write_count);
336    }
337
338    pub fn read_buffer_f32(&self, view: &BufferView) -> Result<Vec<f32>, ForgeError> {
339        let src = self
340            .slab
341            .slice(view.offset..view.offset + view.length_bytes);
342        let bytes = self
343            .stream
344            .clone_dtoh(&src)
345            .map_err(|e| ForgeError::GpuValidation(format!("D2H transfer failed: {:?}", e)))?;
346
347        let elements = view.length_bytes / std::mem::size_of::<f32>();
348        let output = bytemuck::cast_slice::<u8, f32>(&bytes)[..elements].to_vec();
349        Ok(output)
350    }
351
352    /// Copy a device view into a caller-owned `u32` slice.
353    ///
354    /// Unlike [`Self::read_buffer_f32`], this performs no host allocation. It is the decode
355    /// token-readback boundary: the four-byte copy also synchronizes all preceding stream work.
356    pub fn read_buffer_u32_into(
357        &self,
358        view: &BufferView,
359        output: &mut [u32],
360    ) -> Result<(), ForgeError> {
361        let bytes = bytemuck::cast_slice_mut(output);
362        if bytes.len() > view.length_bytes {
363            return Err(ForgeError::GpuValidation(format!(
364                "u32 readback overflow: {} > {}",
365                bytes.len(),
366                view.length_bytes
367            )));
368        }
369        let src = self.slab.slice(view.offset..view.offset + bytes.len());
370        self.stream
371            .memcpy_dtoh(&src, bytes)
372            .map_err(|e| ForgeError::GpuValidation(format!("D2H transfer failed: {e:?}")))
373    }
374
375    /// Double-precision readback, the `f64` mirror of [`Self::read_buffer_f32`]
376    /// (8 bytes/elem). Used by the native CUDA-f64 GEMM path — WGSL has no `f64`,
377    /// so this is CUDA-only by construction.
378    pub fn read_buffer_f64(&self, view: &BufferView) -> Result<Vec<f64>, ForgeError> {
379        let src = self
380            .slab
381            .slice(view.offset..view.offset + view.length_bytes);
382        let bytes = self
383            .stream
384            .clone_dtoh(&src)
385            .map_err(|e| ForgeError::GpuValidation(format!("D2H transfer failed: {:?}", e)))?;
386
387        let elements = view.length_bytes / std::mem::size_of::<f64>();
388        let output = bytemuck::cast_slice::<u8, f64>(&bytes)[..elements].to_vec();
389        Ok(output)
390    }
391}
392
393#[cfg(feature = "cuda")]
394pub struct CudaPipeline<'a> {
395    context: &'a CudaComputeContext,
396    func: CudaFunction,
397    spec: Arc<KernelSpec>,
398    // Kept alive so the loaded function's backing module is not unloaded.
399    _module: Arc<CudaModule>,
400}
401
402#[cfg(feature = "cuda")]
403impl<'a> CudaPipeline<'a> {
404    /// Emit CUDA-C for the kernel and compile it to PTX via NVRTC (mirrors the
405    /// HLSL -> DXC path), then load the resulting module.
406    pub fn compile_cuda_c(
407        context: &'a CudaComputeContext,
408        kernel: &KernelSpec,
409        schedule: Schedule,
410    ) -> Result<Self, ForgeError> {
411        let generated = emit_shader(kernel, schedule, TargetBackend::CudaC)?;
412        Self::from_source(
413            context,
414            &generated.source,
415            &kernel.entry_point,
416            kernel.clone(),
417        )
418    }
419
420    /// Compile a *raw* CUDA-C source string (entry point + storage-buffer bindings
421    /// supplied directly) to PTX via NVRTC and load it. This is for kernels that
422    /// have no portable-IR analogue — notably the `nvcuda::wmma` tensor-core GEMM,
423    /// whose f16/f32 fragment API and fixed 16x16x16 shape cannot be expressed in
424    /// WGSL/IR. `storage_buffer_bindings` lists the kernel's pointer parameters in
425    /// binding order (all treated as storage pointers; no by-value uniform).
426    pub fn compile_cuda_c_source(
427        context: &'a CudaComputeContext,
428        source: &str,
429        entry_point: &str,
430        storage_buffer_bindings: &[u32],
431    ) -> Result<Self, ForgeError> {
432        let buffers: Vec<BufferSpec> = storage_buffer_bindings
433            .iter()
434            .map(|&binding| BufferSpec {
435                group: 0,
436                binding,
437                name: format!("buf{binding}"),
438                element: BufferElement::Scalar(ScalarType::F32),
439                access: BufferAccess::StorageReadWrite,
440            })
441            .collect();
442        let spec = KernelSpec {
443            id: entry_point.to_string(),
444            semantic_version: 1,
445            entry_point: entry_point.to_string(),
446            description: "raw CUDA-C kernel".to_string(),
447            buffers,
448            ops: Vec::new(),
449            shared_memory: Vec::new(),
450        };
451        Self::from_source(context, source, entry_point, spec)
452    }
453
454    fn from_source(
455        context: &'a CudaComputeContext,
456        source: &str,
457        entry_point: &str,
458        spec: KernelSpec,
459    ) -> Result<Self, ForgeError> {
460        let ptx = nvrtc_compile_to_ptx_cached(context, source)?;
461        Self::from_ptx(context, &ptx, entry_point, spec)
462    }
463
464    /// Load a pipeline from already-compiled PTX (no NVRTC). Used by the process-wide
465    /// WMMA cache path so hot GEMM calls only pay `load_module`.
466    pub fn from_ptx(
467        context: &'a CudaComputeContext,
468        ptx: &cudarc::nvrtc::Ptx,
469        entry_point: &str,
470        spec: KernelSpec,
471    ) -> Result<Self, ForgeError> {
472        let module = context
473            .ctx
474            .load_module(ptx.clone())
475            .map_err(|e| ForgeError::GpuValidation(format!("Failed to load module: {:?}", e)))?;
476        let func = module
477            .load_function(entry_point)
478            .map_err(|e| ForgeError::GpuValidation(format!("Entry point not found: {:?}", e)))?;
479
480        Ok(Self {
481            context,
482            func,
483            spec: Arc::new(spec),
484            _module: module,
485        })
486    }
487
488    /// Compile (or reuse cached PTX for) raw CUDA-C and load — same as
489    /// [`compile_cuda_c_source`] but shares the process NVRTC cache when `source`
490    /// matches a previously compiled kernel body.
491    pub fn compile_cuda_c_source_cached(
492        context: &'a CudaComputeContext,
493        source: &str,
494        entry_point: &str,
495        storage_buffer_bindings: &[u32],
496    ) -> Result<Self, ForgeError> {
497        let src_hash = fnv1a64_bytes(source.as_bytes());
498        let cache_key = src_hash ^ fnv1a64_bytes(entry_point.as_bytes()).rotate_left(1);
499
500        // Fast path: function already loaded — skip NVRTC + load_module entirely.
501        // Zero allocations: just Arc clones (atomic increments).
502        if let Ok(guard) = context.module_cache.lock() {
503            if let Some((func, module, spec)) = guard.get(&cache_key) {
504                return Ok(Self {
505                    context,
506                    func: func.clone(),
507                    spec: spec.clone(),
508                    _module: module.clone(),
509                });
510            }
511        }
512
513        // Slow path: NVRTC compile + load_module — construct full KernelSpec.
514        let buffers: Vec<BufferSpec> = storage_buffer_bindings
515            .iter()
516            .map(|&binding| BufferSpec {
517                group: 0,
518                binding,
519                name: format!("buf{binding}"),
520                element: BufferElement::Scalar(ScalarType::F32),
521                access: BufferAccess::StorageReadWrite,
522            })
523            .collect();
524        let spec = KernelSpec {
525            id: entry_point.to_string(),
526            semantic_version: 1,
527            entry_point: entry_point.to_string(),
528            description: "raw CUDA-C kernel (cached PTX)".to_string(),
529            buffers,
530            ops: Vec::new(),
531            shared_memory: Vec::new(),
532        };
533        let ptx = nvrtc_compile_to_ptx_cached(context, source)?;
534        let pipe = Self::from_ptx(context, &ptx, entry_point, spec)?;
535
536        // Store the loaded function + spec so subsequent calls skip load_module.
537        if let Ok(mut guard) = context.module_cache.lock() {
538            guard.insert(
539                cache_key,
540                (pipe.func.clone(), pipe._module.clone(), pipe.spec.clone()),
541            );
542        }
543        Ok(pipe)
544    }
545
546    /// Load a hand-emitted PTX module (from `emit/ptx.rs`) directly into the CUDA
547    /// driver — no NVRTC compilation step. This is the PTX execution bridge:
548    /// the emitter produces complete PTX text with `.version`, `.target`,
549    /// `.address_size`, entry point, and full kernel body; the driver JITs it
550    /// to the actual GPU ISA.
551    ///
552    /// Shared-memory size is passed via `LaunchConfig.shared_mem_bytes` at
553    /// dispatch time, not at compile time.
554    pub fn compile_ptx(
555        context: &'a CudaComputeContext,
556        ptx_source: &str,
557        entry_point: &str,
558        storage_buffer_bindings: &[u32],
559    ) -> Result<Self, ForgeError> {
560        let src_hash = fnv1a64_bytes(ptx_source.as_bytes());
561        let cache_key = src_hash ^ fnv1a64_bytes(entry_point.as_bytes()).rotate_left(1);
562
563        // Fast path: function already loaded — skip load_module entirely.
564        if let Ok(guard) = context.module_cache.lock() {
565            if let Some((func, module, spec)) = guard.get(&cache_key) {
566                return Ok(Self {
567                    context,
568                    func: func.clone(),
569                    spec: spec.clone(),
570                    _module: module.clone(),
571                });
572            }
573        }
574
575        let buffers: Vec<BufferSpec> = storage_buffer_bindings
576            .iter()
577            .map(|&binding| BufferSpec {
578                group: 0,
579                binding,
580                name: format!("buf{binding}"),
581                element: BufferElement::Scalar(ScalarType::F32),
582                access: BufferAccess::StorageReadWrite,
583            })
584            .collect();
585        let spec = KernelSpec {
586            id: entry_point.to_string(),
587            semantic_version: 1,
588            entry_point: entry_point.to_string(),
589            description: "hand-emitted PTX kernel".to_string(),
590            buffers,
591            ops: Vec::new(),
592            shared_memory: Vec::new(),
593        };
594        let ptx = cudarc::nvrtc::Ptx::from_src(ptx_source.to_string());
595        let pipe = Self::from_ptx(context, &ptx, entry_point, spec)?;
596
597        if let Ok(mut guard) = context.module_cache.lock() {
598            guard.insert(
599                cache_key,
600                (pipe.func.clone(), pipe._module.clone(), pipe.spec.clone()),
601            );
602        }
603        Ok(pipe)
604    }
605}
606
607/// Compiles a CUDA-C source string to a driver-loadable PTX module via NVRTC,
608/// targeting the device's *actual* compute capability and making the CUDA toolkit
609/// headers resolvable. NVRTC's default `--include-path` search list is empty, so
610/// tensor-core kernels (`#include <mma.h>`) need the toolkit include dir passed
611/// explicitly — without it NVRTC fails with "could not open source file mma.h".
612/// Process-wide cache: (source_hash, arch, ptx_text) so NVRTC runs once per kernel/arch.
613#[cfg(feature = "cuda")]
614static NVRTC_PTX_CACHE: OnceLock<Mutex<std::collections::HashMap<(u64, String), String>>> =
615    OnceLock::new();
616
617#[cfg(feature = "cuda")]
618fn nvrtc_ptx_cache() -> &'static Mutex<std::collections::HashMap<(u64, String), String>> {
619    NVRTC_PTX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
620}
621
622/// Hash of CUDA-C source body (FNV-1a 64) for cache keys — no heap string key.
623#[cfg(feature = "cuda")]
624fn fnv1a64_bytes(data: &[u8]) -> u64 {
625    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
626    for &b in data {
627        h ^= b as u64;
628        h = h.wrapping_mul(0x0100_0000_01b3);
629    }
630    h
631}
632
633/// Compile CUDA-C → PTX with process-wide cache (key = source FNV + compute arch).
634#[cfg(feature = "cuda")]
635pub(crate) fn nvrtc_compile_to_ptx_cached(
636    context: &CudaComputeContext,
637    source: &str,
638) -> Result<cudarc::nvrtc::Ptx, ForgeError> {
639    use cudarc::nvrtc::{compile_ptx_with_opts, CompileOptions};
640    let (major, minor) = context.ctx.compute_capability().map_err(|e| {
641        ForgeError::GpuUnavailable(format!("compute-capability query failed: {:?}", e))
642    })?;
643    let arch = arch_for_capability(major, minor).to_string();
644    let key = (fnv1a64_bytes(source.as_bytes()), arch.clone());
645
646    if let Ok(guard) = nvrtc_ptx_cache().lock() {
647        if let Some(text) = guard.get(&key) {
648            return Ok(cudarc::nvrtc::Ptx::from_src(text.clone()));
649        }
650    }
651
652    let mut include_paths = Vec::new();
653    if let Ok(cuda_path) = std::env::var("CUDA_PATH") {
654        include_paths.push(format!("{cuda_path}/include"));
655    }
656    let opts = CompileOptions {
657        arch: Some(arch_for_capability(major, minor)),
658        include_paths,
659        ..Default::default()
660    };
661    let compiled = compile_ptx_with_opts(source, opts)
662        .map_err(|e| ForgeError::GpuValidation(format!("NVRTC compile failed: {:?}", e)))?;
663    // The installed nvrtc can be newer than the driver, which then rejects the PTX
664    // ISA version. Our kernels use only long-stable instructions (incl. the stable
665    // WMMA `mma.sync`), so rewrite `.version` down to one the driver supports.
666    let text = downgrade_ptx_isa(&compiled.to_src());
667    if let Ok(mut guard) = nvrtc_ptx_cache().lock() {
668        guard.insert(key, text.clone());
669        log::info!(
670            "cuda_nvrtc|cache_store|arch={arch}|src_hash={:#x}|ptx_bytes={}",
671            fnv1a64_bytes(source.as_bytes()),
672            text.len()
673        );
674    }
675    Ok(cudarc::nvrtc::Ptx::from_src(text))
676}
677
678/// Maps a CUDA compute capability to the `--gpu-architecture=compute_XX` virtual
679/// arch NVRTC should target. Floors unknown/older parts to `compute_70` — the
680/// minimum for WMMA tensor-core ops; the driver JIT-upgrades the emitted PTX to the
681/// real arch, so this stays correct (if not arch-optimal) on newer cards.
682#[cfg(feature = "cuda")]
683fn arch_for_capability(major: i32, minor: i32) -> &'static str {
684    match (major, minor) {
685        (9, 0) => "compute_90",
686        (8, 9) => "compute_89",
687        (8, 7) => "compute_87",
688        (8, 6) => "compute_86",
689        (8, 0) => "compute_80",
690        (7, 5) => "compute_75",
691        (7, 2) => "compute_72",
692        (7, 0) => "compute_70",
693        _ => "compute_70",
694    }
695}
696
697/// Rewrites the PTX `.version M.m` directive to a broadly-supported ISA so an
698/// older driver can JIT NVRTC output from a newer toolkit. Our emitted kernels
699/// use only long-stable instructions (fma, tanh.approx, shared memory, bar.sync),
700/// which are valid at ISA 8.0.
701#[cfg(feature = "cuda")]
702fn downgrade_ptx_isa(ptx: &str) -> String {
703    const TARGET_VERSION: &str = ".version 8.0";
704    let mut out = String::with_capacity(ptx.len());
705    let mut replaced = false;
706    for line in ptx.lines() {
707        if !replaced && line.trim_start().starts_with(".version") {
708            out.push_str(TARGET_VERSION);
709            replaced = true;
710        } else {
711            out.push_str(line);
712        }
713        out.push('\n');
714    }
715    out
716}
717
718#[cfg(feature = "cuda")]
719impl<'a> CudaPipeline<'a> {
720    /// Launch without a host fence. Same-stream kernels stay ordered; the next
721    /// `read_buffer_*` / `synchronize` is the completion barrier. Used by the
722    /// P4 decode attention chain to avoid one PCIe-class fence per micro-kernel.
723    pub fn dispatch_async(
724        &self,
725        buffers: &[BufferView],
726        schedule: &Schedule,
727        element_count: usize,
728    ) -> Result<(), ForgeError> {
729        self.launch_inner(buffers, schedule, element_count, false)
730            .map(|_| ())
731    }
732
733    /// Launch a PTX kernel with shared-memory size and a 3D grid/block config.
734    /// Used by hand-emitted PTX kernels (RMSNorm, Q4K GEMV, WMMA GEMV, SDPA)
735    /// that need `shared_mem_bytes` and multi-dimensional dispatch.
736    pub fn dispatch_ptx(
737        &self,
738        buffers: &[BufferView],
739        grid: (u32, u32, u32),
740        block: (u32, u32, u32),
741        shared_mem_bytes: u32,
742    ) -> Result<(), ForgeError> {
743        let cfg = LaunchConfig {
744            grid_dim: grid,
745            block_dim: block,
746            shared_mem_bytes,
747        };
748
749        let (base, _guard) = self.context.slab.device_ptr(&self.context.stream);
750        let base = base as u64;
751
752        let mut ptr_args: [u64; 16] = [0; 16];
753        let n_bufs = buffers.len().min(16);
754        for i in 0..n_bufs {
755            ptr_args[i] = base + buffers[i].offset as u64;
756        }
757
758        let mut builder = self.context.stream.launch_builder(&self.func);
759        for i in 0..n_bufs {
760            builder.arg(&ptr_args[i]);
761        }
762        unsafe {
763            builder
764                .launch(cfg)
765                .map_err(|e| ForgeError::GpuValidation(format!("PTX launch failed: {:?}", e)))?;
766        }
767        Ok(())
768    }
769
770    /// Fast-path async dispatch for pre-sorted buffer views.
771    ///
772    /// Assumes `buffers` are already in ascending binding order (as the mega-pass
773    /// always provides). Skips `spec.buffers.clone()` + sort + linear search —
774    /// eliminating 2 Vec allocations and O(n²) search per dispatch.
775    pub fn dispatch_async_sorted(
776        &self,
777        buffers: &[BufferView],
778        schedule: &Schedule,
779        element_count: usize,
780    ) -> Result<(), ForgeError> {
781        let dispatch_x = schedule.dispatch_workgroups(element_count);
782        let cfg = LaunchConfig {
783            grid_dim: (dispatch_x, 1, 1),
784            block_dim: (schedule.workgroup_size, 1, 1),
785            shared_mem_bytes: 0,
786        };
787
788        let (base, _guard) = self.context.slab.device_ptr(&self.context.stream);
789        let base = base as u64;
790
791        // Build pointer args directly from pre-sorted buffer views — no clone,
792        // no sort, no linear search. Stack array for typical binding counts.
793        let mut ptr_args: [u64; 16] = [0; 16];
794        let n_bufs = buffers.len().min(16);
795        for i in 0..n_bufs {
796            ptr_args[i] = base + buffers[i].offset as u64;
797        }
798
799        let mut builder = self.context.stream.launch_builder(&self.func);
800        for i in 0..n_bufs {
801            builder.arg(&ptr_args[i]);
802        }
803        unsafe {
804            builder
805                .launch(cfg)
806                .map_err(|e| ForgeError::GpuValidation(format!("CUDA launch failed: {:?}", e)))?;
807        }
808        Ok(())
809    }
810
811    /// Measure one pre-sorted kernel launch with CUDA events.
812    ///
813    /// This is a lab/profiling operation, not a decode hot-path primitive: creating and
814    /// synchronizing timing events intentionally fences the stream. It remains useful when
815    /// hardware performance counters are unavailable because the elapsed value is device time
816    /// rather than host submission/synchronization wall time.
817    pub fn dispatch_gpu_timed_ms_sorted(
818        &self,
819        buffers: &[BufferView],
820        schedule: &Schedule,
821        element_count: usize,
822    ) -> Result<f32, ForgeError> {
823        let dispatch_x = schedule.dispatch_workgroups(element_count);
824        let cfg = LaunchConfig {
825            grid_dim: (dispatch_x, 1, 1),
826            block_dim: (schedule.workgroup_size, 1, 1),
827            shared_mem_bytes: 0,
828        };
829
830        let (base, _guard) = self.context.slab.device_ptr(&self.context.stream);
831        let base = base as u64;
832        let mut ptr_args: [u64; 16] = [0; 16];
833        let n_bufs = buffers.len().min(16);
834        for index in 0..n_bufs {
835            ptr_args[index] = base + buffers[index].offset as u64;
836        }
837
838        let mut builder = self.context.stream.launch_builder(&self.func);
839        for ptr in ptr_args.iter().take(n_bufs) {
840            builder.arg(ptr);
841        }
842        builder.record_kernel_launch(cudarc::driver::sys::CUevent_flags::CU_EVENT_DEFAULT);
843        let events = unsafe {
844            builder.launch(cfg).map_err(|error| {
845                ForgeError::GpuValidation(format!("CUDA timed launch failed: {error:?}"))
846            })?
847        }
848        .ok_or_else(|| {
849            ForgeError::GpuValidation("CUDA timed launch returned no events".to_string())
850        })?;
851        events.0.elapsed_ms(&events.1).map_err(|error| {
852            ForgeError::GpuValidation(format!("CUDA event timing failed: {error:?}"))
853        })
854    }
855
856    fn launch_inner(
857        &self,
858        buffers: &[BufferView],
859        schedule: &Schedule,
860        element_count: usize,
861        sync: bool,
862    ) -> Result<u64, ForgeError> {
863        let dispatch_x = schedule.dispatch_workgroups(element_count);
864        let cfg = LaunchConfig {
865            grid_dim: (dispatch_x, 1, 1),
866            block_dim: (schedule.workgroup_size, 1, 1),
867            shared_mem_bytes: 0,
868        };
869
870        // Build launch args from the kernel spec, in binding order: each storage
871        // buffer becomes a device pointer, and the single uniform block is passed
872        // by value last — matching the CUDA-C signature emitted by emit_cuda_c.
873        let (base, _guard) = self.context.slab.device_ptr(&self.context.stream);
874        let base = base as u64;
875
876        let mut sorted = self.spec.buffers.clone();
877        sorted.sort_by_key(|b| b.binding);
878
879        let mut ptr_args: Vec<u64> = Vec::with_capacity(sorted.len());
880        let mut params: Option<AffineParamsRaw> = None;
881        for bspec in &sorted {
882            let view = buffers
883                .iter()
884                .find(|b| b.binding == bspec.binding)
885                .ok_or_else(|| {
886                    ForgeError::GpuValidation(format!(
887                        "CUDA dispatch missing binding {}",
888                        bspec.binding
889                    ))
890                })?;
891            if bspec.access == BufferAccess::Uniform {
892                let host = self
893                    .context
894                    .stream
895                    .clone_dtoh(&self.context.slab.slice(view.offset..view.offset + 16))
896                    .map_err(|e| {
897                        ForgeError::GpuValidation(format!("Failed to read params: {:?}", e))
898                    })?;
899                let mut blob = AffineParamsRaw { bytes: [0u8; 16] };
900                blob.bytes.copy_from_slice(&host[..16]);
901                params = Some(blob);
902            } else {
903                ptr_args.push(base + view.offset as u64);
904            }
905        }
906
907        let start = std::time::Instant::now();
908        let mut builder = self.context.stream.launch_builder(&self.func);
909        for ptr in &ptr_args {
910            builder.arg(ptr);
911        }
912        if let Some(params) = &params {
913            builder.arg(params);
914        }
915        // Safety: argument count/types match the CUDA-C signature emitted for
916        // this kernel (storage pointers in binding order, then the uniform block).
917        unsafe {
918            builder
919                .launch(cfg)
920                .map_err(|e| ForgeError::GpuValidation(format!("CUDA launch failed: {:?}", e)))?;
921        }
922
923        if sync {
924            // A post-launch synchronize failure is a device-level fault; surface it as
925            // the unified DeviceLost rather than a generic validation error (plan §7).
926            self.context
927                .stream
928                .synchronize()
929                .map_err(|e| ForgeError::DeviceLost(format!("CUDA sync failed: {:?}", e)))?;
930        }
931
932        Ok(start.elapsed().as_nanos().min(u64::MAX as u128) as u64)
933    }
934}
935
936#[cfg(feature = "cuda")]
937impl<'a> QualiaCompute for CudaPipeline<'a> {
938    fn dispatch(
939        &self,
940        buffers: &[BufferView],
941        schedule: &Schedule,
942        element_count: usize,
943    ) -> Result<u64, ForgeError> {
944        self.launch_inner(buffers, schedule, element_count, true)
945    }
946}
947
948#[cfg(feature = "cuda")]
949impl OracleContext for CudaComputeContext {
950    fn allocate_and_write(
951        &mut self,
952        data: &[u8],
953        binding: u32,
954        group: u32,
955        _usage: BindingUsage,
956    ) -> Result<BufferView, ForgeError> {
957        // CUDA addresses one slab through raw pointers, so the binding usage is not
958        // load-bearing; defer to the existing 3-arg inherent method verbatim.
959        CudaComputeContext::allocate_and_write(self, data, binding, group)
960    }
961
962    fn allocate_transient(
963        &mut self,
964        size_bytes: usize,
965        binding: u32,
966        group: u32,
967        _usage: BindingUsage,
968    ) -> Result<BufferView, ForgeError> {
969        CudaComputeContext::allocate_transient(self, size_bytes, binding, group)
970    }
971
972    fn read_buffer_f32(&self, view: &BufferView) -> Result<Vec<f32>, ForgeError> {
973        CudaComputeContext::read_buffer_f32(self, view)
974    }
975
976    fn clear_transient_allocations(&mut self) {
977        CudaComputeContext::clear_transient_allocations(self);
978    }
979
980    fn adapter(&self) -> &AdapterIdentity {
981        &self.adapter
982    }
983
984    fn constraints(&self) -> &AdapterConstraints {
985        &self.constraints
986    }
987
988    fn timestamp_supported(&self) -> bool {
989        // The CUDA backend times on the host wall clock (see [`CudaPipeline::dispatch`]);
990        // there is no GPU-timestamp query path here.
991        false
992    }
993
994    /// Compile the kernel's CUDA-C (NVRTC → PTX, emitted internally by
995    /// [`CudaPipeline::compile_cuda_c`]) and run the warmup + timed-sample dispatch
996    /// loop. Mirrors the wgpu loop shape so the generic oracle is backend-agnostic;
997    /// the cross-backend CUDA oracle uses `warmups = 0, samples = 1`, reproducing the
998    /// single dispatch the previous `evaluate_*_cuda` functions performed.
999    fn run_kernel(
1000        &mut self,
1001        kernel: &KernelSpec,
1002        schedule: &Schedule,
1003        buffers: &[BufferView],
1004        element_count: usize,
1005        warmups: usize,
1006        samples: usize,
1007    ) -> Result<Vec<u64>, ForgeError> {
1008        let pipeline = CudaPipeline::compile_cuda_c(self, kernel, *schedule)?;
1009
1010        for _ in 0..warmups {
1011            pipeline.dispatch(buffers, schedule, element_count)?;
1012        }
1013        let mut timing_samples = Vec::with_capacity(samples);
1014        for _ in 0..samples {
1015            timing_samples.push(pipeline.dispatch(buffers, schedule, element_count)?);
1016        }
1017        Ok(timing_samples)
1018    }
1019}
1020
1021#[cfg(all(test, feature = "cuda"))]
1022mod graph_tests {
1023    use super::*;
1024    use crate::specialized_libs::computational_geometry::allocation_counter::assert_zero_alloc;
1025
1026    #[test]
1027    fn captured_kernel_replays_without_host_redispatch() {
1028        let Ok(mut context) = CudaComputeContext::new(16 * 1024 * 1024) else {
1029            eprintln!("CUDA graph test skipped: CUDA context unavailable");
1030            return;
1031        };
1032        let Ok(mut value) = context.allocate_and_write(bytemuck::cast_slice(&[0u32; 1]), 0, 0)
1033        else {
1034            return;
1035        };
1036        let source = r#"
1037extern "C" __global__ void increment(unsigned *value) {
1038    if (blockIdx.x == 0u && threadIdx.x == 0u) value[0] += 1u;
1039}
1040"#;
1041        let Ok(pipeline) =
1042            CudaPipeline::compile_cuda_c_source_cached(&context, source, "increment", &[0])
1043        else {
1044            eprintln!("CUDA graph test skipped: NVRTC unavailable");
1045            return;
1046        };
1047        value.binding = 0;
1048        let schedule = Schedule {
1049            workgroup_size: 32,
1050            ..Default::default()
1051        };
1052        context.begin_graph_capture().unwrap();
1053        pipeline
1054            .dispatch_async_sorted(&[value], &schedule, 32)
1055            .unwrap();
1056        let graph = context.end_graph_capture().unwrap();
1057        assert_eq!(graph.node_count().unwrap(), 1);
1058        context.launch_graph(&graph).unwrap();
1059        context.launch_graph(&graph).unwrap();
1060        let mut output = [0u32; 1];
1061        context.read_buffer_u32_into(&value, &mut output).unwrap();
1062        assert_eq!(output[0], 2);
1063
1064        assert_zero_alloc("cuda_graph_dynamic_h2d", || {
1065            context
1066                .write_view(&value, bytemuck::cast_slice(&[0u32; 1]))
1067                .unwrap();
1068        });
1069        assert_zero_alloc("cuda_graph_launch", || {
1070            context.launch_graph(&graph).unwrap();
1071        });
1072        assert_zero_alloc("cuda_graph_token_d2h", || {
1073            context.read_buffer_u32_into(&value, &mut output).unwrap();
1074        });
1075    }
1076}