Skip to main content

mermaid_cli/utils/
host_memory.rs

1//! Best-effort host memory detection for Ollama auto-sizing.
2//!
3//! Mermaid sizes an Ollama model's `num_ctx` to fit the host's memory so the
4//! model stays on the GPU (CPU/RAM offload is 5–20× slower, so it's off by
5//! default). That needs two numbers: total system RAM (the budget when the user
6//! opts into RAM offload) and total VRAM (the default budget).
7//!
8//! Both are **best-effort and cached** — VRAM detection is GPU-vendor specific
9//! and there is no Ollama API for it, so we shell out to `nvidia-smi` and fall
10//! back gracefully. When VRAM can't be detected the sizing logic uses a
11//! conservative fixed cap (see [`crate::models::adapters::ollama_sizing`]), so a
12//! `None` here never breaks anything.
13
14use crate::constants::NVIDIA_SMI_TIMEOUT_SECS;
15use std::sync::OnceLock;
16
17/// Total physical system RAM in bytes, or `None` if it can't be read. Cached for
18/// the process (RAM doesn't change during a session).
19pub fn system_ram_bytes() -> Option<u64> {
20    static CACHE: OnceLock<Option<u64>> = OnceLock::new();
21    *CACHE.get_or_init(detect_system_ram)
22}
23
24fn detect_system_ram() -> Option<u64> {
25    let mut sys = sysinfo::System::new();
26    sys.refresh_memory();
27    let total = sys.total_memory(); // bytes (sysinfo ≥ 0.30)
28    (total > 0).then_some(total)
29}
30
31/// Total GPU VRAM in bytes (best-effort), or `None` if no GPU is detected.
32///
33/// On unified-memory Macs there is no separate, slow "RAM offload" target, so
34/// system RAM *is* the GPU budget. On NVIDIA we read `nvidia-smi`. Everything
35/// else (AMD, integrated, headless) returns `None` and the caller uses the
36/// conservative fallback cap. Cached for the process.
37pub async fn gpu_vram_bytes() -> Option<u64> {
38    static CACHE: tokio::sync::OnceCell<Option<u64>> = tokio::sync::OnceCell::const_new();
39    *CACHE.get_or_init(detect_vram).await
40}
41
42async fn detect_vram() -> Option<u64> {
43    if let Some(v) = nvidia_vram_bytes().await {
44        return Some(v);
45    }
46    // Apple Silicon / Intel Macs: unified memory — the GPU draws from system RAM,
47    // and there's no slow separate pool to "offload" to, so treat RAM as VRAM.
48    if cfg!(target_os = "macos") {
49        return system_ram_bytes();
50    }
51    None
52}
53
54/// Largest single NVIDIA GPU's total VRAM via `nvidia-smi`, in bytes. A model
55/// loads onto one GPU unless it spans, so we take the max single GPU rather than
56/// assume the pool. Best-effort: absent binary, non-zero exit, or timeout → `None`.
57async fn nvidia_vram_bytes() -> Option<u64> {
58    let mut cmd = tokio::process::Command::new("nvidia-smi");
59    cmd.args(["--query-gpu=memory.total", "--format=csv,noheader,nounits"]);
60    cmd.kill_on_drop(true);
61
62    let out = tokio::time::timeout(
63        std::time::Duration::from_secs(NVIDIA_SMI_TIMEOUT_SECS),
64        cmd.output(),
65    )
66    .await
67    .ok()? // timed out
68    .ok()?; // failed to spawn (nvidia-smi not installed)
69
70    if !out.status.success() {
71        return None;
72    }
73
74    // One line per GPU, in MiB (`nounits`).
75    let max_mib = String::from_utf8_lossy(&out.stdout)
76        .lines()
77        .filter_map(|l| l.trim().parse::<u64>().ok())
78        .max()?;
79    max_mib.checked_mul(1024 * 1024)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn system_ram_is_detected_and_plausible() {
88        // Any real machine (including CI runners) reports > 0 RAM, and a sane
89        // total is at least ~256 MiB — this also catches a units regression
90        // (KiB vs bytes) in the underlying crate.
91        let ram = system_ram_bytes().expect("system RAM should be detectable");
92        assert!(
93            ram > 256 * 1024 * 1024,
94            "implausibly small RAM ({ram} bytes) — units regression?"
95        );
96    }
97
98    #[tokio::test]
99    async fn gpu_vram_probe_is_best_effort() {
100        // Some on a GPU box, None on CI — must not panic or hang either way.
101        let _ = gpu_vram_bytes().await;
102    }
103}