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