1use std::env;
9use std::fs;
10use std::process::Command;
11use std::sync::Mutex;
12use std::sync::OnceLock;
13use std::time::{Duration, Instant};
14
15#[cfg(target_os = "linux")]
16use std::path::Path;
17
18use crate::protocol::NodeResources;
19
20#[derive(Clone, Debug, Default, Hash)]
26pub struct CpuInfo {
27 pub brand: String,
29 pub logical_cores: usize,
31 pub physical_cores: usize,
33 pub arch: String,
35 pub features: CpuFeatures,
37}
38
39#[derive(Clone, Debug, Default, Hash)]
41pub struct CpuFeatures {
42 pub avx2: bool,
43 pub avx_512: bool,
44 pub neon: bool,
45 pub sve: bool,
46 pub amx: bool,
47 pub sme: bool,
48}
49
50#[derive(Clone, Debug, Default)]
52pub struct MemoryInfo {
53 pub total_gb: f32,
55 pub available_gb: f32,
57}
58
59#[derive(Clone, Debug)]
61pub struct GpuInfo {
62 pub name: String,
64 pub vram_gb: f32,
66 pub backend: super::host::GpuBackend,
68 pub compute_capability: String,
70 pub driver_version: String,
72 pub pci_address: Option<String>,
74}
75
76#[derive(Clone, Debug)]
78pub struct NpuInfo {
79 pub name: String,
80 pub memory_gb: f32,
81 pub driver: String,
82}
83
84#[derive(Clone, Debug, Default, Hash)]
86pub struct NetworkInfo {
87 pub xdp_supported: bool,
88 pub interfaces: Vec<String>,
89}
90
91#[derive(Clone, Debug, Default)]
97pub struct SystemProfile {
98 pub cpu: CpuInfo,
99 pub memory: MemoryInfo,
100 pub gpus: Vec<GpuInfo>,
101 pub npus: Vec<NpuInfo>,
102 pub network: NetworkInfo,
103 pub acceleration_mode: super::host::AccelerationMode,
105 pub recommended_workers: usize,
107 pub system_id: String,
109 pub detection_source: String,
111}
112
113static SYSTEM_PROFILE_CACHE: OnceLock<Mutex<Option<CachedSystemProfile>>> = OnceLock::new();
118const SYSTEM_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30);
119
120struct CachedSystemProfile {
121 captured_at: Instant,
122 profile: SystemProfile,
123}
124
125impl SystemProfile {
130 pub fn detect() -> Self {
134 Self::detect_with_mode(ProbeMode::Full)
135 }
136
137 pub fn detect_fast() -> Self {
139 Self::detect_with_mode(ProbeMode::Fast)
140 }
141
142 fn detect_with_mode(mode: ProbeMode) -> Self {
143 if mode == ProbeMode::Fast {
144 let cache = SYSTEM_PROFILE_CACHE.get_or_init(|| Mutex::new(None));
145 let mut guard = match cache.lock() {
156 Ok(guard) => guard,
157 Err(poisoned) => poisoned.into_inner(),
158 };
159 if let Some(entry) = guard.as_ref() {
160 if entry.captured_at.elapsed() < SYSTEM_PROFILE_CACHE_TTL {
161 return entry.profile.clone();
162 }
163 }
164 let profile = Self::detect_uncached(mode);
165 *guard = Some(CachedSystemProfile {
166 captured_at: Instant::now(),
167 profile: profile.clone(),
168 });
169 return profile;
170 }
171
172 Self::detect_uncached(mode)
173 }
174
175 fn detect_uncached(mode: ProbeMode) -> Self {
176 let (system_id, cpu, memory, gpus, npus, network) = std::thread::scope(|scope| {
183 let system_id = scope.spawn(|| hostname().unwrap_or_else(|| "unknown".into()));
184 let cpu = scope.spawn(detect_cpu_info);
185 let memory = scope.spawn(detect_memory_info);
186 let gpus = scope.spawn(move || detect_gpus(mode));
187 let npus = scope.spawn(detect_npus);
188 let network = scope.spawn(detect_network_info);
189
190 (
191 system_id.join().unwrap(),
192 cpu.join().unwrap(),
193 memory.join().unwrap(),
194 gpus.join().unwrap(),
195 npus.join().unwrap(),
196 network.join().unwrap(),
197 )
198 });
199 let acceleration_mode = pick_acceleration_mode(&gpus, &cpu);
200 let recommended_workers =
201 compute_recommended_workers(&cpu, &memory, &gpus, acceleration_mode);
202 let detection_source = gpus
203 .first()
204 .and_then(|g| {
205 if g.name == "cpu" || g.name.is_empty() {
206 None
207 } else {
208 Some(g.name.clone())
209 }
210 })
211 .unwrap_or_else(|| "cpu".into());
212
213 Self {
214 cpu,
215 memory,
216 gpus,
217 npus,
218 network,
219 acceleration_mode,
220 recommended_workers,
221 system_id,
222 detection_source,
223 }
224 }
225}
226
227impl From<&SystemProfile> for super::host::RuntimeProfile {
232 fn from(sp: &SystemProfile) -> Self {
233 let primary_gpu = sp.gpus.first();
234 let gpu_backend = primary_gpu
235 .map(|g| g.backend)
236 .unwrap_or(super::host::GpuBackend::Cpu);
237 let vram_gb = primary_gpu.map(|g| g.vram_gb).unwrap_or(0.0);
238 let gpu_name = primary_gpu.map(|g| g.name.clone());
239 let compute_cap = primary_gpu
240 .map(|g| g.compute_capability.clone())
241 .unwrap_or_else(|| String::from("cpu"));
242
243 let node_resources = NodeResources::new(
244 sp.system_id.clone(),
245 vram_gb,
246 sp.memory.total_gb,
247 compute_cap,
248 gpu_name,
249 );
250
251 super::host::RuntimeProfile {
252 node_resources,
253 logical_cores: sp.cpu.logical_cores,
254 recommended_workers: sp.recommended_workers,
255 acceleration_mode: sp.acceleration_mode,
256 gpu_backend,
257 xdp_supported: sp.network.xdp_supported,
258 detection_source: sp.detection_source.clone(),
259 probe_mode: super::host::ProbeMode::Fast,
260 }
261 }
262}
263
264impl From<SystemProfile> for super::host::RuntimeProfile {
265 fn from(sp: SystemProfile) -> Self {
266 Self::from(&sp)
267 }
268}
269
270impl SystemProfile {
271 pub fn to_node_resources(&self) -> NodeResources {
273 let primary = self.gpus.first();
274 NodeResources::new(
275 self.system_id.clone(),
276 primary.map(|g| g.vram_gb).unwrap_or(0.0),
277 self.memory.total_gb,
278 primary
279 .map(|g| g.compute_capability.clone())
280 .unwrap_or_else(|| String::from("cpu")),
281 primary.map(|g| g.name.clone()),
282 )
283 }
284}
285
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291enum ProbeMode {
292 Fast,
293 Full,
294}
295
296fn hostname() -> Option<String> {
297 let output = Command::new("hostname").output().ok()?;
298 if !output.status.success() {
299 return None;
300 }
301 let name = String::from_utf8_lossy(&output.stdout);
302 let trimmed = name.trim();
303 if trimmed.is_empty() {
304 None
305 } else {
306 Some(trimmed.to_string())
307 }
308}
309
310fn detect_cpu_info() -> CpuInfo {
315 let logical_cores = std::thread::available_parallelism()
316 .map(usize::from)
317 .unwrap_or(1);
318
319 let arch = std::env::consts::ARCH.to_string();
320
321 let features = detect_cpu_features(&arch);
323
324 let (brand, physical_cores) = std::thread::scope(|scope| {
330 let brand = scope.spawn(detect_cpu_brand);
331 let physical_cores = scope.spawn(detect_physical_cores);
332 (brand.join().unwrap(), physical_cores.join().unwrap())
333 });
334 let physical_cores = physical_cores.unwrap_or(logical_cores / 2).max(1);
335
336 CpuInfo {
337 brand,
338 logical_cores,
339 physical_cores,
340 arch,
341 features,
342 }
343}
344
345fn detect_cpu_brand() -> String {
346 #[cfg(target_os = "linux")]
348 {
349 if let Ok(output) = Command::new("lscpu").output() {
350 let text = String::from_utf8_lossy(&output.stdout);
351 if let Some(line) = text.lines().find(|l| l.contains("Model name")) {
352 if let Some(name) = line.split(':').nth(1) {
353 return name.trim().to_string();
354 }
355 }
356 }
357 }
358
359 #[cfg(target_os = "macos")]
361 {
362 if let Ok(output) = Command::new("sysctl")
363 .args(["-n", "machdep.cpu.brand_string"])
364 .output()
365 {
366 let name = String::from_utf8_lossy(&output.stdout);
367 let trimmed = name.trim();
368 if !trimmed.is_empty() {
369 return trimmed.to_string();
370 }
371 }
372 if let Ok(output) = Command::new("sysctl")
374 .args(["-n", "machdep.cpu.brand_string"])
375 .output()
376 {
377 let name = String::from_utf8_lossy(&output.stdout);
378 let trimmed = name.trim();
379 if !trimmed.is_empty() {
380 return trimmed.to_string();
381 }
382 }
383 if let Ok(output) = Command::new("sysctl").args(["-n", "hw.model"]).output() {
385 let model = String::from_utf8_lossy(&output.stdout);
386 let trimmed = model.trim();
387 if !trimmed.is_empty() {
388 return format!("Apple {}", trimmed);
389 }
390 }
391 }
392
393 #[cfg(target_os = "windows")]
395 {
396 if let Ok(output) = Command::new("wmic")
397 .args(["cpu", "get", "name", "/format:csv"])
398 .output()
399 {
400 let text = String::from_utf8_lossy(&output.stdout);
401 for line in text.lines().skip(1) {
402 let trimmed = line.trim();
403 if !trimmed.is_empty() && !trimmed.contains("Node") {
404 if let Some(name) = trimmed.split(',').nth(1) {
406 let n = name.trim();
407 if !n.is_empty() {
408 return n.to_string();
409 }
410 }
411 }
412 }
413 }
414 if let Ok(output) = Command::new("powershell")
416 .args([
417 "-NoProfile",
418 "-Command",
419 "(Get-CimInstance Win32_Processor).Name",
420 ])
421 .output()
422 {
423 let name = String::from_utf8_lossy(&output.stdout);
424 let trimmed = name.trim();
425 if !trimmed.is_empty() {
426 return trimmed.to_string();
427 }
428 }
429 }
430
431 String::from("Unknown CPU")
432}
433
434fn detect_cpu_features(arch: &str) -> CpuFeatures {
435 match arch {
436 "x86_64" | "x86" => detect_x86_features(),
437 "aarch64" | "arm" => {
438 let neon = true; let sve = detect_sve();
440 CpuFeatures {
441 neon,
442 sve,
443 ..Default::default()
444 }
445 }
446 _ => CpuFeatures::default(),
447 }
448}
449
450#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
451fn detect_x86_features() -> CpuFeatures {
452 let avx2 = is_x86_feature_detected!("avx2");
453 let avx_512 = is_x86_feature_detected!("avx512f");
454 let amx = detect_amx_tile();
455 CpuFeatures {
456 avx2,
457 avx_512,
458 amx,
459 ..Default::default()
460 }
461}
462
463#[cfg(target_arch = "x86_64")]
481#[allow(unused_unsafe)]
482fn detect_amx_tile() -> bool {
483 let max_leaf = unsafe { core::arch::x86_64::__cpuid(0) }.eax;
488 if max_leaf < 7 {
489 return false;
490 }
491 let leaf7 = unsafe { core::arch::x86_64::__cpuid_count(7, 0) };
492 (leaf7.edx & (1 << 24)) != 0
493}
494
495#[cfg(all(target_arch = "x86", not(target_arch = "x86_64")))]
498fn detect_amx_tile() -> bool {
499 false
500}
501
502#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
503fn detect_x86_features() -> CpuFeatures {
504 CpuFeatures::default()
505}
506
507fn detect_sve() -> bool {
508 #[cfg(target_os = "linux")]
509 {
510 if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
512 if content.contains("sve") || content.contains("SVE") {
513 return true;
514 }
515 }
517 }
518 false
519}
520
521#[cfg(target_os = "windows")]
524fn sum_windows_cim_core_counts(text: &str) -> usize {
525 text.lines()
526 .filter_map(|line| line.trim().parse::<usize>().ok())
527 .sum()
528}
529
530fn detect_physical_cores() -> Option<usize> {
531 #[cfg(target_os = "linux")]
533 {
534 if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
536 let mut cores = std::collections::BTreeSet::new();
537 let mut current_package = None;
538 for line in content.lines() {
539 if let Some(val) = line.strip_prefix("physical id\t: ") {
540 current_package = Some(val.trim().to_string());
541 }
542 if let Some(val) = line.strip_prefix("core id\t\t: ") {
543 if let Some(ref pkg) = current_package {
544 cores.insert(format!("{}-{}", pkg, val.trim()));
545 }
546 }
547 }
548 if !cores.is_empty() {
549 return Some(cores.len());
550 }
551 }
552 }
553
554 #[cfg(target_os = "macos")]
556 {
557 if let Ok(output) = Command::new("sysctl")
558 .args(["-n", "hw.physicalcpu"])
559 .output()
560 {
561 if let Ok(count) = String::from_utf8_lossy(&output.stdout)
562 .trim()
563 .parse::<usize>()
564 {
565 return Some(count);
566 }
567 }
568 }
569
570 #[cfg(target_os = "windows")]
572 {
573 if let Ok(output) = Command::new("powershell")
574 .args([
575 "-NoProfile",
576 "-Command",
577 "(Get-CimInstance Win32_Processor).NumberOfCores",
578 ])
579 .output()
580 {
581 let text = String::from_utf8_lossy(&output.stdout);
582 let total = sum_windows_cim_core_counts(&text);
589 if total > 0 {
590 return Some(total);
591 }
592 }
593 }
594
595 None
596}
597
598fn detect_memory_info() -> MemoryInfo {
603 let (total, available) = std::thread::scope(|scope| {
607 let total = scope.spawn(detect_system_memory_gb);
608 let available = scope.spawn(detect_available_memory_gb);
609 (total.join().unwrap(), available.join().unwrap())
610 });
611 let total_gb = total.unwrap_or(0.0);
612 let available_gb = available.unwrap_or(total_gb * 0.5);
613 MemoryInfo {
614 total_gb,
615 available_gb,
616 }
617}
618
619fn detect_system_memory_gb() -> Option<f32> {
620 if let Ok(val) = env::var("GHOSTLINK_SYSTEM_MEMORY_GB") {
622 if let Ok(gb) = val.parse::<f32>() {
623 return Some(gb.max(0.0));
624 }
625 }
626
627 platform_detect_system_memory_gb()
628}
629
630#[cfg(target_os = "linux")]
631fn platform_detect_system_memory_gb() -> Option<f32> {
632 let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
633 let line = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?;
634 let kb = line
635 .split_whitespace()
636 .nth(1)
637 .and_then(|v| v.parse::<f32>().ok())?;
638 Some(kb / 1024.0 / 1024.0)
639}
640
641#[cfg(target_os = "macos")]
642fn platform_detect_system_memory_gb() -> Option<f32> {
643 if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() {
644 if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
645 .trim()
646 .parse::<f64>()
647 {
648 return Some((bytes / 1_073_741_824.0) as f32);
649 }
650 }
651 None
652}
653
654#[cfg(target_os = "windows")]
655fn platform_detect_system_memory_gb() -> Option<f32> {
656 if let Ok(output) = Command::new("powershell")
657 .args([
658 "-NoProfile",
659 "-Command",
660 "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
661 ])
662 .output()
663 {
664 if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
665 .trim()
666 .parse::<f64>()
667 {
668 if bytes > 0.0 {
669 return Some((bytes / 1_073_741_824.0) as f32);
670 }
671 }
672 }
673 if let Ok(output) = Command::new("wmic")
675 .args([
676 "ComputerSystem",
677 "get",
678 "TotalPhysicalMemory",
679 "/format:csv",
680 ])
681 .output()
682 {
683 let text = String::from_utf8_lossy(&output.stdout);
684 for line in text.lines().skip(1) {
685 if let Some(val) = line.split(',').nth(1) {
686 if let Ok(bytes) = val.trim().parse::<f64>() {
687 if bytes > 0.0 {
688 return Some((bytes / 1_073_741_824.0) as f32);
689 }
690 }
691 }
692 }
693 }
694 None
695}
696
697#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
698fn platform_detect_system_memory_gb() -> Option<f32> {
699 None
700}
701
702fn detect_available_memory_gb() -> Option<f32> {
703 platform_detect_available_memory_gb()
704}
705
706#[cfg(target_os = "linux")]
707fn platform_detect_available_memory_gb() -> Option<f32> {
708 let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
709 if let Some(line) = meminfo.lines().find(|l| l.starts_with("MemAvailable:")) {
710 let kb = line
711 .split_whitespace()
712 .nth(1)
713 .and_then(|v| v.parse::<f32>().ok())?;
714 return Some(kb / 1024.0 / 1024.0);
715 }
716 let free = meminfo
717 .lines()
718 .find(|l| l.starts_with("MemFree:"))
719 .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
720 .unwrap_or(0.0);
721 let cached = meminfo
722 .lines()
723 .find(|l| l.starts_with("Cached:"))
724 .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
725 .unwrap_or(0.0);
726 Some((free + cached) / 1024.0 / 1024.0)
727}
728
729#[cfg(target_os = "macos")]
730fn platform_detect_available_memory_gb() -> Option<f32> {
731 if let Ok(output) = Command::new("vm_stat").output() {
732 let text = String::from_utf8_lossy(&output.stdout);
733 let mut free_pages = 0.0f64;
734 let mut inactive_pages = 0.0f64;
735 let mut page_size = 16384.0f64;
736 for line in text.lines() {
737 if let Some(val) = line.strip_prefix("Mach Virtual Memory Statistics: (page size of ") {
738 if let Ok(size) = val.trim_end_matches(" bytes)").parse::<f64>() {
739 page_size = size;
740 }
741 }
742 if line.contains("pages free") {
743 free_pages = line
744 .split(':')
745 .nth(1)
746 .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
747 .unwrap_or(0.0);
748 }
749 if line.contains("pages inactive") {
750 inactive_pages = line
751 .split(':')
752 .nth(1)
753 .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
754 .unwrap_or(0.0);
755 }
756 }
757 let available_bytes = (free_pages + inactive_pages) * page_size;
758 return Some((available_bytes / 1_073_741_824.0) as f32);
759 }
760 None
761}
762
763#[cfg(target_os = "windows")]
764fn platform_detect_available_memory_gb() -> Option<f32> {
765 if let Ok(output) = Command::new("powershell")
766 .args([
767 "-NoProfile",
768 "-Command",
769 "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory",
770 ])
771 .output()
772 {
773 if let Ok(kb) = String::from_utf8_lossy(&output.stdout)
774 .trim()
775 .parse::<f64>()
776 {
777 return Some((kb / 1024.0 / 1024.0) as f32);
778 }
779 }
780 None
781}
782
783#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
784fn platform_detect_available_memory_gb() -> Option<f32> {
785 None
786}
787
788fn detect_gpus(mode: ProbeMode) -> Vec<GpuInfo> {
793 if let Some(gpu) = detect_gpu_from_env() {
795 return vec![gpu];
796 }
797
798 let mut gpus = Vec::new();
800
801 if let Some(result) = probe_nvidia_smi_with_timeout() {
803 gpus.extend(result);
804 }
805
806 #[cfg(feature = "rocm")]
808 if let Some(result) = probe_rocm_smi_with_timeout() {
809 gpus.extend(result);
810 }
811
812 #[cfg(target_os = "windows")]
814 if gpus.is_empty() {
815 if let Some(result) = probe_windows_wmi_gpu_with_timeout() {
816 gpus.extend(result);
817 }
818 }
819
820 #[cfg(target_os = "linux")]
822 if gpus.is_empty() {
823 if let Some(gpu) = probe_lspci_gpu_with_timeout() {
824 gpus.push(gpu);
825 }
826 }
827
828 #[cfg(target_os = "macos")]
830 if gpus.is_empty() {
831 if let Some(gpu) = probe_macos_gpu_with_timeout() {
832 gpus.push(gpu);
833 }
834 }
835
836 #[cfg(target_os = "linux")]
838 if gpus.is_empty() {
839 if let Some(gpu) = probe_sysfs_gpu() {
840 gpus.push(gpu);
841 }
842 }
843
844 if mode == ProbeMode::Full && gpus.is_empty() {
846 if let Some(gpu) = probe_vulkan_gpu_with_timeout() {
847 gpus.push(gpu);
848 }
849 }
850
851 if gpus.is_empty() {
852 let cc = if cfg!(feature = "rocm") {
853 "rocm"
854 } else {
855 "gpu"
856 };
857 gpus.push(GpuInfo {
858 name: String::from("cpu"),
859 vram_gb: 0.0,
860 backend: super::host::GpuBackend::Cpu,
861 compute_capability: cc.to_string(),
862 driver_version: String::from("native"),
863 pci_address: None,
864 });
865 }
866
867 gpus
868}
869
870fn probe_with_timeout<T, F>(timeout: Duration, probe: F) -> Option<T>
881where
882 T: Send + 'static,
883 F: FnOnce() -> Option<T> + Send + 'static,
884{
885 let (tx, rx) = std::sync::mpsc::channel();
886 std::thread::spawn(move || {
887 let _ = tx.send(probe());
888 });
889 rx.recv_timeout(timeout).ok().flatten()
890}
891
892fn probe_nvidia_smi_with_timeout() -> Option<Vec<GpuInfo>> {
894 probe_with_timeout(Duration::from_millis(5000), probe_nvidia_smi)
895}
896
897#[cfg(feature = "rocm")]
899fn probe_rocm_smi_with_timeout() -> Option<Vec<GpuInfo>> {
900 probe_with_timeout(Duration::from_millis(5000), probe_rocm_smi)
901}
902
903#[cfg(target_os = "windows")]
905fn probe_windows_wmi_gpu_with_timeout() -> Option<Vec<GpuInfo>> {
906 probe_with_timeout(Duration::from_millis(5000), probe_windows_wmi_gpu)
907}
908
909#[cfg(target_os = "linux")]
911fn probe_lspci_gpu_with_timeout() -> Option<GpuInfo> {
912 probe_with_timeout(Duration::from_millis(5000), probe_lspci_gpu)
913}
914
915#[cfg(target_os = "macos")]
917fn probe_macos_gpu_with_timeout() -> Option<GpuInfo> {
918 probe_with_timeout(Duration::from_millis(5000), probe_macos_gpu)
919}
920
921fn probe_vulkan_gpu_with_timeout() -> Option<GpuInfo> {
923 probe_with_timeout(Duration::from_millis(10000), probe_vulkan_gpu)
924}
925
926fn detect_gpu_from_env() -> Option<GpuInfo> {
927 let name = env::var("GHOSTLINK_GPU_NAME")
928 .ok()
929 .filter(|s| !s.trim().is_empty());
930 let vram_gb = env::var("GHOSTLINK_VRAM_GB")
931 .ok()
932 .and_then(|v| v.parse::<f32>().ok());
933 let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
934 .ok()
935 .filter(|s| !s.trim().is_empty());
936 let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
942 if !has_gpu_signal {
943 return None;
944 }
945
946 let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
947 let compute_capability =
948 cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
949 let backend = infer_backend(&gpu_name, &compute_capability);
950
951 Some(GpuInfo {
952 name: gpu_name,
953 vram_gb: vram_gb.unwrap_or(0.0),
954 backend,
955 compute_capability,
956 driver_version: String::from("env"),
957 pci_address: None,
958 })
959}
960
961fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
962 let output = Command::new("nvidia-smi")
963 .args([
964 "--query-gpu=index,name,memory.total,compute_cap,driver_version",
965 "--format=csv,noheader,nounits",
966 ])
967 .output()
968 .ok()?;
969 if !output.status.success() {
970 return None;
971 }
972
973 let stdout = String::from_utf8_lossy(&output.stdout);
974 let mut gpus = Vec::new();
975
976 for line in stdout.lines() {
977 let trimmed = line.trim();
978 if trimmed.is_empty() {
979 continue;
980 }
981 let mut parts = trimmed.split(',').map(str::trim);
982 let _index = parts.next()?;
983 let name = parts.next()?.to_string();
984 let vram_mib = parts
985 .next()
986 .and_then(|v| v.parse::<f32>().ok())
987 .unwrap_or(0.0);
988 let compute_cap = parts
989 .next()
990 .map(|s| s.to_string())
991 .unwrap_or_else(|| String::from("gpu"));
992 let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
993
994 gpus.push(GpuInfo {
995 name,
996 vram_gb: vram_mib / 1024.0,
997 backend: super::host::GpuBackend::Cuda,
998 compute_capability: compute_cap,
999 driver_version: driver,
1000 pci_address: None,
1001 });
1002 }
1003
1004 if gpus.is_empty() {
1005 None
1006 } else {
1007 Some(gpus)
1008 }
1009}
1010
1011#[cfg(feature = "rocm")]
1012fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
1013 let output = Command::new("rocm-smi")
1014 .args(["--showproductname"])
1015 .output()
1016 .ok()?;
1017 if !output.status.success() {
1018 return None;
1019 }
1020 let stdout = String::from_utf8_lossy(&output.stdout);
1021
1022 let mut gpus = Vec::new();
1023 let mut current_gpu = None;
1024
1025 for line in stdout.lines() {
1026 if line.contains("GPU[") && line.contains("Card model:") {
1027 let name = line
1028 .split(':')
1029 .nth(2)
1030 .map(|s| s.trim().to_string())
1031 .unwrap_or_else(|| String::from("AMD GPU"));
1032 let vram = probe_rocm_vram();
1033 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1034 gpus.push(GpuInfo {
1035 name,
1036 vram_gb: vram.unwrap_or(0.0),
1037 backend: super::host::GpuBackend::Rocm,
1038 compute_capability: compute_cap,
1039 driver_version: detect_rocm_version().unwrap_or_default(),
1040 pci_address: current_gpu.take(),
1041 });
1042 }
1043 }
1044
1045 if gpus.is_empty() {
1046 None
1047 } else {
1048 Some(gpus)
1049 }
1050}
1051
1052#[cfg(feature = "rocm")]
1053fn probe_rocm_vram() -> Option<f32> {
1054 let output = Command::new("rocm-smi")
1055 .args(["--showmeminfo", "vram"])
1056 .output()
1057 .ok()?;
1058 let stdout = String::from_utf8_lossy(&output.stdout);
1059 stdout
1060 .lines()
1061 .find(|l| l.contains("Total Memory"))
1062 .and_then(|l| {
1063 let val = l.split(':').nth(1)?.trim().to_lowercase();
1064 let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1065 if val.contains("gb") {
1066 Some(num)
1067 } else if val.contains("mb") {
1068 Some(num / 1024.0)
1069 } else {
1070 None
1071 }
1072 })
1073}
1074
1075#[cfg(feature = "rocm")]
1076fn detect_rocm_version() -> Option<String> {
1077 let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1078 let stderr = String::from_utf8_lossy(&output.stderr);
1079 stderr
1080 .lines()
1081 .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1082}
1083
1084#[cfg(target_os = "windows")]
1085fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1086 let output = Command::new("powershell")
1088 .args([
1089 "-NoProfile",
1090 "-Command",
1091 "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notmatch '(?i)(Microsoft Basic Display|Remote Display|VirtualBox|VMware)' } | Select-Object Name, AdapterRAM, DriverVersion, PNPDeviceID | ConvertTo-Csv -NoTypeInformation",
1092 ])
1093 .output()
1094 .ok()?;
1095 if !output.status.success() {
1096 return None;
1097 }
1098
1099 let stdout = String::from_utf8_lossy(&output.stdout);
1100 let mut gpus: Vec<GpuInfo> = Vec::new();
1101
1102 for line in stdout.lines().skip(1) {
1103 let trimmed = line.trim();
1104 if trimmed.is_empty() || trimmed.starts_with('#') {
1105 continue;
1106 }
1107 let mut parts: Vec<String> = Vec::new();
1109 let mut current = String::new();
1110 let mut in_quotes = false;
1111 for ch in trimmed.chars() {
1112 match ch {
1113 '"' => in_quotes = !in_quotes,
1114 ',' if !in_quotes => {
1115 parts.push(std::mem::take(&mut current));
1116 }
1117 c => current.push(c),
1118 }
1119 }
1120 parts.push(current);
1121 if parts.len() < 3 {
1122 continue;
1123 }
1124 let name = parts[0].trim().to_string();
1125 if name.is_empty() {
1126 continue;
1127 }
1128
1129 let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1130 let vram_gb = if vram_bytes > 0.0 {
1131 (vram_bytes / 1_073_741_824.0) as f32
1132 } else {
1133 0.0
1134 };
1135 let driver = parts[2].trim().to_string();
1136 let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1137 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1138
1139 let backend = if let Some(ref id) = pnp_id {
1141 let id_lower = id.to_ascii_lowercase();
1142 if id_lower.contains("ven_10de") {
1143 super::host::GpuBackend::Cuda
1144 } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1145 if cfg!(feature = "rocm") {
1146 super::host::GpuBackend::Rocm
1147 } else {
1148 super::host::GpuBackend::Directml
1149 }
1150 } else if id_lower.contains("ven_8086") {
1151 super::host::GpuBackend::Directml
1152 } else if id_lower.contains("ven_14e4") {
1153 super::host::GpuBackend::Vulkan
1154 } else {
1155 super::host::GpuBackend::Directml
1156 }
1157 } else {
1158 super::host::GpuBackend::Directml
1159 };
1160
1161 let actual_vram = if vram_gb <= 0.0 {
1163 probe_windows_dxgi_vram().unwrap_or(0.0)
1164 } else {
1165 vram_gb
1166 };
1167
1168 gpus.push(GpuInfo {
1169 name,
1170 vram_gb: actual_vram,
1171 backend,
1172 compute_capability: compute_cap,
1173 driver_version: driver,
1174 pci_address: pnp_id,
1175 });
1176 }
1177
1178 if gpus.is_empty() {
1179 None
1180 } else {
1181 Some(gpus)
1182 }
1183}
1184
1185#[cfg(target_os = "windows")]
1196fn probe_windows_dxgi_vram() -> Option<f32> {
1197 if let Some(vram) = probe_windows_registry_vram() {
1198 return Some(vram);
1199 }
1200 probe_windows_dxgi_vram_via_addtype()
1201}
1202
1203#[cfg(target_os = "windows")]
1204fn probe_windows_registry_vram() -> Option<f32> {
1205 let output = Command::new("powershell")
1206 .args([
1207 "-NoProfile",
1208 "-Command",
1209 "Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -Name 'HardwareInformation.qwMemorySize' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'HardwareInformation.qwMemorySize' -First 1",
1210 ])
1211 .output()
1212 .ok()?;
1213 if !output.status.success() {
1214 return None;
1215 }
1216 let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1217 .trim()
1218 .parse()
1219 .ok()?;
1220 if bytes > 0.0 {
1221 Some((bytes / 1_073_741_824.0) as f32)
1222 } else {
1223 None
1224 }
1225}
1226
1227#[cfg(target_os = "windows")]
1228fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1229 let script = r#"
1230Add-Type @"
1231using System;
1232using System.Runtime.InteropServices;
1233public class DXGIVram {
1234 [DllImport("dxgi.dll")]
1235 private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1236 public static float GetVRAM() {
1237 Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1238 if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1239 try {
1240 IntPtr vtable = Marshal.ReadIntPtr(factory);
1241 IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1242 var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1243 IntPtr adapter;
1244 if (enumFunc(factory, 0, out adapter) != 0) return 0;
1245 try {
1246 IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1247 IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1248 var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1249 DXGI_ADAPTER_DESC desc;
1250 if (descFunc(adapter, out desc) != 0) return 0;
1251 return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1252 } finally { Marshal.Release(adapter); }
1253 } finally { Marshal.Release(factory); }
1254 }
1255 [StructLayout(LayoutKind.Sequential)]
1256 private struct DXGI_ADAPTER_DESC {
1257 public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1258 public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1259 [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1260 }
1261 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1262 private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1263 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1264 private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1265}
1266"@
1267[DXGIVram]::GetVRAM()
1268"#;
1269 let output = Command::new("powershell")
1270 .args(["-NoProfile", "-NonInteractive", "-Command", script])
1271 .output()
1272 .ok()?;
1273 if !output.status.success() {
1274 return None;
1275 }
1276 let stdout = String::from_utf8_lossy(&output.stdout);
1277 stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1278}
1279
1280#[cfg(target_os = "linux")]
1281fn probe_lspci_gpu() -> Option<GpuInfo> {
1282 let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1283 if !output.status.success() {
1284 return None;
1285 }
1286 let stdout = String::from_utf8_lossy(&output.stdout);
1287
1288 let line = stdout
1289 .lines()
1290 .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1291 let quoted: Vec<String> = line
1292 .split('"')
1293 .enumerate()
1294 .filter_map(|(i, s)| {
1295 if i % 2 == 1 {
1296 let t = s.trim();
1297 if t.is_empty() {
1298 None
1299 } else {
1300 Some(t.to_string())
1301 }
1302 } else {
1303 None
1304 }
1305 })
1306 .collect();
1307 let description = quoted
1308 .get(2)
1309 .cloned()
1310 .or_else(|| quoted.last().cloned())
1311 .unwrap_or_else(|| line.trim().to_string());
1312
1313 let compute_cap = super::host::infer_compute_capability_from_name(&description);
1314 Some(GpuInfo {
1315 name: description,
1316 vram_gb: 0.0,
1317 backend: super::host::GpuBackend::Vulkan,
1318 compute_capability: compute_cap,
1319 driver_version: String::from("lspci"),
1320 pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1321 })
1322}
1323
1324#[cfg(target_os = "linux")]
1325fn probe_sysfs_gpu() -> Option<GpuInfo> {
1326 let drm_root = Path::new("/sys/class/drm");
1327 let entries = fs::read_dir(drm_root).ok()?;
1328
1329 for entry in entries.flatten() {
1330 let path = entry.path();
1331 let fname = path.file_name()?.to_string_lossy().to_string();
1332 if !fname.starts_with("card") {
1333 continue;
1334 }
1335
1336 let dev = path.join("device");
1337 if !dev.exists() {
1338 continue;
1339 }
1340
1341 let gpu_name = fs::read_to_string(dev.join("product_name"))
1342 .ok()
1343 .map(|v| v.trim().to_string())
1344 .filter(|v| !v.is_empty());
1345 let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1346 .ok()
1347 .and_then(|v| v.trim().parse::<f32>().ok());
1348 let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1349
1350 if let Some(name) = gpu_name {
1351 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1352 return Some(GpuInfo {
1353 name,
1354 vram_gb: vram_gb.unwrap_or(0.0),
1355 backend: super::host::GpuBackend::Vulkan,
1356 compute_capability: compute_cap,
1357 driver_version: String::from("sysfs"),
1358 pci_address: None,
1359 });
1360 }
1361 }
1362 None
1363}
1364
1365#[cfg(target_os = "macos")]
1366fn probe_macos_gpu() -> Option<GpuInfo> {
1367 let output = Command::new("system_profiler")
1369 .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1370 .output()
1371 .ok()?;
1372 if !output.status.success() {
1373 return None;
1374 }
1375
1376 let stdout = String::from_utf8_lossy(&output.stdout);
1377 let mut name = String::from("Apple GPU");
1378 let mut vram_gb = 0.0;
1379 let mut metal_support = String::from("metal");
1380 let mut gpu_metal_family = String::new();
1381
1382 for line in stdout.lines() {
1383 if line.contains("Chipset Model:") {
1384 if let Some(val) = line.split(':').nth(1) {
1385 name = val.trim().to_string();
1386 }
1387 }
1388 if line.contains("VRAM") {
1390 let val = line.split(':').nth(1).unwrap_or("").trim();
1391 if let Some(num_str) = val.split_whitespace().next() {
1392 if let Ok(num) = num_str.parse::<f32>() {
1393 vram_gb = num;
1394 }
1395 }
1396 }
1397 if line.contains("Metal Support") {
1398 if let Some(val) = line.split(':').nth(1) {
1399 metal_support = val.trim().to_string();
1400 }
1401 }
1402 if line.contains("Metal Family") {
1403 if let Some(val) = line.split(':').nth(1) {
1404 gpu_metal_family = val.trim().to_string();
1405 }
1406 }
1407 }
1408
1409 if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1412 if let Ok(ioreg) = Command::new("ioreg")
1413 .args(["-r", "-c", "IOAccelerator", "-l"])
1414 .output()
1415 {
1416 let iotext = String::from_utf8_lossy(&ioreg.stdout);
1417 for line in iotext.lines() {
1418 if line.contains("\"gpu-core-count\"") {
1420 if let Some(mem_line) =
1422 iotext.lines().find(|l| l.contains("\"memory-available\""))
1423 {
1424 if let Some(val) = mem_line.split('=').nth(1) {
1425 if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1426 vram_gb = (bytes / 1_073_741_824.0) as f32;
1427 }
1428 }
1429 }
1430 break;
1431 }
1432 }
1433 if vram_gb <= 0.0 {
1435 vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1436 }
1437 }
1438 }
1439
1440 let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1442 metal_support
1443 } else if !gpu_metal_family.is_empty() {
1444 format!("apple_metal/{}", gpu_metal_family)
1445 } else {
1446 String::from("apple_metal")
1447 };
1448
1449 Some(GpuInfo {
1450 name,
1451 vram_gb,
1452 backend: super::host::GpuBackend::Metal,
1453 compute_capability,
1454 driver_version: String::from("metal"),
1455 pci_address: None,
1456 })
1457}
1458
1459fn probe_vulkan_gpu() -> Option<GpuInfo> {
1460 if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1462 if output.status.success() {
1463 let stdout = String::from_utf8_lossy(&output.stdout);
1464 for line in stdout.lines() {
1465 if line.contains("GPU id") || line.contains("deviceName") {
1466 let name = line
1467 .split(':')
1468 .nth(1)
1469 .map(|s| s.trim().to_string())
1470 .unwrap_or_else(|| String::from("Vulkan GPU"));
1471 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1472 return Some(GpuInfo {
1473 name,
1474 vram_gb: 0.0,
1475 backend: super::host::GpuBackend::Vulkan,
1476 compute_capability: compute_cap,
1477 driver_version: String::from("vulkan"),
1478 pci_address: None,
1479 });
1480 }
1481 }
1482 }
1483 }
1484 None
1485}
1486
1487fn detect_npus() -> Vec<NpuInfo> {
1492 let mut npus = Vec::new();
1493
1494 #[cfg(target_os = "windows")]
1496 {
1497 if let Ok(output) = Command::new("powershell")
1498 .args([
1499 "-NoProfile",
1500 "-Command",
1501 "Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)(NPU|Neural Processor|AI Accelerator|XDNA|Ryzen AI|Intel NPU)' } | Select-Object -ExpandProperty Name",
1502 ])
1503 .output()
1504 {
1505 if output.status.success() {
1506 let stdout = String::from_utf8_lossy(&output.stdout);
1507 for line in stdout.lines() {
1508 let trimmed = line.trim();
1509 if !trimmed.is_empty() {
1510 npus.push(NpuInfo {
1511 name: trimmed.to_string(),
1512 memory_gb: 2.0, driver: String::from("windows"),
1514 });
1515 }
1516 }
1517 }
1518 }
1519 }
1520
1521 #[cfg(target_os = "linux")]
1523 {
1524 let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1525 for base in &npu_paths {
1526 if let Ok(entries) = fs::read_dir(base) {
1527 for entry in entries.flatten() {
1528 let name = entry.file_name().to_string_lossy().to_string();
1529 if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1530 npus.push(NpuInfo {
1531 name,
1532 memory_gb: 2.0,
1533 driver: String::from("linux"),
1534 });
1535 }
1536 }
1537 }
1538 }
1539 if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1541 for entry in entries.flatten() {
1542 let name = entry.file_name().to_string_lossy().to_string();
1543 npus.push(NpuInfo {
1544 name: format!("accel/{}", name),
1545 memory_gb: 2.0,
1546 driver: String::from("linux"),
1547 });
1548 }
1549 }
1550 }
1551
1552 #[cfg(target_os = "macos")]
1554 {
1555 if super::host::is_apple_silicon() {
1556 npus.push(NpuInfo {
1557 name: String::from("Apple Neural Engine"),
1558 memory_gb: 0.0, driver: String::from("ane"),
1560 });
1561 }
1562 }
1563
1564 if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1566 npus.push(NpuInfo {
1567 name: String::from("npu-env"),
1568 memory_gb: 2.0,
1569 driver: String::from("env"),
1570 });
1571 }
1572
1573 npus
1574}
1575
1576fn detect_network_info() -> NetworkInfo {
1581 let xdp_supported = cfg!(target_os = "linux");
1582 let interfaces = detect_network_interfaces();
1583 NetworkInfo {
1584 xdp_supported,
1585 interfaces,
1586 }
1587}
1588
1589fn detect_network_interfaces() -> Vec<String> {
1590 let mut ifaces = Vec::new();
1591 if let Ok(entries) = fs::read_dir("/sys/class/net") {
1592 for entry in entries.flatten() {
1593 if let Ok(name) = entry.file_name().into_string() {
1594 if !name.starts_with("lo") {
1595 ifaces.push(name);
1596 }
1597 }
1598 }
1599 }
1600 ifaces
1601}
1602
1603fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1608 use super::host::GpuBackend;
1609 let lowered = name.to_ascii_lowercase();
1610 let lowered_cc = compute_capability.to_ascii_lowercase();
1611
1612 if lowered_cc.contains("rocm")
1613 || lowered.contains("amd")
1614 || lowered.contains("radeon")
1615 || lowered.contains("instinct")
1616 || lowered.contains("mi250")
1617 || lowered.contains("mi300")
1618 || lowered.contains("mi350")
1619 {
1620 return GpuBackend::Rocm;
1621 }
1622
1623 if lowered_cc.contains("metal")
1624 || lowered.contains("apple")
1625 || lowered.contains("m1")
1626 || lowered.contains("m2")
1627 || lowered.contains("m3")
1628 || lowered.contains("m4")
1629 {
1630 return GpuBackend::Metal;
1631 }
1632
1633 if lowered_cc.contains("directml") || lowered.contains("directml") {
1634 return GpuBackend::Directml;
1635 }
1636
1637 if lowered_cc.contains("npu")
1638 || lowered.contains("npu")
1639 || lowered.contains("xdna")
1640 || lowered.contains("neural")
1641 {
1642 return GpuBackend::Npu;
1643 }
1644
1645 if lowered.contains("nvidia")
1646 || lowered.contains("rtx")
1647 || lowered.contains("gtx")
1648 || lowered.contains("tesla")
1649 || lowered.contains("a100")
1650 || lowered.contains("h100")
1651 || lowered.contains("b100")
1652 || lowered.contains("b200")
1653 || lowered_cc
1654 .chars()
1655 .next()
1656 .is_some_and(|c| c.is_ascii_digit())
1657 {
1658 return GpuBackend::Cuda;
1659 }
1660
1661 GpuBackend::Vulkan
1662}
1663
1664fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1669 use super::host::AccelerationMode;
1670
1671 if gpus.iter().any(|g| {
1673 g.vram_gb > 0.0
1674 || (g.backend != super::host::GpuBackend::Cpu
1675 && g.backend != super::host::GpuBackend::Vulkan)
1676 }) {
1677 return AccelerationMode::Gpu;
1678 }
1679
1680 if gpus.iter().any(|g| {
1682 g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1683 }) {
1684 return AccelerationMode::Gpu;
1685 }
1686
1687 if cpu.features.avx_512 {
1689 return AccelerationMode::Avx512;
1690 }
1691 if cpu.features.avx2 {
1692 return AccelerationMode::Avx2;
1693 }
1694 if cpu.features.neon || cpu.features.sve {
1695 return AccelerationMode::Neon;
1696 }
1697
1698 AccelerationMode::Generic
1699}
1700
1701fn compute_recommended_workers(
1706 cpu: &CpuInfo,
1707 memory: &MemoryInfo,
1708 gpus: &[GpuInfo],
1709 accel: super::host::AccelerationMode,
1710) -> usize {
1711 let cores = cpu.logical_cores.max(1);
1712 let memory_bound = if memory.total_gb >= 1.0 {
1713 (memory.total_gb / 4.0).floor() as usize
1714 } else {
1715 1
1716 }
1717 .max(1);
1718
1719 let reserved_core = cores.saturating_sub(1).max(1);
1720
1721 let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1723 let accelerator_bonus = match accel {
1724 super::host::AccelerationMode::Gpu => {
1725 let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1726 2
1727 } else {
1728 1
1729 };
1730 vram_bonus + gpu_count.saturating_sub(1) }
1732 super::host::AccelerationMode::Avx512 => 1,
1733 _ => 0,
1734 };
1735
1736 reserved_core
1737 .min(memory_bound)
1738 .saturating_add(accelerator_bonus)
1739}
1740
1741pub fn infer_compute_capability_from_name(name: &str) -> String {
1747 let lowered = name.to_ascii_lowercase();
1748 if lowered.contains("rtx 50")
1749 || lowered.contains("rtx50")
1750 || lowered.contains("b100")
1751 || lowered.contains("b200")
1752 {
1753 String::from("9.0")
1754 } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1755 String::from("8.9")
1756 } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1757 String::from("8.6")
1758 } else if lowered.contains("arc") {
1759 String::from("xe")
1760 } else if lowered.contains("radeon")
1761 || lowered.contains("amd")
1762 || lowered.contains("advanced micro")
1763 || lowered.contains("rx ")
1764 || lowered.contains("w7900")
1765 || lowered.contains("w6800")
1766 || lowered.contains("mi250")
1767 || lowered.contains("mi300")
1768 || lowered.contains("mi350")
1769 || lowered.contains("instinct")
1770 {
1771 if cfg!(feature = "rocm") {
1772 String::from("rocm")
1773 } else {
1774 String::from("gpu")
1775 }
1776 } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1777 String::from("xe")
1778 } else if lowered.contains("apple")
1779 || lowered.contains("m1")
1780 || lowered.contains("m2")
1781 || lowered.contains("m3")
1782 || lowered.contains("m4")
1783 {
1784 String::from("apple_metal")
1785 } else {
1786 String::from("gpu")
1787 }
1788}
1789
1790#[cfg(test)]
1795mod tests {
1796 use super::*;
1797 use crate::GpuBackend;
1798
1799 #[test]
1800 fn system_profile_detects_cpu() {
1801 let sp = SystemProfile::detect_fast();
1802 assert!(sp.cpu.logical_cores >= 1);
1803 assert!(!sp.cpu.arch.is_empty());
1804 }
1805
1806 #[test]
1807 fn system_profile_detects_memory() {
1808 let sp = SystemProfile::detect_fast();
1809 assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
1811 }
1812
1813 #[test]
1814 fn system_profile_always_has_at_least_one_gpu_entry() {
1815 let sp = SystemProfile::detect_fast();
1816 assert!(
1817 !sp.gpus.is_empty(),
1818 "Should have at least 'cpu' fallback GPU entry"
1819 );
1820 }
1821
1822 #[test]
1823 fn system_profile_converts_to_runtime_profile() {
1824 let sp = SystemProfile::detect_fast();
1825 let rp: crate::host::RuntimeProfile = (&sp).into();
1826 assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
1827 assert_eq!(rp.recommended_workers, sp.recommended_workers);
1828 }
1829
1830 #[test]
1835 fn detect_gpu_handles_env_overrides() {
1836 std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
1837 std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
1838 std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
1839
1840 let gpu = detect_gpu_from_env().expect("env probe should work");
1841 assert_eq!(gpu.name, "RTX 4090");
1842 assert!((gpu.vram_gb - 24.0).abs() < 0.1);
1843 assert_eq!(gpu.compute_capability, "8.9");
1844
1845 std::env::remove_var("GHOSTLINK_GPU_NAME");
1846 std::env::remove_var("GHOSTLINK_VRAM_GB");
1847 std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
1848
1849 std::env::set_var("GHOSTLINK_VRAM_GB", "0");
1853 assert!(detect_gpu_from_env().is_none());
1854 std::env::remove_var("GHOSTLINK_VRAM_GB");
1855 }
1856
1857 #[test]
1858 fn infer_backend_rocm() {
1859 let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
1860 assert_eq!(b, GpuBackend::Rocm);
1861 }
1862
1863 #[test]
1864 fn infer_backend_cuda() {
1865 let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
1866 assert_eq!(b, GpuBackend::Cuda);
1867 }
1868
1869 #[test]
1870 fn infer_backend_metal() {
1871 let b = infer_backend("Apple M3 Max", "apple_metal");
1872 assert_eq!(b, GpuBackend::Metal);
1873 }
1874
1875 #[test]
1876 fn compute_workers_scales_with_gpus() {
1877 let cpu = CpuInfo {
1878 logical_cores: 16,
1879 physical_cores: 8,
1880 arch: "x86_64".into(),
1881 ..Default::default()
1882 };
1883 let mem = MemoryInfo {
1884 total_gb: 64.0,
1885 available_gb: 32.0,
1886 };
1887 let gpus = vec![
1888 GpuInfo {
1889 name: "RTX 4090".into(),
1890 vram_gb: 24.0,
1891 backend: GpuBackend::Cuda,
1892 compute_capability: "8.9".into(),
1893 driver_version: "555".into(),
1894 pci_address: None,
1895 },
1896 GpuInfo {
1897 name: "RTX 3090".into(),
1898 vram_gb: 24.0,
1899 backend: GpuBackend::Cuda,
1900 compute_capability: "8.6".into(),
1901 driver_version: "555".into(),
1902 pci_address: None,
1903 },
1904 ];
1905 let accel = pick_acceleration_mode(&gpus, &cpu);
1906 let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
1907 assert!(workers > 0);
1909 }
1910
1911 #[test]
1912 fn physical_cores_nonzero() {
1913 let cpu = detect_cpu_info();
1914 assert!(cpu.physical_cores >= 1);
1915 }
1916
1917 #[cfg(target_os = "windows")]
1918 #[test]
1919 fn sum_windows_cim_core_counts_handles_multi_socket_output() {
1920 assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
1924 assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
1925 assert_eq!(sum_windows_cim_core_counts(""), 0);
1926 assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
1928 }
1929
1930 #[cfg(target_arch = "x86_64")]
1931 #[test]
1932 fn amx_tile_detection_does_not_panic() {
1933 let _ = detect_amx_tile();
1937 }
1938}