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}