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 pub performance_cores: Option<usize>,
43 pub efficiency_cores: Option<usize>,
46}
47
48#[derive(Clone, Debug, Default, Hash)]
50pub struct CpuFeatures {
51 pub avx2: bool,
52 pub avx_512: bool,
53 pub neon: bool,
54 pub sve: bool,
55 pub amx: bool,
56 pub sme: bool,
57}
58
59#[derive(Clone, Debug, Default)]
61pub struct MemoryInfo {
62 pub total_gb: f32,
64 pub available_gb: f32,
66}
67
68#[derive(Clone, Debug)]
70pub struct GpuInfo {
71 pub name: String,
73 pub vram_gb: f32,
75 pub backend: super::host::GpuBackend,
77 pub compute_capability: String,
79 pub driver_version: String,
81 pub pci_address: Option<String>,
83}
84
85#[derive(Clone, Debug)]
87pub struct NpuInfo {
88 pub name: String,
89 pub memory_gb: f32,
90 pub driver: String,
91}
92
93#[derive(Clone, Debug, Default, Hash)]
95pub struct NetworkInfo {
96 pub xdp_supported: bool,
97 pub interfaces: Vec<String>,
98}
99
100#[derive(Clone, Debug, Default)]
106pub struct SystemProfile {
107 pub cpu: CpuInfo,
108 pub memory: MemoryInfo,
109 pub gpus: Vec<GpuInfo>,
110 pub npus: Vec<NpuInfo>,
111 pub network: NetworkInfo,
112 pub acceleration_mode: super::host::AccelerationMode,
114 pub recommended_workers: usize,
116 pub system_id: String,
118 pub detection_source: String,
120}
121
122static SYSTEM_PROFILE_CACHE: OnceLock<Mutex<Option<CachedSystemProfile>>> = OnceLock::new();
127const SYSTEM_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30);
128
129struct CachedSystemProfile {
130 captured_at: Instant,
131 profile: SystemProfile,
132}
133
134impl SystemProfile {
139 pub fn detect() -> Self {
143 Self::detect_with_mode(ProbeMode::Full)
144 }
145
146 pub fn detect_fast() -> Self {
148 Self::detect_with_mode(ProbeMode::Fast)
149 }
150
151 fn detect_with_mode(mode: ProbeMode) -> Self {
152 if mode == ProbeMode::Fast {
153 let cache = SYSTEM_PROFILE_CACHE.get_or_init(|| Mutex::new(None));
154 let mut guard = match cache.lock() {
165 Ok(guard) => guard,
166 Err(poisoned) => poisoned.into_inner(),
167 };
168 if let Some(entry) = guard.as_ref() {
169 if entry.captured_at.elapsed() < SYSTEM_PROFILE_CACHE_TTL {
170 return entry.profile.clone();
171 }
172 }
173 let profile = Self::detect_uncached(mode);
174 *guard = Some(CachedSystemProfile {
175 captured_at: Instant::now(),
176 profile: profile.clone(),
177 });
178 return profile;
179 }
180
181 Self::detect_uncached(mode)
182 }
183
184 fn detect_uncached(mode: ProbeMode) -> Self {
185 let (system_id, cpu, memory, gpus, npus, network) = std::thread::scope(|scope| {
192 let system_id = scope.spawn(|| hostname().unwrap_or_else(|| "unknown".into()));
193 let cpu = scope.spawn(detect_cpu_info);
194 let memory = scope.spawn(detect_memory_info);
195 let gpus = scope.spawn(move || detect_gpus(mode));
196 let npus = scope.spawn(detect_npus);
197 let network = scope.spawn(detect_network_info);
198
199 (
200 system_id.join().unwrap(),
201 cpu.join().unwrap(),
202 memory.join().unwrap(),
203 gpus.join().unwrap(),
204 npus.join().unwrap(),
205 network.join().unwrap(),
206 )
207 });
208 let acceleration_mode = pick_acceleration_mode(&gpus, &cpu);
209 let recommended_workers =
210 compute_recommended_workers(&cpu, &memory, &gpus, acceleration_mode);
211 let detection_source = gpus
212 .first()
213 .and_then(|g| {
214 if g.name == "cpu" || g.name.is_empty() {
215 None
216 } else {
217 Some(g.name.clone())
218 }
219 })
220 .unwrap_or_else(|| "cpu".into());
221
222 Self {
223 cpu,
224 memory,
225 gpus,
226 npus,
227 network,
228 acceleration_mode,
229 recommended_workers,
230 system_id,
231 detection_source,
232 }
233 }
234}
235
236impl From<&SystemProfile> for super::host::RuntimeProfile {
241 fn from(sp: &SystemProfile) -> Self {
242 let primary_gpu = sp.gpus.first();
243 let gpu_backend = primary_gpu
244 .map(|g| g.backend)
245 .unwrap_or(super::host::GpuBackend::Cpu);
246 let vram_gb = primary_gpu.map(|g| g.vram_gb).unwrap_or(0.0);
247 let gpu_name = primary_gpu.map(|g| g.name.clone());
248 let compute_cap = primary_gpu
249 .map(|g| g.compute_capability.clone())
250 .unwrap_or_else(|| String::from("cpu"));
251
252 let node_resources = NodeResources::new(
253 sp.system_id.clone(),
254 vram_gb,
255 sp.memory.total_gb,
256 compute_cap,
257 gpu_name,
258 );
259
260 super::host::RuntimeProfile {
261 node_resources,
262 logical_cores: sp.cpu.logical_cores,
263 recommended_workers: sp.recommended_workers,
264 acceleration_mode: sp.acceleration_mode,
265 gpu_backend,
266 xdp_supported: sp.network.xdp_supported,
267 detection_source: sp.detection_source.clone(),
268 probe_mode: super::host::ProbeMode::Fast,
269 }
270 }
271}
272
273impl From<SystemProfile> for super::host::RuntimeProfile {
274 fn from(sp: SystemProfile) -> Self {
275 Self::from(&sp)
276 }
277}
278
279impl SystemProfile {
280 pub fn to_node_resources(&self) -> NodeResources {
282 let primary = self.gpus.first();
283 NodeResources::new(
284 self.system_id.clone(),
285 primary.map(|g| g.vram_gb).unwrap_or(0.0),
286 self.memory.total_gb,
287 primary
288 .map(|g| g.compute_capability.clone())
289 .unwrap_or_else(|| String::from("cpu")),
290 primary.map(|g| g.name.clone()),
291 )
292 }
293}
294
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300enum ProbeMode {
301 Fast,
302 Full,
303}
304
305#[cfg(unix)]
306fn hostname() -> Option<String> {
307 let mut buf = [0u8; 512];
308 let res = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
309 if res == 0 {
310 let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
311 if let Ok(s) = std::str::from_utf8(&buf[..len]) {
312 let trimmed = s.trim();
313 if !trimmed.is_empty() {
314 return Some(trimmed.to_string());
315 }
316 }
317 }
318 let output = Command::new("hostname").output().ok()?;
320 if !output.status.success() {
321 return None;
322 }
323 let name = String::from_utf8_lossy(&output.stdout);
324 let trimmed = name.trim();
325 if trimmed.is_empty() {
326 None
327 } else {
328 Some(trimmed.to_string())
329 }
330}
331
332#[cfg(not(unix))]
333fn hostname() -> Option<String> {
334 if let Ok(name) = std::env::var("COMPUTERNAME") {
335 let trimmed = name.trim();
336 if !trimmed.is_empty() {
337 return Some(trimmed.to_string());
338 }
339 }
340 let output = Command::new("hostname").output().ok()?;
341 if !output.status.success() {
342 return None;
343 }
344 let name = String::from_utf8_lossy(&output.stdout);
345 let trimmed = name.trim();
346 if trimmed.is_empty() {
347 None
348 } else {
349 Some(trimmed.to_string())
350 }
351}
352
353fn detect_cpu_info() -> CpuInfo {
358 let logical_cores = std::thread::available_parallelism()
359 .map(usize::from)
360 .unwrap_or(1);
361
362 let arch = std::env::consts::ARCH.to_string();
363
364 let features = detect_cpu_features(&arch);
366
367 let (brand, physical_cores) = std::thread::scope(|scope| {
373 let brand = scope.spawn(detect_cpu_brand);
374 let physical_cores = scope.spawn(detect_physical_cores);
375 (brand.join().unwrap(), physical_cores.join().unwrap())
376 });
377 let physical_cores = physical_cores.unwrap_or(logical_cores / 2).max(1);
378 let (performance_cores, efficiency_cores) = detect_hybrid_core_counts();
379
380 CpuInfo {
381 brand,
382 logical_cores,
383 physical_cores,
384 arch,
385 features,
386 performance_cores,
387 efficiency_cores,
388 }
389}
390
391#[cfg(target_os = "windows")]
402fn detect_hybrid_core_counts() -> (Option<usize>, Option<usize>) {
403 use windows_sys::Win32::System::SystemInformation::{
404 GetLogicalProcessorInformationEx, RelationProcessorCore,
405 };
406
407 let mut len: u32 = 0;
408 unsafe {
413 GetLogicalProcessorInformationEx(RelationProcessorCore, std::ptr::null_mut(), &mut len);
414 }
415 if len == 0 {
416 return (None, None);
417 }
418
419 let mut buf = vec![0u8; len as usize];
420 let ok = unsafe {
421 GetLogicalProcessorInformationEx(RelationProcessorCore, buf.as_mut_ptr().cast(), &mut len)
422 };
423 if ok == 0 {
424 return (None, None);
425 }
426 buf.truncate(len as usize);
427 parse_processor_core_relationships(&buf)
428}
429
430#[cfg(not(target_os = "windows"))]
431fn detect_hybrid_core_counts() -> (Option<usize>, Option<usize>) {
432 (None, None)
433}
434
435#[cfg(any(windows, test))]
472fn parse_processor_core_relationships(buf: &[u8]) -> (Option<usize>, Option<usize>) {
473 const RELATION_PROCESSOR_CORE: u32 = 0;
474
475 let mut classes: Vec<u8> = Vec::new();
476 let mut offset = 0usize;
477 while offset + 8 <= buf.len() {
478 let relationship = u32::from_ne_bytes(buf[offset..offset + 4].try_into().unwrap());
479 let size = u32::from_ne_bytes(buf[offset + 4..offset + 8].try_into().unwrap()) as usize;
480 if size < 8 || offset + size > buf.len() {
481 break;
482 }
483 if relationship == RELATION_PROCESSOR_CORE && offset + 10 <= buf.len() {
484 classes.push(buf[offset + 9]);
485 }
486 offset += size;
487 }
488
489 if classes.is_empty() {
490 return (None, None);
491 }
492 let max_class = *classes.iter().max().expect("classes is non-empty");
493 if classes.iter().all(|&c| c == max_class) {
494 return (None, None);
495 }
496 let performance = classes.iter().filter(|&&c| c == max_class).count();
497 let efficiency = classes.len() - performance;
498 (Some(performance), Some(efficiency))
499}
500
501fn detect_cpu_brand() -> String {
502 #[cfg(target_os = "linux")]
504 {
505 if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
506 for line in content.lines() {
507 if line.starts_with("model name") || line.starts_with("Model name") {
508 if let Some(name) = line.split(':').nth(1) {
509 let trimmed = name.trim();
510 if !trimmed.is_empty() {
511 return trimmed.to_string();
512 }
513 }
514 }
515 }
516 }
517 if let Ok(output) = Command::new("lscpu").output() {
518 let text = String::from_utf8_lossy(&output.stdout);
519 if let Some(line) = text.lines().find(|l| l.contains("Model name")) {
520 if let Some(name) = line.split(':').nth(1) {
521 return name.trim().to_string();
522 }
523 }
524 }
525 }
526
527 #[cfg(target_os = "macos")]
529 {
530 if let Ok(output) = Command::new("sysctl")
531 .args(["-n", "machdep.cpu.brand_string"])
532 .output()
533 {
534 let name = String::from_utf8_lossy(&output.stdout);
535 let trimmed = name.trim();
536 if !trimmed.is_empty() {
537 return trimmed.to_string();
538 }
539 }
540 if let Ok(output) = Command::new("sysctl")
542 .args(["-n", "machdep.cpu.brand_string"])
543 .output()
544 {
545 let name = String::from_utf8_lossy(&output.stdout);
546 let trimmed = name.trim();
547 if !trimmed.is_empty() {
548 return trimmed.to_string();
549 }
550 }
551 if let Ok(output) = Command::new("sysctl").args(["-n", "hw.model"]).output() {
553 let model = String::from_utf8_lossy(&output.stdout);
554 let trimmed = model.trim();
555 if !trimmed.is_empty() {
556 return format!("Apple {}", trimmed);
557 }
558 }
559 }
560
561 #[cfg(target_os = "windows")]
563 {
564 if let Ok(output) = Command::new("wmic")
565 .args(["cpu", "get", "name", "/format:csv"])
566 .output()
567 {
568 let text = String::from_utf8_lossy(&output.stdout);
569 for line in text.lines().skip(1) {
570 let trimmed = line.trim();
571 if !trimmed.is_empty() && !trimmed.contains("Node") {
572 if let Some(name) = trimmed.split(',').nth(1) {
574 let n = name.trim();
575 if !n.is_empty() {
576 return n.to_string();
577 }
578 }
579 }
580 }
581 }
582 if let Ok(output) = Command::new("powershell")
584 .args([
585 "-NoProfile",
586 "-Command",
587 "(Get-CimInstance Win32_Processor).Name",
588 ])
589 .output()
590 {
591 let name = String::from_utf8_lossy(&output.stdout);
592 let trimmed = name.trim();
593 if !trimmed.is_empty() {
594 return trimmed.to_string();
595 }
596 }
597 }
598
599 String::from("Unknown CPU")
600}
601
602fn detect_cpu_features(arch: &str) -> CpuFeatures {
603 match arch {
604 "x86_64" | "x86" => detect_x86_features(),
605 "aarch64" | "arm" => {
606 let neon = true; let sve = detect_sve();
608 CpuFeatures {
609 neon,
610 sve,
611 ..Default::default()
612 }
613 }
614 _ => CpuFeatures::default(),
615 }
616}
617
618#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
619fn detect_x86_features() -> CpuFeatures {
620 let avx2 = is_x86_feature_detected!("avx2");
621 let avx_512 = is_x86_feature_detected!("avx512f");
622 let amx = detect_amx_tile();
623 CpuFeatures {
624 avx2,
625 avx_512,
626 amx,
627 ..Default::default()
628 }
629}
630
631#[cfg(target_arch = "x86_64")]
649#[allow(unused_unsafe)]
650fn detect_amx_tile() -> bool {
651 let max_leaf = unsafe { core::arch::x86_64::__cpuid(0) }.eax;
656 if max_leaf < 7 {
657 return false;
658 }
659 let leaf7 = unsafe { core::arch::x86_64::__cpuid_count(7, 0) };
660 (leaf7.edx & (1 << 24)) != 0
661}
662
663#[cfg(all(target_arch = "x86", not(target_arch = "x86_64")))]
666fn detect_amx_tile() -> bool {
667 false
668}
669
670#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
671fn detect_x86_features() -> CpuFeatures {
672 CpuFeatures::default()
673}
674
675fn detect_sve() -> bool {
676 #[cfg(target_os = "linux")]
677 {
678 if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
680 if content.contains("sve") || content.contains("SVE") {
681 return true;
682 }
683 }
685 }
686 false
687}
688
689#[cfg(target_os = "windows")]
692fn sum_windows_cim_core_counts(text: &str) -> usize {
693 text.lines()
694 .filter_map(|line| line.trim().parse::<usize>().ok())
695 .sum()
696}
697
698fn detect_physical_cores() -> Option<usize> {
699 #[cfg(target_os = "linux")]
701 {
702 if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
704 let mut cores = std::collections::BTreeSet::new();
705 let mut current_package = None;
706 for line in content.lines() {
707 if let Some(val) = line.strip_prefix("physical id\t: ") {
708 current_package = Some(val.trim().to_string());
709 }
710 if let Some(val) = line.strip_prefix("core id\t\t: ") {
711 if let Some(ref pkg) = current_package {
712 cores.insert(format!("{}-{}", pkg, val.trim()));
713 }
714 }
715 }
716 if !cores.is_empty() {
717 return Some(cores.len());
718 }
719 }
720 }
721
722 #[cfg(target_os = "macos")]
724 {
725 if let Ok(output) = Command::new("sysctl")
726 .args(["-n", "hw.physicalcpu"])
727 .output()
728 {
729 if let Ok(count) = String::from_utf8_lossy(&output.stdout)
730 .trim()
731 .parse::<usize>()
732 {
733 return Some(count);
734 }
735 }
736 }
737
738 #[cfg(target_os = "windows")]
740 {
741 if let Ok(output) = Command::new("powershell")
742 .args([
743 "-NoProfile",
744 "-Command",
745 "(Get-CimInstance Win32_Processor).NumberOfCores",
746 ])
747 .output()
748 {
749 let text = String::from_utf8_lossy(&output.stdout);
750 let total = sum_windows_cim_core_counts(&text);
757 if total > 0 {
758 return Some(total);
759 }
760 }
761 }
762
763 None
764}
765
766fn detect_memory_info() -> MemoryInfo {
771 let (total, available) = std::thread::scope(|scope| {
775 let total = scope.spawn(detect_system_memory_gb);
776 let available = scope.spawn(detect_available_memory_gb);
777 (total.join().unwrap(), available.join().unwrap())
778 });
779 let total_gb = total.unwrap_or(0.0);
780 let available_gb = available.unwrap_or(total_gb * 0.5);
781 MemoryInfo {
782 total_gb,
783 available_gb,
784 }
785}
786
787fn detect_system_memory_gb() -> Option<f32> {
788 if let Ok(val) = env::var("GHOSTLINK_SYSTEM_MEMORY_GB") {
790 if let Ok(gb) = val.parse::<f32>() {
791 return Some(gb.max(0.0));
792 }
793 }
794
795 platform_detect_system_memory_gb()
796}
797
798#[cfg(target_os = "linux")]
799fn platform_detect_system_memory_gb() -> Option<f32> {
800 let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
801 let line = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?;
802 let kb = line
803 .split_whitespace()
804 .nth(1)
805 .and_then(|v| v.parse::<f32>().ok())?;
806 Some(kb / 1024.0 / 1024.0)
807}
808
809#[cfg(target_os = "macos")]
810fn platform_detect_system_memory_gb() -> Option<f32> {
811 if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() {
812 if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
813 .trim()
814 .parse::<f64>()
815 {
816 return Some((bytes / 1_073_741_824.0) as f32);
817 }
818 }
819 None
820}
821
822#[cfg(target_os = "windows")]
823fn platform_detect_system_memory_gb() -> Option<f32> {
824 if let Ok(output) = Command::new("powershell")
825 .args([
826 "-NoProfile",
827 "-Command",
828 "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
829 ])
830 .output()
831 {
832 if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
833 .trim()
834 .parse::<f64>()
835 {
836 if bytes > 0.0 {
837 return Some((bytes / 1_073_741_824.0) as f32);
838 }
839 }
840 }
841 if let Ok(output) = Command::new("wmic")
843 .args([
844 "ComputerSystem",
845 "get",
846 "TotalPhysicalMemory",
847 "/format:csv",
848 ])
849 .output()
850 {
851 let text = String::from_utf8_lossy(&output.stdout);
852 for line in text.lines().skip(1) {
853 if let Some(val) = line.split(',').nth(1) {
854 if let Ok(bytes) = val.trim().parse::<f64>() {
855 if bytes > 0.0 {
856 return Some((bytes / 1_073_741_824.0) as f32);
857 }
858 }
859 }
860 }
861 }
862 None
863}
864
865#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
866fn platform_detect_system_memory_gb() -> Option<f32> {
867 None
868}
869
870fn detect_available_memory_gb() -> Option<f32> {
871 platform_detect_available_memory_gb()
872}
873
874#[cfg(target_os = "linux")]
875fn platform_detect_available_memory_gb() -> Option<f32> {
876 let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
877 if let Some(line) = meminfo.lines().find(|l| l.starts_with("MemAvailable:")) {
878 let kb = line
879 .split_whitespace()
880 .nth(1)
881 .and_then(|v| v.parse::<f32>().ok())?;
882 return Some(kb / 1024.0 / 1024.0);
883 }
884 let free = meminfo
885 .lines()
886 .find(|l| l.starts_with("MemFree:"))
887 .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
888 .unwrap_or(0.0);
889 let cached = meminfo
890 .lines()
891 .find(|l| l.starts_with("Cached:"))
892 .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
893 .unwrap_or(0.0);
894 Some((free + cached) / 1024.0 / 1024.0)
895}
896
897#[cfg(target_os = "macos")]
898fn platform_detect_available_memory_gb() -> Option<f32> {
899 if let Ok(output) = Command::new("vm_stat").output() {
900 let text = String::from_utf8_lossy(&output.stdout);
901 let mut free_pages = 0.0f64;
902 let mut inactive_pages = 0.0f64;
903 let mut page_size = 16384.0f64;
904 for line in text.lines() {
905 if let Some(val) = line.strip_prefix("Mach Virtual Memory Statistics: (page size of ") {
906 if let Ok(size) = val.trim_end_matches(" bytes)").parse::<f64>() {
907 page_size = size;
908 }
909 }
910 if line.contains("pages free") {
911 free_pages = line
912 .split(':')
913 .nth(1)
914 .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
915 .unwrap_or(0.0);
916 }
917 if line.contains("pages inactive") {
918 inactive_pages = line
919 .split(':')
920 .nth(1)
921 .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
922 .unwrap_or(0.0);
923 }
924 }
925 let available_bytes = (free_pages + inactive_pages) * page_size;
926 return Some((available_bytes / 1_073_741_824.0) as f32);
927 }
928 None
929}
930
931#[cfg(target_os = "windows")]
932fn platform_detect_available_memory_gb() -> Option<f32> {
933 if let Ok(output) = Command::new("powershell")
934 .args([
935 "-NoProfile",
936 "-Command",
937 "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory",
938 ])
939 .output()
940 {
941 if let Ok(kb) = String::from_utf8_lossy(&output.stdout)
942 .trim()
943 .parse::<f64>()
944 {
945 return Some((kb / 1024.0 / 1024.0) as f32);
946 }
947 }
948 None
949}
950
951#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
952fn platform_detect_available_memory_gb() -> Option<f32> {
953 None
954}
955
956fn detect_gpus(mode: ProbeMode) -> Vec<GpuInfo> {
961 if let Some(gpu) = detect_gpu_from_env() {
963 return vec![gpu];
964 }
965
966 let mut gpus = Vec::new();
968
969 if let Some(result) = probe_nvidia_smi_with_timeout() {
971 gpus.extend(result);
972 }
973
974 #[cfg(feature = "rocm")]
976 if let Some(result) = probe_rocm_smi_with_timeout() {
977 gpus.extend(result);
978 }
979
980 #[cfg(target_os = "windows")]
982 if gpus.is_empty() {
983 if let Some(result) = probe_windows_wmi_gpu_with_timeout() {
984 gpus.extend(result);
985 }
986 }
987
988 #[cfg(target_os = "linux")]
990 if gpus.is_empty() {
991 if let Some(gpu) = probe_lspci_gpu_with_timeout() {
992 gpus.push(gpu);
993 }
994 }
995
996 #[cfg(target_os = "macos")]
998 if gpus.is_empty() {
999 if let Some(gpu) = probe_macos_gpu_with_timeout() {
1000 gpus.push(gpu);
1001 }
1002 }
1003
1004 #[cfg(target_os = "linux")]
1006 if gpus.is_empty() {
1007 if let Some(gpu) = probe_sysfs_gpu() {
1008 gpus.push(gpu);
1009 }
1010 }
1011
1012 if mode == ProbeMode::Full && gpus.is_empty() {
1014 if let Some(gpu) = probe_vulkan_gpu_with_timeout() {
1015 gpus.push(gpu);
1016 }
1017 }
1018
1019 if gpus.is_empty() {
1020 let cc = if cfg!(feature = "rocm") {
1021 "rocm"
1022 } else {
1023 "gpu"
1024 };
1025 gpus.push(GpuInfo {
1026 name: String::from("cpu"),
1027 vram_gb: 0.0,
1028 backend: super::host::GpuBackend::Cpu,
1029 compute_capability: cc.to_string(),
1030 driver_version: String::from("native"),
1031 pci_address: None,
1032 });
1033 }
1034
1035 gpus
1036}
1037
1038fn probe_with_timeout<T, F>(timeout: Duration, probe: F) -> Option<T>
1049where
1050 T: Send + 'static,
1051 F: FnOnce() -> Option<T> + Send + 'static,
1052{
1053 let (tx, rx) = std::sync::mpsc::channel();
1054 std::thread::spawn(move || {
1055 let _ = tx.send(probe());
1056 });
1057 rx.recv_timeout(timeout).ok().flatten()
1058}
1059
1060fn probe_nvidia_smi_with_timeout() -> Option<Vec<GpuInfo>> {
1062 probe_with_timeout(Duration::from_millis(5000), probe_nvidia_smi)
1063}
1064
1065#[cfg(feature = "rocm")]
1067fn probe_rocm_smi_with_timeout() -> Option<Vec<GpuInfo>> {
1068 probe_with_timeout(Duration::from_millis(5000), probe_rocm_smi)
1069}
1070
1071#[cfg(target_os = "windows")]
1073fn probe_windows_wmi_gpu_with_timeout() -> Option<Vec<GpuInfo>> {
1074 probe_with_timeout(Duration::from_millis(5000), probe_windows_wmi_gpu)
1075}
1076
1077#[cfg(target_os = "linux")]
1079fn probe_lspci_gpu_with_timeout() -> Option<GpuInfo> {
1080 probe_with_timeout(Duration::from_millis(5000), probe_lspci_gpu)
1081}
1082
1083#[cfg(target_os = "macos")]
1085fn probe_macos_gpu_with_timeout() -> Option<GpuInfo> {
1086 probe_with_timeout(Duration::from_millis(5000), probe_macos_gpu)
1087}
1088
1089fn probe_vulkan_gpu_with_timeout() -> Option<GpuInfo> {
1091 probe_with_timeout(Duration::from_millis(10000), probe_vulkan_gpu)
1092}
1093
1094fn detect_gpu_from_env() -> Option<GpuInfo> {
1095 let name = env::var("GHOSTLINK_GPU_NAME")
1096 .ok()
1097 .filter(|s| !s.trim().is_empty());
1098 let vram_gb = env::var("GHOSTLINK_VRAM_GB")
1099 .ok()
1100 .and_then(|v| v.parse::<f32>().ok());
1101 let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
1102 .ok()
1103 .filter(|s| !s.trim().is_empty());
1104 let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
1110 if !has_gpu_signal {
1111 return None;
1112 }
1113
1114 let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
1115 let compute_capability =
1116 cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
1117 let backend = infer_backend(&gpu_name, &compute_capability);
1118
1119 Some(GpuInfo {
1120 name: gpu_name,
1121 vram_gb: vram_gb.unwrap_or(0.0),
1122 backend,
1123 compute_capability,
1124 driver_version: String::from("env"),
1125 pci_address: None,
1126 })
1127}
1128
1129fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
1130 let output = Command::new("nvidia-smi")
1131 .args([
1132 "--query-gpu=index,name,memory.total,compute_cap,driver_version",
1133 "--format=csv,noheader,nounits",
1134 ])
1135 .output()
1136 .ok()?;
1137 if !output.status.success() {
1138 return None;
1139 }
1140
1141 let stdout = String::from_utf8_lossy(&output.stdout);
1142 let mut gpus = Vec::new();
1143
1144 for line in stdout.lines() {
1145 let trimmed = line.trim();
1146 if trimmed.is_empty() {
1147 continue;
1148 }
1149 let mut parts = trimmed.split(',').map(str::trim);
1150 let _index = parts.next()?;
1151 let name = parts.next()?.to_string();
1152 let vram_mib = parts
1153 .next()
1154 .and_then(|v| v.parse::<f32>().ok())
1155 .unwrap_or(0.0);
1156 let compute_cap = parts
1157 .next()
1158 .map(|s| s.to_string())
1159 .unwrap_or_else(|| String::from("gpu"));
1160 let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
1161
1162 gpus.push(GpuInfo {
1163 name,
1164 vram_gb: vram_mib / 1024.0,
1165 backend: super::host::GpuBackend::Cuda,
1166 compute_capability: compute_cap,
1167 driver_version: driver,
1168 pci_address: None,
1169 });
1170 }
1171
1172 if gpus.is_empty() {
1173 None
1174 } else {
1175 Some(gpus)
1176 }
1177}
1178
1179#[cfg(feature = "rocm")]
1180fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
1181 let output = Command::new("rocm-smi")
1182 .args(["--showproductname"])
1183 .output()
1184 .ok()?;
1185 if !output.status.success() {
1186 return None;
1187 }
1188 let stdout = String::from_utf8_lossy(&output.stdout);
1189
1190 let mut gpus = Vec::new();
1191 let mut current_gpu = None;
1192
1193 for line in stdout.lines() {
1194 if line.contains("GPU[") && line.contains("Card model:") {
1195 let name = line
1196 .split(':')
1197 .nth(2)
1198 .map(|s| s.trim().to_string())
1199 .unwrap_or_else(|| String::from("AMD GPU"));
1200 let vram = probe_rocm_vram();
1201 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1202 gpus.push(GpuInfo {
1203 name,
1204 vram_gb: vram.unwrap_or(0.0),
1205 backend: super::host::GpuBackend::Rocm,
1206 compute_capability: compute_cap,
1207 driver_version: detect_rocm_version().unwrap_or_default(),
1208 pci_address: current_gpu.take(),
1209 });
1210 }
1211 }
1212
1213 if gpus.is_empty() {
1214 None
1215 } else {
1216 Some(gpus)
1217 }
1218}
1219
1220#[cfg(feature = "rocm")]
1221fn probe_rocm_vram() -> Option<f32> {
1222 let output = Command::new("rocm-smi")
1223 .args(["--showmeminfo", "vram"])
1224 .output()
1225 .ok()?;
1226 let stdout = String::from_utf8_lossy(&output.stdout);
1227 stdout
1228 .lines()
1229 .find(|l| l.contains("Total Memory"))
1230 .and_then(|l| {
1231 let val = l.split(':').nth(1)?.trim().to_lowercase();
1232 let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1233 if val.contains("gb") {
1234 Some(num)
1235 } else if val.contains("mb") {
1236 Some(num / 1024.0)
1237 } else {
1238 None
1239 }
1240 })
1241}
1242
1243#[cfg(feature = "rocm")]
1244fn detect_rocm_version() -> Option<String> {
1245 let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1246 let stderr = String::from_utf8_lossy(&output.stderr);
1247 stderr
1248 .lines()
1249 .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1250}
1251
1252#[cfg(target_os = "windows")]
1253fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1254 let output = Command::new("powershell")
1256 .args([
1257 "-NoProfile",
1258 "-Command",
1259 "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",
1260 ])
1261 .output()
1262 .ok()?;
1263 if !output.status.success() {
1264 return None;
1265 }
1266
1267 let stdout = String::from_utf8_lossy(&output.stdout);
1268 let mut gpus: Vec<GpuInfo> = Vec::new();
1269
1270 for line in stdout.lines().skip(1) {
1271 let trimmed = line.trim();
1272 if trimmed.is_empty() || trimmed.starts_with('#') {
1273 continue;
1274 }
1275 let mut parts: Vec<String> = Vec::new();
1277 let mut current = String::new();
1278 let mut in_quotes = false;
1279 for ch in trimmed.chars() {
1280 match ch {
1281 '"' => in_quotes = !in_quotes,
1282 ',' if !in_quotes => {
1283 parts.push(std::mem::take(&mut current));
1284 }
1285 c => current.push(c),
1286 }
1287 }
1288 parts.push(current);
1289 if parts.len() < 3 {
1290 continue;
1291 }
1292 let name = parts[0].trim().to_string();
1293 if name.is_empty() {
1294 continue;
1295 }
1296
1297 let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1298 let vram_gb = if vram_bytes > 0.0 {
1299 (vram_bytes / 1_073_741_824.0) as f32
1300 } else {
1301 0.0
1302 };
1303 let driver = parts[2].trim().to_string();
1304 let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1305 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1306
1307 let backend = if let Some(ref id) = pnp_id {
1309 let id_lower = id.to_ascii_lowercase();
1310 if id_lower.contains("ven_10de") {
1311 super::host::GpuBackend::Cuda
1312 } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1313 if cfg!(feature = "rocm") {
1314 super::host::GpuBackend::Rocm
1315 } else {
1316 super::host::GpuBackend::Directml
1317 }
1318 } else if id_lower.contains("ven_8086") {
1319 super::host::GpuBackend::Directml
1320 } else if id_lower.contains("ven_14e4") {
1321 super::host::GpuBackend::Vulkan
1322 } else {
1323 super::host::GpuBackend::Directml
1324 }
1325 } else {
1326 super::host::GpuBackend::Directml
1327 };
1328
1329 let actual_vram = if vram_gb <= 0.0 {
1331 probe_windows_dxgi_vram().unwrap_or(0.0)
1332 } else {
1333 vram_gb
1334 };
1335
1336 gpus.push(GpuInfo {
1337 name,
1338 vram_gb: actual_vram,
1339 backend,
1340 compute_capability: compute_cap,
1341 driver_version: driver,
1342 pci_address: pnp_id,
1343 });
1344 }
1345
1346 if gpus.is_empty() {
1347 None
1348 } else {
1349 Some(gpus)
1350 }
1351}
1352
1353#[cfg(target_os = "windows")]
1364fn probe_windows_dxgi_vram() -> Option<f32> {
1365 if let Some(vram) = probe_windows_registry_vram() {
1366 return Some(vram);
1367 }
1368 probe_windows_dxgi_vram_via_addtype()
1369}
1370
1371#[cfg(target_os = "windows")]
1372fn probe_windows_registry_vram() -> Option<f32> {
1373 let output = Command::new("powershell")
1374 .args([
1375 "-NoProfile",
1376 "-Command",
1377 "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",
1378 ])
1379 .output()
1380 .ok()?;
1381 if !output.status.success() {
1382 return None;
1383 }
1384 let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1385 .trim()
1386 .parse()
1387 .ok()?;
1388 if bytes > 0.0 {
1389 Some((bytes / 1_073_741_824.0) as f32)
1390 } else {
1391 None
1392 }
1393}
1394
1395#[cfg(target_os = "windows")]
1396fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1397 let script = r#"
1398Add-Type @"
1399using System;
1400using System.Runtime.InteropServices;
1401public class DXGIVram {
1402 [DllImport("dxgi.dll")]
1403 private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1404 public static float GetVRAM() {
1405 Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1406 if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1407 try {
1408 IntPtr vtable = Marshal.ReadIntPtr(factory);
1409 IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1410 var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1411 IntPtr adapter;
1412 if (enumFunc(factory, 0, out adapter) != 0) return 0;
1413 try {
1414 IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1415 IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1416 var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1417 DXGI_ADAPTER_DESC desc;
1418 if (descFunc(adapter, out desc) != 0) return 0;
1419 return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1420 } finally { Marshal.Release(adapter); }
1421 } finally { Marshal.Release(factory); }
1422 }
1423 [StructLayout(LayoutKind.Sequential)]
1424 private struct DXGI_ADAPTER_DESC {
1425 public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1426 public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1427 [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1428 }
1429 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1430 private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1431 [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1432 private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1433}
1434"@
1435[DXGIVram]::GetVRAM()
1436"#;
1437 let output = Command::new("powershell")
1438 .args(["-NoProfile", "-NonInteractive", "-Command", script])
1439 .output()
1440 .ok()?;
1441 if !output.status.success() {
1442 return None;
1443 }
1444 let stdout = String::from_utf8_lossy(&output.stdout);
1445 stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1446}
1447
1448#[cfg(target_os = "linux")]
1449fn probe_lspci_gpu() -> Option<GpuInfo> {
1450 let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1451 if !output.status.success() {
1452 return None;
1453 }
1454 let stdout = String::from_utf8_lossy(&output.stdout);
1455
1456 let line = stdout
1457 .lines()
1458 .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1459 let quoted: Vec<String> = line
1460 .split('"')
1461 .enumerate()
1462 .filter_map(|(i, s)| {
1463 if i % 2 == 1 {
1464 let t = s.trim();
1465 if t.is_empty() {
1466 None
1467 } else {
1468 Some(t.to_string())
1469 }
1470 } else {
1471 None
1472 }
1473 })
1474 .collect();
1475 let description = quoted
1476 .get(2)
1477 .cloned()
1478 .or_else(|| quoted.last().cloned())
1479 .unwrap_or_else(|| line.trim().to_string());
1480
1481 let compute_cap = super::host::infer_compute_capability_from_name(&description);
1482 Some(GpuInfo {
1483 name: description,
1484 vram_gb: 0.0,
1485 backend: super::host::GpuBackend::Vulkan,
1486 compute_capability: compute_cap,
1487 driver_version: String::from("lspci"),
1488 pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1489 })
1490}
1491
1492#[cfg(target_os = "linux")]
1493fn probe_sysfs_gpu() -> Option<GpuInfo> {
1494 let drm_root = Path::new("/sys/class/drm");
1495 let entries = fs::read_dir(drm_root).ok()?;
1496
1497 for entry in entries.flatten() {
1498 let path = entry.path();
1499 let fname = path.file_name()?.to_string_lossy().to_string();
1500 if !fname.starts_with("card") {
1501 continue;
1502 }
1503
1504 let dev = path.join("device");
1505 if !dev.exists() {
1506 continue;
1507 }
1508
1509 let gpu_name = fs::read_to_string(dev.join("product_name"))
1510 .ok()
1511 .map(|v| v.trim().to_string())
1512 .filter(|v| !v.is_empty());
1513 let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1514 .ok()
1515 .and_then(|v| v.trim().parse::<f32>().ok());
1516 let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1517
1518 if let Some(name) = gpu_name {
1519 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1520 return Some(GpuInfo {
1521 name,
1522 vram_gb: vram_gb.unwrap_or(0.0),
1523 backend: super::host::GpuBackend::Vulkan,
1524 compute_capability: compute_cap,
1525 driver_version: String::from("sysfs"),
1526 pci_address: None,
1527 });
1528 }
1529 }
1530 None
1531}
1532
1533#[cfg(target_os = "macos")]
1534fn probe_macos_gpu() -> Option<GpuInfo> {
1535 let output = Command::new("system_profiler")
1537 .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1538 .output()
1539 .ok()?;
1540 if !output.status.success() {
1541 return None;
1542 }
1543
1544 let stdout = String::from_utf8_lossy(&output.stdout);
1545 let mut name = String::from("Apple GPU");
1546 let mut vram_gb = 0.0;
1547 let mut metal_support = String::from("metal");
1548 let mut gpu_metal_family = String::new();
1549
1550 for line in stdout.lines() {
1551 if line.contains("Chipset Model:") {
1552 if let Some(val) = line.split(':').nth(1) {
1553 name = val.trim().to_string();
1554 }
1555 }
1556 if line.contains("VRAM") {
1558 let val = line.split(':').nth(1).unwrap_or("").trim();
1559 if let Some(num_str) = val.split_whitespace().next() {
1560 if let Ok(num) = num_str.parse::<f32>() {
1561 vram_gb = num;
1562 }
1563 }
1564 }
1565 if line.contains("Metal Support") {
1566 if let Some(val) = line.split(':').nth(1) {
1567 metal_support = val.trim().to_string();
1568 }
1569 }
1570 if line.contains("Metal Family") {
1571 if let Some(val) = line.split(':').nth(1) {
1572 gpu_metal_family = val.trim().to_string();
1573 }
1574 }
1575 }
1576
1577 if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1580 if let Ok(ioreg) = Command::new("ioreg")
1581 .args(["-r", "-c", "IOAccelerator", "-l"])
1582 .output()
1583 {
1584 let iotext = String::from_utf8_lossy(&ioreg.stdout);
1585 for line in iotext.lines() {
1586 if line.contains("\"gpu-core-count\"") {
1588 if let Some(mem_line) =
1590 iotext.lines().find(|l| l.contains("\"memory-available\""))
1591 {
1592 if let Some(val) = mem_line.split('=').nth(1) {
1593 if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1594 vram_gb = (bytes / 1_073_741_824.0) as f32;
1595 }
1596 }
1597 }
1598 break;
1599 }
1600 }
1601 if vram_gb <= 0.0 {
1603 vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1604 }
1605 }
1606 }
1607
1608 let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1610 metal_support
1611 } else if !gpu_metal_family.is_empty() {
1612 format!("apple_metal/{}", gpu_metal_family)
1613 } else {
1614 String::from("apple_metal")
1615 };
1616
1617 Some(GpuInfo {
1618 name,
1619 vram_gb,
1620 backend: super::host::GpuBackend::Metal,
1621 compute_capability,
1622 driver_version: String::from("metal"),
1623 pci_address: None,
1624 })
1625}
1626
1627fn probe_vulkan_gpu() -> Option<GpuInfo> {
1628 if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1630 if output.status.success() {
1631 let stdout = String::from_utf8_lossy(&output.stdout);
1632 for line in stdout.lines() {
1633 if line.contains("GPU id") || line.contains("deviceName") {
1634 let name = line
1635 .split(':')
1636 .nth(1)
1637 .map(|s| s.trim().to_string())
1638 .unwrap_or_else(|| String::from("Vulkan GPU"));
1639 let compute_cap = super::host::infer_compute_capability_from_name(&name);
1640 return Some(GpuInfo {
1641 name,
1642 vram_gb: 0.0,
1643 backend: super::host::GpuBackend::Vulkan,
1644 compute_capability: compute_cap,
1645 driver_version: String::from("vulkan"),
1646 pci_address: None,
1647 });
1648 }
1649 }
1650 }
1651 }
1652 None
1653}
1654
1655fn detect_npus() -> Vec<NpuInfo> {
1660 let mut npus = Vec::new();
1661
1662 #[cfg(target_os = "windows")]
1664 {
1665 if let Ok(output) = Command::new("powershell")
1666 .args([
1667 "-NoProfile",
1668 "-Command",
1669 "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",
1670 ])
1671 .output()
1672 {
1673 if output.status.success() {
1674 let stdout = String::from_utf8_lossy(&output.stdout);
1675 for line in stdout.lines() {
1676 let trimmed = line.trim();
1677 if !trimmed.is_empty() {
1678 npus.push(NpuInfo {
1679 name: trimmed.to_string(),
1680 memory_gb: 2.0, driver: String::from("windows"),
1682 });
1683 }
1684 }
1685 }
1686 }
1687 }
1688
1689 #[cfg(target_os = "linux")]
1691 {
1692 let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1693 for base in &npu_paths {
1694 if let Ok(entries) = fs::read_dir(base) {
1695 for entry in entries.flatten() {
1696 let name = entry.file_name().to_string_lossy().to_string();
1697 if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1698 npus.push(NpuInfo {
1699 name,
1700 memory_gb: 2.0,
1701 driver: String::from("linux"),
1702 });
1703 }
1704 }
1705 }
1706 }
1707 if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1709 for entry in entries.flatten() {
1710 let name = entry.file_name().to_string_lossy().to_string();
1711 npus.push(NpuInfo {
1712 name: format!("accel/{}", name),
1713 memory_gb: 2.0,
1714 driver: String::from("linux"),
1715 });
1716 }
1717 }
1718 }
1719
1720 #[cfg(target_os = "macos")]
1722 {
1723 if super::host::is_apple_silicon() {
1724 npus.push(NpuInfo {
1725 name: String::from("Apple Neural Engine"),
1726 memory_gb: 0.0, driver: String::from("ane"),
1728 });
1729 }
1730 }
1731
1732 if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1734 npus.push(NpuInfo {
1735 name: String::from("npu-env"),
1736 memory_gb: 2.0,
1737 driver: String::from("env"),
1738 });
1739 }
1740
1741 npus
1742}
1743
1744fn detect_network_info() -> NetworkInfo {
1749 let xdp_supported = cfg!(target_os = "linux");
1750 let interfaces = detect_network_interfaces();
1751 NetworkInfo {
1752 xdp_supported,
1753 interfaces,
1754 }
1755}
1756
1757fn detect_network_interfaces() -> Vec<String> {
1758 let mut ifaces = Vec::new();
1759 if let Ok(entries) = fs::read_dir("/sys/class/net") {
1760 for entry in entries.flatten() {
1761 if let Ok(name) = entry.file_name().into_string() {
1762 if !name.starts_with("lo") {
1763 ifaces.push(name);
1764 }
1765 }
1766 }
1767 }
1768 ifaces
1769}
1770
1771fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1776 use super::host::GpuBackend;
1777 let lowered = name.to_ascii_lowercase();
1778 let lowered_cc = compute_capability.to_ascii_lowercase();
1779
1780 if lowered_cc.contains("rocm")
1781 || lowered.contains("amd")
1782 || lowered.contains("radeon")
1783 || lowered.contains("instinct")
1784 || lowered.contains("mi250")
1785 || lowered.contains("mi300")
1786 || lowered.contains("mi350")
1787 {
1788 return GpuBackend::Rocm;
1789 }
1790
1791 if lowered_cc.contains("metal")
1792 || lowered.contains("apple")
1793 || lowered.contains("m1")
1794 || lowered.contains("m2")
1795 || lowered.contains("m3")
1796 || lowered.contains("m4")
1797 {
1798 return GpuBackend::Metal;
1799 }
1800
1801 if lowered_cc.contains("directml") || lowered.contains("directml") {
1802 return GpuBackend::Directml;
1803 }
1804
1805 if lowered_cc.contains("npu")
1806 || lowered.contains("npu")
1807 || lowered.contains("xdna")
1808 || lowered.contains("neural")
1809 {
1810 return GpuBackend::Npu;
1811 }
1812
1813 if lowered.contains("nvidia")
1814 || lowered.contains("rtx")
1815 || lowered.contains("gtx")
1816 || lowered.contains("tesla")
1817 || lowered.contains("a100")
1818 || lowered.contains("h100")
1819 || lowered.contains("b100")
1820 || lowered.contains("b200")
1821 || lowered_cc
1822 .chars()
1823 .next()
1824 .is_some_and(|c| c.is_ascii_digit())
1825 {
1826 return GpuBackend::Cuda;
1827 }
1828
1829 GpuBackend::Vulkan
1830}
1831
1832fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1837 use super::host::AccelerationMode;
1838
1839 if gpus.iter().any(|g| {
1841 g.vram_gb > 0.0
1842 || (g.backend != super::host::GpuBackend::Cpu
1843 && g.backend != super::host::GpuBackend::Vulkan)
1844 }) {
1845 return AccelerationMode::Gpu;
1846 }
1847
1848 if gpus.iter().any(|g| {
1850 g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1851 }) {
1852 return AccelerationMode::Gpu;
1853 }
1854
1855 if cpu.features.avx_512 {
1857 return AccelerationMode::Avx512;
1858 }
1859 if cpu.features.avx2 {
1860 return AccelerationMode::Avx2;
1861 }
1862 if cpu.features.neon || cpu.features.sve {
1863 return AccelerationMode::Neon;
1864 }
1865
1866 AccelerationMode::Generic
1867}
1868
1869fn compute_recommended_workers(
1874 cpu: &CpuInfo,
1875 memory: &MemoryInfo,
1876 gpus: &[GpuInfo],
1877 accel: super::host::AccelerationMode,
1878) -> usize {
1879 let cores = cpu.logical_cores.max(1);
1880 let memory_bound = if memory.total_gb >= 1.0 {
1881 (memory.total_gb / 4.0).floor() as usize
1882 } else {
1883 1
1884 }
1885 .max(1);
1886
1887 let reserved_core = cores.saturating_sub(1).max(1);
1888
1889 let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1891 let accelerator_bonus = match accel {
1892 super::host::AccelerationMode::Gpu => {
1893 let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1894 2
1895 } else {
1896 1
1897 };
1898 vram_bonus + gpu_count.saturating_sub(1) }
1900 super::host::AccelerationMode::Avx512 => 1,
1901 _ => 0,
1902 };
1903
1904 reserved_core
1905 .min(memory_bound)
1906 .saturating_add(accelerator_bonus)
1907}
1908
1909pub fn infer_compute_capability_from_name(name: &str) -> String {
1915 let lowered = name.to_ascii_lowercase();
1916 if lowered.contains("rtx 50")
1917 || lowered.contains("rtx50")
1918 || lowered.contains("b100")
1919 || lowered.contains("b200")
1920 {
1921 String::from("9.0")
1922 } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1923 String::from("8.9")
1924 } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1925 String::from("8.6")
1926 } else if lowered.contains("arc") {
1927 String::from("xe")
1928 } else if lowered.contains("radeon")
1929 || lowered.contains("amd")
1930 || lowered.contains("advanced micro")
1931 || lowered.contains("rx ")
1932 || lowered.contains("w7900")
1933 || lowered.contains("w6800")
1934 || lowered.contains("mi250")
1935 || lowered.contains("mi300")
1936 || lowered.contains("mi350")
1937 || lowered.contains("instinct")
1938 {
1939 if cfg!(feature = "rocm") {
1940 String::from("rocm")
1941 } else {
1942 String::from("gpu")
1943 }
1944 } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1945 String::from("xe")
1946 } else if lowered.contains("apple")
1947 || lowered.contains("m1")
1948 || lowered.contains("m2")
1949 || lowered.contains("m3")
1950 || lowered.contains("m4")
1951 {
1952 String::from("apple_metal")
1953 } else {
1954 String::from("gpu")
1955 }
1956}
1957
1958#[cfg(test)]
1963mod tests {
1964 use super::*;
1965 use crate::GpuBackend;
1966
1967 #[test]
1968 fn system_profile_detects_cpu() {
1969 let sp = SystemProfile::detect_fast();
1970 assert!(sp.cpu.logical_cores >= 1);
1971 assert!(!sp.cpu.arch.is_empty());
1972 }
1973
1974 fn synthetic_core_record(efficiency_class: u8) -> Vec<u8> {
1982 let total_size: u32 = 48;
1983 let mut rec = vec![0u8; total_size as usize];
1984 rec[0..4].copy_from_slice(&0u32.to_ne_bytes()); rec[4..8].copy_from_slice(&total_size.to_ne_bytes());
1986 rec[8] = 0; rec[9] = efficiency_class;
1988 rec
1989 }
1990
1991 fn synthetic_non_core_record(relationship: u32, size: u32) -> Vec<u8> {
1992 let mut rec = vec![0u8; size as usize];
1993 rec[0..4].copy_from_slice(&relationship.to_ne_bytes());
1994 rec[4..8].copy_from_slice(&size.to_ne_bytes());
1995 rec
1996 }
1997
1998 #[test]
1999 fn parse_processor_core_relationships_detects_hybrid_split() {
2000 let mut buf = Vec::new();
2001 for _ in 0..6 {
2002 buf.extend(synthetic_core_record(1)); }
2004 for _ in 0..8 {
2005 buf.extend(synthetic_core_record(0)); }
2007 assert_eq!(parse_processor_core_relationships(&buf), (Some(6), Some(8)));
2008 }
2009
2010 #[test]
2011 fn parse_processor_core_relationships_returns_none_for_uniform_cpu() {
2012 let mut buf = Vec::new();
2013 for _ in 0..8 {
2014 buf.extend(synthetic_core_record(0));
2015 }
2016 assert_eq!(parse_processor_core_relationships(&buf), (None, None));
2017 }
2018
2019 #[test]
2020 fn parse_processor_core_relationships_handles_empty_buffer() {
2021 assert_eq!(parse_processor_core_relationships(&[]), (None, None));
2022 }
2023
2024 #[test]
2025 fn parse_processor_core_relationships_stops_at_malformed_record_without_panicking() {
2026 let mut buf = Vec::new();
2027 buf.extend(synthetic_core_record(1));
2028 buf.extend(synthetic_core_record(0));
2029 let mut bad_tail = vec![0u8; 8];
2035 bad_tail[0..4].copy_from_slice(&0u32.to_ne_bytes());
2036 bad_tail[4..8].copy_from_slice(&100u32.to_ne_bytes());
2037 buf.extend(bad_tail);
2038 assert_eq!(parse_processor_core_relationships(&buf), (Some(1), Some(1)));
2039 }
2040
2041 #[test]
2042 fn parse_processor_core_relationships_ignores_non_core_relationships() {
2043 let mut buf = Vec::new();
2044 buf.extend(synthetic_non_core_record(1, 16)); buf.extend(synthetic_core_record(1));
2046 buf.extend(synthetic_core_record(0));
2047 assert_eq!(parse_processor_core_relationships(&buf), (Some(1), Some(1)));
2048 }
2049
2050 #[test]
2051 fn system_profile_detects_memory() {
2052 let sp = SystemProfile::detect_fast();
2053 assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
2055 }
2056
2057 #[test]
2058 fn system_profile_always_has_at_least_one_gpu_entry() {
2059 let sp = SystemProfile::detect_fast();
2060 assert!(
2061 !sp.gpus.is_empty(),
2062 "Should have at least 'cpu' fallback GPU entry"
2063 );
2064 }
2065
2066 #[test]
2067 fn system_profile_converts_to_runtime_profile() {
2068 let sp = SystemProfile::detect_fast();
2069 let rp: crate::host::RuntimeProfile = (&sp).into();
2070 assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
2071 assert_eq!(rp.recommended_workers, sp.recommended_workers);
2072 }
2073
2074 #[test]
2079 fn detect_gpu_handles_env_overrides() {
2080 std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
2081 std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
2082 std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
2083
2084 let gpu = detect_gpu_from_env().expect("env probe should work");
2085 assert_eq!(gpu.name, "RTX 4090");
2086 assert!((gpu.vram_gb - 24.0).abs() < 0.1);
2087 assert_eq!(gpu.compute_capability, "8.9");
2088
2089 std::env::remove_var("GHOSTLINK_GPU_NAME");
2090 std::env::remove_var("GHOSTLINK_VRAM_GB");
2091 std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
2092
2093 std::env::set_var("GHOSTLINK_VRAM_GB", "0");
2097 assert!(detect_gpu_from_env().is_none());
2098 std::env::remove_var("GHOSTLINK_VRAM_GB");
2099 }
2100
2101 #[test]
2102 fn infer_backend_rocm() {
2103 let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
2104 assert_eq!(b, GpuBackend::Rocm);
2105 }
2106
2107 #[test]
2108 fn infer_backend_cuda() {
2109 let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
2110 assert_eq!(b, GpuBackend::Cuda);
2111 }
2112
2113 #[test]
2114 fn infer_backend_metal() {
2115 let b = infer_backend("Apple M3 Max", "apple_metal");
2116 assert_eq!(b, GpuBackend::Metal);
2117 }
2118
2119 #[test]
2120 fn compute_workers_scales_with_gpus() {
2121 let cpu = CpuInfo {
2122 logical_cores: 16,
2123 physical_cores: 8,
2124 arch: "x86_64".into(),
2125 ..Default::default()
2126 };
2127 let mem = MemoryInfo {
2128 total_gb: 64.0,
2129 available_gb: 32.0,
2130 };
2131 let gpus = vec![
2132 GpuInfo {
2133 name: "RTX 4090".into(),
2134 vram_gb: 24.0,
2135 backend: GpuBackend::Cuda,
2136 compute_capability: "8.9".into(),
2137 driver_version: "555".into(),
2138 pci_address: None,
2139 },
2140 GpuInfo {
2141 name: "RTX 3090".into(),
2142 vram_gb: 24.0,
2143 backend: GpuBackend::Cuda,
2144 compute_capability: "8.6".into(),
2145 driver_version: "555".into(),
2146 pci_address: None,
2147 },
2148 ];
2149 let accel = pick_acceleration_mode(&gpus, &cpu);
2150 let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
2151 assert!(workers > 0);
2153 }
2154
2155 #[test]
2156 fn physical_cores_nonzero() {
2157 let cpu = detect_cpu_info();
2158 assert!(cpu.physical_cores >= 1);
2159 }
2160
2161 #[cfg(target_os = "windows")]
2162 #[test]
2163 fn sum_windows_cim_core_counts_handles_multi_socket_output() {
2164 assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
2168 assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
2169 assert_eq!(sum_windows_cim_core_counts(""), 0);
2170 assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
2172 }
2173
2174 #[cfg(target_arch = "x86_64")]
2175 #[test]
2176 fn amx_tile_detection_does_not_panic() {
2177 let _ = detect_amx_tile();
2181 }
2182}