Skip to main content

gam_gpu/
device_cache.rs

1//! Shared host-side scaffolding for every cudarc-backed module under
2//! `src/gpu/*` and `src/solver/gpu/*`.
3//!
4//! Before this module existed, each device backend (`bms_flex`,
5//! `survival_flex`, `polya_gamma`, `reml_trace`, ...) carried its own
6//! near-identical copy of two patterns:
7//!
8//!   1. A power-of-two bucketed free list of reusable f64 device slices
9//!      (the per-backend `DeviceArena`).
10//!   2. A `OnceLock<Result<{module: Arc<CudaModule>}, GpuError>>` that
11//!      NVRTC-compiled one source string the first time the backend
12//!      dispatched and cached the resulting module for the process lifetime.
13//!
14//! Both are now provided here so every cudarc backend points at the same
15//! implementation. The migration is atomic: no per-backend `DeviceArena`
16//! type, no per-backend ad-hoc OnceLock, no transitional shim.
17
18#[cfg(target_os = "linux")]
19pub use linux::{DeviceArena, KeyedPtxModuleCache, PtxModuleCache, compile_ptx_arch};
20
21#[cfg(target_os = "linux")]
22mod linux {
23    use super::super::gpu_error::GpuError;
24    use crate::gpu_error::GpuResultExt;
25    use cudarc::driver::{CudaContext, CudaModule, CudaSlice, CudaStream};
26    use cudarc::nvrtc::{CompileOptions, compile_ptx_with_opts};
27    use std::collections::HashMap;
28    use std::path::Path;
29    use std::sync::{Arc, Mutex};
30
31    /// Power-of-two bucketed free list of f64 device slices.
32    ///
33    /// Allocations round the requested element count up to the next
34    /// `usize::next_power_of_two`. On drop the slab is handed back to the
35    /// arena under the same bucket via [`DeviceArena::release`]. Held under
36    /// a `Mutex` by every backend that uses it because large-scale fits
37    /// dispatch from multiple rayon workers; the mutex is only held during
38    /// `alloc` / `release`, never across kernel launches.
39    #[derive(Default)]
40    pub struct DeviceArena {
41        free: HashMap<usize, Vec<CudaSlice<f64>>>,
42    }
43
44    impl DeviceArena {
45        #[inline]
46        pub fn bucket_of(elements: usize) -> usize {
47            elements.max(1).next_power_of_two()
48        }
49
50        /// Allocate a device slice of at least `elements` f64s. Returns the
51        /// bucket size actually allocated so the caller can release into the
52        /// same bucket on drop. `label` is woven into the error message if
53        /// the underlying `alloc_zeros` fails so failures stay attributable
54        /// to the originating backend (matching the pre-extraction wording).
55        pub fn alloc(
56            &mut self,
57            stream: &Arc<CudaStream>,
58            elements: usize,
59            label: &'static str,
60        ) -> Result<(usize, CudaSlice<f64>), GpuError> {
61            let bucket = Self::bucket_of(elements);
62            if let Some(bucket_vec) = self.free.get_mut(&bucket)
63                && let Some(slot) = bucket_vec.pop()
64            {
65                return Ok((bucket, slot));
66            }
67            let fresh = stream
68                .alloc_zeros::<f64>(bucket)
69                .gpu_ctx_with(|err| format!("{label} arena alloc_zeros<{bucket}>: {err}"))?;
70            Ok((bucket, fresh))
71        }
72
73        pub fn release(&mut self, bucket: usize, slab: CudaSlice<f64>) {
74            self.free.entry(bucket).or_default().push(slab);
75        }
76    }
77
78    /// Process-wide NVRTC module cache for a single PTX source string.
79    ///
80    /// The first call to [`PtxModuleCache::get_or_compile`] compiles the
81    /// source via `cudarc::nvrtc::compile_ptx`, loads the module on the
82    /// supplied context, and stores the resulting `Arc<CudaModule>`.
83    /// Subsequent calls return the cached module without recompiling.
84    ///
85    /// The `label` is woven into the error message so the originating
86    /// backend stays identifiable in logs; the wording matches each
87    /// caller's previous bespoke `format!` so existing log assertions
88    /// continue to hold.
89    #[derive(Default)]
90    pub struct PtxModuleCache {
91        module: std::sync::OnceLock<Arc<CudaModule>>,
92    }
93
94    impl PtxModuleCache {
95        pub const fn new() -> Self {
96            Self {
97                module: std::sync::OnceLock::new(),
98            }
99        }
100
101        pub fn get(&self) -> Option<&Arc<CudaModule>> {
102            self.module.get()
103        }
104
105        /// Compile `source` and load it on `ctx` the first time; return
106        /// the cached `Arc<CudaModule>` on every subsequent call.
107        pub fn get_or_compile(
108            &self,
109            ctx: &Arc<CudaContext>,
110            label: &'static str,
111            source: &str,
112        ) -> Result<&Arc<CudaModule>, GpuError> {
113            self.get_or_load(ctx, label, || {
114                compile_ptx_with_opts(source, nvrtc_compile_options()?)
115                    .map_err(|err| {
116                        // The historical silent-CPU class: an NVRTC failure here
117                        // is swallowed by callers' `.ok()?`; count it so telemetry
118                        // can distinguish "device engaged" from "silently
119                        // declined".
120                        crate::profile::telemetry_record_cpu_fallback(format!(
121                            "{label} NVRTC compile failed: {err}"
122                        ));
123                        err
124                    })
125                    .gpu_ctx_with(|err| format!("{label} NVRTC compile failed: {err}"))
126            })
127        }
128
129        /// Load the PTX that `compile` produces on `ctx` the first time; return
130        /// the cached `Arc<CudaModule>` on every subsequent call. This is the
131        /// entry for a kernel whose NVRTC options differ from the shared ones
132        /// (a family that must disable FMA contraction, say); everything else
133        /// goes through [`Self::get_or_compile`].
134        pub fn get_or_load<F>(
135            &self,
136            ctx: &Arc<CudaContext>,
137            label: &'static str,
138            compile: F,
139        ) -> Result<&Arc<CudaModule>, GpuError>
140        where
141            F: FnOnce() -> Result<cudarc::nvrtc::Ptx, GpuError>,
142        {
143            if let Some(existing) = self.module.get() {
144                return Ok(existing);
145            }
146            let ptx = compile()?;
147            let module = ctx
148                .load_module(ptx)
149                .gpu_ctx_with(|err| format!("{label} module load failed: {err}"))?;
150            if self.module.set(module).is_err() {
151                // A concurrent compile of the same label won the race; its
152                // module is already in the slot and is the one we return.
153                log::debug!("{label} module slot already populated by a concurrent compile");
154            }
155            Ok(self
156                .module
157                .get()
158                .expect("module slot populated immediately after set"))
159        }
160    }
161
162    /// A per-key family of compiled modules: one [`CudaModule`] per value of a
163    /// kernel-shape parameter that is baked into the source (a row width `P`,
164    /// say), each compiled with the shared device-keyed NVRTC options on first
165    /// use and shared thereafter. The keyed twin of [`PtxModuleCache`].
166    pub struct KeyedPtxModuleCache<K> {
167        modules: Mutex<HashMap<K, Arc<CudaModule>>>,
168    }
169
170    impl<K: Eq + std::hash::Hash + Copy + std::fmt::Display> KeyedPtxModuleCache<K> {
171        pub fn new() -> Self {
172            Self {
173                modules: Mutex::new(HashMap::new()),
174            }
175        }
176
177        /// The module for `key`, compiling `source(key)` and loading it on
178        /// `ctx` the first time that key is seen.
179        pub fn get_or_compile<S>(
180            &self,
181            ctx: &Arc<CudaContext>,
182            key: K,
183            label: &'static str,
184            source: S,
185        ) -> Result<Arc<CudaModule>, GpuError>
186        where
187            S: FnOnce(K) -> String,
188        {
189            if let Ok(guard) = self.modules.lock() {
190                if let Some(module) = guard.get(&key) {
191                    return Ok(Arc::clone(module));
192                }
193            }
194            let ptx = compile_ptx_arch(source(key))
195                .gpu_ctx_with(|err| format!("{label} NVRTC compile failed (key={key}): {err}"))?;
196            let module = ctx
197                .load_module(ptx)
198                .gpu_ctx_with(|err| format!("{label} module load failed (key={key}): {err}"))?;
199            if let Ok(mut guard) = self.modules.lock() {
200                // A concurrent compile of the same key may have won the race;
201                // its module is the one every later caller sees.
202                return Ok(Arc::clone(guard.entry(key).or_insert(module)));
203            }
204            Ok(module)
205        }
206    }
207
208    impl<K: Eq + std::hash::Hash + Copy + std::fmt::Display> Default for KeyedPtxModuleCache<K> {
209        fn default() -> Self {
210            Self::new()
211        }
212    }
213
214    /// Compile a kernel source string to PTX with the SAME device-keyed NVRTC
215    /// options [`PtxModuleCache::get_or_compile`] uses — crucially the
216    /// `--gpu-architecture` pin (#1551), without which NVRTC defaults below
217    /// `sm_60` and rejects `atomicAdd(double*, double)`. Call sites that compile
218    /// via the bare `cudarc::nvrtc::compile_ptx` (no options) MUST route through
219    /// this instead when their kernel uses double atomics, or the device path
220    /// silently falls back to the CPU.
221    pub fn compile_ptx_arch<S: AsRef<str>>(source: S) -> Result<cudarc::nvrtc::Ptx, GpuError> {
222        compile_ptx_with_opts(source.as_ref(), nvrtc_compile_options()?)
223            .gpu_ctx_with(|err| std::format!("NVRTC compile failed: {err}"))
224    }
225
226    fn nvrtc_compile_options() -> Result<CompileOptions, GpuError> {
227        let mut opts = CompileOptions::default();
228        opts.include_paths = nvrtc_include_paths();
229        // GPU↔CPU PARITY: disable FMA contraction. NVRTC's default is
230        // `--fmad=true`, which fuses `a*b + c` into a single fused multiply-add
231        // (ONE rounding). The CPU oracle computes `a*b` then `+ c` as two
232        // SEPARATELY-rounded f64 ops. For shallow kernels the gap is ~1 ULP;
233        // for deep derivative towers (the survival/SAE seeded jets, whose
234        // Hessian + contracted third/fourth channels chain dozens of mul/add
235        // steps) the per-op FMA divergence accumulates to ~5e-8 — enough to
236        // blow a 1e-9 parity gate on a real device (measured on a V100,
237        // compute 7.0: survival_rowjet device-vs-CPU max abs diff 5.09e-8).
238        // `--use_fast_math` was already off, but that does NOT imply fmad off
239        // (use_fast_math only ADDS fmad=true; the converse default is still
240        // on). Pinning fmad=false makes every shared-options kernel
241        // bit-comparable to the separately-rounded CPU path. `Option::None`
242        // would defer to NVRTC's `true` default, so we set it explicitly.
243        opts.fmad = Some(false);
244        // #1551: pin the NVRTC virtual arch to the selected device's compute
245        // capability. Without it NVRTC defaults below sm_60, where the
246        // `atomicAdd(double*, double)` overload is absent — so kernels using
247        // double atomics (the SAE arrow/Schur PCG kernels) fail to compile and
248        // the device path silently falls back to the CPU (SAE ran at 0% GPU).
249        // `arch` is `Option<&'static str>`; `nvrtc_arch()` returns a static
250        // `compute_NN` for the device's real capability.
251        if let Some(runtime) = crate::device_runtime::GpuRuntime::resolve(crate::global_policy())? {
252            opts.arch = Some(runtime.selected_device().capability.nvrtc_arch());
253        }
254        Ok(opts)
255    }
256
257    fn nvrtc_include_paths() -> Vec<String> {
258        let mut paths = Vec::new();
259        push_existing_include_path(&mut paths, Path::new("/usr/local/cuda/include"));
260        push_existing_include_path(&mut paths, Path::new("/usr/include"));
261        push_existing_include_path(&mut paths, Path::new("/usr/include/x86_64-linux-gnu"));
262        push_gcc_include_paths(&mut paths, Path::new("/usr/lib/gcc/x86_64-linux-gnu"));
263        paths
264    }
265
266    fn push_gcc_include_paths(paths: &mut Vec<String>, root: &Path) {
267        let Ok(entries) = std::fs::read_dir(root) else {
268            return;
269        };
270        for entry in entries.flatten() {
271            push_existing_include_path(paths, &entry.path().join("include"));
272        }
273    }
274
275    fn push_existing_include_path(paths: &mut Vec<String>, path: &Path) {
276        if !path.is_dir() {
277            return;
278        }
279        let display = path.to_string_lossy().into_owned();
280        if !paths.iter().any(|existing| existing == &display) {
281            paths.push(display);
282        }
283    }
284}