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;