Skip to main content

ghostlink_core/
system_profile.rs

1//! Unified system profile: single source of truth for host capabilities.
2//!
3//! `SystemProfile` consolidates all hardware/runtime detection previously
4//! scattered across `host.rs`, `runtime.rs`, and `backend_registry.rs`.
5//! Every platform probe feeds into one struct, and downstream code converts
6//! it into the legacy `RuntimeProfile` / `BackendInfo` shapes as needed.
7
8use 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// ---------------------------------------------------------------------------
21// Sub-types
22// ---------------------------------------------------------------------------
23
24/// CPU topology and feature-set information.
25#[derive(Clone, Debug, Default, Hash)]
26pub struct CpuInfo {
27    /// Human-readable brand string (e.g. "Intel(R) Core(TM) i9-14900K").
28    pub brand: String,
29    /// Number of logical cores (hyperthreads).
30    pub logical_cores: usize,
31    /// Number of physical cores (approximate).
32    pub physical_cores: usize,
33    /// Architecture family (x86_64, aarch64, …).
34    pub arch: String,
35    /// Set of detected CPU features.
36    pub features: CpuFeatures,
37}
38
39/// CPU SIMD / accelerator features.
40#[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/// System memory information.
51#[derive(Clone, Debug, Default)]
52pub struct MemoryInfo {
53    /// Total physical RAM in GB.
54    pub total_gb: f32,
55    /// Approximate available RAM in GB (cached/free).
56    pub available_gb: f32,
57}
58
59/// A single GPU detected on the system.
60#[derive(Clone, Debug)]
61pub struct GpuInfo {
62    /// Human-readable name (e.g. "NVIDIA GeForce RTX 4090").
63    pub name: String,
64    /// VRAM in GB.
65    pub vram_gb: f32,
66    /// Likely backend technology.
67    pub backend: super::host::GpuBackend,
68    /// Compute capability string (e.g. "8.9", "rocm", "xe", "apple_metal").
69    pub compute_capability: String,
70    /// Driver version when available.
71    pub driver_version: String,
72    /// PCI domain:bus:device.function (when available).
73    pub pci_address: Option<String>,
74}
75
76/// A Neural Processing Unit detected on the system.
77#[derive(Clone, Debug)]
78pub struct NpuInfo {
79    pub name: String,
80    pub memory_gb: f32,
81    pub driver: String,
82}
83
84/// Network interface information relevant to cluster tuning.
85#[derive(Clone, Debug, Default, Hash)]
86pub struct NetworkInfo {
87    pub xdp_supported: bool,
88    pub interfaces: Vec<String>,
89}
90
91// ---------------------------------------------------------------------------
92// SystemProfile
93// ---------------------------------------------------------------------------
94
95/// Complete, platform-agnostic description of the local host.
96#[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    /// Derived acceleration mode (best available).
104    pub acceleration_mode: super::host::AccelerationMode,
105    /// Recommended worker count for scheduling / pipelines.
106    pub recommended_workers: usize,
107    /// Platform-specific hostname / node identifier.
108    pub system_id: String,
109    /// Best-effort description of how detection was performed.
110    pub detection_source: String,
111}
112
113// ---------------------------------------------------------------------------
114// Cache helpers (re-use existing cache infra from host.rs)
115// ---------------------------------------------------------------------------
116
117static 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
125// ---------------------------------------------------------------------------
126// Public API
127// ---------------------------------------------------------------------------
128
129impl SystemProfile {
130    /// Run full system detection and return the unified profile.
131    ///
132    /// Uses a short-lived cache (30 s) so repeated calls are cheap.
133    pub fn detect() -> Self {
134        Self::detect_with_mode(ProbeMode::Full)
135    }
136
137    /// Fast path: skip slow external commands, use env + sysfs + cached data.
138    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            // Hold the lock across the whole check-compute-populate sequence
146            // instead of just the check. Only checking (and releasing) the
147            // lock here would let every concurrent cache-miss caller within
148            // the TTL window race into its own full multi-subprocess
149            // detection at once — observed in practice when several
150            // Fast-mode callers start near-simultaneously (e.g. concurrently
151            // running tests), each spawning ~6 external processes and
152            // causing severe CPU/process-table contention. Holding the lock
153            // makes followers wait for the leader's result instead of
154            // duplicating the work.
155            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        // Each of these probes independently spawns 0-N external processes
177        // (wmic/powershell on Windows, lscpu/sysctl on Linux/macOS, nvidia-smi
178        // everywhere) with no shared mutable state between them. Sequentially
179        // these can sum to multiple seconds of wall-clock time (measured via
180        // `cargo bench`: ~4s for a Full probe); running them concurrently
181        // bounds total latency to the single slowest probe instead of the sum.
182        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
227// ---------------------------------------------------------------------------
228// Conversion to legacy types
229// ---------------------------------------------------------------------------
230
231impl 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    /// Convenience: build a `NodeResources` for cluster advertisement.
272    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// ---------------------------------------------------------------------------
287// Probe utilities
288// ---------------------------------------------------------------------------
289
290#[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
310// ---------------------------------------------------------------------------
311// CPU detection
312// ---------------------------------------------------------------------------
313
314fn 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    // SIMD features are compile-time/CPUID-only (no subprocess), cheap to run inline.
322    let features = detect_cpu_features(&arch);
323
324    // Brand string and physical-core count are independent external-process
325    // probes (e.g. on Windows, two separate `Get-CimInstance Win32_Processor`
326    // PowerShell invocations). Measured via `cargo bench`: ~1-1.5s each,
327    // ~2.8s combined when run sequentially. Running them concurrently bounds
328    // this to the slower of the two.
329    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    // Linux
347    #[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    // macOS
360    #[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        // Apple Silicon fallback
373        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        // For Apple Silicon, sysctl may not return a brand string
384        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    // Windows
394    #[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                    // CSV format: Node,Name
405                    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        // Fallback: Win32_Processor via PowerShell
415        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; // all ARMv8+ have NEON
439            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/// Runtime AMX-TILE detection via raw CPUID.
464///
465/// `is_x86_feature_detected!("amx-tile")` is gated behind the unstable
466/// `x86_amx_intrinsics` feature on current stable Rust, so it can't be used
467/// here. The previous implementation substituted
468/// `cfg!(target_feature = "amx-tile")`, a *compile-time* check that reflects
469/// only whether this binary was built with `-C target-feature=+amx-tile`
470/// (true for essentially no real-world build) — so `amx` silently reported
471/// `false` on every AMX-capable host (e.g. Sapphire Rapids) regardless of
472/// actual hardware support. CPUID leaf 7, sub-leaf 0, EDX bit 24 is the
473/// architectural AMX-TILE feature bit.
474// `__cpuid`/`__cpuid_count` require an explicit `unsafe` block on this
475// project's MSRV toolchain, but a newer compiler considers that block
476// unnecessary (their signature became safe) and denies it under
477// `-D warnings` instead. `#[allow(unused_unsafe)]` keeps this function
478// compiling on both: required for MSRV, a silenced no-op warning on newer
479// rustc.
480#[cfg(target_arch = "x86_64")]
481#[allow(unused_unsafe)]
482fn detect_amx_tile() -> bool {
483    // Safety: `__cpuid`/`__cpuid_count` require the CPUID instruction to be
484    // available, which is guaranteed on every x86_64 CPU (part of the
485    // baseline x86_64 architecture, unlike 32-bit x86 where it can be
486    // absent on pre-Pentium hardware) — this cfg-gates to x86_64 only.
487    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// AMX only exists on 64-bit server-class parts; no known 32-bit x86 target
496// implements it, so avoid the extra unsafe surface for that arch.
497#[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        // Check /proc/cpuinfo for "sve" feature flag
511        if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
512            if content.contains("sve") || content.contains("SVE") {
513                return true;
514            }
515            // Also check /proc/self/auxv for HWCAP_SVE
516        }
517    }
518    false
519}
520
521/// Sum `NumberOfCores` values across all lines of CIM/WMI output, one line
522/// per physical CPU package on multi-socket hosts.
523#[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    // Linux
532    #[cfg(target_os = "linux")]
533    {
534        // Count unique core IDs across all CPU packages
535        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    // macOS
555    #[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    // Windows
571    #[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            // Get-CimInstance without -ErrorAction/indexing returns one line per
583            // physical CPU package on multi-socket hosts. The previous code
584            // `return`ed on the first parseable line, silently reporting only
585            // one socket's core count (e.g. 16 instead of 32 on a dual-socket
586            // Xeon box) and skewing worker/tuning decisions that divide by
587            // physical core count. Sum across all reported packages instead.
588            let total = sum_windows_cim_core_counts(&text);
589            if total > 0 {
590                return Some(total);
591            }
592        }
593    }
594
595    None
596}
597
598// ---------------------------------------------------------------------------
599// Memory detection
600// ---------------------------------------------------------------------------
601
602fn detect_memory_info() -> MemoryInfo {
603    // Total and available memory are independent external-process probes
604    // (e.g. two separate PowerShell/CIM calls on Windows); run them
605    // concurrently rather than paying both costs sequentially.
606    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    // Env override (cross-platform)
621    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    // Fallback: wmic
674    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
788// ---------------------------------------------------------------------------
789// GPU detection
790// ---------------------------------------------------------------------------
791
792fn detect_gpus(mode: ProbeMode) -> Vec<GpuInfo> {
793    // Try env override first (single GPU)
794    if let Some(gpu) = detect_gpu_from_env() {
795        return vec![gpu];
796    }
797
798    // Platform-specific probes with timeout for subprocess calls
799    let mut gpus = Vec::new();
800
801    // nvidia-smi (cross-platform, with timeout)
802    if let Some(result) = probe_nvidia_smi_with_timeout() {
803        gpus.extend(result);
804    }
805
806    // rocm-smi (Linux, feature-gated, with timeout)
807    #[cfg(feature = "rocm")]
808    if let Some(result) = probe_rocm_smi_with_timeout() {
809        gpus.extend(result);
810    }
811
812    // Windows WMI (fallback for non-NVIDIA GPUs, with timeout)
813    #[cfg(target_os = "windows")]
814    if gpus.is_empty() {
815        if let Some(result) = probe_windows_wmi_gpu_with_timeout() {
816            gpus.extend(result);
817        }
818    }
819
820    // Linux lspci (fallback, no VRAM info, with timeout)
821    #[cfg(target_os = "linux")]
822    if gpus.is_empty() {
823        if let Some(gpu) = probe_lspci_gpu_with_timeout() {
824            gpus.push(gpu);
825        }
826    }
827
828    // macOS system_profiler (with timeout)
829    #[cfg(target_os = "macos")]
830    if gpus.is_empty() {
831        if let Some(gpu) = probe_macos_gpu_with_timeout() {
832            gpus.push(gpu);
833        }
834    }
835
836    // Linux sysfs (VRAM info for AMD + Intel)
837    #[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    // Vulkan probe (cross-platform, full mode only to avoid slowdown, with timeout)
845    if mode == ProbeMode::Full && gpus.is_empty() {
846        if let Some(gpu) = probe_vulkan_gpu_with_timeout() {
847            gpus.push(gpu);
848        }
849    }
850
851    if gpus.is_empty() {
852        let cc = if cfg!(feature = "rocm") {
853            "rocm"
854        } else {
855            "gpu"
856        };
857        gpus.push(GpuInfo {
858            name: String::from("cpu"),
859            vram_gb: 0.0,
860            backend: super::host::GpuBackend::Cpu,
861            compute_capability: cc.to_string(),
862            driver_version: String::from("native"),
863            pci_address: None,
864        });
865    }
866
867    gpus
868}
869
870/// Runs `probe` on a detached background thread and waits up to `timeout`
871/// for a result, returning as soon as it's ready rather than always blocking
872/// for the full duration — `std::thread::scope` can't do this: it implicitly
873/// joins every spawned thread before returning, so wrapping a probe in a
874/// scope and then sleeping for the timeout duration blocks for that full
875/// duration on *every* call, fast or slow, and still hangs forever on a
876/// genuinely stuck probe (the earlier bug this replaces). Rust has no way to
877/// forcibly kill a thread, so a probe that's still running past `timeout` is
878/// abandoned: the background thread runs to completion on its own time and
879/// its late result is silently dropped when `rx` goes out of scope.
880fn probe_with_timeout<T, F>(timeout: Duration, probe: F) -> Option<T>
881where
882    T: Send + 'static,
883    F: FnOnce() -> Option<T> + Send + 'static,
884{
885    let (tx, rx) = std::sync::mpsc::channel();
886    std::thread::spawn(move || {
887        let _ = tx.send(probe());
888    });
889    rx.recv_timeout(timeout).ok().flatten()
890}
891
892/// Timeout-wrapped GPU probe: nvidia-smi with 5-second timeout
893fn probe_nvidia_smi_with_timeout() -> Option<Vec<GpuInfo>> {
894    probe_with_timeout(Duration::from_millis(5000), probe_nvidia_smi)
895}
896
897/// Timeout-wrapped GPU probe: rocm-smi with 5-second timeout
898#[cfg(feature = "rocm")]
899fn probe_rocm_smi_with_timeout() -> Option<Vec<GpuInfo>> {
900    probe_with_timeout(Duration::from_millis(5000), probe_rocm_smi)
901}
902
903/// Timeout-wrapped GPU probe: Windows WMI with 5-second timeout
904#[cfg(target_os = "windows")]
905fn probe_windows_wmi_gpu_with_timeout() -> Option<Vec<GpuInfo>> {
906    probe_with_timeout(Duration::from_millis(5000), probe_windows_wmi_gpu)
907}
908
909/// Timeout-wrapped GPU probe: lspci with 5-second timeout
910#[cfg(target_os = "linux")]
911fn probe_lspci_gpu_with_timeout() -> Option<GpuInfo> {
912    probe_with_timeout(Duration::from_millis(5000), probe_lspci_gpu)
913}
914
915/// Timeout-wrapped GPU probe: macOS system_profiler with 5-second timeout
916#[cfg(target_os = "macos")]
917fn probe_macos_gpu_with_timeout() -> Option<GpuInfo> {
918    probe_with_timeout(Duration::from_millis(5000), probe_macos_gpu)
919}
920
921/// Timeout-wrapped GPU probe: Vulkan with 10-second timeout (slower probe)
922fn probe_vulkan_gpu_with_timeout() -> Option<GpuInfo> {
923    probe_with_timeout(Duration::from_millis(10000), probe_vulkan_gpu)
924}
925
926fn detect_gpu_from_env() -> Option<GpuInfo> {
927    let name = env::var("GHOSTLINK_GPU_NAME")
928        .ok()
929        .filter(|s| !s.trim().is_empty());
930    let vram_gb = env::var("GHOSTLINK_VRAM_GB")
931        .ok()
932        .and_then(|v| v.parse::<f32>().ok());
933    let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
934        .ok()
935        .filter(|s| !s.trim().is_empty());
936    // A VRAM value of 0 (or absent) is not a meaningful GPU signal on its own —
937    // launch scripts unconditionally export GHOSTLINK_VRAM_GB=0 in CPU-only mode,
938    // and treating that as an override used to short-circuit the real hardware
939    // probes below, permanently masking true auto-discovery and falsely reporting
940    // GPU acceleration as available on CPU-only hosts.
941    let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
942    if !has_gpu_signal {
943        return None;
944    }
945
946    let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
947    let compute_capability =
948        cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
949    let backend = infer_backend(&gpu_name, &compute_capability);
950
951    Some(GpuInfo {
952        name: gpu_name,
953        vram_gb: vram_gb.unwrap_or(0.0),
954        backend,
955        compute_capability,
956        driver_version: String::from("env"),
957        pci_address: None,
958    })
959}
960
961fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
962    let output = Command::new("nvidia-smi")
963        .args([
964            "--query-gpu=index,name,memory.total,compute_cap,driver_version",
965            "--format=csv,noheader,nounits",
966        ])
967        .output()
968        .ok()?;
969    if !output.status.success() {
970        return None;
971    }
972
973    let stdout = String::from_utf8_lossy(&output.stdout);
974    let mut gpus = Vec::new();
975
976    for line in stdout.lines() {
977        let trimmed = line.trim();
978        if trimmed.is_empty() {
979            continue;
980        }
981        let mut parts = trimmed.split(',').map(str::trim);
982        let _index = parts.next()?;
983        let name = parts.next()?.to_string();
984        let vram_mib = parts
985            .next()
986            .and_then(|v| v.parse::<f32>().ok())
987            .unwrap_or(0.0);
988        let compute_cap = parts
989            .next()
990            .map(|s| s.to_string())
991            .unwrap_or_else(|| String::from("gpu"));
992        let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
993
994        gpus.push(GpuInfo {
995            name,
996            vram_gb: vram_mib / 1024.0,
997            backend: super::host::GpuBackend::Cuda,
998            compute_capability: compute_cap,
999            driver_version: driver,
1000            pci_address: None,
1001        });
1002    }
1003
1004    if gpus.is_empty() {
1005        None
1006    } else {
1007        Some(gpus)
1008    }
1009}
1010
1011#[cfg(feature = "rocm")]
1012fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
1013    let output = Command::new("rocm-smi")
1014        .args(["--showproductname"])
1015        .output()
1016        .ok()?;
1017    if !output.status.success() {
1018        return None;
1019    }
1020    let stdout = String::from_utf8_lossy(&output.stdout);
1021
1022    let mut gpus = Vec::new();
1023    let mut current_gpu = None;
1024
1025    for line in stdout.lines() {
1026        if line.contains("GPU[") && line.contains("Card model:") {
1027            let name = line
1028                .split(':')
1029                .nth(2)
1030                .map(|s| s.trim().to_string())
1031                .unwrap_or_else(|| String::from("AMD GPU"));
1032            let vram = probe_rocm_vram();
1033            let compute_cap = super::host::infer_compute_capability_from_name(&name);
1034            gpus.push(GpuInfo {
1035                name,
1036                vram_gb: vram.unwrap_or(0.0),
1037                backend: super::host::GpuBackend::Rocm,
1038                compute_capability: compute_cap,
1039                driver_version: detect_rocm_version().unwrap_or_default(),
1040                pci_address: current_gpu.take(),
1041            });
1042        }
1043    }
1044
1045    if gpus.is_empty() {
1046        None
1047    } else {
1048        Some(gpus)
1049    }
1050}
1051
1052#[cfg(feature = "rocm")]
1053fn probe_rocm_vram() -> Option<f32> {
1054    let output = Command::new("rocm-smi")
1055        .args(["--showmeminfo", "vram"])
1056        .output()
1057        .ok()?;
1058    let stdout = String::from_utf8_lossy(&output.stdout);
1059    stdout
1060        .lines()
1061        .find(|l| l.contains("Total Memory"))
1062        .and_then(|l| {
1063            let val = l.split(':').nth(1)?.trim().to_lowercase();
1064            let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1065            if val.contains("gb") {
1066                Some(num)
1067            } else if val.contains("mb") {
1068                Some(num / 1024.0)
1069            } else {
1070                None
1071            }
1072        })
1073}
1074
1075#[cfg(feature = "rocm")]
1076fn detect_rocm_version() -> Option<String> {
1077    let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1078    let stderr = String::from_utf8_lossy(&output.stderr);
1079    stderr
1080        .lines()
1081        .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1082}
1083
1084#[cfg(target_os = "windows")]
1085fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1086    // Primary: WMI query with Name, AdapterRAM, DriverVersion, PNPDeviceID
1087    let output = Command::new("powershell")
1088        .args([
1089            "-NoProfile",
1090            "-Command",
1091            "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notmatch '(?i)(Microsoft Basic Display|Remote Display|VirtualBox|VMware)' } | Select-Object Name, AdapterRAM, DriverVersion, PNPDeviceID | ConvertTo-Csv -NoTypeInformation",
1092        ])
1093        .output()
1094        .ok()?;
1095    if !output.status.success() {
1096        return None;
1097    }
1098
1099    let stdout = String::from_utf8_lossy(&output.stdout);
1100    let mut gpus: Vec<GpuInfo> = Vec::new();
1101
1102    for line in stdout.lines().skip(1) {
1103        let trimmed = line.trim();
1104        if trimmed.is_empty() || trimmed.starts_with('#') {
1105            continue;
1106        }
1107        // CSV: "Name","AdapterRAM","DriverVersion","PNPDeviceID"
1108        let mut parts: Vec<String> = Vec::new();
1109        let mut current = String::new();
1110        let mut in_quotes = false;
1111        for ch in trimmed.chars() {
1112            match ch {
1113                '"' => in_quotes = !in_quotes,
1114                ',' if !in_quotes => {
1115                    parts.push(std::mem::take(&mut current));
1116                }
1117                c => current.push(c),
1118            }
1119        }
1120        parts.push(current);
1121        if parts.len() < 3 {
1122            continue;
1123        }
1124        let name = parts[0].trim().to_string();
1125        if name.is_empty() {
1126            continue;
1127        }
1128
1129        let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1130        let vram_gb = if vram_bytes > 0.0 {
1131            (vram_bytes / 1_073_741_824.0) as f32
1132        } else {
1133            0.0
1134        };
1135        let driver = parts[2].trim().to_string();
1136        let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1137        let compute_cap = super::host::infer_compute_capability_from_name(&name);
1138
1139        // Determine backend from PNP ID or name
1140        let backend = if let Some(ref id) = pnp_id {
1141            let id_lower = id.to_ascii_lowercase();
1142            if id_lower.contains("ven_10de") {
1143                super::host::GpuBackend::Cuda
1144            } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1145                if cfg!(feature = "rocm") {
1146                    super::host::GpuBackend::Rocm
1147                } else {
1148                    super::host::GpuBackend::Directml
1149                }
1150            } else if id_lower.contains("ven_8086") {
1151                super::host::GpuBackend::Directml
1152            } else if id_lower.contains("ven_14e4") {
1153                super::host::GpuBackend::Vulkan
1154            } else {
1155                super::host::GpuBackend::Directml
1156            }
1157        } else {
1158            super::host::GpuBackend::Directml
1159        };
1160
1161        // If WMI returned 0 VRAM for a real GPU, try a secondary DXGI-based probe
1162        let actual_vram = if vram_gb <= 0.0 {
1163            probe_windows_dxgi_vram().unwrap_or(0.0)
1164        } else {
1165            vram_gb
1166        };
1167
1168        gpus.push(GpuInfo {
1169            name,
1170            vram_gb: actual_vram,
1171            backend,
1172            compute_capability: compute_cap,
1173            driver_version: driver,
1174            pci_address: pnp_id,
1175        });
1176    }
1177
1178    if gpus.is_empty() {
1179        None
1180    } else {
1181        Some(gpus)
1182    }
1183}
1184
1185/// Secondary VRAM probe used when WMI returns 0 VRAM for a detected GPU
1186/// (common for integrated GPUs, where `Win32_VideoController.AdapterRAM`'s
1187/// 32-bit field is unreliable).
1188///
1189/// Tries a plain registry read first (`HardwareInformation.qwMemorySize`,
1190/// the same technique GPU-Z uses), which needs no in-process compilation.
1191/// Falls back to the DXGI `Add-Type` C# probe — measured via `cargo bench`
1192/// to cost ~1-2s of wall-clock time compiling the inline C# shim, the
1193/// dominant cost in a Full hardware probe — only if the registry value is
1194/// absent, so drivers that don't publish it still get a VRAM reading.
1195#[cfg(target_os = "windows")]
1196fn probe_windows_dxgi_vram() -> Option<f32> {
1197    if let Some(vram) = probe_windows_registry_vram() {
1198        return Some(vram);
1199    }
1200    probe_windows_dxgi_vram_via_addtype()
1201}
1202
1203#[cfg(target_os = "windows")]
1204fn probe_windows_registry_vram() -> Option<f32> {
1205    let output = Command::new("powershell")
1206        .args([
1207            "-NoProfile",
1208            "-Command",
1209            "Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -Name 'HardwareInformation.qwMemorySize' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'HardwareInformation.qwMemorySize' -First 1",
1210        ])
1211        .output()
1212        .ok()?;
1213    if !output.status.success() {
1214        return None;
1215    }
1216    let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1217        .trim()
1218        .parse()
1219        .ok()?;
1220    if bytes > 0.0 {
1221        Some((bytes / 1_073_741_824.0) as f32)
1222    } else {
1223        None
1224    }
1225}
1226
1227#[cfg(target_os = "windows")]
1228fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1229    let script = r#"
1230Add-Type @"
1231using System;
1232using System.Runtime.InteropServices;
1233public class DXGIVram {
1234    [DllImport("dxgi.dll")]
1235    private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1236    public static float GetVRAM() {
1237        Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1238        if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1239        try {
1240            IntPtr vtable = Marshal.ReadIntPtr(factory);
1241            IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1242            var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1243            IntPtr adapter;
1244            if (enumFunc(factory, 0, out adapter) != 0) return 0;
1245            try {
1246                IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1247                IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1248                var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1249                DXGI_ADAPTER_DESC desc;
1250                if (descFunc(adapter, out desc) != 0) return 0;
1251                return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1252            } finally { Marshal.Release(adapter); }
1253        } finally { Marshal.Release(factory); }
1254    }
1255    [StructLayout(LayoutKind.Sequential)]
1256    private struct DXGI_ADAPTER_DESC {
1257        public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1258        public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1259        [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1260    }
1261    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1262    private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1263    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1264    private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1265}
1266"@
1267[DXGIVram]::GetVRAM()
1268"#;
1269    let output = Command::new("powershell")
1270        .args(["-NoProfile", "-NonInteractive", "-Command", script])
1271        .output()
1272        .ok()?;
1273    if !output.status.success() {
1274        return None;
1275    }
1276    let stdout = String::from_utf8_lossy(&output.stdout);
1277    stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1278}
1279
1280#[cfg(target_os = "linux")]
1281fn probe_lspci_gpu() -> Option<GpuInfo> {
1282    let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1283    if !output.status.success() {
1284        return None;
1285    }
1286    let stdout = String::from_utf8_lossy(&output.stdout);
1287
1288    let line = stdout
1289        .lines()
1290        .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1291    let quoted: Vec<String> = line
1292        .split('"')
1293        .enumerate()
1294        .filter_map(|(i, s)| {
1295            if i % 2 == 1 {
1296                let t = s.trim();
1297                if t.is_empty() {
1298                    None
1299                } else {
1300                    Some(t.to_string())
1301                }
1302            } else {
1303                None
1304            }
1305        })
1306        .collect();
1307    let description = quoted
1308        .get(2)
1309        .cloned()
1310        .or_else(|| quoted.last().cloned())
1311        .unwrap_or_else(|| line.trim().to_string());
1312
1313    let compute_cap = super::host::infer_compute_capability_from_name(&description);
1314    Some(GpuInfo {
1315        name: description,
1316        vram_gb: 0.0,
1317        backend: super::host::GpuBackend::Vulkan,
1318        compute_capability: compute_cap,
1319        driver_version: String::from("lspci"),
1320        pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1321    })
1322}
1323
1324#[cfg(target_os = "linux")]
1325fn probe_sysfs_gpu() -> Option<GpuInfo> {
1326    let drm_root = Path::new("/sys/class/drm");
1327    let entries = fs::read_dir(drm_root).ok()?;
1328
1329    for entry in entries.flatten() {
1330        let path = entry.path();
1331        let fname = path.file_name()?.to_string_lossy().to_string();
1332        if !fname.starts_with("card") {
1333            continue;
1334        }
1335
1336        let dev = path.join("device");
1337        if !dev.exists() {
1338            continue;
1339        }
1340
1341        let gpu_name = fs::read_to_string(dev.join("product_name"))
1342            .ok()
1343            .map(|v| v.trim().to_string())
1344            .filter(|v| !v.is_empty());
1345        let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1346            .ok()
1347            .and_then(|v| v.trim().parse::<f32>().ok());
1348        let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1349
1350        if let Some(name) = gpu_name {
1351            let compute_cap = super::host::infer_compute_capability_from_name(&name);
1352            return Some(GpuInfo {
1353                name,
1354                vram_gb: vram_gb.unwrap_or(0.0),
1355                backend: super::host::GpuBackend::Vulkan,
1356                compute_capability: compute_cap,
1357                driver_version: String::from("sysfs"),
1358                pci_address: None,
1359            });
1360        }
1361    }
1362    None
1363}
1364
1365#[cfg(target_os = "macos")]
1366fn probe_macos_gpu() -> Option<GpuInfo> {
1367    // Fast path: try `system_profiler SPDisplaysDataType -detailLevel mini` first
1368    let output = Command::new("system_profiler")
1369        .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1370        .output()
1371        .ok()?;
1372    if !output.status.success() {
1373        return None;
1374    }
1375
1376    let stdout = String::from_utf8_lossy(&output.stdout);
1377    let mut name = String::from("Apple GPU");
1378    let mut vram_gb = 0.0;
1379    let mut metal_support = String::from("metal");
1380    let mut gpu_metal_family = String::new();
1381
1382    for line in stdout.lines() {
1383        if line.contains("Chipset Model:") {
1384            if let Some(val) = line.split(':').nth(1) {
1385                name = val.trim().to_string();
1386            }
1387        }
1388        // "VRAM (Total): 16 GB" or "VRAM (Dynamic, Max): 16 GB"
1389        if line.contains("VRAM") {
1390            let val = line.split(':').nth(1).unwrap_or("").trim();
1391            if let Some(num_str) = val.split_whitespace().next() {
1392                if let Ok(num) = num_str.parse::<f32>() {
1393                    vram_gb = num;
1394                }
1395            }
1396        }
1397        if line.contains("Metal Support") {
1398            if let Some(val) = line.split(':').nth(1) {
1399                metal_support = val.trim().to_string();
1400            }
1401        }
1402        if line.contains("Metal Family") {
1403            if let Some(val) = line.split(':').nth(1) {
1404                gpu_metal_family = val.trim().to_string();
1405            }
1406        }
1407    }
1408
1409    // On Apple Silicon, shared memory wasn't exported by system_profiler;
1410    // query the IOKit registry for accurate GPU memory.
1411    if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1412        if let Ok(ioreg) = Command::new("ioreg")
1413            .args(["-r", "-c", "IOAccelerator", "-l"])
1414            .output()
1415        {
1416            let iotext = String::from_utf8_lossy(&ioreg.stdout);
1417            for line in iotext.lines() {
1418                // "gpu-core-count" or "performanceState" indicate Apple GPU
1419                if line.contains("\"gpu-core-count\"") {
1420                    // Try "memory-available" which reports shared GPU memory
1421                    if let Some(mem_line) =
1422                        iotext.lines().find(|l| l.contains("\"memory-available\""))
1423                    {
1424                        if let Some(val) = mem_line.split('=').nth(1) {
1425                            if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1426                                vram_gb = (bytes / 1_073_741_824.0) as f32;
1427                            }
1428                        }
1429                    }
1430                    break;
1431                }
1432            }
1433            // Fallback: total system memory / 2 as rough ANE+GPU shared estimate
1434            if vram_gb <= 0.0 {
1435                vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1436            }
1437        }
1438    }
1439
1440    // Build compute capability string from Metal info
1441    let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1442        metal_support
1443    } else if !gpu_metal_family.is_empty() {
1444        format!("apple_metal/{}", gpu_metal_family)
1445    } else {
1446        String::from("apple_metal")
1447    };
1448
1449    Some(GpuInfo {
1450        name,
1451        vram_gb,
1452        backend: super::host::GpuBackend::Metal,
1453        compute_capability,
1454        driver_version: String::from("metal"),
1455        pci_address: None,
1456    })
1457}
1458
1459fn probe_vulkan_gpu() -> Option<GpuInfo> {
1460    // Try `vulkaninfo` (cross-platform, part of Vulkan SDK)
1461    if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1462        if output.status.success() {
1463            let stdout = String::from_utf8_lossy(&output.stdout);
1464            for line in stdout.lines() {
1465                if line.contains("GPU id") || line.contains("deviceName") {
1466                    let name = line
1467                        .split(':')
1468                        .nth(1)
1469                        .map(|s| s.trim().to_string())
1470                        .unwrap_or_else(|| String::from("Vulkan GPU"));
1471                    let compute_cap = super::host::infer_compute_capability_from_name(&name);
1472                    return Some(GpuInfo {
1473                        name,
1474                        vram_gb: 0.0,
1475                        backend: super::host::GpuBackend::Vulkan,
1476                        compute_capability: compute_cap,
1477                        driver_version: String::from("vulkan"),
1478                        pci_address: None,
1479                    });
1480                }
1481            }
1482        }
1483    }
1484    None
1485}
1486
1487// ---------------------------------------------------------------------------
1488// NPU detection
1489// ---------------------------------------------------------------------------
1490
1491fn detect_npus() -> Vec<NpuInfo> {
1492    let mut npus = Vec::new();
1493
1494    // Windows WMI
1495    #[cfg(target_os = "windows")]
1496    {
1497        if let Ok(output) = Command::new("powershell")
1498            .args([
1499                "-NoProfile",
1500                "-Command",
1501                "Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)(NPU|Neural Processor|AI Accelerator|XDNA|Ryzen AI|Intel NPU)' } | Select-Object -ExpandProperty Name",
1502            ])
1503            .output()
1504        {
1505            if output.status.success() {
1506                let stdout = String::from_utf8_lossy(&output.stdout);
1507                for line in stdout.lines() {
1508                    let trimmed = line.trim();
1509                    if !trimmed.is_empty() {
1510                        npus.push(NpuInfo {
1511                            name: trimmed.to_string(),
1512                            memory_gb: 2.0, // typical
1513                            driver: String::from("windows"),
1514                        });
1515                    }
1516                }
1517            }
1518        }
1519    }
1520
1521    // Linux sysfs
1522    #[cfg(target_os = "linux")]
1523    {
1524        let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1525        for base in &npu_paths {
1526            if let Ok(entries) = fs::read_dir(base) {
1527                for entry in entries.flatten() {
1528                    let name = entry.file_name().to_string_lossy().to_string();
1529                    if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1530                        npus.push(NpuInfo {
1531                            name,
1532                            memory_gb: 2.0,
1533                            driver: String::from("linux"),
1534                        });
1535                    }
1536                }
1537            }
1538        }
1539        // Check for Intel NPU via /sys/class/accel
1540        if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1541            for entry in entries.flatten() {
1542                let name = entry.file_name().to_string_lossy().to_string();
1543                npus.push(NpuInfo {
1544                    name: format!("accel/{}", name),
1545                    memory_gb: 2.0,
1546                    driver: String::from("linux"),
1547                });
1548            }
1549        }
1550    }
1551
1552    // macOS: check for ANE via sysctl
1553    #[cfg(target_os = "macos")]
1554    {
1555        if super::host::is_apple_silicon() {
1556            npus.push(NpuInfo {
1557                name: String::from("Apple Neural Engine"),
1558                memory_gb: 0.0, // shared memory
1559                driver: String::from("ane"),
1560            });
1561        }
1562    }
1563
1564    // Env override
1565    if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1566        npus.push(NpuInfo {
1567            name: String::from("npu-env"),
1568            memory_gb: 2.0,
1569            driver: String::from("env"),
1570        });
1571    }
1572
1573    npus
1574}
1575
1576// ---------------------------------------------------------------------------
1577// Network detection
1578// ---------------------------------------------------------------------------
1579
1580fn detect_network_info() -> NetworkInfo {
1581    let xdp_supported = cfg!(target_os = "linux");
1582    let interfaces = detect_network_interfaces();
1583    NetworkInfo {
1584        xdp_supported,
1585        interfaces,
1586    }
1587}
1588
1589fn detect_network_interfaces() -> Vec<String> {
1590    let mut ifaces = Vec::new();
1591    if let Ok(entries) = fs::read_dir("/sys/class/net") {
1592        for entry in entries.flatten() {
1593            if let Ok(name) = entry.file_name().into_string() {
1594                if !name.starts_with("lo") {
1595                    ifaces.push(name);
1596                }
1597            }
1598        }
1599    }
1600    ifaces
1601}
1602
1603// ---------------------------------------------------------------------------
1604// Backend inference
1605// ---------------------------------------------------------------------------
1606
1607fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1608    use super::host::GpuBackend;
1609    let lowered = name.to_ascii_lowercase();
1610    let lowered_cc = compute_capability.to_ascii_lowercase();
1611
1612    if lowered_cc.contains("rocm")
1613        || lowered.contains("amd")
1614        || lowered.contains("radeon")
1615        || lowered.contains("instinct")
1616        || lowered.contains("mi250")
1617        || lowered.contains("mi300")
1618        || lowered.contains("mi350")
1619    {
1620        return GpuBackend::Rocm;
1621    }
1622
1623    if lowered_cc.contains("metal")
1624        || lowered.contains("apple")
1625        || lowered.contains("m1")
1626        || lowered.contains("m2")
1627        || lowered.contains("m3")
1628        || lowered.contains("m4")
1629    {
1630        return GpuBackend::Metal;
1631    }
1632
1633    if lowered_cc.contains("directml") || lowered.contains("directml") {
1634        return GpuBackend::Directml;
1635    }
1636
1637    if lowered_cc.contains("npu")
1638        || lowered.contains("npu")
1639        || lowered.contains("xdna")
1640        || lowered.contains("neural")
1641    {
1642        return GpuBackend::Npu;
1643    }
1644
1645    if lowered.contains("nvidia")
1646        || lowered.contains("rtx")
1647        || lowered.contains("gtx")
1648        || lowered.contains("tesla")
1649        || lowered.contains("a100")
1650        || lowered.contains("h100")
1651        || lowered.contains("b100")
1652        || lowered.contains("b200")
1653        || lowered_cc
1654            .chars()
1655            .next()
1656            .is_some_and(|c| c.is_ascii_digit())
1657    {
1658        return GpuBackend::Cuda;
1659    }
1660
1661    GpuBackend::Vulkan
1662}
1663
1664// ---------------------------------------------------------------------------
1665// Acceleration mode
1666// ---------------------------------------------------------------------------
1667
1668fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1669    use super::host::AccelerationMode;
1670
1671    // Any real GPU detected → GPU mode
1672    if gpus.iter().any(|g| {
1673        g.vram_gb > 0.0
1674            || (g.backend != super::host::GpuBackend::Cpu
1675                && g.backend != super::host::GpuBackend::Vulkan)
1676    }) {
1677        return AccelerationMode::Gpu;
1678    }
1679
1680    // Vulkan-only with pending VRAM (lspci/sysfs probe, no driver)
1681    if gpus.iter().any(|g| {
1682        g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1683    }) {
1684        return AccelerationMode::Gpu;
1685    }
1686
1687    // CPU features
1688    if cpu.features.avx_512 {
1689        return AccelerationMode::Avx512;
1690    }
1691    if cpu.features.avx2 {
1692        return AccelerationMode::Avx2;
1693    }
1694    if cpu.features.neon || cpu.features.sve {
1695        return AccelerationMode::Neon;
1696    }
1697
1698    AccelerationMode::Generic
1699}
1700
1701// ---------------------------------------------------------------------------
1702// Worker count recommendation
1703// ---------------------------------------------------------------------------
1704
1705fn compute_recommended_workers(
1706    cpu: &CpuInfo,
1707    memory: &MemoryInfo,
1708    gpus: &[GpuInfo],
1709    accel: super::host::AccelerationMode,
1710) -> usize {
1711    let cores = cpu.logical_cores.max(1);
1712    let memory_bound = if memory.total_gb >= 1.0 {
1713        (memory.total_gb / 4.0).floor() as usize
1714    } else {
1715        1
1716    }
1717    .max(1);
1718
1719    let reserved_core = cores.saturating_sub(1).max(1);
1720
1721    // Multi-GPU bonus
1722    let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1723    let accelerator_bonus = match accel {
1724        super::host::AccelerationMode::Gpu => {
1725            let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1726                2
1727            } else {
1728                1
1729            };
1730            vram_bonus + gpu_count.saturating_sub(1) // +1 per extra GPU
1731        }
1732        super::host::AccelerationMode::Avx512 => 1,
1733        _ => 0,
1734    };
1735
1736    reserved_core
1737        .min(memory_bound)
1738        .saturating_add(accelerator_bonus)
1739}
1740
1741// ---------------------------------------------------------------------------
1742// Re-export known inference helper for host.rs compat
1743// ---------------------------------------------------------------------------
1744
1745/// Re-exported for host.rs backward compatibility.
1746pub fn infer_compute_capability_from_name(name: &str) -> String {
1747    let lowered = name.to_ascii_lowercase();
1748    if lowered.contains("rtx 50")
1749        || lowered.contains("rtx50")
1750        || lowered.contains("b100")
1751        || lowered.contains("b200")
1752    {
1753        String::from("9.0")
1754    } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1755        String::from("8.9")
1756    } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1757        String::from("8.6")
1758    } else if lowered.contains("arc") {
1759        String::from("xe")
1760    } else if lowered.contains("radeon")
1761        || lowered.contains("amd")
1762        || lowered.contains("advanced micro")
1763        || lowered.contains("rx ")
1764        || lowered.contains("w7900")
1765        || lowered.contains("w6800")
1766        || lowered.contains("mi250")
1767        || lowered.contains("mi300")
1768        || lowered.contains("mi350")
1769        || lowered.contains("instinct")
1770    {
1771        if cfg!(feature = "rocm") {
1772            String::from("rocm")
1773        } else {
1774            String::from("gpu")
1775        }
1776    } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1777        String::from("xe")
1778    } else if lowered.contains("apple")
1779        || lowered.contains("m1")
1780        || lowered.contains("m2")
1781        || lowered.contains("m3")
1782        || lowered.contains("m4")
1783    {
1784        String::from("apple_metal")
1785    } else {
1786        String::from("gpu")
1787    }
1788}
1789
1790// ---------------------------------------------------------------------------
1791// Tests
1792// ---------------------------------------------------------------------------
1793
1794#[cfg(test)]
1795mod tests {
1796    use super::*;
1797    use crate::GpuBackend;
1798
1799    #[test]
1800    fn system_profile_detects_cpu() {
1801        let sp = SystemProfile::detect_fast();
1802        assert!(sp.cpu.logical_cores >= 1);
1803        assert!(!sp.cpu.arch.is_empty());
1804    }
1805
1806    #[test]
1807    fn system_profile_detects_memory() {
1808        let sp = SystemProfile::detect_fast();
1809        // Memory should be >= 0.5 GB on any real system
1810        assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
1811    }
1812
1813    #[test]
1814    fn system_profile_always_has_at_least_one_gpu_entry() {
1815        let sp = SystemProfile::detect_fast();
1816        assert!(
1817            !sp.gpus.is_empty(),
1818            "Should have at least 'cpu' fallback GPU entry"
1819        );
1820    }
1821
1822    #[test]
1823    fn system_profile_converts_to_runtime_profile() {
1824        let sp = SystemProfile::detect_fast();
1825        let rp: crate::host::RuntimeProfile = (&sp).into();
1826        assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
1827        assert_eq!(rp.recommended_workers, sp.recommended_workers);
1828    }
1829
1830    // Both scenarios below live in one test (rather than two separate #[test]
1831    // fns) because they mutate process-global env vars — Rust runs tests in
1832    // parallel by default, and a second test setting/clearing the same vars
1833    // races with this one regardless of what either asserts.
1834    #[test]
1835    fn detect_gpu_handles_env_overrides() {
1836        std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
1837        std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
1838        std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
1839
1840        let gpu = detect_gpu_from_env().expect("env probe should work");
1841        assert_eq!(gpu.name, "RTX 4090");
1842        assert!((gpu.vram_gb - 24.0).abs() < 0.1);
1843        assert_eq!(gpu.compute_capability, "8.9");
1844
1845        std::env::remove_var("GHOSTLINK_GPU_NAME");
1846        std::env::remove_var("GHOSTLINK_VRAM_GB");
1847        std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
1848
1849        // launch.sh always exports GHOSTLINK_VRAM_GB (defaulting to "0" on
1850        // CPU-only hosts). That alone must not be treated as a GPU override,
1851        // or CPU-only hosts would falsely report GPU acceleration available.
1852        std::env::set_var("GHOSTLINK_VRAM_GB", "0");
1853        assert!(detect_gpu_from_env().is_none());
1854        std::env::remove_var("GHOSTLINK_VRAM_GB");
1855    }
1856
1857    #[test]
1858    fn infer_backend_rocm() {
1859        let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
1860        assert_eq!(b, GpuBackend::Rocm);
1861    }
1862
1863    #[test]
1864    fn infer_backend_cuda() {
1865        let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
1866        assert_eq!(b, GpuBackend::Cuda);
1867    }
1868
1869    #[test]
1870    fn infer_backend_metal() {
1871        let b = infer_backend("Apple M3 Max", "apple_metal");
1872        assert_eq!(b, GpuBackend::Metal);
1873    }
1874
1875    #[test]
1876    fn compute_workers_scales_with_gpus() {
1877        let cpu = CpuInfo {
1878            logical_cores: 16,
1879            physical_cores: 8,
1880            arch: "x86_64".into(),
1881            ..Default::default()
1882        };
1883        let mem = MemoryInfo {
1884            total_gb: 64.0,
1885            available_gb: 32.0,
1886        };
1887        let gpus = vec![
1888            GpuInfo {
1889                name: "RTX 4090".into(),
1890                vram_gb: 24.0,
1891                backend: GpuBackend::Cuda,
1892                compute_capability: "8.9".into(),
1893                driver_version: "555".into(),
1894                pci_address: None,
1895            },
1896            GpuInfo {
1897                name: "RTX 3090".into(),
1898                vram_gb: 24.0,
1899                backend: GpuBackend::Cuda,
1900                compute_capability: "8.6".into(),
1901                driver_version: "555".into(),
1902                pci_address: None,
1903            },
1904        ];
1905        let accel = pick_acceleration_mode(&gpus, &cpu);
1906        let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
1907        // 15 (reserved from 16 cores) min 16 (mem/4=16) = 15, + VGPU bonus (2) + extra GPU (1) = 18
1908        assert!(workers > 0);
1909    }
1910
1911    #[test]
1912    fn physical_cores_nonzero() {
1913        let cpu = detect_cpu_info();
1914        assert!(cpu.physical_cores >= 1);
1915    }
1916
1917    #[cfg(target_os = "windows")]
1918    #[test]
1919    fn sum_windows_cim_core_counts_handles_multi_socket_output() {
1920        // Get-CimInstance emits one line per physical CPU package on
1921        // multi-socket hosts. The old code `return`ed on the first parseable
1922        // line, undercounting total physical cores on such hosts.
1923        assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
1924        assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
1925        assert_eq!(sum_windows_cim_core_counts(""), 0);
1926        // Tolerate stray blank/non-numeric lines some locales/tools emit.
1927        assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
1928    }
1929
1930    #[cfg(target_arch = "x86_64")]
1931    #[test]
1932    fn amx_tile_detection_does_not_panic() {
1933        // Exercises the raw CPUID leaf-7 path. The actual bit value is
1934        // hardware-dependent; this guards against a panic/miscount on hosts
1935        // whose CPUID max leaf is below 7.
1936        let _ = detect_amx_tile();
1937    }
1938}