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() {
803 gpus.extend(result);
804 }
805
806 #[cfg(feature = "rocm")]
808 if let Some(result) = probe_rocm_smi() {
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() {
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() {
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() {
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() {
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 detect_gpu_from_env() -> Option<GpuInfo> {
871 let name = env::var("GHOSTLINK_GPU_NAME")
872 .ok()
873 .filter(|s| !s.trim().is_empty());
874 let vram_gb = env::var("GHOSTLINK_VRAM_GB")
875 .ok()
876 .and_then(|v| v.parse::<f32>().ok());
877 let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
878 .ok()
879 .filter(|s| !s.trim().is_empty());
880 let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
886 if !has_gpu_signal {
887 return None;
888 }
889
890 let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
891 let compute_capability =
892 cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
893 let backend = infer_backend(&gpu_name, &compute_capability);
894
895 Some(GpuInfo {
896 name: gpu_name,
897 vram_gb: vram_gb.unwrap_or(0.0),
898 backend,
899 compute_capability,
900 driver_version: String::from("env"),
901 pci_address: None,
902 })
903}
904
905fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
906 let output = Command::new("nvidia-smi")
907 .args([
908 "--query-gpu=index,name,memory.total,compute_cap,driver_version",
909 "--format=csv,noheader,nounits",
910 ])
911 .output()
912 .ok()?;
913 if !output.status.success() {
914 return None;
915 }
916
917 let stdout = String::from_utf8_lossy(&output.stdout);
918 let mut gpus = Vec::new();
919
920 for line in stdout.lines() {
921 let trimmed = line.trim();
922 if trimmed.is_empty() {
923 continue;
924 }
925 let mut parts = trimmed.split(',').map(str::trim);
926 let _index = parts.next()?;
927 let name = parts.next()?.to_string();
928 let vram_mib = parts
929 .next()
930 .and_then(|v| v.parse::<f32>().ok())
931 .unwrap_or(0.0);
932 let compute_cap = parts
933 .next()
934 .map(|s| s.to_string())
935 .unwrap_or_else(|| String::from("gpu"));
936 let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
937
938 gpus.push(GpuInfo {
939 name,
940 vram_gb: vram_mib / 1024.0,
941 backend: super::host::GpuBackend::Cuda,
942 compute_capability: compute_cap,
943 driver_version: driver,
944 pci_address: None,
945 });
946 }
947
948 if gpus.is_empty() {
949 None
950 } else {
951 Some(gpus)
952 }
953}
954
955#[cfg(feature = "rocm")]
956fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
957 let output = Command::new("rocm-smi")
958 .args(["--showproductname"])
959 .output()
960 .ok()?;
961 if !output.status.success() {
962 return None;
963 }
964 let stdout = String::from_utf8_lossy(&output.stdout);
965
966 let mut gpus = Vec::new();
967 let mut current_gpu = None;
968
969 for line in stdout.lines() {
970 if line.contains("GPU[") && line.contains("Card model:") {
971 let name = line
972 .split(':')
973 .nth(2)
974 .map(|s| s.trim().to_string())
975 .unwrap_or_else(|| String::from("AMD GPU"));
976 let vram = probe_rocm_vram();
977 let compute_cap = super::host::infer_compute_capability_from_name(&name);
978 gpus.push(GpuInfo {
979 name,
980 vram_gb: vram.unwrap_or(0.0),
981 backend: super::host::GpuBackend::Rocm,
982 compute_capability: compute_cap,
983 driver_version: detect_rocm_version().unwrap_or_default(),
984 pci_address: current_gpu.take(),
985 });
986 }
987 }
988
989 if gpus.is_empty() {
990 None
991 } else {
992 Some(gpus)
993 }
994}
995
996#[cfg(feature = "rocm")]
997fn probe_rocm_vram() -> Option<f32> {
998 let output = Command::new("rocm-smi")
999 .args(["--showmeminfo", "vram"])
1000 .output()
1001 .ok()?;
1002 let stdout = String::from_utf8_lossy(&output.stdout);
1003 stdout
1004 .lines()
1005 .find(|l| l.contains("Total Memory"))
1006 .and_then(|l| {
1007 let val = l.split(':').nth(1)?.trim().to_lowercase();
1008 let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1009 if val.contains("gb") {
1010 Some(num)
1011 } else if val.contains("mb") {
1012 Some(num / 1024.0)
1013 } else {
1014 None
1015 }
1016 })
1017}
1018
1019#[cfg(feature = "rocm")]
1020fn detect_rocm_version() -> Option<String> {
1021 let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1022 let stderr = String::from_utf8_lossy(&output.stderr);
1023 stderr
1024 .lines()
1025 .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1026}
1027
1028#[cfg(target_os = "windows")]
1029fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1030 let output = Command::new("powershell")
1032 .args([
1033 "-NoProfile",
1034 "-Command",
1035 "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",
1036 ])
1037 .output()
1038 .ok()?;
1039 if !output.status.success() {
1040 return None;
1041 }
1042
1043 let stdout = String::from_utf8_lossy(&output.stdout);
1044 let mut gpus: Vec<GpuInfo> = Vec::new();
1045
1046 for line in stdout.lines().skip(1) {
1047 let trimmed = line.trim();
1048 if trimmed.is_empty() || trimmed.starts_with('#') {
1049 continue;
1050 }
1051 let mut parts: Vec<String> = Vec::new();
1053 let mut current = String::new();
1054 let mut in_quotes = false;
1055 for ch in trimmed.chars() {
1056 match ch {
1057 '"' => in_quotes = !in_quotes,
1058 ',' if !in_quotes => {
1059 parts.push(std::mem::take(&mut current));
1060 }
1061 c => current.push(c),
1062 }
1063 }
1064 parts.push(current);
1065 if parts.len() < 3 {
1066 continue;
1067 }
1068 let name = parts[0].trim().to_string();
1069 if name.is_empty() {
1070 continue;
1071 }
1072
1073 let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1074 let vram_gb = if vram_bytes > 0.0 {
1075 (vram_bytes / 1_073_741_824.0) as f32
1076 } else {
1077 0.0
1078 };
1079 let driver = parts[2].trim().to_string();
1080 let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1081 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1082
1083 let backend = if let Some(ref id) = pnp_id {
1085 let id_lower = id.to_ascii_lowercase();
1086 if id_lower.contains("ven_10de") {
1087 super::host::GpuBackend::Cuda
1088 } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1089 if cfg!(feature = "rocm") {
1090 super::host::GpuBackend::Rocm
1091 } else {
1092 super::host::GpuBackend::Directml
1093 }
1094 } else if id_lower.contains("ven_8086") {
1095 super::host::GpuBackend::Directml
1096 } else if id_lower.contains("ven_14e4") {
1097 super::host::GpuBackend::Vulkan
1098 } else {
1099 super::host::GpuBackend::Directml
1100 }
1101 } else {
1102 super::host::GpuBackend::Directml
1103 };
1104
1105 let actual_vram = if vram_gb <= 0.0 {
1107 probe_windows_dxgi_vram().unwrap_or(0.0)
1108 } else {
1109 vram_gb
1110 };
1111
1112 gpus.push(GpuInfo {
1113 name,
1114 vram_gb: actual_vram,
1115 backend,
1116 compute_capability: compute_cap,
1117 driver_version: driver,
1118 pci_address: pnp_id,
1119 });
1120 }
1121
1122 if gpus.is_empty() {
1123 None
1124 } else {
1125 Some(gpus)
1126 }
1127}
1128
1129#[cfg(target_os = "windows")]
1140fn probe_windows_dxgi_vram() -> Option<f32> {
1141 if let Some(vram) = probe_windows_registry_vram() {
1142 return Some(vram);
1143 }
1144 probe_windows_dxgi_vram_via_addtype()
1145}
1146
1147#[cfg(target_os = "windows")]
1148fn probe_windows_registry_vram() -> Option<f32> {
1149 let output = Command::new("powershell")
1150 .args([
1151 "-NoProfile",
1152 "-Command",
1153 "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",
1154 ])
1155 .output()
1156 .ok()?;
1157 if !output.status.success() {
1158 return None;
1159 }
1160 let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1161 .trim()
1162 .parse()
1163 .ok()?;
1164 if bytes > 0.0 {
1165 Some((bytes / 1_073_741_824.0) as f32)
1166 } else {
1167 None
1168 }
1169}
1170
1171#[cfg(target_os = "windows")]
1172fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1173 let script = r#"
1174Add-Type @"
1175using System;
1176using System.Runtime.InteropServices;
1177public class DXGIVram {
1178 [DllImport("dxgi.dll")]
1179 private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1180 public static float GetVRAM() {
1181 Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1182 if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1183 try {
1184 IntPtr vtable = Marshal.ReadIntPtr(factory);
1185 IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1186 var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1187 IntPtr adapter;
1188 if (enumFunc(factory, 0, out adapter) != 0) return 0;
1189 try {
1190 IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1191 IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1192 var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1193 DXGI_ADAPTER_DESC desc;
1194 if (descFunc(adapter, out desc) != 0) return 0;
1195 return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1196 } finally { Marshal.Release(adapter); }
1197 } finally { Marshal.Release(factory); }
1198 }
1199 [StructLayout(LayoutKind.Sequential)]
1200 private struct DXGI_ADAPTER_DESC {
1201 public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1202 public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1203 [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1204 }
1205 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1206 private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1207 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1208 private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1209}
1210"@
1211[DXGIVram]::GetVRAM()
1212"#;
1213 let output = Command::new("powershell")
1214 .args(["-NoProfile", "-NonInteractive", "-Command", script])
1215 .output()
1216 .ok()?;
1217 if !output.status.success() {
1218 return None;
1219 }
1220 let stdout = String::from_utf8_lossy(&output.stdout);
1221 stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1222}
1223
1224#[cfg(target_os = "linux")]
1225fn probe_lspci_gpu() -> Option<GpuInfo> {
1226 let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1227 if !output.status.success() {
1228 return None;
1229 }
1230 let stdout = String::from_utf8_lossy(&output.stdout);
1231
1232 let line = stdout
1233 .lines()
1234 .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1235 let quoted: Vec<String> = line
1236 .split('"')
1237 .enumerate()
1238 .filter_map(|(i, s)| {
1239 if i % 2 == 1 {
1240 let t = s.trim();
1241 if t.is_empty() {
1242 None
1243 } else {
1244 Some(t.to_string())
1245 }
1246 } else {
1247 None
1248 }
1249 })
1250 .collect();
1251 let description = quoted
1252 .get(2)
1253 .cloned()
1254 .or_else(|| quoted.last().cloned())
1255 .unwrap_or_else(|| line.trim().to_string());
1256
1257 let compute_cap = super::host::infer_compute_capability_from_name(&description);
1258 Some(GpuInfo {
1259 name: description,
1260 vram_gb: 0.0,
1261 backend: super::host::GpuBackend::Vulkan,
1262 compute_capability: compute_cap,
1263 driver_version: String::from("lspci"),
1264 pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1265 })
1266}
1267
1268#[cfg(target_os = "linux")]
1269fn probe_sysfs_gpu() -> Option<GpuInfo> {
1270 let drm_root = Path::new("/sys/class/drm");
1271 let entries = fs::read_dir(drm_root).ok()?;
1272
1273 for entry in entries.flatten() {
1274 let path = entry.path();
1275 let fname = path.file_name()?.to_string_lossy().to_string();
1276 if !fname.starts_with("card") {
1277 continue;
1278 }
1279
1280 let dev = path.join("device");
1281 if !dev.exists() {
1282 continue;
1283 }
1284
1285 let gpu_name = fs::read_to_string(dev.join("product_name"))
1286 .ok()
1287 .map(|v| v.trim().to_string())
1288 .filter(|v| !v.is_empty());
1289 let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1290 .ok()
1291 .and_then(|v| v.trim().parse::<f32>().ok());
1292 let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1293
1294 if let Some(name) = gpu_name {
1295 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1296 return Some(GpuInfo {
1297 name,
1298 vram_gb: vram_gb.unwrap_or(0.0),
1299 backend: super::host::GpuBackend::Vulkan,
1300 compute_capability: compute_cap,
1301 driver_version: String::from("sysfs"),
1302 pci_address: None,
1303 });
1304 }
1305 }
1306 None
1307}
1308
1309#[cfg(target_os = "macos")]
1310fn probe_macos_gpu() -> Option<GpuInfo> {
1311 let output = Command::new("system_profiler")
1313 .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1314 .output()
1315 .ok()?;
1316 if !output.status.success() {
1317 return None;
1318 }
1319
1320 let stdout = String::from_utf8_lossy(&output.stdout);
1321 let mut name = String::from("Apple GPU");
1322 let mut vram_gb = 0.0;
1323 let mut metal_support = String::from("metal");
1324 let mut gpu_metal_family = String::new();
1325
1326 for line in stdout.lines() {
1327 if line.contains("Chipset Model:") {
1328 if let Some(val) = line.split(':').nth(1) {
1329 name = val.trim().to_string();
1330 }
1331 }
1332 if line.contains("VRAM") {
1334 let val = line.split(':').nth(1).unwrap_or("").trim();
1335 if let Some(num_str) = val.split_whitespace().next() {
1336 if let Ok(num) = num_str.parse::<f32>() {
1337 vram_gb = num;
1338 }
1339 }
1340 }
1341 if line.contains("Metal Support") {
1342 if let Some(val) = line.split(':').nth(1) {
1343 metal_support = val.trim().to_string();
1344 }
1345 }
1346 if line.contains("Metal Family") {
1347 if let Some(val) = line.split(':').nth(1) {
1348 gpu_metal_family = val.trim().to_string();
1349 }
1350 }
1351 }
1352
1353 if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1356 if let Ok(ioreg) = Command::new("ioreg")
1357 .args(["-r", "-c", "IOAccelerator", "-l"])
1358 .output()
1359 {
1360 let iotext = String::from_utf8_lossy(&ioreg.stdout);
1361 for line in iotext.lines() {
1362 if line.contains("\"gpu-core-count\"") {
1364 if let Some(mem_line) =
1366 iotext.lines().find(|l| l.contains("\"memory-available\""))
1367 {
1368 if let Some(val) = mem_line.split('=').nth(1) {
1369 if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1370 vram_gb = (bytes / 1_073_741_824.0) as f32;
1371 }
1372 }
1373 }
1374 break;
1375 }
1376 }
1377 if vram_gb <= 0.0 {
1379 vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1380 }
1381 }
1382 }
1383
1384 let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1386 metal_support
1387 } else if !gpu_metal_family.is_empty() {
1388 format!("apple_metal/{}", gpu_metal_family)
1389 } else {
1390 String::from("apple_metal")
1391 };
1392
1393 Some(GpuInfo {
1394 name,
1395 vram_gb,
1396 backend: super::host::GpuBackend::Metal,
1397 compute_capability,
1398 driver_version: String::from("metal"),
1399 pci_address: None,
1400 })
1401}
1402
1403fn probe_vulkan_gpu() -> Option<GpuInfo> {
1404 if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1406 if output.status.success() {
1407 let stdout = String::from_utf8_lossy(&output.stdout);
1408 for line in stdout.lines() {
1409 if line.contains("GPU id") || line.contains("deviceName") {
1410 let name = line
1411 .split(':')
1412 .nth(1)
1413 .map(|s| s.trim().to_string())
1414 .unwrap_or_else(|| String::from("Vulkan GPU"));
1415 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1416 return Some(GpuInfo {
1417 name,
1418 vram_gb: 0.0,
1419 backend: super::host::GpuBackend::Vulkan,
1420 compute_capability: compute_cap,
1421 driver_version: String::from("vulkan"),
1422 pci_address: None,
1423 });
1424 }
1425 }
1426 }
1427 }
1428 None
1429}
1430
1431fn detect_npus() -> Vec<NpuInfo> {
1436 let mut npus = Vec::new();
1437
1438 #[cfg(target_os = "windows")]
1440 {
1441 if let Ok(output) = Command::new("powershell")
1442 .args([
1443 "-NoProfile",
1444 "-Command",
1445 "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",
1446 ])
1447 .output()
1448 {
1449 if output.status.success() {
1450 let stdout = String::from_utf8_lossy(&output.stdout);
1451 for line in stdout.lines() {
1452 let trimmed = line.trim();
1453 if !trimmed.is_empty() {
1454 npus.push(NpuInfo {
1455 name: trimmed.to_string(),
1456 memory_gb: 2.0, driver: String::from("windows"),
1458 });
1459 }
1460 }
1461 }
1462 }
1463 }
1464
1465 #[cfg(target_os = "linux")]
1467 {
1468 let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1469 for base in &npu_paths {
1470 if let Ok(entries) = fs::read_dir(base) {
1471 for entry in entries.flatten() {
1472 let name = entry.file_name().to_string_lossy().to_string();
1473 if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1474 npus.push(NpuInfo {
1475 name,
1476 memory_gb: 2.0,
1477 driver: String::from("linux"),
1478 });
1479 }
1480 }
1481 }
1482 }
1483 if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1485 for entry in entries.flatten() {
1486 let name = entry.file_name().to_string_lossy().to_string();
1487 npus.push(NpuInfo {
1488 name: format!("accel/{}", name),
1489 memory_gb: 2.0,
1490 driver: String::from("linux"),
1491 });
1492 }
1493 }
1494 }
1495
1496 #[cfg(target_os = "macos")]
1498 {
1499 if super::host::is_apple_silicon() {
1500 npus.push(NpuInfo {
1501 name: String::from("Apple Neural Engine"),
1502 memory_gb: 0.0, driver: String::from("ane"),
1504 });
1505 }
1506 }
1507
1508 if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1510 npus.push(NpuInfo {
1511 name: String::from("npu-env"),
1512 memory_gb: 2.0,
1513 driver: String::from("env"),
1514 });
1515 }
1516
1517 npus
1518}
1519
1520fn detect_network_info() -> NetworkInfo {
1525 let xdp_supported = cfg!(target_os = "linux");
1526 let interfaces = detect_network_interfaces();
1527 NetworkInfo {
1528 xdp_supported,
1529 interfaces,
1530 }
1531}
1532
1533fn detect_network_interfaces() -> Vec<String> {
1534 let mut ifaces = Vec::new();
1535 if let Ok(entries) = fs::read_dir("/sys/class/net") {
1536 for entry in entries.flatten() {
1537 if let Ok(name) = entry.file_name().into_string() {
1538 if !name.starts_with("lo") {
1539 ifaces.push(name);
1540 }
1541 }
1542 }
1543 }
1544 ifaces
1545}
1546
1547fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1552 use super::host::GpuBackend;
1553 let lowered = name.to_ascii_lowercase();
1554 let lowered_cc = compute_capability.to_ascii_lowercase();
1555
1556 if lowered_cc.contains("rocm")
1557 || lowered.contains("amd")
1558 || lowered.contains("radeon")
1559 || lowered.contains("instinct")
1560 || lowered.contains("mi250")
1561 || lowered.contains("mi300")
1562 || lowered.contains("mi350")
1563 {
1564 return GpuBackend::Rocm;
1565 }
1566
1567 if lowered_cc.contains("metal")
1568 || lowered.contains("apple")
1569 || lowered.contains("m1")
1570 || lowered.contains("m2")
1571 || lowered.contains("m3")
1572 || lowered.contains("m4")
1573 {
1574 return GpuBackend::Metal;
1575 }
1576
1577 if lowered_cc.contains("directml") || lowered.contains("directml") {
1578 return GpuBackend::Directml;
1579 }
1580
1581 if lowered_cc.contains("npu")
1582 || lowered.contains("npu")
1583 || lowered.contains("xdna")
1584 || lowered.contains("neural")
1585 {
1586 return GpuBackend::Npu;
1587 }
1588
1589 if lowered.contains("nvidia")
1590 || lowered.contains("rtx")
1591 || lowered.contains("gtx")
1592 || lowered.contains("tesla")
1593 || lowered.contains("a100")
1594 || lowered.contains("h100")
1595 || lowered.contains("b100")
1596 || lowered.contains("b200")
1597 || lowered_cc
1598 .chars()
1599 .next()
1600 .is_some_and(|c| c.is_ascii_digit())
1601 {
1602 return GpuBackend::Cuda;
1603 }
1604
1605 GpuBackend::Vulkan
1606}
1607
1608fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1613 use super::host::AccelerationMode;
1614
1615 if gpus.iter().any(|g| {
1617 g.vram_gb > 0.0
1618 || (g.backend != super::host::GpuBackend::Cpu
1619 && g.backend != super::host::GpuBackend::Vulkan)
1620 }) {
1621 return AccelerationMode::Gpu;
1622 }
1623
1624 if gpus.iter().any(|g| {
1626 g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1627 }) {
1628 return AccelerationMode::Gpu;
1629 }
1630
1631 if cpu.features.avx_512 {
1633 return AccelerationMode::Avx512;
1634 }
1635 if cpu.features.avx2 {
1636 return AccelerationMode::Avx2;
1637 }
1638 if cpu.features.neon || cpu.features.sve {
1639 return AccelerationMode::Neon;
1640 }
1641
1642 AccelerationMode::Generic
1643}
1644
1645fn compute_recommended_workers(
1650 cpu: &CpuInfo,
1651 memory: &MemoryInfo,
1652 gpus: &[GpuInfo],
1653 accel: super::host::AccelerationMode,
1654) -> usize {
1655 let cores = cpu.logical_cores.max(1);
1656 let memory_bound = if memory.total_gb >= 1.0 {
1657 (memory.total_gb / 4.0).floor() as usize
1658 } else {
1659 1
1660 }
1661 .max(1);
1662
1663 let reserved_core = cores.saturating_sub(1).max(1);
1664
1665 let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1667 let accelerator_bonus = match accel {
1668 super::host::AccelerationMode::Gpu => {
1669 let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1670 2
1671 } else {
1672 1
1673 };
1674 vram_bonus + gpu_count.saturating_sub(1) }
1676 super::host::AccelerationMode::Avx512 => 1,
1677 _ => 0,
1678 };
1679
1680 reserved_core
1681 .min(memory_bound)
1682 .saturating_add(accelerator_bonus)
1683}
1684
1685pub fn infer_compute_capability_from_name(name: &str) -> String {
1691 let lowered = name.to_ascii_lowercase();
1692 if lowered.contains("rtx 50")
1693 || lowered.contains("rtx50")
1694 || lowered.contains("b100")
1695 || lowered.contains("b200")
1696 {
1697 String::from("9.0")
1698 } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1699 String::from("8.9")
1700 } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1701 String::from("8.6")
1702 } else if lowered.contains("arc") {
1703 String::from("xe")
1704 } else if lowered.contains("radeon")
1705 || lowered.contains("amd")
1706 || lowered.contains("advanced micro")
1707 || lowered.contains("rx ")
1708 || lowered.contains("w7900")
1709 || lowered.contains("w6800")
1710 || lowered.contains("mi250")
1711 || lowered.contains("mi300")
1712 || lowered.contains("mi350")
1713 || lowered.contains("instinct")
1714 {
1715 if cfg!(feature = "rocm") {
1716 String::from("rocm")
1717 } else {
1718 String::from("gpu")
1719 }
1720 } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1721 String::from("xe")
1722 } else if lowered.contains("apple")
1723 || lowered.contains("m1")
1724 || lowered.contains("m2")
1725 || lowered.contains("m3")
1726 || lowered.contains("m4")
1727 {
1728 String::from("apple_metal")
1729 } else {
1730 String::from("gpu")
1731 }
1732}
1733
1734#[cfg(test)]
1739mod tests {
1740 use super::*;
1741 use crate::GpuBackend;
1742
1743 #[test]
1744 fn system_profile_detects_cpu() {
1745 let sp = SystemProfile::detect_fast();
1746 assert!(sp.cpu.logical_cores >= 1);
1747 assert!(!sp.cpu.arch.is_empty());
1748 }
1749
1750 #[test]
1751 fn system_profile_detects_memory() {
1752 let sp = SystemProfile::detect_fast();
1753 assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
1755 }
1756
1757 #[test]
1758 fn system_profile_always_has_at_least_one_gpu_entry() {
1759 let sp = SystemProfile::detect_fast();
1760 assert!(
1761 !sp.gpus.is_empty(),
1762 "Should have at least 'cpu' fallback GPU entry"
1763 );
1764 }
1765
1766 #[test]
1767 fn system_profile_converts_to_runtime_profile() {
1768 let sp = SystemProfile::detect_fast();
1769 let rp: crate::host::RuntimeProfile = (&sp).into();
1770 assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
1771 assert_eq!(rp.recommended_workers, sp.recommended_workers);
1772 }
1773
1774 #[test]
1779 fn detect_gpu_handles_env_overrides() {
1780 std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
1781 std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
1782 std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
1783
1784 let gpu = detect_gpu_from_env().expect("env probe should work");
1785 assert_eq!(gpu.name, "RTX 4090");
1786 assert!((gpu.vram_gb - 24.0).abs() < 0.1);
1787 assert_eq!(gpu.compute_capability, "8.9");
1788
1789 std::env::remove_var("GHOSTLINK_GPU_NAME");
1790 std::env::remove_var("GHOSTLINK_VRAM_GB");
1791 std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
1792
1793 std::env::set_var("GHOSTLINK_VRAM_GB", "0");
1797 assert!(detect_gpu_from_env().is_none());
1798 std::env::remove_var("GHOSTLINK_VRAM_GB");
1799 }
1800
1801 #[test]
1802 fn infer_backend_rocm() {
1803 let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
1804 assert_eq!(b, GpuBackend::Rocm);
1805 }
1806
1807 #[test]
1808 fn infer_backend_cuda() {
1809 let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
1810 assert_eq!(b, GpuBackend::Cuda);
1811 }
1812
1813 #[test]
1814 fn infer_backend_metal() {
1815 let b = infer_backend("Apple M3 Max", "apple_metal");
1816 assert_eq!(b, GpuBackend::Metal);
1817 }
1818
1819 #[test]
1820 fn compute_workers_scales_with_gpus() {
1821 let cpu = CpuInfo {
1822 logical_cores: 16,
1823 physical_cores: 8,
1824 arch: "x86_64".into(),
1825 ..Default::default()
1826 };
1827 let mem = MemoryInfo {
1828 total_gb: 64.0,
1829 available_gb: 32.0,
1830 };
1831 let gpus = vec![
1832 GpuInfo {
1833 name: "RTX 4090".into(),
1834 vram_gb: 24.0,
1835 backend: GpuBackend::Cuda,
1836 compute_capability: "8.9".into(),
1837 driver_version: "555".into(),
1838 pci_address: None,
1839 },
1840 GpuInfo {
1841 name: "RTX 3090".into(),
1842 vram_gb: 24.0,
1843 backend: GpuBackend::Cuda,
1844 compute_capability: "8.6".into(),
1845 driver_version: "555".into(),
1846 pci_address: None,
1847 },
1848 ];
1849 let accel = pick_acceleration_mode(&gpus, &cpu);
1850 let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
1851 assert!(workers > 0);
1853 }
1854
1855 #[test]
1856 fn physical_cores_nonzero() {
1857 let cpu = detect_cpu_info();
1858 assert!(cpu.physical_cores >= 1);
1859 }
1860
1861 #[cfg(target_os = "windows")]
1862 #[test]
1863 fn sum_windows_cim_core_counts_handles_multi_socket_output() {
1864 assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
1868 assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
1869 assert_eq!(sum_windows_cim_core_counts(""), 0);
1870 assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
1872 }
1873
1874 #[cfg(target_arch = "x86_64")]
1875 #[test]
1876 fn amx_tile_detection_does_not_panic() {
1877 let _ = detect_amx_tile();
1881 }
1882}