metrale_gpu_runtime/cuda_backend/
memory.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Device free-memory queries, which add host `MemAvailable` only
4//! on an integrated GPU, and the OOM watchdog that polls them.
5//!
6//! Owner: gpu-runtime (CUDA backend).
7//! Invariants:
8//! - At most one OOM watchdog task is spawned per process (`WATCHDOG_RUNNING`).
9
10use super::*;
11
12/// 2026-09-25: Free device memory without a `GpuBackend`, for the OOM
13/// watchdog and the TUI. `None` when `cuMemGetInfo_v2` fails.
14///
15/// The same rule as `free_memory` (`effective_free_bytes`), except that a
16/// failed integrated query counts as discrete (`polled_free_bytes`).
17pub fn cuda_free_memory_bytes() -> Option<usize> {
18    let mut free: usize = 0;
19    let mut total: usize = 0;
20    let status = unsafe { cuMemGetInfo_v2(&mut free, &mut total) };
21    if status != 0 {
22        return None;
23    }
24    Some(polled_free_bytes(
25        free,
26        system_available_memory_bytes(),
27        current_device_is_integrated(),
28    ))
29}
30
31/// 2026-09-25: `CU_DEVICE_ATTRIBUTE_INTEGRATED` of the current context's
32/// device. Requires a current context (`cuCtxGetDevice`); fails, rather than
33/// guessing, when the driver does not answer. Both callers query
34/// `cuMemGetInfo_v2` first, which needs the same context.
35///
36/// Measured 2026-09-04: attribute 18 reads 1 on GB10 and 0 on RTX PRO 6000
37/// Blackwell; `CU_DEVICE_ATTRIBUTE_PAGEABLE_MEMORY_ACCESS` (99) reads 1 on
38/// both, so it does not tell them apart.
39pub(crate) fn device_is_integrated() -> Result<bool> {
40    const CU_DEVICE_ATTRIBUTE_INTEGRATED: u32 = 18;
41    let mut dev: i32 = 0;
42    let status = unsafe { cuCtxGetDevice(&mut dev) };
43    if status != 0 {
44        bail!("cuCtxGetDevice failed: status {status}");
45    }
46    let mut integrated: i32 = 0;
47    let status =
48        unsafe { cuDeviceGetAttribute(&mut integrated, CU_DEVICE_ATTRIBUTE_INTEGRATED, dev) };
49    if status != 0 {
50        bail!("cuDeviceGetAttribute(INTEGRATED) failed: status {status}");
51    }
52    Ok(integrated != 0)
53}
54
55/// 2026-09-25: [`device_is_integrated`] as an `Option`, for
56/// `cuda_free_memory_bytes`, which returns `Option` and cannot report an
57/// error.
58fn current_device_is_integrated() -> Option<bool> {
59    device_is_integrated().ok()
60}
61
62/// 2026-09-25: Free device memory to report: the larger of `cu_free` and
63/// `mem_available` on an integrated GPU, `cu_free` otherwise or when
64/// `mem_available` is `None`. On a discrete GPU host RAM is a separate pool.
65/// Pure, so the rule is tested without a GPU.
66pub(crate) fn effective_free_bytes(
67    cu_free: usize,
68    mem_available: Option<usize>,
69    integrated: bool,
70) -> usize {
71    match mem_available {
72        Some(avail) if integrated => cu_free.max(avail),
73        _ => cu_free,
74    }
75}
76
77/// 2026-09-25: Whether a watchdog has been spawned. Process-wide, because the
78/// watchdog reads the device's free memory, which does not depend on the
79/// model: a second model load must not start a second watchdog.
80static WATCHDOG_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
81
82/// 2026-09-25: Spawn the OOM watchdog on the tokio runtime, or return `None`
83/// if one was already spawned.
84///
85/// Every `interval` it reads `cuda_free_memory_bytes`. Below `threshold_mb`
86/// MiB it logs an error; at the third consecutive such reading it calls
87/// `std::process::exit(1)`. A reading at or above the threshold resets the
88/// count, and a `None` reading leaves it unchanged. Dropping the returned
89/// handle does not stop the task.
90pub fn spawn_oom_watchdog(
91    threshold_mb: usize,
92    interval: std::time::Duration,
93) -> Option<tokio::task::JoinHandle<()>> {
94    if WATCHDOG_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) {
95        return None;
96    }
97    let threshold_bytes = threshold_mb * 1024 * 1024;
98    Some(tokio::spawn(async move {
99        let mut tick = tokio::time::interval(interval);
100        let mut consecutive_low = 0u32;
101        loop {
102            tick.tick().await;
103            if let Some(free) = cuda_free_memory_bytes() {
104                if free < threshold_bytes {
105                    consecutive_low += 1;
106                    let free_mb = free / (1024 * 1024);
107                    tracing::error!(
108                        "OOM watchdog: GPU free memory critically low: {} MB (threshold: {} MB) [{}/3]",
109                        free_mb,
110                        threshold_mb,
111                        consecutive_low,
112                    );
113                    if consecutive_low >= 3 {
114                        tracing::error!(
115                            "OOM watchdog: 3 consecutive readings below threshold. \
116                             Terminating to prevent system freeze."
117                        );
118                        std::process::exit(1);
119                    }
120                } else {
121                    consecutive_low = 0;
122                }
123            }
124        }
125    }))
126}