atlas_core/registry.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Global kernel registry — load PTX once, cache modules/functions/streams.
4//!
5//! Eliminates ~0.06-0.26ms overhead per kernel call from:
6//! - CudaContext::new (driver init)
7//! - CudaContext::load_module (PTX JIT compilation)
8//! - CudaContext::new_stream (stream creation)
9//! - cuModuleGetFunction (function lookup) — now cached after first call
10//!
11//! Usage:
12//! let reg = AtlasRegistry::get_or_init(ordinal, &[("gemm", PTX_SRC), ...])?;
13//! let func = reg.function("gemm", "dense_gemm_tc_bf16")?;
14//! unsafe { reg.stream.launch_builder(&func).arg(&ptr).launch(cfg)?; }
15//! reg.stream.synchronize()?;
16
17use std::collections::HashMap;
18use std::ffi::{CString, c_void};
19use std::sync::{Arc, OnceLock};
20
21use cudarc::driver::{CudaContext, CudaFunction, CudaModule, CudaStream, LaunchConfig};
22use cudarc::nvrtc::Ptx;
23
24pub use crate::cuda_host::{CudaHost, host, release};
25use crate::error::{AtlasError, Result};
26
27// Raw CUDA driver API. (`cuModuleLoadData`/`cuModuleUnload` left this list
28// when the raw handles became views into the cudarc-loaded modules — the
29// registry no longer loads or unloads anything through the raw API.)
30unsafe extern "C" {
31 fn cuModuleGetFunction(hfunc: *mut *mut c_void, hmod: *mut c_void, name: *const i8) -> i32;
32 fn cuLaunchKernel(
33 f: *mut c_void,
34 gridDimX: u32,
35 gridDimY: u32,
36 gridDimZ: u32,
37 blockDimX: u32,
38 blockDimY: u32,
39 blockDimZ: u32,
40 sharedMemBytes: u32,
41 hStream: *mut c_void,
42 kernelParams: *mut *mut c_void,
43 extra: *mut *mut c_void,
44 ) -> i32;
45 fn cuFuncSetAttribute(hfunc: *mut c_void, attrib: i32, value: i32) -> i32;
46 fn cuGetErrorName(error: i32, pStr: *mut *const i8) -> i32;
47 fn cuGetErrorString(error: i32, pStr: *mut *const i8) -> i32;
48 // Resolve a `__device__` symbol in a loaded CUmodule into a device pointer
49 // + size in bytes. Used by drivers that need to read/write device globals
50 // (e.g. InnerQ calibration state) without round-tripping through a kernel.
51 fn cuModuleGetGlobal_v2(
52 dptr: *mut u64,
53 bytes: *mut usize,
54 hmod: *mut c_void,
55 name: *const i8,
56 ) -> i32;
57 fn cuMemcpyHtoDAsync_v2(dst: u64, src: *const c_void, bytes: usize, stream: u64) -> i32;
58 fn cuMemcpyDtoHAsync_v2(dst: *mut c_void, src: u64, bytes: usize, stream: u64) -> i32;
59 fn cuStreamSynchronize(stream: u64) -> i32;
60}
61
62/// Resolve a CUresult status code into `"<NAME>: <description>"` via
63/// cuGetErrorName + cuGetErrorString. Returns "CUDA_UNKNOWN" / "(no message)"
64/// if the driver doesn't recognize the code.
65pub fn cuda_error_text(status: i32) -> String {
66 use std::ffi::CStr;
67 let mut name_ptr: *const i8 = std::ptr::null();
68 let mut msg_ptr: *const i8 = std::ptr::null();
69 let name = unsafe {
70 if cuGetErrorName(status, &mut name_ptr) == 0 && !name_ptr.is_null() {
71 CStr::from_ptr(name_ptr as *const std::os::raw::c_char)
72 .to_string_lossy()
73 .into_owned()
74 } else {
75 "CUDA_UNKNOWN".to_string()
76 }
77 };
78 let msg = unsafe {
79 if cuGetErrorString(status, &mut msg_ptr) == 0 && !msg_ptr.is_null() {
80 CStr::from_ptr(msg_ptr as *const std::os::raw::c_char)
81 .to_string_lossy()
82 .into_owned()
83 } else {
84 "(no message)".to_string()
85 }
86 };
87 format!("{name} ({status}): {msg}")
88}
89
90/// `CUDA_ERROR_DEINITIALIZED`. The driver tears the primary context down in its
91/// own `atexit` handler, which can run before our `Drop` impls do. Every
92/// module unload and every host free then reports this code.
93///
94/// It is **not a failure**: a module cannot leak out of a context that no
95/// longer exists, and the memory it occupied went with it. Reporting 158 of
96/// them at exit is pure noise that buries anything real.
97pub const CUDA_ERROR_DEINITIALIZED: i32 = 4;
98
99/// Whether a CUresult means "the context is already gone, nothing to do".
100///
101/// Also covers `CUDA_ERROR_INVALID_CONTEXT` (201) and
102/// `CUDA_ERROR_CONTEXT_IS_DESTROYED` (709), which arrive by the same route
103/// depending on how far the driver got before we ran.
104pub fn is_teardown_noop(status: i32) -> bool {
105 matches!(status, CUDA_ERROR_DEINITIALIZED | 201 | 709)
106}
107
108/// Wrapper for raw CUfunction handle (Send+Sync safe — handles are context-wide).
109#[derive(Clone, Copy)]
110pub struct RawCudaFunc(pub *mut c_void);
111// SAFETY: CUfunction handles returned by `cuModuleGetFunction` remain valid
112// for the lifetime of the owning CUcontext (the Atlas registry binds the
113// process-wide context once at startup and never destroys it). The handle
114// itself is opaque metadata — actual kernel launches go through cuLaunchKernel
115// with caller-supplied stream synchronisation, so `Sync` does not imply
116// concurrent execution, only concurrent reads of an immutable pointer.
117unsafe impl Send for RawCudaFunc {}
118unsafe impl Sync for RawCudaFunc {}
119
120/// The PTX/CUBIN modules for **one** loaded model.
121///
122/// Model-scoped: the blob set comes from `atlas_kernels::ptx_for_model`, so it
123/// changes with the checkpoint. Previously this was fused into a process
124/// `OnceLock` singleton whose `get_or_init(ordinal, kernel_blobs)` silently
125/// discarded the second caller's blobs — a swapped-in model would have run the
126/// *previous* model's kernels with no error at all.
127///
128/// Obtain one with [`AtlasRegistry::load`] and propagate it (`Arc<AtlasRegistry>`);
129/// there is deliberately no global accessor. Dropping the last handle unloads
130/// the modules.
131pub struct AtlasRegistry {
132 host: Arc<CudaHost>,
133 modules: HashMap<&'static str, Arc<CudaModule>>,
134 /// Raw CUmodule handles for direct cuLaunchKernel access.
135 raw_modules: HashMap<&'static str, *mut c_void>,
136}
137
138impl Drop for AtlasRegistry {
139 /// Unloads this model's modules. Reached when the last `Arc` handle goes,
140 /// which — because there is no global accessor — happens exactly when the
141 /// owning run ends.
142 fn drop(&mut self) {
143 let failures = self.unload_raw();
144 if !failures.is_empty() {
145 // No `tracing` in atlas-core's dependency budget, and a `Drop` has
146 // nowhere to return an error to. `release` below is the path that
147 // reports properly; this is the backstop.
148 eprintln!(
149 "atlas: {} module(s) failed to unload: {}",
150 failures.len(),
151 failures.join("; ")
152 );
153 }
154 }
155}
156
157// SAFETY: Same rationale as `RawCudaFunc`: the `raw_modules` map holds
158// CUmodule handles obtained at startup from a single CUcontext. The map is
159// populated once during registry init and is read-only from that point on,
160// so concurrent reads are race-free at the Rust level. CUDA itself
161// serializes kernel launches via the stream the caller supplies — this impl
162// only asserts that the *handle metadata* is shareable across threads.
163unsafe impl Send for AtlasRegistry {}
164unsafe impl Sync for AtlasRegistry {}
165
166impl AtlasRegistry {
167 /// Load this model's kernel modules into the process CUDA context.
168 ///
169 /// Each call produces a fresh, independent module set; nothing is shared
170 /// with a previously loaded model except the context and stream.
171 pub fn load(
172 ordinal: usize,
173 kernel_blobs: &[(&'static str, &'static [u8])],
174 ) -> Result<Arc<Self>> {
175 Ok(Arc::new(Self::init(host(ordinal)?, kernel_blobs)?))
176 }
177
178 /// The process CUDA context this registry's modules live in.
179 pub fn host(&self) -> &Arc<CudaHost> {
180 &self.host
181 }
182
183 pub fn ctx(&self) -> &Arc<CudaContext> {
184 &self.host.ctx
185 }
186
187 pub fn stream(&self) -> &Arc<CudaStream> {
188 &self.host.stream
189 }
190
191 /// Module names this registry loaded, for diagnostics.
192 pub fn module_names(&self) -> impl Iterator<Item = &'static str> + '_ {
193 self.modules.keys().copied()
194 }
195
196 fn init(
197 host: Arc<CudaHost>,
198 kernel_blobs: &[(&'static str, &'static [u8])],
199 ) -> Result<AtlasRegistry> {
200 let ctx = &host.ctx;
201
202 let mut modules = HashMap::new();
203 let mut raw_modules = HashMap::new();
204 for &(name, blob) in kernel_blobs {
205 // NVIDIA emits PTX (ASCII text); SCALE/AMD (gfx1151) and HIP
206 // emit a binary code object (ELF / clang offload bundle).
207 // `cuModuleLoadData` accepts either, but PTX must arrive
208 // NUL-terminated (the driver JIT parses it as a C string)
209 // while a binary object is self-describing. Sniff per blob.
210 let is_binary = blob.starts_with(b"\x7fELF")
211 || blob.starts_with(b"__CLANG_OFFLOAD_BUNDLE__")
212 || std::str::from_utf8(&blob[..blob.len().min(64)]).is_err();
213
214 // Load via cudarc (safe API) — backs `function()` lookups.
215 let ptx = if is_binary {
216 Ptx::from_binary(blob.to_vec())
217 } else {
218 let src = std::str::from_utf8(blob).map_err(|e| {
219 AtlasError::ModuleLoad(format!("{name}: PTX not valid UTF-8: {e}"))
220 })?;
221 Ptx::from_src(src)
222 };
223 let module = ctx
224 .load_module(ptx)
225 .map_err(|e| AtlasError::ModuleLoad(format!("{name}: {e}")))?;
226
227 // The raw handle for launch_on_stream (which avoids cudarc's
228 // struct layouts) is the SAME module: derive it instead of
229 // JIT-compiling the blob a second time through
230 // `cuModuleLoadData`. The double load kept a second copy of
231 // every module's SASS resident and doubled driver-JIT time at
232 // boot for the entire kernel set. Lifetime: the handle is owned
233 // by the `Arc<CudaModule>` stored right beside it — `modules`
234 // and `raw_modules` live and die together in this struct, and
235 // `unload_raw` no longer unloads (cudarc's `Drop` does).
236 raw_modules.insert(name, module.cu_module_raw() as *mut c_void);
237 modules.insert(name, module);
238 }
239
240 Ok(AtlasRegistry {
241 host,
242 modules,
243 raw_modules,
244 })
245 }
246
247 /// Look up a cached function handle (cudarc safe API).
248 pub fn function(&self, module_name: &str, func_name: &str) -> Result<CudaFunction> {
249 let module = self
250 .modules
251 .get(module_name)
252 .ok_or_else(|| AtlasError::ModuleLoad(format!("Module '{module_name}' not loaded")))?;
253 module
254 .load_function(func_name)
255 .map_err(|e| AtlasError::ModuleLoad(format!("{module_name}::{func_name}: {e}")))
256 }
257
258 /// Look up a function handle with OnceLock caching (cudarc safe API).
259 pub fn function_cached(
260 &self,
261 cache: &OnceLock<CudaFunction>,
262 module_name: &str,
263 func_name: &str,
264 ) -> Result<CudaFunction> {
265 if let Some(f) = cache.get() {
266 return Ok(f.clone());
267 }
268 let func = self.function(module_name, func_name)?;
269 let _ = cache.set(func.clone());
270 Ok(func)
271 }
272
273 /// Look up a raw CUfunction handle with OnceLock caching.
274 /// Uses the raw CUDA driver API — no cudarc struct layout dependency.
275 pub fn raw_function_cached(
276 &self,
277 cache: &OnceLock<RawCudaFunc>,
278 module_name: &str,
279 func_name: &str,
280 ) -> Result<RawCudaFunc> {
281 if let Some(f) = cache.get() {
282 return Ok(*f);
283 }
284 let raw_mod = self
285 .raw_modules
286 .get(module_name)
287 .ok_or_else(|| AtlasError::ModuleLoad(format!("Module '{module_name}' not loaded")))?;
288 let c_name = CString::new(func_name).map_err(|e| {
289 AtlasError::ModuleLoad(format!("{module_name}::{func_name}: CString: {e}"))
290 })?;
291 let mut func: *mut c_void = std::ptr::null_mut();
292 let status =
293 // SAFETY: pointer cast handles the platform difference between
294 // `c_char = i8` (x86_64) and `c_char = u8` (aarch64); we use
295 // `.cast()` rather than `as *const i8` so clippy's
296 // `unnecessary_cast` is satisfied on x86_64 builds while the
297 // call still type-checks on aarch64 (Atlas's actual GB10 target).
298 unsafe { cuModuleGetFunction(&mut func, *raw_mod, c_name.as_ptr().cast()) };
299 if status != 0 {
300 return Err(AtlasError::ModuleLoad(format!(
301 "{module_name}::{func_name}: cuModuleGetFunction failed: {}",
302 cuda_error_text(status)
303 )));
304 }
305 let raw = RawCudaFunc(func);
306 let _ = cache.set(raw);
307 Ok(raw)
308 }
309
310 /// Retire the raw handles. Idempotent; `Drop` calls it.
311 ///
312 /// Since the raw map stopped being a second `cuModuleLoadData` of every
313 /// blob and became views into the cudarc-owned `modules`, there is
314 /// nothing to `cuModuleUnload` here — the `Arc<CudaModule>`s unload the
315 /// one real copy when they drop. Draining first keeps the invariant
316 /// that no raw handle survives its module: the maps are torn down
317 /// together, raw side first.
318 pub(crate) fn unload_raw(&mut self) -> Vec<String> {
319 self.raw_modules.drain().for_each(drop);
320 self.modules.drain().for_each(drop);
321 Vec::new()
322 }
323
324 /// Get the raw CUstream handle for Atlas's own stream.
325 pub fn raw_stream(&self) -> u64 {
326 self.host.stream.cu_stream() as u64
327 }
328
329 /// Resolve a `__device__` symbol in a loaded PTX module to its device
330 /// pointer + byte length. Required for drivers that read/write device
331 /// globals without launching a kernel (e.g. InnerQ calibration state).
332 /// `symbol` must be the linker-visible name — C++ namespace symbols are
333 /// Itanium-mangled (`_ZN7tq_plus14d_innerq_scaleE`).
334 pub fn device_symbol(&self, module_name: &str, symbol: &str) -> Result<(u64, usize)> {
335 let raw_mod = self
336 .raw_modules
337 .get(module_name)
338 .ok_or_else(|| AtlasError::ModuleLoad(format!("Module '{module_name}' not loaded")))?;
339 let c_sym = CString::new(symbol).map_err(|e| {
340 AtlasError::ModuleLoad(format!("{module_name}::{symbol}: CString: {e}"))
341 })?;
342 let mut dptr: u64 = 0;
343 let mut bytes: usize = 0;
344 let status =
345 unsafe { cuModuleGetGlobal_v2(&mut dptr, &mut bytes, *raw_mod, c_sym.as_ptr().cast()) };
346 if status != 0 {
347 return Err(AtlasError::ModuleLoad(format!(
348 "{module_name}::{symbol}: cuModuleGetGlobal_v2 failed: {}",
349 cuda_error_text(status)
350 )));
351 }
352 Ok((dptr, bytes))
353 }
354
355 /// Async H2D copy into a previously-resolved device pointer.
356 ///
357 /// # Safety
358 /// Caller must ensure `dst` is a valid device pointer and the bytes
359 /// pointed to by `src` outlive the copy (host buffers must persist
360 /// until the next sync on `stream`).
361 pub unsafe fn copy_h2d_async(
362 &self,
363 dst: u64,
364 src: *const c_void,
365 bytes: usize,
366 stream: u64,
367 ) -> Result<()> {
368 let status = unsafe { cuMemcpyHtoDAsync_v2(dst, src, bytes, stream) };
369 if status != 0 {
370 return Err(AtlasError::KernelLaunch(format!(
371 "cuMemcpyHtoDAsync_v2 failed: {}",
372 cuda_error_text(status)
373 )));
374 }
375 Ok(())
376 }
377
378 /// Async D2H copy from a device pointer. Same lifetime caveats as the
379 /// H2D variant.
380 ///
381 /// # Safety
382 /// Caller must keep `dst` alive until `stream` is synchronised.
383 pub unsafe fn copy_d2h_async(
384 &self,
385 dst: *mut c_void,
386 src: u64,
387 bytes: usize,
388 stream: u64,
389 ) -> Result<()> {
390 let status = unsafe { cuMemcpyDtoHAsync_v2(dst, src, bytes, stream) };
391 if status != 0 {
392 return Err(AtlasError::KernelLaunch(format!(
393 "cuMemcpyDtoHAsync_v2 failed: {}",
394 cuda_error_text(status)
395 )));
396 }
397 Ok(())
398 }
399
400 /// Block the calling thread until all prior work on `stream` completes.
401 pub fn stream_synchronize(&self, stream: u64) -> Result<()> {
402 let status = unsafe { cuStreamSynchronize(stream) };
403 if status != 0 {
404 return Err(AtlasError::KernelLaunch(format!(
405 "cuStreamSynchronize failed: {}",
406 cuda_error_text(status)
407 )));
408 }
409 Ok(())
410 }
411
412 /// Launch a kernel on a specified raw CUDA stream.
413 ///
414 /// When `stream_ptr` comes from the caller (e.g. `torch.cuda.current_stream().cuda_stream`),
415 /// this ensures kernels are captured during CUDA graph recording.
416 ///
417 /// # Safety
418 /// - `kernel_params` must contain valid pointers to arguments matching the kernel signature.
419 /// - `stream_ptr` must be a valid CUstream handle (or 0 to use Atlas's own stream).
420 /// - `raw_func` must be a valid CUfunction obtained from `raw_function_cached`.
421 pub unsafe fn launch_on_stream(
422 &self,
423 raw_func: RawCudaFunc,
424 cfg: LaunchConfig,
425 stream_ptr: u64,
426 kernel_params: &mut [*mut c_void],
427 ) -> Result<()> {
428 // Always use the caller's stream directly. When stream_ptr=0, CUDA
429 // treats it as the legacy default stream which has implicit
430 // synchronization with all other streams in the same context.
431 // Never fall back to Atlas's private stream — that breaks ordering
432 // with PyTorch operations and prevents CUDA graph capture.
433 let stream = stream_ptr;
434 // Opt in to >48KB dynamic shared memory when requested.
435 if cfg.shared_mem_bytes > 48 * 1024 {
436 const CU_FUNC_ATTRIBUTE_MAX_DYNAMIC_SHARED_SIZE_BYTES: i32 = 8;
437 let attr_status = unsafe {
438 cuFuncSetAttribute(
439 raw_func.0,
440 CU_FUNC_ATTRIBUTE_MAX_DYNAMIC_SHARED_SIZE_BYTES,
441 cfg.shared_mem_bytes as i32,
442 )
443 };
444 if attr_status != 0 {
445 return Err(AtlasError::KernelLaunch(format!(
446 "cuFuncSetAttribute(MAX_DYNAMIC_SHARED={}) failed: {}",
447 cfg.shared_mem_bytes,
448 cuda_error_text(attr_status)
449 )));
450 }
451 }
452 let status = unsafe {
453 cuLaunchKernel(
454 raw_func.0,
455 cfg.grid_dim.0,
456 cfg.grid_dim.1,
457 cfg.grid_dim.2,
458 cfg.block_dim.0,
459 cfg.block_dim.1,
460 cfg.block_dim.2,
461 cfg.shared_mem_bytes,
462 stream as *mut c_void,
463 kernel_params.as_mut_ptr(),
464 std::ptr::null_mut(),
465 )
466 };
467 if status != 0 {
468 return Err(AtlasError::KernelLaunch(format!(
469 "cuLaunchKernel failed: {} (grid=[{},{},{}], block=[{},{},{}], shared_mem={})",
470 cuda_error_text(status),
471 cfg.grid_dim.0,
472 cfg.grid_dim.1,
473 cfg.grid_dim.2,
474 cfg.block_dim.0,
475 cfg.block_dim.1,
476 cfg.block_dim.2,
477 cfg.shared_mem_bytes
478 )));
479 }
480 Ok(())
481 }
482}