1use 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 #[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}