Skip to main content

rlx_distributed/cluster/
caps.rs

1// RLX — versatile ML compiler + runtime.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! **Node hardware capabilities** — what each machine can contribute. Probed
5//! locally (system queries + micro-benchmarks) and shipped between nodes as JSON
6//! so a coordinator can plan placement without hard-coding the cluster.
7
8use rlx_runtime::{Device, device_label, is_available, parse_device};
9use serde::{Deserialize, Serialize};
10use std::time::Instant;
11
12/// A compute device present on a node, with its usable memory.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct DeviceInfo {
15    /// RLX device label (`cpu`, `metal`, `cuda`, `ane`/NPU, `vulkan`, …). Stored
16    /// as its canonical string so [`NodeCaps`] stays plainly (de)serializable.
17    pub device: String,
18    /// Human name (e.g. "Apple M4 Pro", "NVIDIA RTX 3080 Ti").
19    pub name: String,
20    /// Dedicated device memory in bytes (0 / host-RAM for unified/iGPU).
21    pub mem_bytes: u64,
22    /// True when the device shares host RAM (Apple unified, iGPU) — no separate
23    /// VRAM ceiling, but it competes with the CPU stage for the same pool.
24    pub unified: bool,
25    /// Measured f32 matmul throughput on THIS device (GFLOP/s), 0 when the
26    /// backend is not built in or the bench did not run.
27    ///
28    /// Placement needs this per device, not per node: ranking a machine by its
29    /// CPU tells you nothing about the 4090 it will actually run the stage on.
30    #[serde(default)]
31    pub gflops: f64,
32    /// True when the *node's own build* can actually target this device.
33    ///
34    /// The OS reporting a GPU and the binary being able to run on it are
35    /// different facts: a build without the `metal` feature still sees an M4
36    /// Pro, but every `Session::new(Metal)` on it panics. Recording that here
37    /// keeps the distinction visible — the alternative, leaving `gflops` at
38    /// 0.0, reads to a planner as "present but infinitely slow" and to a human
39    /// as a benchmark failure, when the real answer is "rebuild with
40    /// `--features metal`".
41    #[serde(default = "default_true")]
42    pub available: bool,
43}
44
45fn default_true() -> bool {
46    true
47}
48
49impl DeviceInfo {
50    fn new(device: Device, name: String, mem_bytes: u64, unified: bool) -> Self {
51        Self {
52            device: device_label(device).to_string(),
53            name,
54            mem_bytes,
55            unified,
56            gflops: 0.0,
57            available: is_available(device),
58        }
59    }
60    /// Parsed device kind, or `None` when this coordinator does not recognise
61    /// the label.
62    ///
63    /// Worth distinguishing: nodes report whatever their own rlx build calls a
64    /// device, so a cluster running mixed versions can send back a name this
65    /// binary has never heard of. Collapsing that to `Cpu` — as [`Self::kind`]
66    /// must, to keep its infallible signature — makes an unrecognised
67    /// accelerator *invisible*: the planner skips it as though it were the host
68    /// CPU and ignores its memory.
69    pub fn parsed(&self) -> Option<Device> {
70        parse_device(&self.device).ok()
71    }
72
73    /// Parsed device kind, falling back to CPU for an unrecognised label.
74    ///
75    /// Prefer [`Self::parsed`] where "unrecognised" and "CPU" mean different
76    /// things — which is most places.
77    pub fn kind(&self) -> Device {
78        self.parsed().unwrap_or(Device::Cpu)
79    }
80
81    /// True when this coordinator understands the reported device.
82    pub fn is_recognized(&self) -> bool {
83        self.parsed().is_some()
84    }
85
86    /// True when a stage may be *placed* here: recognised by this coordinator
87    /// and runnable by the node that reported it.
88    pub fn is_usable(&self) -> bool {
89        self.is_recognized() && self.available
90    }
91}
92
93/// A node's measured hardware profile.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct NodeCaps {
96    /// Address the coordinator reaches this node's worker at (`host:port`).
97    pub addr: String,
98    /// OS string (`macos`, `linux`).
99    pub os: String,
100    /// Logical CPU cores.
101    pub cores: usize,
102    /// Total / currently-available host RAM (bytes).
103    pub ram_total: u64,
104    pub ram_avail: u64,
105    /// Free disk at the checkpoint location (bytes).
106    pub disk_free: u64,
107    /// Compute devices, CPU first.
108    pub devices: Vec<DeviceInfo>,
109    /// Rough sustained matmul throughput (GFLOP/s, f32) from a micro-bench.
110    pub gflops: f64,
111    /// Rough disk read throughput (MB/s) from a micro-bench (0 if not measured).
112    pub io_mbps: f64,
113}
114
115impl NodeCaps {
116    /// Best (non-CPU) accelerator memory ceiling, else host RAM — the largest a
117    /// stage can occupy on this node's device. A GPU that reports a positive
118    /// `mem_bytes` (discrete VRAM, or an APU's GTT / GPU-addressable system RAM)
119    /// caps the stage even when it is "unified": an amdgpu APU shares system RAM
120    /// but the kernel bounds GPU access at GTT, so we must honor it. Devices with
121    /// no meaningful ceiling (Apple Metal, `mem_bytes == 0`) fall through to RAM.
122    pub fn accel_mem(&self) -> u64 {
123        self.devices
124            .iter()
125            .filter(|d| d.is_usable() && d.kind() != Device::Cpu && d.mem_bytes > 0)
126            .map(|d| d.mem_bytes)
127            .max()
128            .unwrap_or(self.ram_total)
129    }
130
131    /// Measured throughput of `dev` specifically, falling back to the node's
132    /// headline figure when that device was not benched.
133    ///
134    /// Costing a stage by [`Self::gflops`] — the node's *fastest* device — is
135    /// only right when the stage runs there, and it need not: the device is
136    /// picked for its memory ceiling (or pinned by hand in the config). On an
137    /// M4 Pro the CPU's AMX benches ~1100 GFLOP/s against Metal's ~540, so a
138    /// node placed on Metal and costed on the headline is priced at twice the
139    /// throughput it will deliver.
140    pub fn gflops_for(&self, dev: Device) -> f64 {
141        self.devices
142            .iter()
143            .find(|d| d.parsed() == Some(dev) && d.gflops > 0.0)
144            .map(|d| d.gflops)
145            .unwrap_or(self.gflops)
146    }
147
148    /// One-line summary for logs / the monitor table.
149    pub fn summary(&self) -> String {
150        let devs: Vec<String> = self
151            .devices
152            .iter()
153            .map(|d| device_label(d.kind()).to_string())
154            .collect();
155        format!(
156            "{} | {} cores | {:.0}/{:.0} GB RAM | {:.0} GFLOP/s | {}",
157            self.os,
158            self.cores,
159            self.ram_avail as f64 / 1e9,
160            self.ram_total as f64 / 1e9,
161            self.gflops,
162            devs.join("+"),
163        )
164    }
165}
166
167fn run(cmd: &str, args: &[&str]) -> Option<String> {
168    std::process::Command::new(cmd)
169        .args(args)
170        .output()
171        .ok()
172        .filter(|o| o.status.success())
173        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
174}
175
176/// Quick f32 matmul micro-bench → GFLOP/s (single-thread; a throughput proxy).
177fn bench_gflops() -> f64 {
178    let n = 384usize;
179    let a = vec![1.0000117f32; n * n];
180    let b = vec![0.9999931f32; n * n];
181    let mut c = vec![0f32; n * n];
182    let t = Instant::now();
183    for i in 0..n {
184        for k in 0..n {
185            let av = a[i * n + k];
186            let brow = &b[k * n..k * n + n];
187            let crow = &mut c[i * n..i * n + n];
188            for j in 0..n {
189                crow[j] += av * brow[j];
190            }
191        }
192    }
193    std::hint::black_box(&c);
194    let secs = t.elapsed().as_secs_f64();
195    (2.0 * (n * n * n) as f64) / secs / 1e9
196}
197
198/// Time a real f32 matmul on `dev` through rlx itself → GFLOP/s.
199///
200/// The point is to rank *accelerators*: the host-side scalar loop in
201/// [`bench_gflops`] says a 4090 node and a laptop are the same speed, which is
202/// exactly backwards for placement.
203///
204/// Wrapped in `catch_unwind` deliberately. Backend availability is not reliably
205/// reportable — a backend can be compiled in, claim support, and still abort on
206/// a machine without the driver — and a hardware probe must degrade to "unknown"
207/// rather than take the coordinator down. `None` means "could not measure",
208/// which the planner treats as no information rather than as zero speed.
209fn bench_device_gflops(dev: Device) -> Option<f64> {
210    use rlx_ir::{DType, Graph, Shape};
211    // Asking first is not just tidiness: compiling for a device the build lacks
212    // panics, and while `catch_unwind` below contains it, the default hook still
213    // prints a backtrace-shaped line per device per node. A clean probe that
214    // prints three panics looks like a broken probe.
215    if !is_available(dev) {
216        return None;
217    }
218    // Sized and timed to survive being a *ranking* input. The previous
219    // 4 iterations of 256^3 was 0.34 ms of work on an M4 Pro, so what it
220    // actually measured was timer granularity, thread-pool spin-up and clock
221    // ramp: the same machine probed three times reported 276, 331 and 493
222    // GFLOP/s, and the planner moved layers around on the strength of it.
223    // Shape matters as much as duration. A square 512^3 GEMM is compute-bound
224    // and reports what the AMX/tensor units can do; an autoregressive decode
225    // step is a batch-1 GEMV against a full weight matrix, which is bound by
226    // how fast the machine can stream those weights. Benching the square case
227    // and then planning decode stages with it overstates throughput by roughly
228    // an order of magnitude, and overstates it MOST on the nodes with the
229    // widest gap between arithmetic and bandwidth. Bench what we are planning.
230    let n = 4096usize;
231    let rows = 1usize;
232    /// Each timing round must run at least this long to swamp the fixed costs.
233    const ROUND_SECS: f64 = 0.03;
234    /// Best-of, not mean: interference from other work can only ever make a
235    /// round look slower, so the fastest round is the closest to the truth.
236    const ROUNDS: usize = 3;
237
238    let run_once = || -> Option<f64> {
239        let mut g = Graph::new("caps_bench");
240        let a = g.input("a", Shape::new(&[rows, n], DType::F32));
241        let b = g.param("b", Shape::new(&[n, n], DType::F32));
242        let c = g.matmul(a, b, Shape::new(&[rows, n], DType::F32));
243        g.set_outputs(vec![c]);
244        let mut compiled = rlx_runtime::Session::new(dev).compile(g);
245        compiled.set_param("b", &vec![0.5f32; n * n]);
246        let x = vec![0.25f32; rows * n];
247        // Warm-up: compile, upload, JIT, and let the clocks come up.
248        let t = Instant::now();
249        let _ = compiled.run(&[("a", x.as_slice())]);
250        let first = t.elapsed().as_secs_f64().max(1e-6);
251        // Enough iterations that one round lasts ROUND_SECS at the warm-up rate.
252        let iters = ((ROUND_SECS / first).ceil() as usize).clamp(2, 4096);
253        let flop = 2.0 * (rows * n * n) as f64 * iters as f64;
254        let mut best = 0.0f64;
255        for _ in 0..ROUNDS {
256            let t = Instant::now();
257            for _ in 0..iters {
258                let _ = compiled.run(&[("a", x.as_slice())]);
259            }
260            let secs = t.elapsed().as_secs_f64();
261            if secs > 0.0 {
262                best = best.max(flop / secs / 1e9);
263            }
264        }
265        (best > 0.0).then_some(best)
266    };
267    // A backend can still fail past the availability check (no device present,
268    // driver refused, unsupported op). That is a benign "no measurement", so
269    // keep the unwind but drop the hook's stderr spew for its duration.
270    // The hook is process-global, so two benches racing here would restore each
271    // other's hook and leak the silent one. One at a time.
272    static HOOK: std::sync::Mutex<()> = std::sync::Mutex::new(());
273    let _guard = HOOK.lock().unwrap_or_else(|e| e.into_inner());
274    let hook = std::panic::take_hook();
275    std::panic::set_hook(Box::new(|_| {}));
276    let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run_once))
277        .ok()
278        .flatten();
279    std::panic::set_hook(hook);
280    out
281}
282
283/// Ask the kernel not to keep this bench's own reads in the page cache, so a
284/// probe does not poison the next one.
285///
286/// Advisory on both platforms; note it does NOT evict pages that are already
287/// resident, which is why the caller must also pick a cold offset.
288#[cfg(unix)]
289fn no_cache_hint(f: &std::fs::File, off: u64, len: u64) {
290    use std::os::fd::AsRawFd;
291    let fd = f.as_raw_fd();
292    // SAFETY: `fd` is owned by `f` and outlives the call; both are advisory
293    // hints whose failure we deliberately ignore.
294    unsafe {
295        #[cfg(target_vendor = "apple")]
296        {
297            let _ = (off, len);
298            libc::fcntl(fd, libc::F_NOCACHE, 1);
299        }
300        #[cfg(target_os = "linux")]
301        {
302            libc::posix_fadvise(fd, off as i64, len as i64, libc::POSIX_FADV_DONTNEED);
303        }
304        #[cfg(not(any(target_vendor = "apple", target_os = "linux")))]
305        {
306            let _ = (fd, off, len);
307        }
308    }
309}
310
311#[cfg(not(unix))]
312fn no_cache_hint(_f: &std::fs::File, _off: u64, _len: u64) {}
313
314/// Fraction of the pages in `[off, off+len)` already in the page cache, or
315/// `None` when the question cannot be answered.
316///
317/// This is the difference between measuring a disk and measuring RAM. Timing a
318/// read of cached pages on this M4 Pro returns ~20 GB/s; the same range read
319/// cold off the actual device returns ~0.9 GB/s. Twenty-fold, and in the
320/// dangerous direction: `stage_secs` divides the expert bytes each token touches
321/// by this number, so a cached probe says paging 86 GB of experts off disk is
322/// nearly free and the planner cheerfully builds a stage that will be an order
323/// of magnitude slower than predicted.
324///
325/// A varying offset alone does not save us — it only helps while the file is
326/// bigger than free RAM, and it never helps on a re-probe of a small one.
327#[cfg(unix)]
328fn resident_fraction(f: &std::fs::File, off: u64, len: usize) -> Option<f64> {
329    use std::os::fd::AsRawFd;
330    // SAFETY: sysconf(_SC_PAGESIZE) takes no pointers and cannot fail here.
331    let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
332    if page <= 0 {
333        return None;
334    }
335    let page = page as u64;
336    // mmap needs a page-aligned offset; widen the window to cover the request.
337    let base = off - (off % page);
338    let span = (len as u64 + (off - base)) as usize;
339    let pages = span.div_ceil(page as usize);
340
341    // SAFETY: a read-only shared mapping of a file we hold open. `addr` is
342    // checked against MAP_FAILED before use and unmapped on every path.
343    unsafe {
344        let addr = libc::mmap(
345            std::ptr::null_mut(),
346            span,
347            libc::PROT_READ,
348            libc::MAP_SHARED,
349            f.as_raw_fd(),
350            base as libc::off_t,
351        );
352        if addr == libc::MAP_FAILED {
353            return None;
354        }
355        let mut vec = vec![0u8; pages];
356        let rc = libc::mincore(addr, span, vec.as_mut_ptr().cast());
357        libc::munmap(addr, span);
358        if rc != 0 {
359            return None;
360        }
361        // Bit 0 is "resident" on both Linux and the BSDs.
362        let hot = vec.iter().filter(|b| *b & 1 != 0).count();
363        Some(hot as f64 / pages.max(1) as f64)
364    }
365}
366
367#[cfg(not(unix))]
368fn resident_fraction(_f: &std::fs::File, _off: u64, _len: usize) -> Option<f64> {
369    None
370}
371
372/// Sequential read throughput (MB/s) at the checkpoint location.
373///
374/// Reads a slice of the LARGEST file already in `dir` — for a cluster node that
375/// is the checkpoint itself, i.e. the exact medium the routed experts will page
376/// from. Falls back to a write+fsync probe when the directory is empty, which
377/// still separates NVMe from SATA from spinning rust even if the absolute number
378/// is a write figure.
379///
380/// This used to be hardcoded to 0.0, which silently made every node look equally
381/// slow to any IO-aware policy.
382fn bench_io_mbps(dir: &str) -> f64 {
383    const CHUNK: usize = 64 << 20;
384    // How many offsets to try before concluding the file is simply all cached.
385    const TRIES: usize = 8;
386    // Above this, the window is cache-served and timing it measures RAM.
387    const COLD: f64 = 0.10;
388
389    if let Some((path, len)) = largest_file(dir)
390        && len > (8 << 20)
391        && let Ok(mut f) = std::fs::File::open(&path)
392    {
393        use std::io::{Read, Seek, SeekFrom};
394        let want = CHUNK.min(len as usize);
395        let span = len as usize - want;
396        // Look for a window the page cache does not already hold. Without this
397        // the number is RAM bandwidth — see `resident_fraction`.
398        let mut chosen = None;
399        for i in 0..TRIES {
400            let off = if span > 0 {
401                // Vary per attempt and per run; no RNG dependency needed.
402                (nanos() as usize).wrapping_mul(i * 2 + 1) % span
403            } else {
404                0
405            };
406            match resident_fraction(&f, off as u64, want) {
407                // Cannot tell (no mincore, mmap refused): the historical
408                // behaviour, one read at a varying offset, is the best we have.
409                None => {
410                    chosen = Some(off);
411                    break;
412                }
413                Some(r) if r <= COLD => {
414                    chosen = Some(off);
415                    break;
416                }
417                Some(_) => {
418                    if span == 0 {
419                        break; // Only one window exists and it is hot.
420                    }
421                }
422            }
423        }
424        if let Some(off) = chosen {
425            no_cache_hint(&f, off as u64, want as u64);
426            if f.seek(SeekFrom::Start(off as u64)).is_ok() {
427                let mut buf = vec![0u8; want];
428                let t = Instant::now();
429                if f.read_exact(&mut buf).is_ok() {
430                    let secs = t.elapsed().as_secs_f64();
431                    std::hint::black_box(&buf);
432                    if secs > 0.0 {
433                        return want as f64 / 1e6 / secs;
434                    }
435                }
436            }
437        }
438        // Every window sampled was resident — typical when the checkpoint is
439        // smaller than free RAM. Reporting that read would claim ~20 GB/s of
440        // "disk". Fall through to the write probe, which at least has to reach
441        // the device.
442    }
443    write_probe_mbps(dir)
444}
445
446fn nanos() -> u128 {
447    std::time::SystemTime::now()
448        .duration_since(std::time::UNIX_EPOCH)
449        .map(|d| d.as_nanos())
450        .unwrap_or(1)
451}
452
453/// Largest regular file directly in `dir` (not recursive).
454fn largest_file(dir: &str) -> Option<(std::path::PathBuf, u64)> {
455    let rd = std::fs::read_dir(dir).ok()?;
456    rd.flatten()
457        .filter_map(|e| {
458            let md = e.metadata().ok()?;
459            md.is_file().then(|| (e.path(), md.len()))
460        })
461        .max_by_key(|(_, l)| *l)
462}
463
464/// Write + fsync a small file and time it — the fallback when there is nothing
465/// to read. Cleans up after itself.
466fn write_probe_mbps(dir: &str) -> f64 {
467    use std::io::Write;
468    const N: usize = 16 << 20;
469    let path = std::path::Path::new(dir).join(format!(".rlx-io-probe-{}", nanos()));
470    let buf = vec![0xA5u8; N];
471    let t = Instant::now();
472    let ok = (|| -> std::io::Result<()> {
473        let mut f = std::fs::File::create(&path)?;
474        f.write_all(&buf)?;
475        f.sync_all()
476    })()
477    .is_ok();
478    let secs = t.elapsed().as_secs_f64();
479    let _ = std::fs::remove_file(&path);
480    if ok && secs > 0.0 {
481        N as f64 / 1e6 / secs
482    } else {
483        0.0
484    }
485}
486
487fn detect_devices(os: &str) -> Vec<DeviceInfo> {
488    let mut out = vec![DeviceInfo::new(Device::Cpu, cpu_name(os), 0, false)];
489    if os == "macos" {
490        // Apple Silicon: unified-memory Metal/MLX GPU + Neural Engine.
491        let name = cpu_name(os);
492        out.push(DeviceInfo::new(Device::Metal, name.clone(), 0, true));
493        out.push(DeviceInfo::new(
494            Device::Ane,
495            format!("{name} Neural Engine"),
496            0,
497            true,
498        ));
499    } else {
500        // NVIDIA via nvidia-smi.
501        if let Some(s) = run(
502            "nvidia-smi",
503            &[
504                "--query-gpu=name,memory.total",
505                "--format=csv,noheader,nounits",
506            ],
507        ) {
508            for line in s.lines() {
509                let mut it = line.split(',');
510                let name = it.next().unwrap_or("CUDA GPU").trim().to_string();
511                let mem_mib: u64 = it.next().and_then(|m| m.trim().parse().ok()).unwrap_or(0);
512                out.push(DeviceInfo::new(
513                    Device::Cuda,
514                    name,
515                    mem_mib * 1024 * 1024,
516                    false,
517                ));
518            }
519        }
520        // AMD/Vulkan iGPU (shared RAM). Report GTT — the GPU-addressable slice of
521        // system RAM (amdgpu sysfs) — as the memory ceiling. A Vulkan arena OOMs
522        // if a stage exceeds it, and unlike CUDA managed memory it cannot page
523        // past GTT, so the planner must treat it as a hard cap.
524        let amd_gtt = amdgpu_gtt_total();
525        if run("rocminfo", &[]).is_some() {
526            out.push(DeviceInfo::new(
527                Device::Rocm,
528                "AMD ROCm".into(),
529                amd_gtt,
530                true,
531            ));
532        } else if run("vulkaninfo", &["--summary"])
533            .map(|s| s.contains("GPU"))
534            .unwrap_or(false)
535            || amd_gtt > 0
536        {
537            out.push(DeviceInfo::new(
538                Device::Vulkan,
539                "Vulkan GPU".into(),
540                amd_gtt,
541                true,
542            ));
543        }
544    }
545    out
546}
547
548/// AMD APU GPU-addressable system memory (GTT) in bytes, from amdgpu sysfs.
549/// GTT is the real ceiling for a Vulkan/ROCm arena on an iGPU (VRAM is a tiny
550/// BAR carve-out). Returns 0 when not an amdgpu system.
551fn amdgpu_gtt_total() -> u64 {
552    let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
553        return 0;
554    };
555    for ent in rd.flatten() {
556        let name = ent.file_name();
557        let name = name.to_string_lossy();
558        // Match `card0`, `card1`, … (skip connector nodes like `card0-eDP-1`).
559        if !name.starts_with("card") || name.contains('-') {
560            continue;
561        }
562        let p = ent.path().join("device/mem_info_gtt_total");
563        if let Ok(s) = std::fs::read_to_string(&p)
564            && let Ok(v) = s.trim().parse::<u64>()
565            && v > 0
566        {
567            return v;
568        }
569    }
570    0
571}
572
573fn cpu_name(os: &str) -> String {
574    if os == "macos" {
575        run("sysctl", &["-n", "machdep.cpu.brand_string"])
576            .map(|s| s.trim().to_string())
577            .unwrap_or_else(|| "CPU".into())
578    } else {
579        std::fs::read_to_string("/proc/cpuinfo")
580            .ok()
581            .and_then(|s| {
582                s.lines()
583                    .find(|l| l.starts_with("model name"))
584                    .map(|l| l.split(':').nth(1).unwrap_or("CPU").trim().to_string())
585            })
586            .unwrap_or_else(|| "CPU".into())
587    }
588}
589
590fn ram(os: &str) -> (u64, u64) {
591    if os == "macos" {
592        let total: u64 = run("sysctl", &["-n", "hw.memsize"])
593            .and_then(|s| s.trim().parse().ok())
594            .unwrap_or(0);
595        // Available ≈ free + inactive pages × page size (vm_stat).
596        let page: u64 = run("sysctl", &["-n", "hw.pagesize"])
597            .and_then(|s| s.trim().parse().ok())
598            .unwrap_or(16384);
599        let vm = run("vm_stat", &[]).unwrap_or_default();
600        let grab = |key: &str| -> u64 {
601            vm.lines()
602                .find(|l| l.contains(key))
603                .and_then(|l| l.rsplit(' ').next())
604                .map(|n| n.trim_end_matches('.').parse().unwrap_or(0))
605                .unwrap_or(0)
606        };
607        let avail =
608            (grab("Pages free:") + grab("Pages inactive:") + grab("Pages purgeable:")) * page;
609        (total, avail.min(total))
610    } else {
611        let mi = std::fs::read_to_string("/proc/meminfo").unwrap_or_default();
612        let kb = |key: &str| -> u64 {
613            mi.lines()
614                .find(|l| l.starts_with(key))
615                .and_then(|l| l.split_whitespace().nth(1))
616                .and_then(|n| n.parse::<u64>().ok())
617                .unwrap_or(0)
618                * 1024
619        };
620        (kb("MemTotal:"), kb("MemAvailable:"))
621    }
622}
623
624fn disk_free(dir: &str) -> u64 {
625    // `df -k <dir>` → available KiB in the 4th column of the data row.
626    run("df", &["-k", dir])
627        .and_then(|s| s.lines().nth(1).map(String::from))
628        .and_then(|row| {
629            row.split_whitespace()
630                .nth(3)
631                .and_then(|k| k.parse::<u64>().ok())
632        })
633        .map(|k| k * 1024)
634        .unwrap_or(0)
635}
636
637/// Probe THIS machine. `addr` is how the coordinator will reach its worker;
638/// `ckpt_dir` sizes the disk check; `bench` runs the (short) FLOP micro-bench.
639pub fn probe_local(addr: &str, ckpt_dir: &str, bench: bool) -> NodeCaps {
640    let os = std::env::consts::OS.to_string();
641    let (ram_total, ram_avail) = ram(&os);
642    let mut devices = detect_devices(&os);
643    if bench {
644        for d in devices.iter_mut() {
645            d.gflops = bench_device_gflops(d.kind()).unwrap_or(0.0);
646        }
647    }
648    // The node's headline throughput is its FASTEST device, not its CPU — that
649    // is the one a stage will actually run on. Fall back to the host loop only
650    // when no device could be measured.
651    let gflops = if bench {
652        devices
653            .iter()
654            .map(|d| d.gflops)
655            .fold(0.0f64, f64::max)
656            .max(0.0)
657    } else {
658        0.0
659    };
660    let gflops = if bench && gflops <= 0.0 {
661        bench_gflops()
662    } else {
663        gflops
664    };
665    NodeCaps {
666        addr: addr.to_string(),
667        cores: std::thread::available_parallelism()
668            .map(|n| n.get())
669            .unwrap_or(1),
670        ram_total,
671        ram_avail,
672        disk_free: disk_free(ckpt_dir),
673        devices,
674        gflops,
675        io_mbps: if bench { bench_io_mbps(ckpt_dir) } else { 0.0 },
676        os,
677    }
678}
679
680/// Probe a REMOTE node over SSH by running `<remote_bin> --probe --addr <addr>
681/// --ckpt <dir>` there and parsing its JSON. The same worker binary self-reports.
682pub fn probe_remote(
683    ssh_host: &str,
684    remote_bin: &str,
685    addr: &str,
686    ckpt_dir: &str,
687) -> anyhow::Result<NodeCaps> {
688    let cmd = format!("{remote_bin} --probe --addr {addr} --ckpt {ckpt_dir}");
689    let out = std::process::Command::new("ssh")
690        .arg(ssh_host)
691        .arg(&cmd)
692        .output()?;
693    if !out.status.success() {
694        anyhow::bail!(
695            "probe {ssh_host} failed: {}",
696            String::from_utf8_lossy(&out.stderr)
697        );
698    }
699    let text = String::from_utf8_lossy(&out.stdout);
700    let json = text
701        .lines()
702        .find(|l| l.trim_start().starts_with('{'))
703        .unwrap_or("");
704    serde_json::from_str(json)
705        .map_err(|e| anyhow::anyhow!("parse caps from {ssh_host}: {e}: {json}"))
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    fn scratch(name: &str, bytes: usize) -> std::path::PathBuf {
713        let dir = std::env::temp_dir().join(format!("rlx-caps-{name}"));
714        let _ = std::fs::create_dir_all(&dir);
715        let p = dir.join("probe.bin");
716        std::fs::write(&p, vec![7u8; bytes]).unwrap();
717        p
718    }
719
720    /// `mincore` has to actually answer, or the cold-window search silently
721    /// degrades to the old behaviour of timing the page cache.
722    #[cfg(unix)]
723    #[test]
724    fn residency_is_observable_and_tracks_reads() {
725        let p = scratch("residency", 4 << 20);
726        let f = std::fs::File::open(&p).unwrap();
727        // Just written, so the pages are dirty in cache: definitely resident.
728        let hot = resident_fraction(&f, 0, 4 << 20)
729            .expect("mincore must answer on a plain file-backed mapping");
730        assert!(
731            hot > 0.5,
732            "a file this process just wrote reads as {hot:.2} resident — the \
733             residency probe is not seeing the page cache, so the disk bench \
734             cannot tell RAM from storage"
735        );
736        let _ = std::fs::remove_dir_all(p.parent().unwrap());
737    }
738
739    /// A checkpoint smaller than RAM is entirely cached, so every window is hot
740    /// and the read timing would report RAM bandwidth. The bench must fall back
741    /// rather than return ~20 GB/s of imaginary disk.
742    #[test]
743    fn a_fully_cached_checkpoint_does_not_report_ram_bandwidth() {
744        let p = scratch("cached", 32 << 20);
745        let dir = p.parent().unwrap().to_str().unwrap().to_string();
746        // Pull it all into cache first, the way loading a model would.
747        let _ = std::fs::read(&p).unwrap();
748        let mbps = bench_io_mbps(&dir);
749        assert!(mbps > 0.0 && mbps.is_finite(), "{mbps}");
750        assert!(
751            mbps < 10_000.0,
752            "{mbps:.0} MB/s is memory bandwidth, not a disk — the planner would \
753             price expert paging at roughly nothing"
754        );
755        let _ = std::fs::remove_dir_all(p.parent().unwrap());
756    }
757}