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, 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, Eq)]
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}
26
27impl DeviceInfo {
28    fn new(device: Device, name: String, mem_bytes: u64, unified: bool) -> Self {
29        Self {
30            device: device_label(device).to_string(),
31            name,
32            mem_bytes,
33            unified,
34        }
35    }
36    /// Parsed device kind.
37    pub fn kind(&self) -> Device {
38        parse_device(&self.device).unwrap_or(Device::Cpu)
39    }
40}
41
42/// A node's measured hardware profile.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct NodeCaps {
45    /// Address the coordinator reaches this node's worker at (`host:port`).
46    pub addr: String,
47    /// OS string (`macos`, `linux`).
48    pub os: String,
49    /// Logical CPU cores.
50    pub cores: usize,
51    /// Total / currently-available host RAM (bytes).
52    pub ram_total: u64,
53    pub ram_avail: u64,
54    /// Free disk at the checkpoint location (bytes).
55    pub disk_free: u64,
56    /// Compute devices, CPU first.
57    pub devices: Vec<DeviceInfo>,
58    /// Rough sustained matmul throughput (GFLOP/s, f32) from a micro-bench.
59    pub gflops: f64,
60    /// Rough disk read throughput (MB/s) from a micro-bench (0 if not measured).
61    pub io_mbps: f64,
62}
63
64impl NodeCaps {
65    /// Best (non-CPU) accelerator memory ceiling, else host RAM — the largest a
66    /// stage can occupy on this node's device. A GPU that reports a positive
67    /// `mem_bytes` (discrete VRAM, or an APU's GTT / GPU-addressable system RAM)
68    /// caps the stage even when it is "unified": an amdgpu APU shares system RAM
69    /// but the kernel bounds GPU access at GTT, so we must honor it. Devices with
70    /// no meaningful ceiling (Apple Metal, `mem_bytes == 0`) fall through to RAM.
71    pub fn accel_mem(&self) -> u64 {
72        self.devices
73            .iter()
74            .filter(|d| d.kind() != Device::Cpu && d.mem_bytes > 0)
75            .map(|d| d.mem_bytes)
76            .max()
77            .unwrap_or(self.ram_total)
78    }
79
80    /// One-line summary for logs / the monitor table.
81    pub fn summary(&self) -> String {
82        let devs: Vec<String> = self
83            .devices
84            .iter()
85            .map(|d| device_label(d.kind()).to_string())
86            .collect();
87        format!(
88            "{} | {} cores | {:.0}/{:.0} GB RAM | {:.0} GFLOP/s | {}",
89            self.os,
90            self.cores,
91            self.ram_avail as f64 / 1e9,
92            self.ram_total as f64 / 1e9,
93            self.gflops,
94            devs.join("+"),
95        )
96    }
97}
98
99fn run(cmd: &str, args: &[&str]) -> Option<String> {
100    std::process::Command::new(cmd)
101        .args(args)
102        .output()
103        .ok()
104        .filter(|o| o.status.success())
105        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
106}
107
108/// Quick f32 matmul micro-bench → GFLOP/s (single-thread; a throughput proxy).
109fn bench_gflops() -> f64 {
110    let n = 384usize;
111    let a = vec![1.0000117f32; n * n];
112    let b = vec![0.9999931f32; n * n];
113    let mut c = vec![0f32; n * n];
114    let t = Instant::now();
115    for i in 0..n {
116        for k in 0..n {
117            let av = a[i * n + k];
118            let brow = &b[k * n..k * n + n];
119            let crow = &mut c[i * n..i * n + n];
120            for j in 0..n {
121                crow[j] += av * brow[j];
122            }
123        }
124    }
125    std::hint::black_box(&c);
126    let secs = t.elapsed().as_secs_f64();
127    (2.0 * (n * n * n) as f64) / secs / 1e9
128}
129
130fn detect_devices(os: &str) -> Vec<DeviceInfo> {
131    let mut out = vec![DeviceInfo::new(Device::Cpu, cpu_name(os), 0, false)];
132    if os == "macos" {
133        // Apple Silicon: unified-memory Metal/MLX GPU + Neural Engine.
134        let name = cpu_name(os);
135        out.push(DeviceInfo::new(Device::Metal, name.clone(), 0, true));
136        out.push(DeviceInfo::new(
137            Device::Ane,
138            format!("{name} Neural Engine"),
139            0,
140            true,
141        ));
142    } else {
143        // NVIDIA via nvidia-smi.
144        if let Some(s) = run(
145            "nvidia-smi",
146            &[
147                "--query-gpu=name,memory.total",
148                "--format=csv,noheader,nounits",
149            ],
150        ) {
151            for line in s.lines() {
152                let mut it = line.split(',');
153                let name = it.next().unwrap_or("CUDA GPU").trim().to_string();
154                let mem_mib: u64 = it.next().and_then(|m| m.trim().parse().ok()).unwrap_or(0);
155                out.push(DeviceInfo::new(
156                    Device::Cuda,
157                    name,
158                    mem_mib * 1024 * 1024,
159                    false,
160                ));
161            }
162        }
163        // AMD/Vulkan iGPU (shared RAM). Report GTT — the GPU-addressable slice of
164        // system RAM (amdgpu sysfs) — as the memory ceiling. A Vulkan arena OOMs
165        // if a stage exceeds it, and unlike CUDA managed memory it cannot page
166        // past GTT, so the planner must treat it as a hard cap.
167        let amd_gtt = amdgpu_gtt_total();
168        if run("rocminfo", &[]).is_some() {
169            out.push(DeviceInfo::new(
170                Device::Rocm,
171                "AMD ROCm".into(),
172                amd_gtt,
173                true,
174            ));
175        } else if run("vulkaninfo", &["--summary"])
176            .map(|s| s.contains("GPU"))
177            .unwrap_or(false)
178            || amd_gtt > 0
179        {
180            out.push(DeviceInfo::new(
181                Device::Vulkan,
182                "Vulkan GPU".into(),
183                amd_gtt,
184                true,
185            ));
186        }
187    }
188    out
189}
190
191/// AMD APU GPU-addressable system memory (GTT) in bytes, from amdgpu sysfs.
192/// GTT is the real ceiling for a Vulkan/ROCm arena on an iGPU (VRAM is a tiny
193/// BAR carve-out). Returns 0 when not an amdgpu system.
194fn amdgpu_gtt_total() -> u64 {
195    let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
196        return 0;
197    };
198    for ent in rd.flatten() {
199        let name = ent.file_name();
200        let name = name.to_string_lossy();
201        // Match `card0`, `card1`, … (skip connector nodes like `card0-eDP-1`).
202        if !name.starts_with("card") || name.contains('-') {
203            continue;
204        }
205        let p = ent.path().join("device/mem_info_gtt_total");
206        if let Ok(s) = std::fs::read_to_string(&p)
207            && let Ok(v) = s.trim().parse::<u64>()
208            && v > 0
209        {
210            return v;
211        }
212    }
213    0
214}
215
216fn cpu_name(os: &str) -> String {
217    if os == "macos" {
218        run("sysctl", &["-n", "machdep.cpu.brand_string"])
219            .map(|s| s.trim().to_string())
220            .unwrap_or_else(|| "CPU".into())
221    } else {
222        std::fs::read_to_string("/proc/cpuinfo")
223            .ok()
224            .and_then(|s| {
225                s.lines()
226                    .find(|l| l.starts_with("model name"))
227                    .map(|l| l.split(':').nth(1).unwrap_or("CPU").trim().to_string())
228            })
229            .unwrap_or_else(|| "CPU".into())
230    }
231}
232
233fn ram(os: &str) -> (u64, u64) {
234    if os == "macos" {
235        let total: u64 = run("sysctl", &["-n", "hw.memsize"])
236            .and_then(|s| s.trim().parse().ok())
237            .unwrap_or(0);
238        // Available ≈ free + inactive pages × page size (vm_stat).
239        let page: u64 = run("sysctl", &["-n", "hw.pagesize"])
240            .and_then(|s| s.trim().parse().ok())
241            .unwrap_or(16384);
242        let vm = run("vm_stat", &[]).unwrap_or_default();
243        let grab = |key: &str| -> u64 {
244            vm.lines()
245                .find(|l| l.contains(key))
246                .and_then(|l| l.rsplit(' ').next())
247                .map(|n| n.trim_end_matches('.').parse().unwrap_or(0))
248                .unwrap_or(0)
249        };
250        let avail =
251            (grab("Pages free:") + grab("Pages inactive:") + grab("Pages purgeable:")) * page;
252        (total, avail.min(total))
253    } else {
254        let mi = std::fs::read_to_string("/proc/meminfo").unwrap_or_default();
255        let kb = |key: &str| -> u64 {
256            mi.lines()
257                .find(|l| l.starts_with(key))
258                .and_then(|l| l.split_whitespace().nth(1))
259                .and_then(|n| n.parse::<u64>().ok())
260                .unwrap_or(0)
261                * 1024
262        };
263        (kb("MemTotal:"), kb("MemAvailable:"))
264    }
265}
266
267fn disk_free(dir: &str) -> u64 {
268    // `df -k <dir>` → available KiB in the 4th column of the data row.
269    run("df", &["-k", dir])
270        .and_then(|s| s.lines().nth(1).map(String::from))
271        .and_then(|row| {
272            row.split_whitespace()
273                .nth(3)
274                .and_then(|k| k.parse::<u64>().ok())
275        })
276        .map(|k| k * 1024)
277        .unwrap_or(0)
278}
279
280/// Probe THIS machine. `addr` is how the coordinator will reach its worker;
281/// `ckpt_dir` sizes the disk check; `bench` runs the (short) FLOP micro-bench.
282pub fn probe_local(addr: &str, ckpt_dir: &str, bench: bool) -> NodeCaps {
283    let os = std::env::consts::OS.to_string();
284    let (ram_total, ram_avail) = ram(&os);
285    NodeCaps {
286        addr: addr.to_string(),
287        cores: std::thread::available_parallelism()
288            .map(|n| n.get())
289            .unwrap_or(1),
290        ram_total,
291        ram_avail,
292        disk_free: disk_free(ckpt_dir),
293        devices: detect_devices(&os),
294        gflops: if bench { bench_gflops() } else { 0.0 },
295        io_mbps: 0.0,
296        os,
297    }
298}
299
300/// Probe a REMOTE node over SSH by running `<remote_bin> --probe --addr <addr>
301/// --ckpt <dir>` there and parsing its JSON. The same worker binary self-reports.
302pub fn probe_remote(
303    ssh_host: &str,
304    remote_bin: &str,
305    addr: &str,
306    ckpt_dir: &str,
307) -> anyhow::Result<NodeCaps> {
308    let cmd = format!("{remote_bin} --probe --addr {addr} --ckpt {ckpt_dir}");
309    let out = std::process::Command::new("ssh")
310        .arg(ssh_host)
311        .arg(&cmd)
312        .output()?;
313    if !out.status.success() {
314        anyhow::bail!(
315            "probe {ssh_host} failed: {}",
316            String::from_utf8_lossy(&out.stderr)
317        );
318    }
319    let text = String::from_utf8_lossy(&out.stdout);
320    let json = text
321        .lines()
322        .find(|l| l.trim_start().starts_with('{'))
323        .unwrap_or("");
324    serde_json::from_str(json)
325        .map_err(|e| anyhow::anyhow!("parse caps from {ssh_host}: {e}: {json}"))
326}