Skip to main content

mold_server/
resources.rs

1//! Always-on VRAM + system-RAM telemetry aggregator.
2//!
3//! A single `tokio::spawn`ed task builds a `ResourceSnapshot` every 1 s and
4//! broadcasts it through `ResourceBroadcaster`. The HTTP layer in
5//! `routes.rs` exposes both a one-shot `GET /api/resources` endpoint (reads
6//! the most recently published snapshot) and an SSE stream
7//! `GET /api/resources/stream` that replays the broadcast channel.
8
9use mold_core::ResourceSnapshot;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use tokio::sync::broadcast;
13use tokio::task::JoinHandle;
14
15/// Broadcast buffer size. Per spec 2.3 — small because downstream consumers
16/// only care about the latest tick and lagging receivers (slow SSE clients)
17/// recover by reading the `latest` cache on reconnect.
18const BROADCAST_BUFFER: usize = 4;
19
20/// Wraps a `tokio::sync::broadcast::Sender<ResourceSnapshot>` and a
21/// `Mutex<Option<ResourceSnapshot>>` that caches the most recently published
22/// snapshot for the REST endpoint and for new subscribers that connect
23/// between ticks.
24#[derive(Clone)]
25pub struct ResourceBroadcaster {
26    tx: broadcast::Sender<ResourceSnapshot>,
27    latest: Arc<Mutex<Option<ResourceSnapshot>>>,
28}
29
30impl ResourceBroadcaster {
31    pub fn new() -> Arc<Self> {
32        let (tx, _rx) = broadcast::channel(BROADCAST_BUFFER);
33        Arc::new(Self {
34            tx,
35            latest: Arc::new(Mutex::new(None)),
36        })
37    }
38
39    /// Publish a new snapshot. Failures (no subscribers yet) are deliberately
40    /// ignored — the cache still updates, so the next `GET /api/resources`
41    /// call will see it.
42    pub fn publish(&self, snapshot: ResourceSnapshot) {
43        // Cache first, then fan out. The critical section is a pointer write
44        // so a `std::sync::Mutex` is the right primitive — no async scheduler
45        // overhead, no silent-drop-on-contention from `try_lock`.
46        *self.latest.lock().expect("resource cache mutex poisoned") = Some(snapshot.clone());
47        let _ = self.tx.send(snapshot);
48    }
49
50    pub fn subscribe(&self) -> broadcast::Receiver<ResourceSnapshot> {
51        self.tx.subscribe()
52    }
53
54    /// Returns the most recent published snapshot. Used by `GET /api/resources`.
55    pub fn latest(&self) -> Option<ResourceSnapshot> {
56        self.latest
57            .lock()
58            .expect("resource cache mutex poisoned")
59            .clone()
60    }
61}
62
63#[cfg(feature = "nvml")]
64pub(crate) mod nvml_source {
65    use mold_core::{GpuBackend, GpuSnapshot};
66    use nvml_wrapper::enums::device::UsedGpuMemory;
67    use nvml_wrapper::Nvml;
68
69    pub struct NvmlSource {
70        nvml: Nvml,
71    }
72
73    impl NvmlSource {
74        pub fn try_new() -> anyhow::Result<Self> {
75            let nvml = Nvml::init()?;
76            Ok(Self { nvml })
77        }
78
79        /// Produce a per-GPU snapshot. `pid` is `std::process::id()` of this
80        /// server process; we filter `running_compute_processes()` against it
81        /// to attribute `vram_used_by_mold`.
82        pub fn snapshot(&self, pid: u32) -> Vec<GpuSnapshot> {
83            let count = match self.nvml.device_count() {
84                Ok(c) => c,
85                Err(e) => {
86                    tracing::debug!(err = %e, "NVML device_count failed");
87                    return Vec::new();
88                }
89            };
90            let mut out = Vec::with_capacity(count as usize);
91            for ordinal in 0..count {
92                let Ok(dev) = self.nvml.device_by_index(ordinal) else {
93                    continue;
94                };
95                let name = dev
96                    .name()
97                    .unwrap_or_else(|_| format!("CUDA Device {ordinal}"));
98                let mem = match dev.memory_info() {
99                    Ok(m) => m,
100                    Err(e) => {
101                        tracing::debug!(ordinal, err = %e, "NVML memory_info failed");
102                        continue;
103                    }
104                };
105                let used_by_mold = dev.running_compute_processes().ok().map(|procs| {
106                    procs
107                        .iter()
108                        .filter(|p| p.pid == pid)
109                        .map(|p| match p.used_gpu_memory {
110                            UsedGpuMemory::Used(b) => b,
111                            UsedGpuMemory::Unavailable => 0,
112                        })
113                        .sum::<u64>()
114                });
115                let used_by_other = used_by_mold.map(|m| mem.used.saturating_sub(m));
116                // NVML's GPU-core utilization over the last sample period.
117                // Cheap — this is just a driver query, not a counter reset.
118                let gpu_util = dev.utilization_rates().ok().map(|u| u.gpu.min(100) as u8);
119                out.push(GpuSnapshot {
120                    ordinal: ordinal as usize,
121                    name,
122                    backend: GpuBackend::Cuda,
123                    vram_total: mem.total,
124                    vram_used: mem.used,
125                    vram_used_by_mold: used_by_mold,
126                    vram_used_by_other: used_by_other,
127                    gpu_utilization: gpu_util,
128                });
129            }
130            out
131        }
132    }
133}
134
135#[cfg(feature = "nvml")]
136pub use nvml_source::NvmlSource;
137
138use mold_core::{GpuBackend, GpuSnapshot};
139
140/// Locate the `nvidia-smi` binary. Matches the existing resolver in
141/// `routes.rs::query_gpu_info` so NixOS hosts still work.
142pub(crate) fn resolve_nvidia_smi() -> &'static str {
143    if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
144        "/run/current-system/sw/bin/nvidia-smi"
145    } else {
146        "nvidia-smi"
147    }
148}
149
150/// Parse a single `nvidia-smi --query-gpu=index,name,memory.total,memory.used --format=csv,noheader,nounits`
151/// line. Returns `(ordinal, name, total_bytes, used_bytes)` or `None` if the
152/// line doesn't have the expected shape.
153pub fn parse_nvidia_smi_line(line: &str) -> Option<(usize, String, u64, u64)> {
154    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
155    if parts.len() < 4 {
156        return None;
157    }
158    let ordinal: usize = parts[0].parse().ok()?;
159    let name = parts[1].to_string();
160    let total_mb: u64 = parts[2].parse().ok()?;
161    let used_mb: u64 = parts[3].parse().ok()?;
162    // nvidia-smi with `nounits` reports MiB; we expose bytes. Upstream uses
163    // 1 MiB = 1_000_000 for display consistency with the rest of mold.
164    Some((ordinal, name, total_mb * 1_000_000, used_mb * 1_000_000))
165}
166
167use mold_core::{CpuSnapshot, RamSnapshot};
168use sysinfo::{CpuRefreshKind, Pid, ProcessRefreshKind, RefreshKind, System};
169
170/// Metal unified-memory snapshot — macOS only. Off-Darwin returns an empty
171/// Vec so callers on Linux/CUDA hosts can unconditionally call this.
172///
173/// Unified memory means there's no distinct VRAM total; we report the
174/// system RAM total so the SPA's GPU row still communicates "this is how
175/// much the GPU can address." Per-process attribution is unavailable on
176/// macOS (IOKit doesn't expose it in userspace), so both per-process fields
177/// are `None` and the SPA hides those rows.
178pub fn metal_snapshot() -> Vec<GpuSnapshot> {
179    #[cfg(target_os = "macos")]
180    {
181        let mut sys = sysinfo::System::new_with_specifics(
182            sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()),
183        );
184        sys.refresh_memory();
185        let total = sys.total_memory();
186        let used = sys.used_memory();
187        vec![GpuSnapshot {
188            ordinal: 0,
189            name: "Apple Metal GPU".to_string(),
190            backend: GpuBackend::Metal,
191            vram_total: total,
192            vram_used: used,
193            vram_used_by_mold: None,
194            vram_used_by_other: None,
195            gpu_utilization: None,
196        }]
197    }
198    #[cfg(not(target_os = "macos"))]
199    {
200        Vec::new()
201    }
202}
203
204/// Build a single `RamSnapshot` using `sysinfo`. Refreshes only memory and
205/// the current process — cheap enough to run at 1 Hz (~200 µs).
206pub fn ram_snapshot() -> RamSnapshot {
207    let mut sys = System::new_with_specifics(
208        RefreshKind::nothing()
209            .with_memory(sysinfo::MemoryRefreshKind::everything())
210            .with_processes(ProcessRefreshKind::nothing().with_memory()),
211    );
212    sys.refresh_memory();
213    let pid = Pid::from_u32(std::process::id());
214    sys.refresh_processes_specifics(
215        sysinfo::ProcessesToUpdate::Some(&[pid]),
216        true,
217        ProcessRefreshKind::nothing().with_memory(),
218    );
219    let total = sys.total_memory();
220    let used = sys.used_memory();
221    let used_by_mold = sys.process(pid).map(|p| p.memory()).unwrap_or(0);
222    let used_by_other = used.saturating_sub(used_by_mold);
223    RamSnapshot {
224        total,
225        used,
226        used_by_mold,
227        used_by_other,
228    }
229}
230
231pub struct SmiSource;
232
233impl SmiSource {
234    /// Invoke `nvidia-smi` and parse the output. Returns an empty Vec if the
235    /// binary isn't present or returns non-zero.
236    ///
237    /// Cost note: this fork/execs `nvidia-smi`, which takes on the order of
238    /// tens of milliseconds — not microseconds. Call from a blocking task
239    /// (e.g. `tokio::task::spawn_blocking`) if invoked from an async context.
240    pub fn snapshot() -> Vec<GpuSnapshot> {
241        let bin = resolve_nvidia_smi();
242        let output = match std::process::Command::new(bin)
243            .args([
244                "--query-gpu=index,name,memory.total,memory.used",
245                "--format=csv,noheader,nounits",
246            ])
247            .output()
248        {
249            Ok(o) if o.status.success() => o,
250            Ok(_) => return Vec::new(),
251            Err(_) => return Vec::new(),
252        };
253        let text = match String::from_utf8(output.stdout) {
254            Ok(s) => s,
255            Err(_) => return Vec::new(),
256        };
257        Self::parse_snapshot(&text)
258    }
259
260    /// Pure parser — split out for testability.
261    pub fn parse_snapshot(text: &str) -> Vec<GpuSnapshot> {
262        text.lines()
263            .filter_map(|l| {
264                let (ordinal, name, total, used) = parse_nvidia_smi_line(l)?;
265                Some(GpuSnapshot {
266                    ordinal,
267                    name,
268                    backend: GpuBackend::Cuda,
269                    vram_total: total,
270                    vram_used: used,
271                    vram_used_by_mold: None,
272                    vram_used_by_other: None,
273                    gpu_utilization: None,
274                })
275            })
276            .collect()
277    }
278}
279
280/// Assemble a single `ResourceSnapshot` from whichever data sources are
281/// available on the current host. Cheap enough to run at 1 Hz (~200 µs).
282///
283/// Source priority on CUDA: NVML (if linked) → `nvidia-smi` subprocess → empty.
284/// On macOS: `metal_snapshot()`.
285///
286/// CPU utilization is `None` — call `build_snapshot_with_cpu` with a
287/// persistent `System` to populate it (sysinfo computes CPU usage from
288/// deltas between refreshes, so the aggregator needs to hold state).
289pub fn build_snapshot() -> ResourceSnapshot {
290    build_snapshot_inner(None)
291}
292
293fn build_snapshot_inner(cpu: Option<CpuSnapshot>) -> ResourceSnapshot {
294    let hostname = hostname::get()
295        .ok()
296        .and_then(|h| h.into_string().ok())
297        .unwrap_or_else(|| "unknown".to_string());
298    let timestamp = SystemTime::now()
299        .duration_since(UNIX_EPOCH)
300        .map(|d| d.as_millis() as i64)
301        .unwrap_or(0);
302
303    let gpus = collect_gpus();
304    let system_ram = ram_snapshot();
305
306    ResourceSnapshot {
307        hostname,
308        timestamp,
309        gpus,
310        system_ram,
311        cpu,
312    }
313}
314
315/// Holds the persistent `System` sysinfo needs for CPU delta computation.
316pub struct CpuSampler {
317    sys: System,
318    cores: u16,
319}
320
321impl CpuSampler {
322    pub fn new() -> Self {
323        let mut sys = System::new_with_specifics(
324            RefreshKind::nothing().with_cpu(CpuRefreshKind::everything().with_cpu_usage()),
325        );
326        // Prime the sampler. The first `global_cpu_usage()` read always
327        // returns 0 — the real number shows up on the second refresh.
328        sys.refresh_cpu_usage();
329        let cores = sys.cpus().len().min(u16::MAX as usize) as u16;
330        Self { sys, cores }
331    }
332
333    pub fn sample(&mut self) -> CpuSnapshot {
334        self.sys.refresh_cpu_usage();
335        CpuSnapshot {
336            cores: self.cores,
337            usage_percent: self.sys.global_cpu_usage().clamp(0.0, 100.0),
338        }
339    }
340}
341
342impl Default for CpuSampler {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348#[allow(clippy::needless_return)]
349fn collect_gpus() -> Vec<GpuSnapshot> {
350    // Darwin: Metal is the only GPU path.
351    #[cfg(target_os = "macos")]
352    {
353        return metal_snapshot();
354    }
355    // Linux / other: try NVML first, fall back to nvidia-smi.
356    #[cfg(all(not(target_os = "macos"), feature = "nvml"))]
357    {
358        if let Ok(src) = NvmlSource::try_new() {
359            let gpus = src.snapshot(std::process::id());
360            if !gpus.is_empty() {
361                return gpus;
362            }
363        }
364    }
365    #[cfg(not(target_os = "macos"))]
366    {
367        SmiSource::snapshot()
368    }
369}
370
371/// Spawn the 1 Hz aggregator task. Returns the `JoinHandle` so `run_server`
372/// can drop it on shutdown. The task fires once immediately on startup so
373/// `GET /api/resources` succeeds without waiting a full second.
374pub fn spawn_aggregator(bcast: Arc<ResourceBroadcaster>) -> JoinHandle<()> {
375    tokio::spawn(async move {
376        // Immediate first tick so `latest()` is populated before any HTTP
377        // request arrives. CPU usage is None on this first sample (no delta
378        // to compute against yet).
379        bcast.publish(build_snapshot_inner(None));
380        let mut interval = tokio::time::interval(Duration::from_secs(1));
381        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
382        // Consume the first tick (it fires immediately) so we don't double-emit.
383        interval.tick().await;
384
385        // The sampler lives on the blocking thread across ticks — sysinfo
386        // computes CPU usage from deltas, so we can't rebuild it every tick.
387        let mut sampler: Option<CpuSampler> = None;
388        loop {
389            interval.tick().await;
390            let taken = sampler.take();
391            let (snap, returned) = tokio::task::spawn_blocking(move || {
392                let mut s = taken.unwrap_or_default();
393                let cpu = s.sample();
394                let snap = build_snapshot_inner(Some(cpu));
395                (snap, s)
396            })
397            .await
398            .unwrap_or_else(|_| {
399                (
400                    ResourceSnapshot {
401                        hostname: "unknown".to_string(),
402                        timestamp: 0,
403                        gpus: Vec::new(),
404                        system_ram: mold_core::RamSnapshot {
405                            total: 0,
406                            used: 0,
407                            used_by_mold: 0,
408                            used_by_other: 0,
409                        },
410                        cpu: None,
411                    },
412                    CpuSampler::new(),
413                )
414            });
415            sampler = Some(returned);
416            bcast.publish(snap);
417        }
418    })
419}