Skip to main content

falsegreen_agent/
hardware.rs

1//! Conservative host discovery used to select a compatible managed runtime.
2
3use std::fmt;
4use std::fs;
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum OperatingSystem {
12    Linux,
13    Macos,
14    Windows,
15    Unsupported,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Architecture {
21    X86_64,
22    Aarch64,
23    Unsupported,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub struct RuntimePlatform {
28    pub os: OperatingSystem,
29    pub architecture: Architecture,
30}
31
32impl RuntimePlatform {
33    #[must_use]
34    pub const fn current() -> Self {
35        let os = if cfg!(target_os = "linux") {
36            OperatingSystem::Linux
37        } else if cfg!(target_os = "macos") {
38            OperatingSystem::Macos
39        } else if cfg!(target_os = "windows") {
40            OperatingSystem::Windows
41        } else {
42            OperatingSystem::Unsupported
43        };
44        let architecture = if cfg!(target_arch = "x86_64") {
45            Architecture::X86_64
46        } else if cfg!(target_arch = "aarch64") {
47            Architecture::Aarch64
48        } else {
49            Architecture::Unsupported
50        };
51        Self { os, architecture }
52    }
53
54    #[must_use]
55    pub const fn cache_key(self) -> &'static str {
56        match (self.os, self.architecture) {
57            (OperatingSystem::Linux, Architecture::X86_64) => "linux-x86_64",
58            (OperatingSystem::Linux, Architecture::Aarch64) => "linux-aarch64",
59            (OperatingSystem::Macos, Architecture::X86_64) => "macos-x86_64",
60            (OperatingSystem::Macos, Architecture::Aarch64) => "macos-aarch64",
61            (OperatingSystem::Windows, Architecture::X86_64) => "windows-x86_64",
62            (OperatingSystem::Windows, Architecture::Aarch64) => "windows-aarch64",
63            _ => "unsupported",
64        }
65    }
66}
67
68impl fmt::Display for RuntimePlatform {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str(self.cache_key())
71    }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum RuntimeBackend {
77    Cpu,
78    Metal,
79    Rocm,
80    Vulkan,
81}
82
83impl RuntimeBackend {
84    #[must_use]
85    pub const fn cache_key(self) -> &'static str {
86        match self {
87            Self::Cpu => "cpu",
88            Self::Metal => "metal",
89            Self::Rocm => "rocm",
90            Self::Vulkan => "vulkan",
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct GpuDevice {
97    pub identifier: String,
98    pub vendor: String,
99    #[serde(default)]
100    pub vendor_id: Option<String>,
101    pub device_id: Option<String>,
102    #[serde(default)]
103    pub subsystem_vendor_id: Option<String>,
104    #[serde(default)]
105    pub subsystem_device_id: Option<String>,
106    #[serde(default)]
107    pub architecture: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct HardwareProfile {
112    pub platform: RuntimePlatform,
113    #[serde(default)]
114    pub kernel_release: Option<String>,
115    pub logical_cpus: usize,
116    pub total_memory_bytes: Option<u64>,
117    pub gpus: Vec<GpuDevice>,
118    pub rocm_available: bool,
119    pub vulkan_available: bool,
120    #[serde(default)]
121    pub graphics_driver: Option<String>,
122    pub diagnostics: Vec<String>,
123}
124
125impl HardwareProfile {
126    #[must_use]
127    pub fn detect() -> Self {
128        let platform = RuntimePlatform::current();
129        let kernel_release = (platform.os == OperatingSystem::Linux)
130            .then(|| read_trimmed(Path::new("/proc/sys/kernel/osrelease")))
131            .flatten();
132        let logical_cpus = std::thread::available_parallelism().map_or(1, usize::from);
133        let mut diagnostics = Vec::new();
134        let total_memory_bytes = detect_memory(platform, &mut diagnostics);
135        let (gpus, rocm_available, vulkan_available) =
136            detect_accelerators(platform, &mut diagnostics);
137        let graphics_driver = detect_graphics_driver(platform, &gpus, kernel_release.as_deref());
138        Self {
139            platform,
140            kernel_release,
141            logical_cpus,
142            total_memory_bytes,
143            gpus,
144            rocm_available,
145            vulkan_available,
146            graphics_driver,
147            diagnostics,
148        }
149    }
150
151    /// Choose the strongest backend that has both observable host support and a pinned artifact.
152    /// Unknown Windows GPU state deliberately falls back to CPU rather than guessing.
153    #[must_use]
154    pub fn recommended_backend(&self) -> RuntimeBackend {
155        if self.platform.os == OperatingSystem::Macos {
156            return RuntimeBackend::Metal;
157        }
158        if self.platform.os == OperatingSystem::Linux && self.rocm_available {
159            return RuntimeBackend::Rocm;
160        }
161        if self.platform.os == OperatingSystem::Linux && self.vulkan_available {
162            return RuntimeBackend::Vulkan;
163        }
164        RuntimeBackend::Cpu
165    }
166}
167
168fn detect_memory(platform: RuntimePlatform, diagnostics: &mut Vec<String>) -> Option<u64> {
169    match platform.os {
170        OperatingSystem::Linux => match fs::read_to_string("/proc/meminfo") {
171            Ok(contents) => parse_linux_memory(&contents).or_else(|| {
172                diagnostics.push("/proc/meminfo did not contain a valid MemTotal".to_owned());
173                None
174            }),
175            Err(error) => {
176                diagnostics.push(format!("could not read /proc/meminfo: {error}"));
177                None
178            }
179        },
180        OperatingSystem::Macos => macos_memory(diagnostics),
181        _ => {
182            diagnostics
183                .push("physical memory detection is unavailable on this platform".to_owned());
184            None
185        }
186    }
187}
188
189fn parse_linux_memory(contents: &str) -> Option<u64> {
190    let line = contents
191        .lines()
192        .find(|line| line.starts_with("MemTotal:"))?;
193    let kibibytes = line.split_whitespace().nth(1)?.parse::<u64>().ok()?;
194    kibibytes.checked_mul(1024)
195}
196
197#[cfg(target_os = "macos")]
198fn macos_memory(diagnostics: &mut Vec<String>) -> Option<u64> {
199    let output = std::process::Command::new("/usr/sbin/sysctl")
200        .args(["-n", "hw.memsize"])
201        .output();
202    match output {
203        Ok(output) if output.status.success() => String::from_utf8(output.stdout)
204            .ok()
205            .and_then(|value| value.trim().parse::<u64>().ok()),
206        Ok(output) => {
207            diagnostics.push(format!("sysctl hw.memsize exited with {}", output.status));
208            None
209        }
210        Err(error) => {
211            diagnostics.push(format!("could not run sysctl hw.memsize: {error}"));
212            None
213        }
214    }
215}
216
217#[cfg(not(target_os = "macos"))]
218fn macos_memory(_diagnostics: &mut Vec<String>) -> Option<u64> {
219    None
220}
221
222fn detect_accelerators(
223    platform: RuntimePlatform,
224    diagnostics: &mut Vec<String>,
225) -> (Vec<GpuDevice>, bool, bool) {
226    if platform.os == OperatingSystem::Macos {
227        return (
228            vec![GpuDevice {
229                identifier: "apple-metal".to_owned(),
230                vendor: "Apple".to_owned(),
231                vendor_id: None,
232                device_id: None,
233                subsystem_vendor_id: None,
234                subsystem_device_id: None,
235                architecture: None,
236            }],
237            false,
238            false,
239        );
240    }
241    if platform.os != OperatingSystem::Linux {
242        diagnostics.push(
243            "GPU auto-detection is conservative on this platform; selecting CPU by default"
244                .to_owned(),
245        );
246        return (Vec::new(), false, false);
247    }
248
249    let gpus = linux_drm_devices(Path::new("/sys/class/drm"), diagnostics);
250    let rocm_available =
251        Path::new("/dev/kfd").exists() && gpus.iter().any(|gpu| gpu.vendor == "AMD");
252    let vulkan_available = Path::new("/dev/dri").read_dir().is_ok_and(|entries| {
253        entries
254            .filter_map(Result::ok)
255            .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD"))
256    });
257    (gpus, rocm_available, vulkan_available)
258}
259
260fn linux_drm_devices(root: &Path, diagnostics: &mut Vec<String>) -> Vec<GpuDevice> {
261    let entries = match fs::read_dir(root) {
262        Ok(entries) => entries,
263        Err(error) => {
264            diagnostics.push(format!("could not inspect {}: {error}", root.display()));
265            return Vec::new();
266        }
267    };
268    let mut devices = Vec::new();
269    for entry in entries.filter_map(Result::ok) {
270        let name = entry.file_name().to_string_lossy().into_owned();
271        if !name.starts_with("card") || name.contains('-') {
272            continue;
273        }
274        let vendor_path = entry.path().join("device/vendor");
275        let Ok(vendor_id) = fs::read_to_string(&vendor_path) else {
276            continue;
277        };
278        let vendor_id = vendor_id.trim().to_ascii_lowercase();
279        let vendor = match vendor_id.as_str() {
280            "0x1002" => "AMD",
281            "0x10de" => "NVIDIA",
282            "0x8086" => "Intel",
283            _ => vendor_id.as_str(),
284        };
285        let device_id = fs::read_to_string(entry.path().join("device/device"))
286            .ok()
287            .map(|value| value.trim().to_ascii_lowercase());
288        let subsystem_vendor_id = read_trimmed(&entry.path().join("device/subsystem_vendor"))
289            .map(|value| value.to_ascii_lowercase());
290        let subsystem_device_id = read_trimmed(&entry.path().join("device/subsystem_device"))
291            .map(|value| value.to_ascii_lowercase());
292        let architecture = known_gpu_architecture(&vendor_id, device_id.as_deref());
293        devices.push(GpuDevice {
294            identifier: name,
295            vendor: vendor.to_owned(),
296            vendor_id: Some(vendor_id),
297            device_id,
298            subsystem_vendor_id,
299            subsystem_device_id,
300            architecture,
301        });
302    }
303    devices.sort_by(|left, right| left.identifier.cmp(&right.identifier));
304    devices
305}
306
307fn read_trimmed(path: &Path) -> Option<String> {
308    fs::read_to_string(path)
309        .ok()
310        .map(|value| value.trim().to_owned())
311        .filter(|value| !value.is_empty())
312}
313
314fn known_gpu_architecture(vendor_id: &str, device_id: Option<&str>) -> Option<String> {
315    match (vendor_id, device_id) {
316        ("0x1002", Some("0x744c")) => Some("gfx1100".to_owned()),
317        _ => None,
318    }
319}
320
321fn detect_graphics_driver(
322    platform: RuntimePlatform,
323    gpus: &[GpuDevice],
324    kernel_release: Option<&str>,
325) -> Option<String> {
326    if platform.os == OperatingSystem::Macos {
327        return Some("Metal".to_owned());
328    }
329    if platform.os != OperatingSystem::Linux {
330        return None;
331    }
332    let driver = if gpus.iter().any(|gpu| gpu.vendor == "AMD")
333        && Path::new("/sys/module/amdgpu").exists()
334    {
335        "amdgpu"
336    } else if gpus.iter().any(|gpu| gpu.vendor == "NVIDIA")
337        && Path::new("/sys/module/nvidia").exists()
338    {
339        "nvidia"
340    } else if gpus.iter().any(|gpu| gpu.vendor == "Intel") && Path::new("/sys/module/i915").exists()
341    {
342        "i915"
343    } else {
344        return None;
345    };
346    Some(kernel_release.map_or_else(
347        || driver.to_owned(),
348        |release| format!("{driver} on kernel {release}"),
349    ))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::{
355        Architecture, GpuDevice, HardwareProfile, OperatingSystem, RuntimeBackend, RuntimePlatform,
356        parse_linux_memory,
357    };
358
359    #[test]
360    fn parses_linux_memtotal_as_bytes() {
361        assert_eq!(
362            parse_linux_memory("MemFree: 1 kB\nMemTotal:       16384 kB\n"),
363            Some(16 * 1024 * 1024)
364        );
365        assert_eq!(parse_linux_memory("MemFree: 1 kB\n"), None);
366    }
367
368    #[test]
369    fn backend_selection_requires_observable_support() {
370        let base = HardwareProfile {
371            platform: RuntimePlatform {
372                os: OperatingSystem::Linux,
373                architecture: Architecture::X86_64,
374            },
375            kernel_release: Some("test-kernel".to_owned()),
376            logical_cpus: 8,
377            total_memory_bytes: Some(32 * 1024 * 1024 * 1024),
378            gpus: vec![GpuDevice {
379                identifier: "card0".to_owned(),
380                vendor: "AMD".to_owned(),
381                vendor_id: Some("0x1002".to_owned()),
382                device_id: Some("0x744c".to_owned()),
383                subsystem_vendor_id: Some("0x1eae".to_owned()),
384                subsystem_device_id: Some("0x7901".to_owned()),
385                architecture: Some("gfx1100".to_owned()),
386            }],
387            rocm_available: false,
388            vulkan_available: false,
389            graphics_driver: Some("amdgpu on kernel test-kernel".to_owned()),
390            diagnostics: Vec::new(),
391        };
392        assert_eq!(base.recommended_backend(), RuntimeBackend::Cpu);
393        assert_eq!(
394            HardwareProfile {
395                vulkan_available: true,
396                ..base.clone()
397            }
398            .recommended_backend(),
399            RuntimeBackend::Vulkan
400        );
401        assert_eq!(
402            HardwareProfile {
403                rocm_available: true,
404                vulkan_available: true,
405                ..base
406            }
407            .recommended_backend(),
408            RuntimeBackend::Rocm
409        );
410    }
411
412    #[test]
413    fn apple_platform_selects_metal() {
414        let profile = HardwareProfile {
415            platform: RuntimePlatform {
416                os: OperatingSystem::Macos,
417                architecture: Architecture::Aarch64,
418            },
419            kernel_release: None,
420            logical_cpus: 8,
421            total_memory_bytes: None,
422            gpus: Vec::new(),
423            rocm_available: false,
424            vulkan_available: false,
425            graphics_driver: Some("Metal".to_owned()),
426            diagnostics: Vec::new(),
427        };
428        assert_eq!(profile.recommended_backend(), RuntimeBackend::Metal);
429    }
430}