spark_runtime/cuda_backend.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Real CUDA GPU backend using AtlasRegistry.
4//!
5//! SBIO IORouter: all CUDA operations flow through `GpuBackend`.
6//! Uses `AtlasRegistry` for kernel loading/launching and raw CUDA
7//! driver API for memory management.
8
9use std::ffi::c_void;
10
11use anyhow::{Result, bail};
12use std::sync::Arc;
13
14use atlas_core::registry::AtlasRegistry;
15
16pub mod arch_preflight;
17mod fault_probe;
18mod gpu_copy;
19mod gpu_impl;
20mod gpu_impl_graph;
21pub mod tensormap;
22
23// ── Raw CUDA driver API for memory operations ──
24
25unsafe extern "C" {
26 pub(super) fn cuMemAlloc_v2(dptr: *mut u64, bytesize: usize) -> i32;
27 pub(super) fn cuMemFree_v2(dptr: u64) -> i32;
28 pub(super) fn cuMemcpyHtoDAsync_v2(
29 dst: u64,
30 src: *const c_void,
31 bytes: usize,
32 stream: u64,
33 ) -> i32;
34 pub(super) fn cuMemcpyDtoHAsync_v2(
35 dst: *mut c_void,
36 src: u64,
37 bytes: usize,
38 stream: u64,
39 ) -> i32;
40 pub(super) fn cuMemcpyDtoDAsync_v2(dst: u64, src: u64, bytes: usize, stream: u64) -> i32;
41 pub(super) fn cuStreamSynchronize(stream: u64) -> i32;
42 pub(super) fn cuStreamQuery(stream: u64) -> i32;
43 pub(super) fn cuMemHostGetDevicePointer_v2(
44 dptr: *mut u64,
45 host: *mut std::ffi::c_void,
46 flags: u32,
47 ) -> i32;
48 pub(super) fn cuMemGetInfo_v2(free: *mut usize, total: *mut usize) -> i32;
49 /// Device of the calling context, then any `CUdevice_attribute` on it.
50 /// Used for `sm_count` (attribute 16 = MULTIPROCESSOR_COUNT).
51 pub(super) fn cuCtxGetDevice(device: *mut i32) -> i32;
52 /// The `CUdevice` for a device ORDINAL. Unlike `cuCtxGetDevice` this reads
53 /// no thread-current state — it needs `cuInit` and nothing else — so it is
54 /// the only one of the pair usable before a context is bound to the
55 /// calling thread. See `arch_preflight`.
56 pub(super) fn cuDeviceGet(device: *mut i32, ordinal: i32) -> i32;
57 pub(super) fn cuDeviceGetAttribute(pi: *mut i32, attrib: u32, dev: i32) -> i32;
58 pub(super) fn cuMemsetD8Async(dst: u64, value: u8, n: usize, stream: u64) -> i32;
59 /// Synchronous variants, used ONLY by the A55 red-zone diagnostic (`scan_redzones`),
60 /// which runs between decode steps and wants the device drained anyway.
61 pub(super) fn cuMemsetD8_v2(dst: u64, value: u8, n: usize) -> i32;
62 pub(super) fn cuMemcpyDtoH_v2(dst: *mut c_void, src: u64, bytes: usize) -> i32;
63 // CUDA graph capture/replay
64 pub(super) fn cuStreamBeginCapture(hStream: u64, mode: u32) -> i32;
65 // Capture-status query (telemetry taps must not sync/copy inside an
66 // active capture). Not declared under SCALE — its libcuda export set is
67 // minimal and an unresolved extern would break the gfx1151 link.
68 #[cfg(not(atlas_scale))]
69 pub(super) fn cuStreamIsCapturing(hStream: u64, captureStatus: *mut u32) -> i32;
70 pub(super) fn cuStreamEndCapture(hStream: u64, phGraph: *mut u64) -> i32;
71 // CUDA-graph instantiate. NVIDIA's libcuda exports the 3-arg
72 // `cuGraphInstantiateWithFlags`; SCALE's libcuda (gfx1151) exports only
73 // `cuGraphInstantiate` — same ABI `(CUgraphExec*, CUgraph, u64)`, no
74 // `WithFlags` alias. `atlas_scale` (set by build.rs from ATLAS_TARGET_HW)
75 // picks the symbol that exists so the binary links on both targets.
76 #[cfg(not(atlas_scale))]
77 pub(super) fn cuGraphInstantiateWithFlags(
78 phGraphExec: *mut u64,
79 hGraph: u64,
80 flags: u64,
81 ) -> i32;
82 #[cfg(atlas_scale)]
83 pub(super) fn cuGraphInstantiate(phGraphExec: *mut u64, hGraph: u64, flags: u64) -> i32;
84 pub(super) fn cuGraphLaunch(hGraphExec: u64, hStream: u64) -> i32;
85 pub(super) fn cuGraphExecDestroy(hGraphExec: u64) -> i32;
86 pub(super) fn cuGraphDestroy(hGraph: u64) -> i32;
87 fn cuCtxGetCurrent(pctx: *mut u64) -> i32;
88 pub(super) fn cuCtxSetCurrent(ctx: u64) -> i32;
89 pub(super) fn cuStreamCreate(phStream: *mut u64, flags: u32) -> i32;
90 // Page-locked host memory for efficient async transfers
91 pub(super) fn cuMemAllocHost_v2(pp: *mut *mut c_void, bytesize: usize) -> i32;
92 pub(super) fn cuMemFreeHost(p: *mut c_void) -> i32;
93 // Managed (unified) memory — allows over-subscription with Linux swap paging
94 pub(super) fn cuMemAllocManaged(dptr: *mut u64, bytesize: usize, flags: u32) -> i32;
95 // CUDA events for inter-stream synchronization
96 pub(super) fn cuEventCreate(phEvent: *mut u64, flags: u32) -> i32;
97 pub(super) fn cuEventRecord(hEvent: u64, hStream: u64) -> i32;
98 pub(super) fn cuStreamWaitEvent(hStream: u64, hEvent: u64, flags: u32) -> i32;
99 pub(super) fn cuEventSynchronize(hEvent: u64) -> i32;
100 pub(super) fn cuEventDestroy_v2(hEvent: u64) -> i32;
101}
102
103/// Production GPU backend wrapping AtlasRegistry + raw CUDA driver API.
104///
105/// **Owns this model's kernel modules.** The registry used to be a process
106/// singleton reached through `AtlasRegistry::get()`; it is now loaded per model
107/// and propagated from here, so a swapped-in model cannot run the previous
108/// model's kernels. Dropping the last backend unloads them.
109pub struct AtlasCudaBackend {
110 /// This model's kernel modules. `Arc` because the backend is cloned into
111 /// the layers that launch kernels.
112 registry: Arc<AtlasRegistry>,
113 /// `ATLAS_DEBUG_SYNC_KERNELS=1` — sync after every launch. Read once here
114 /// rather than per launch, and carried rather than cached in a static.
115 debug_sync_kernels: bool,
116 /// This model's kernel handles and op scratch. Dropped with the backend,
117 /// so neither can outlive the registry or context it came from.
118 op_cache: crate::op_cache::OpCache,
119 /// Every device allocation this backend made and has not freed.
120 ///
121 /// The backend is created per model (`preflight.rs`) and moved into it, so
122 /// this ledger is exactly model-scoped: what is still outstanding when the
123 /// model is torn down is what that model leaked.
124 ///
125 /// This exists because enumerating owners does not scale. The loaders
126 /// FUSE weights — `qwen35_dense.rs:98` allocates a new buffer and copies
127 /// two source tensors into it — and hand the result to a layer struct. The
128 /// sources live in `WeightStore` and are released with it; the fused copy
129 /// is owned by a `Box<dyn TransformerLayer>` and was released by nothing.
130 /// Measured on a 27B: 15.3 GB leaked per load/teardown cycle, linear
131 /// across six cycles with no plateau.
132 ///
133 /// Process-lifetime workspaces are NOT in here and must not be: CUTLASS
134 /// (`cutlass.rs:246`) and FlashInfer (`flashinfer.rs:145`) call
135 /// `cuMemAlloc_v2` directly rather than through this allocator, so freeing
136 /// the ledger cannot invalidate a static that outlives the model.
137 /// Keyed by pointer, valued by SIZE and ALLOCATING CALL SITE.
138 ///
139 /// It carried only the pointer until 2026-08-19, which made the ledger
140 /// unable to answer the one question the memory bugs keep asking: what is
141 /// using the GPU? A serve that reports 59.4 GB consumed before the KV
142 /// decision against 21.8 GB of weights has ~37 GB that no log line
143 /// attributes to anyone, and every instance of the size-a-buffer-from-a-
144 /// ceiling bug class found so far (four of them, ~64 GB) had to be located
145 /// by reading allocation code rather than by reading a number. The size is
146 /// free (the caller passes it to `cuMemAlloc_v2` already) and the site is
147 /// free (`#[track_caller]`), so anonymity here was never buying anything.
148 live_allocs: parking_lot::Mutex<std::collections::HashMap<u64, AllocRecord>>,
149 /// `ATLAS_REDZONE=<bytes>` — every live allocation's trailing guard band.
150 ///
151 /// Diagnostic for ANOMALIES A55. Each entry is
152 /// `(user_ptr, user_bytes, pad_bytes, creation_index)`; the pad occupies
153 /// `[user_ptr + user_bytes, user_ptr + user_bytes + pad_bytes)` and is filled with
154 /// `ATLAS_REDZONE_FILL` at birth. [`AtlasCudaBackend::scan_redzones`] reads them back and
155 /// reports any that changed — i.e. a kernel that wrote past the end of its buffer, which
156 /// is invisible to compute-sanitizer when the buffer is a pooled suballocation.
157 redzones: parking_lot::Mutex<Vec<RedZone>>,
158 /// Default CUDA stream handle (from the process CUDA host).
159 default_stream: u64,
160 /// CUDA context handle for cross-thread binding.
161 cuda_ctx: u64,
162}
163
164/// One allocation's trailing guard band. See [`AtlasCudaBackend::scan_redzones`].
165#[derive(Clone, Copy)]
166pub(crate) struct RedZone {
167 user_ptr: u64,
168 user_bytes: usize,
169 pad_bytes: usize,
170 idx: usize,
171}
172
173/// `ATLAS_REDZONE=<bytes>` — guard-band size, 0 (default) disables. Rounded up to 16.
174pub(crate) fn redzone_bytes() -> usize {
175 static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
176 *N.get_or_init(|| {
177 std::env::var("ATLAS_REDZONE")
178 .ok()
179 .and_then(|v| v.parse::<usize>().ok())
180 .map(|n| if n == 0 { 0 } else { n.next_multiple_of(16) })
181 .unwrap_or(0)
182 })
183}
184
185/// `ATLAS_REDZONE_MIN_IDX=<n>` — pad only allocations whose creation index is `>= n`.
186///
187/// 🔴 Not a memory optimisation, a targeting decision. GLM-5.3 loads **57,346 weight tensors**
188/// through this allocator before a single arena buffer exists; padding all of them costs ~4 GB
189/// once `cuMemAlloc`'s page granularity rounds each one up, which does not fit. It is also the
190/// wrong set: weights are READ-ONLY to every kernel, so an out-of-bounds WRITE cannot originate
191/// from one. The arena/workspace allocations that kernels write into all come after the load,
192/// so `ATLAS_REDZONE_MIN_IDX=57346` guards exactly the plausible set for ~1 MB.
193///
194/// (An out-of-bounds READ past a weight would be missed by that choice. Widen it only after
195/// the write detector comes back clean.)
196pub(crate) fn redzone_min_idx() -> usize {
197 static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
198 *N.get_or_init(|| {
199 std::env::var("ATLAS_REDZONE_MIN_IDX")
200 .ok()
201 .and_then(|v| v.parse::<usize>().ok())
202 .unwrap_or(0)
203 })
204}
205
206/// `ATLAS_REDZONE_TRACE_IDX=<n>` — dump a Rust backtrace at the allocation with this creation
207/// index, which is how a bisected index becomes a source line.
208pub(crate) fn redzone_trace_idx() -> Option<usize> {
209 static N: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
210 *N.get_or_init(|| {
211 std::env::var("ATLAS_REDZONE_TRACE_IDX")
212 .ok()
213 .and_then(|v| v.parse::<usize>().ok())
214 })
215}
216
217/// Monotonic count of allocations this process has made through `GpuBackend::alloc`.
218pub(crate) static ALLOC_SEQ: std::sync::atomic::AtomicUsize =
219 std::sync::atomic::AtomicUsize::new(0);
220
221/// `ATLAS_REDZONE_FILL=<decimal byte>` — the poison value, default `0xEE`.
222///
223/// 🔴 The VALUE is itself an experiment. If the defect is an out-of-bounds READ rather than a
224/// write, the guard band is never modified but its CONTENTS reach the model, so running the
225/// same build at two different fills and diffing the completions separates the two: a write
226/// shows up in `scan_redzones`, a read shows up as different tokens with a clean scan.
227pub(crate) fn redzone_fill() -> u8 {
228 static F: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
229 *F.get_or_init(|| {
230 std::env::var("ATLAS_REDZONE_FILL")
231 .ok()
232 .and_then(|v| v.parse::<u8>().ok())
233 .unwrap_or(0xEE)
234 })
235}
236
237impl AtlasCudaBackend {
238 /// Initialize the CUDA backend on the given GPU ordinal.
239 ///
240 /// Loads the provided PTX modules for THIS model. Use
241 /// `atlas_kernels::ptx_for_model()` or `ptx_modules()` to obtain the
242 /// correct module set. Each call produces an independent module set — the
243 /// CUDA context and stream are shared, nothing else is.
244 pub fn new(ordinal: usize, ptx_modules: &[(&'static str, &'static [u8])]) -> Result<Self> {
245 // A new model's GPU state begins here, so the run mailboxes start
246 // clean — upstream of the first kernel lookup, so the kernel audit
247 // records only this model's modules. See `crate::run_metrics`.
248 crate::run_metrics::reset_for_new_run();
249 let registry = AtlasRegistry::load(ordinal, ptx_modules)
250 .map_err(|e| anyhow::anyhow!("AtlasRegistry load failed: {e}"))?;
251 let default_stream = registry.raw_stream();
252
253 // Capture current CUDA context for cross-thread binding.
254 let mut cuda_ctx: u64 = 0;
255 let status = unsafe { cuCtxGetCurrent(&mut cuda_ctx) };
256 if status != 0 || cuda_ctx == 0 {
257 bail!("cuCtxGetCurrent failed: status {status}, ctx {cuda_ctx:#x}");
258 }
259
260 tracing::info!(
261 "AtlasCudaBackend initialized on GPU {ordinal} with {} PTX modules",
262 ptx_modules.len()
263 );
264
265 Ok(Self {
266 live_allocs: parking_lot::Mutex::new(std::collections::HashMap::new()),
267 redzones: parking_lot::Mutex::new(Vec::new()),
268 registry,
269 debug_sync_kernels: std::env::var("ATLAS_DEBUG_SYNC_KERNELS").as_deref() == Ok("1"),
270 op_cache: crate::op_cache::OpCache::new(),
271 default_stream,
272 cuda_ctx,
273 })
274 }
275
276 /// Live allocations not yet freed. The ledger already exists for teardown;
277 /// this only reads it, so a per-request leak check costs one lock.
278 pub(crate) fn live_alloc_len(&self) -> usize {
279 self.live_allocs.lock().len()
280 }
281
282 /// Register one allocation's guard band. Returns its creation index.
283 pub(crate) fn record_redzone(
284 &self,
285 user_ptr: u64,
286 user_bytes: usize,
287 pad_bytes: usize,
288 idx: usize,
289 ) {
290 self.redzones.lock().push(RedZone {
291 user_ptr,
292 user_bytes,
293 pad_bytes,
294 idx,
295 });
296 }
297
298 pub(crate) fn forget_redzone(&self, user_ptr: u64) {
299 self.redzones.lock().retain(|z| z.user_ptr != user_ptr);
300 }
301
302 /// Re-poison every guard band: `0xEE` for zones whose creation index is in `[lo, hi)`,
303 /// `0x00` for all the others.
304 ///
305 /// The BISECTION half of the A55 red-zone hunt. The zones themselves never move, so
306 /// every call leaves the device heap byte-for-byte identical and only the CONTENTS of the
307 /// guard bands change — which is exactly the variable the read detector proved matters.
308 /// Narrowing `[lo, hi)` until the completion flips names the allocation being read past.
309 pub fn poison_redzones(&self, lo: usize, hi: usize) -> anyhow::Result<()> {
310 let zones: Vec<RedZone> = self.redzones.lock().clone();
311 for z in &zones {
312 let v = if z.idx >= lo && z.idx < hi {
313 0xEEu8
314 } else {
315 0x00u8
316 };
317 let st = unsafe { cuMemsetD8_v2(z.user_ptr + z.user_bytes as u64, v, z.pad_bytes) };
318 if st != 0 {
319 anyhow::bail!("poison_redzones: cuMemsetD8_v2 failed: status {st}");
320 }
321 }
322 Ok(())
323 }
324
325 /// Read every guard band back and report the ones that no longer hold the fill byte.
326 ///
327 /// Returns the number of violated zones. Each violation is logged with the allocation's
328 /// creation index, its size, and the first byte of the pad that changed — the size is
329 /// what identifies the buffer (cross-reference the arena sizes in `BufferSizes`), and the
330 /// offset is how far past the end the writer reached.
331 ///
332 /// RE-FILLS every violated zone before returning, so a repeat offender is reported once
333 /// per scan rather than once and then forever.
334 pub fn scan_redzones(&self) -> anyhow::Result<usize> {
335 let fill = redzone_fill();
336 let zones: Vec<RedZone> = self.redzones.lock().clone();
337 let mut bad = 0usize;
338 let mut host = Vec::new();
339 for z in &zones {
340 host.clear();
341 host.resize(z.pad_bytes, 0u8);
342 let st = unsafe {
343 cuMemcpyDtoH_v2(
344 host.as_mut_ptr() as *mut std::ffi::c_void,
345 z.user_ptr + z.user_bytes as u64,
346 z.pad_bytes,
347 )
348 };
349 if st != 0 {
350 anyhow::bail!("redzone scan: cuMemcpyDtoH_v2 failed: status {st}");
351 }
352 let Some(first) = host.iter().position(|b| *b != fill) else {
353 continue;
354 };
355 let changed = host.iter().filter(|b| **b != fill).count();
356 let last = host.iter().rposition(|b| *b != fill).unwrap_or(first);
357 tracing::error!(
358 "🔴 REDZONE VIOLATION alloc#{} user_bytes={} pad={} : bytes [{}..={}] past the end were written ({} of {} pad bytes changed), first bad value {:#04x}",
359 z.idx,
360 z.user_bytes,
361 z.pad_bytes,
362 first,
363 last,
364 changed,
365 z.pad_bytes,
366 host[first],
367 );
368 bad += 1;
369 let st = unsafe { cuMemsetD8_v2(z.user_ptr + z.user_bytes as u64, fill, z.pad_bytes) };
370 if st != 0 {
371 anyhow::bail!("redzone scan: refill cuMemsetD8_v2 failed: status {st}");
372 }
373 }
374 tracing::info!(
375 "redzone scan: {} zones checked, {} violated",
376 zones.len(),
377 bad
378 );
379 Ok(bad)
380 }
381
382 /// Free every allocation this backend made and nobody released.
383 ///
384 /// The backstop for allocations no `ModelResource` covers — chiefly the
385 /// loaders' fused weights, which are owned by layer structs rather than by
386 /// any pool. Returns how many were reclaimed; since 2026-08-19 the ledger
387 /// also carries each one's size and call site, so the sweep can say how
388 /// many BYTES had no owner and name the sites they came from instead of
389 /// only counting them. A non-zero count after a clean teardown is a leak,
390 /// and the log line now points at the code that made it.
391 ///
392 /// Runs LAST in teardown, after every `ModelResource::release`, so it only
393 /// ever sees what those missed — and each `free` here has already been
394 /// removed from the ledger by `forget_alloc`, so it cannot double-free.
395 pub fn sweep_unreleased(&self) -> usize {
396 let outstanding: Vec<(u64, AllocRecord)> = self.live_allocs.lock().drain().collect();
397 let count = outstanding.len();
398 if count > 0 {
399 let bytes: usize = outstanding.iter().map(|(_, r)| r.bytes).sum();
400 // Aggregate before logging: an unreleased pool is hundreds of
401 // per-layer allocations from ONE site, and hundreds of lines
402 // would bury the site that actually needs fixing.
403 let mut by_site: std::collections::HashMap<String, (usize, usize)> =
404 std::collections::HashMap::new();
405 for (_, r) in &outstanding {
406 let e = by_site
407 .entry(format!("{}:{}", r.site.file(), r.site.line()))
408 .or_insert((0, 0));
409 e.0 += r.bytes;
410 e.1 += 1;
411 }
412 let mut rows: Vec<_> = by_site.into_iter().collect();
413 rows.sort_by(|a, b| b.1.0.cmp(&a.1.0));
414 let top: Vec<String> = rows
415 .iter()
416 .take(5)
417 .map(|(site, (b, n))| {
418 format!("{site} ({:.1} MB x{n})", *b as f64 / (1024.0 * 1024.0))
419 })
420 .collect();
421 tracing::warn!(
422 "sweep: {count} allocation(s) totalling {:.2} GB had no owner; \
423 largest sites: {}",
424 bytes as f64 / 1e9,
425 top.join(", ")
426 );
427 }
428 for (raw, _) in outstanding {
429 // Bypass `free`: the ledger is already drained, and a failure here
430 // must not abort the rest of the sweep.
431 let status = unsafe { cuMemFree_v2(raw) };
432 if status != 0 && !atlas_core::registry::is_teardown_noop(status) {
433 tracing::warn!("sweep: cuMemFree failed for {raw:#x}: status {status}");
434 }
435 }
436 count
437 }
438
439 pub fn registry(&self) -> &Arc<AtlasRegistry> {
440 &self.registry
441 }
442
443 pub(crate) fn debug_sync_kernels(&self) -> bool {
444 self.debug_sync_kernels
445 }
446}
447
448/// Last-resort reclamation for a backend that never reached model teardown.
449///
450/// A load that FAILS part-way leaves whatever it had already allocated on the
451/// ledger, and no `Model` is ever built to tear down. On a hot-swap that memory
452/// is not merely leaked, it is actively harmful: the outgoing model is already
453/// gone, and the restore then loads into a budget the dead attempt is still
454/// holding. That is not hypothetical — a 35B swap failed at kernel selection
455/// and the 27B restore died with "only 14.08 GB remains but 17.38 GB is
456/// needed", leaving the server with no model at all.
457///
458/// On the normal path this frees nothing: `Model::teardown` drains the ledger
459/// first, so the sweep finds an empty set. Freeing here is the safe case
460/// described in `atlas_core::scope` — nothing is allocating against a backend
461/// that is being dropped.
462impl Drop for AtlasCudaBackend {
463 fn drop(&mut self) {
464 let swept = self.sweep_unreleased();
465 if swept > 0 {
466 // Not necessarily a failure: a load abandoned part-way never
467 // reaches `Model::teardown`, and this is where its allocations come
468 // back. But on a model that DID serve, teardown has already drained
469 // the ledger, so anything here belongs to an owner that never
470 // registered — say which without asserting a cause the log cannot
471 // know. (An earlier wording claimed "from a load that never
472 // completed"; it fired on two perfectly healthy swaps and would
473 // have sent an operator hunting a failure that had not happened.)
474 tracing::warn!(
475 "backend drop reclaimed {swept} allocation(s) that no owner released — \
476 expected if a load was abandoned part-way, otherwise an unregistered owner"
477 );
478 }
479 }
480}
481
482// ── OOM Watchdog ────────────────────────────────────────────────────
483//
484// Background task that polls GPU free memory every `interval` and calls
485// `std::process::exit(1)` if it drops below `threshold_bytes`.
486// On GB10 unified memory, GPU OOM = system OOM = kernel freeze, so
487// killing the process early prevents unrecoverable system hangs. On a
488// discrete card the process would merely OOM rather than take the machine
489// with it, but the watchdog still has to read a device-free figure that is
490// actually the device's — see `cuda_free_memory_bytes`.
491
492/// Query GPU free memory without requiring a GpuBackend reference.
493/// Safe to call from any thread that shares the CUDA context.
494///
495/// Applies the same rule as `AtlasCudaBackend`'s `free_memory`: host
496/// `MemAvailable` stands in for the driver's figure ONLY on an INTEGRATED
497/// GPU (GB10 and friends), where `cuMemGetInfo` reports Linux MemFree and so
498/// omits reclaimable buff/cache. On a discrete card host RAM is a different
499/// pool and the substitution reports many times the card's capacity — which
500/// pinned the watchdog's reading near total host RAM, so it could never cross
501/// its threshold, and put the same fiction on the TUI's memory gauge.
502pub fn cuda_free_memory_bytes() -> Option<usize> {
503 let mut free: usize = 0;
504 let mut total: usize = 0;
505 let status = unsafe { cuMemGetInfo_v2(&mut free, &mut total) };
506 if status != 0 {
507 return None;
508 }
509 Some(polled_free_bytes(
510 free,
511 system_available_memory_bytes(),
512 current_device_is_integrated(),
513 ))
514}
515
516/// `CU_DEVICE_ATTRIBUTE_INTEGRATED` on the current context's device: true
517/// when the GPU shares the host's physical memory (GB10), false on a discrete
518/// card. Cheap enough to query per call — it is a driver-side table lookup,
519/// and free-memory queries are not on any hot path.
520///
521/// The caller must already have a current context, which every caller does:
522/// `cuMemGetInfo_v2` needs one too.
523///
524/// Fails loudly rather than guessing, like `sm_count_cu`: a wrong answer here
525/// mis-sizes the KV pool by hundreds of gigabytes in either direction. Only
526/// the poll path, which has no way to report an error, degrades to a guess —
527/// see `current_device_is_integrated`.
528///
529/// Measured 2026-09-04: attribute 18 reads 1 on NVIDIA GB10 and 0 on RTX PRO
530/// 6000 Blackwell (and 0 on every discrete datacenter part).
531/// `CU_DEVICE_ATTRIBUTE_PAGEABLE_MEMORY_ACCESS` (99) reads 1 on BOTH, so it
532/// does NOT discriminate and must not be used here.
533pub(crate) fn device_is_integrated() -> Result<bool> {
534 const CU_DEVICE_ATTRIBUTE_INTEGRATED: u32 = 18;
535 let mut dev: i32 = 0;
536 let status = unsafe { cuCtxGetDevice(&mut dev) };
537 if status != 0 {
538 bail!("cuCtxGetDevice failed: status {status}");
539 }
540 let mut integrated: i32 = 0;
541 let status =
542 unsafe { cuDeviceGetAttribute(&mut integrated, CU_DEVICE_ATTRIBUTE_INTEGRATED, dev) };
543 if status != 0 {
544 bail!("cuDeviceGetAttribute(INTEGRATED) failed: status {status}");
545 }
546 Ok(integrated != 0)
547}
548
549/// [`device_is_integrated`] in the `Option` style `cuda_free_memory_bytes`
550/// uses: `None` when the driver would not answer. This path cannot `bail!`
551/// like `free_memory` does — its whole contract is `Option<usize>`, and the
552/// watchdog polls it in a loop where a hard error has nowhere to go.
553fn current_device_is_integrated() -> Option<bool> {
554 device_is_integrated().ok()
555}
556
557/// Free device memory to report from a poll, where the integrated/discrete
558/// answer may be missing.
559///
560/// `None` is treated as NOT integrated: substituting host RAM on a discrete
561/// card inflates the reading by orders of magnitude and disarms the watchdog,
562/// while declining to substitute on an integrated one merely under-reports by
563/// the reclaimable buff/cache — an early exit is recoverable, a watchdog that
564/// never fires is not. Never inflate device free memory on a guess.
565pub(crate) fn polled_free_bytes(
566 cu_free: usize,
567 mem_available: Option<usize>,
568 integrated: Option<bool>,
569) -> usize {
570 effective_free_bytes(cu_free, mem_available, integrated.unwrap_or(false))
571}
572
573/// Free device memory to report, given the driver's figure, host
574/// `MemAvailable`, and whether the device is integrated.
575///
576/// Host memory may stand in for device memory on an INTEGRATED GPU ONLY,
577/// where the two are one physical pool. On a discrete GPU they are unrelated,
578/// and substituting host RAM reports a free figure many times the card's
579/// capacity. Pure so the rule is testable without a GPU.
580pub(crate) fn effective_free_bytes(
581 cu_free: usize,
582 mem_available: Option<usize>,
583 integrated: bool,
584) -> usize {
585 match mem_available {
586 Some(avail) if integrated => cu_free.max(avail),
587 _ => cu_free,
588 }
589}
590
591/// Read MemAvailable from /proc/meminfo (Linux only).
592/// Returns None on non-Linux or if parsing fails.
593fn system_available_memory_bytes() -> Option<usize> {
594 let contents = std::fs::read_to_string("/proc/meminfo").ok()?;
595 for line in contents.lines() {
596 if line.starts_with("MemAvailable:") {
597 let kb: usize = line.split_whitespace().nth(1)?.parse().ok()?;
598 return Some(kb * 1024);
599 }
600 }
601 None
602}
603
604/// Start a background OOM watchdog that polls GPU memory every `interval`.
605/// If free memory drops below `threshold_mb` MB, the process exits immediately.
606///
607/// Returns a `tokio::task::JoinHandle` — drop it to stop the watchdog (on shutdown).
608/// Whether the watchdog is already running.
609///
610/// STATIC, DELIBERATELY — process lifecycle. The watchdog polls DEVICE free
611/// memory, which is a property of the process and its GPU, not of any model:
612/// one is correct for the whole process no matter how many models come and go.
613/// It is nonetheless spawned from inside the model-dependent startup range
614/// (after GPU init, which it needs for a context), so a second load would
615/// otherwise start a second watchdog polling the same number and logging the
616/// same warning twice. Guarding at the source rather than at the call site
617/// means a future swap path cannot get this wrong by forgetting.
618static WATCHDOG_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
619
620/// Start the OOM watchdog, or return `None` if one is already running.
621pub fn spawn_oom_watchdog(
622 threshold_mb: usize,
623 interval: std::time::Duration,
624) -> Option<tokio::task::JoinHandle<()>> {
625 if WATCHDOG_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) {
626 return None;
627 }
628 let threshold_bytes = threshold_mb * 1024 * 1024;
629 Some(tokio::spawn(async move {
630 let mut tick = tokio::time::interval(interval);
631 // Track consecutive low-memory readings to avoid false positives
632 // during transient allocation spikes.
633 let mut consecutive_low = 0u32;
634 loop {
635 tick.tick().await;
636 if let Some(free) = cuda_free_memory_bytes() {
637 if free < threshold_bytes {
638 consecutive_low += 1;
639 let free_mb = free / (1024 * 1024);
640 tracing::error!(
641 "OOM watchdog: GPU free memory critically low: {} MB (threshold: {} MB) [{}/3]",
642 free_mb,
643 threshold_mb,
644 consecutive_low,
645 );
646 if consecutive_low >= 3 {
647 tracing::error!(
648 "OOM watchdog: 3 consecutive readings below threshold. \
649 Terminating to prevent system freeze."
650 );
651 // Flush logs before exit
652 std::process::exit(1);
653 }
654 } else {
655 consecutive_low = 0;
656 }
657 }
658 }
659 }))
660}
661
662#[path = "cuda_backend/alloc_ledger.rs"]
663mod alloc_ledger;
664use alloc_ledger::AllocRecord;
665
666#[cfg(test)]
667mod tests;