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/// Translate a CUDA logical ordinal to the physical NVML/nvidia-smi index.
141/// Numeric `CUDA_VISIBLE_DEVICES` entries are physical ordinals in logical
142/// order. UUID/MIG selectors cannot be mapped without carrying device identity,
143/// so return `None` rather than overlaying telemetry from the wrong card.
144pub(crate) fn physical_ordinal_for_worker(
145    logical_ordinal: usize,
146    cuda_visible_devices: Option<&str>,
147) -> Option<usize> {
148    let Some(visible) = cuda_visible_devices
149        .map(str::trim)
150        .filter(|v| !v.is_empty())
151    else {
152        return Some(logical_ordinal);
153    };
154    visible
155        .split(',')
156        .map(str::trim)
157        .nth(logical_ordinal)?
158        .parse::<usize>()
159        .ok()
160}
161
162pub(crate) fn resolve_nvidia_smi() -> &'static str {
163    if std::path::Path::new("/run/current-system/sw/bin/nvidia-smi").exists() {
164        "/run/current-system/sw/bin/nvidia-smi"
165    } else {
166        "nvidia-smi"
167    }
168}
169
170/// Parse a single `nvidia-smi --query-gpu=index,name,memory.total,memory.used --format=csv,noheader,nounits`
171/// line. Returns `(ordinal, name, total_bytes, used_bytes)` or `None` if the
172/// line doesn't have the expected shape.
173pub fn parse_nvidia_smi_line(line: &str) -> Option<(usize, String, u64, u64)> {
174    let parts: Vec<&str> = line.split(',').map(str::trim).collect();
175    if parts.len() < 4 {
176        return None;
177    }
178    let ordinal: usize = parts[0].parse().ok()?;
179    let name = parts[1].to_string();
180    let total_mb: u64 = parts[2].parse().ok()?;
181    let used_mb: u64 = parts[3].parse().ok()?;
182    // nvidia-smi with `nounits` reports MiB; we expose bytes. Upstream uses
183    // 1 MiB = 1_000_000 for display consistency with the rest of mold.
184    Some((ordinal, name, total_mb * 1_000_000, used_mb * 1_000_000))
185}
186
187use mold_core::{CpuSnapshot, RamSnapshot};
188use sysinfo::{CpuRefreshKind, Pid, ProcessRefreshKind, RefreshKind, System};
189
190/// Metal unified-memory snapshot — macOS only. Off-Darwin returns an empty
191/// Vec so callers on Linux/CUDA hosts can unconditionally call this.
192///
193/// Unified memory means there's no distinct VRAM total; we report the
194/// system RAM total so the SPA's GPU row still communicates "this is how
195/// much the GPU can address." Per-process attribution is unavailable on
196/// macOS (IOKit doesn't expose it in userspace), so both per-process fields
197/// are `None` and the SPA hides those rows.
198pub fn metal_snapshot() -> Vec<GpuSnapshot> {
199    #[cfg(target_os = "macos")]
200    {
201        let mut sys = sysinfo::System::new_with_specifics(
202            sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()),
203        );
204        sys.refresh_memory();
205        let total = sys.total_memory();
206        let used = sys.used_memory();
207        vec![GpuSnapshot {
208            ordinal: 0,
209            name: "Apple Metal GPU".to_string(),
210            backend: GpuBackend::Metal,
211            vram_total: total,
212            vram_used: used,
213            vram_used_by_mold: None,
214            vram_used_by_other: None,
215            gpu_utilization: None,
216        }]
217    }
218    #[cfg(not(target_os = "macos"))]
219    {
220        Vec::new()
221    }
222}
223
224/// Build a single `RamSnapshot` using `sysinfo`. Refreshes only memory and
225/// the current process — cheap enough to run at 1 Hz (~200 µs).
226pub fn ram_snapshot() -> RamSnapshot {
227    let mut sys = System::new_with_specifics(
228        RefreshKind::nothing()
229            .with_memory(sysinfo::MemoryRefreshKind::everything())
230            .with_processes(ProcessRefreshKind::nothing().with_memory()),
231    );
232    sys.refresh_memory();
233    let pid = Pid::from_u32(std::process::id());
234    sys.refresh_processes_specifics(
235        sysinfo::ProcessesToUpdate::Some(&[pid]),
236        true,
237        ProcessRefreshKind::nothing().with_memory(),
238    );
239    let total = sys.total_memory();
240    let used = sys.used_memory();
241    let used_by_mold = sys.process(pid).map(|p| p.memory()).unwrap_or(0);
242    let used_by_other = used.saturating_sub(used_by_mold);
243    RamSnapshot {
244        total,
245        used,
246        used_by_mold,
247        used_by_other,
248    }
249}
250
251pub struct SmiSource;
252
253impl SmiSource {
254    /// Invoke `nvidia-smi` and parse the output. Returns an empty Vec if the
255    /// binary isn't present or returns non-zero.
256    ///
257    /// Cost note: this fork/execs `nvidia-smi`, which takes on the order of
258    /// tens of milliseconds — not microseconds. Call from a blocking task
259    /// (e.g. `tokio::task::spawn_blocking`) if invoked from an async context.
260    pub fn snapshot() -> Vec<GpuSnapshot> {
261        let bin = resolve_nvidia_smi();
262        let output = match std::process::Command::new(bin)
263            .args([
264                "--query-gpu=index,name,memory.total,memory.used",
265                "--format=csv,noheader,nounits",
266            ])
267            .output()
268        {
269            Ok(o) if o.status.success() => o,
270            Ok(_) => return Vec::new(),
271            Err(_) => return Vec::new(),
272        };
273        let text = match String::from_utf8(output.stdout) {
274            Ok(s) => s,
275            Err(_) => return Vec::new(),
276        };
277        Self::parse_snapshot(&text)
278    }
279
280    /// Pure parser — split out for testability.
281    pub fn parse_snapshot(text: &str) -> Vec<GpuSnapshot> {
282        text.lines()
283            .filter_map(|l| {
284                let (ordinal, name, total, used) = parse_nvidia_smi_line(l)?;
285                Some(GpuSnapshot {
286                    ordinal,
287                    name,
288                    backend: GpuBackend::Cuda,
289                    vram_total: total,
290                    vram_used: used,
291                    vram_used_by_mold: None,
292                    vram_used_by_other: None,
293                    gpu_utilization: None,
294                })
295            })
296            .collect()
297    }
298}
299
300/// Assemble a single `ResourceSnapshot` from whichever data sources are
301/// available on the current host. Cheap enough to run at 1 Hz (~200 µs).
302///
303/// Source priority on CUDA: NVML (if linked) → `nvidia-smi` subprocess → empty.
304/// On macOS: `metal_snapshot()`.
305///
306/// CPU utilization is `None` — call `build_snapshot_with_cpu` with a
307/// persistent `System` to populate it (sysinfo computes CPU usage from
308/// deltas between refreshes, so the aggregator needs to hold state).
309pub fn build_snapshot() -> ResourceSnapshot {
310    build_snapshot_inner(None)
311}
312
313fn build_snapshot_inner(cpu: Option<CpuSnapshot>) -> ResourceSnapshot {
314    let hostname = hostname::get()
315        .ok()
316        .and_then(|h| h.into_string().ok())
317        .unwrap_or_else(|| "unknown".to_string());
318    let timestamp = SystemTime::now()
319        .duration_since(UNIX_EPOCH)
320        .map(|d| d.as_millis() as i64)
321        .unwrap_or(0);
322
323    let gpus = collect_gpus();
324    let system_ram = ram_snapshot();
325
326    ResourceSnapshot {
327        hostname,
328        timestamp,
329        gpus,
330        system_ram,
331        cpu,
332    }
333}
334
335/// Holds the persistent `System` sysinfo needs for CPU delta computation.
336pub struct CpuSampler {
337    sys: System,
338    cores: u16,
339}
340
341impl CpuSampler {
342    pub fn new() -> Self {
343        let mut sys = System::new_with_specifics(
344            RefreshKind::nothing().with_cpu(CpuRefreshKind::everything().with_cpu_usage()),
345        );
346        // Prime the sampler. The first `global_cpu_usage()` read always
347        // returns 0 — the real number shows up on the second refresh.
348        sys.refresh_cpu_usage();
349        let cores = sys.cpus().len().min(u16::MAX as usize) as u16;
350        Self { sys, cores }
351    }
352
353    pub fn sample(&mut self) -> CpuSnapshot {
354        self.sys.refresh_cpu_usage();
355        CpuSnapshot {
356            cores: self.cores,
357            usage_percent: self.sys.global_cpu_usage().clamp(0.0, 100.0),
358        }
359    }
360}
361
362impl Default for CpuSampler {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368#[allow(clippy::needless_return)]
369fn collect_gpus() -> Vec<GpuSnapshot> {
370    // Darwin: Metal is the only GPU path.
371    #[cfg(target_os = "macos")]
372    {
373        return metal_snapshot();
374    }
375    // Linux / other: try NVML first, fall back to nvidia-smi.
376    #[cfg(all(not(target_os = "macos"), feature = "nvml"))]
377    {
378        if let Ok(src) = NvmlSource::try_new() {
379            let gpus = src.snapshot(std::process::id());
380            if !gpus.is_empty() {
381                return gpus;
382            }
383        }
384    }
385    #[cfg(not(target_os = "macos"))]
386    {
387        SmiSource::snapshot()
388    }
389}
390
391/// Spawn the 1 Hz aggregator task. Returns the `JoinHandle` so `run_server`
392/// can drop it on shutdown. The task fires once immediately on startup so
393/// `GET /api/resources` succeeds without waiting a full second.
394pub fn spawn_aggregator(bcast: Arc<ResourceBroadcaster>) -> JoinHandle<()> {
395    tokio::spawn(async move {
396        // Immediate first tick so `latest()` is populated before any HTTP
397        // request arrives. CPU usage is None on this first sample (no delta
398        // to compute against yet).
399        bcast.publish(build_snapshot_inner(None));
400        let mut interval = tokio::time::interval(Duration::from_secs(1));
401        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
402        // Consume the first tick (it fires immediately) so we don't double-emit.
403        interval.tick().await;
404
405        // The sampler lives on the blocking thread across ticks — sysinfo
406        // computes CPU usage from deltas, so we can't rebuild it every tick.
407        let mut sampler: Option<CpuSampler> = None;
408        loop {
409            interval.tick().await;
410            let taken = sampler.take();
411            let (snap, returned) = tokio::task::spawn_blocking(move || {
412                let mut s = taken.unwrap_or_default();
413                let cpu = s.sample();
414                let snap = build_snapshot_inner(Some(cpu));
415                (snap, s)
416            })
417            .await
418            .unwrap_or_else(|_| {
419                (
420                    ResourceSnapshot {
421                        hostname: "unknown".to_string(),
422                        timestamp: 0,
423                        gpus: Vec::new(),
424                        system_ram: mold_core::RamSnapshot {
425                            total: 0,
426                            used: 0,
427                            used_by_mold: 0,
428                            used_by_other: 0,
429                        },
430                        cpu: None,
431                    },
432                    CpuSampler::new(),
433                )
434            });
435            sampler = Some(returned);
436            bcast.publish(snap);
437        }
438    })
439}