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}