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;