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
799    let mut gpus = Vec::new();
800
801    // nvidia-smi (cross-platform)
802    if let Some(result) = probe_nvidia_smi() {
803        gpus.extend(result);
804    }
805
806    // rocm-smi (Linux, feature-gated)
807    #[cfg(feature = "rocm")]
808    if let Some(result) = probe_rocm_smi() {
809        gpus.extend(result);
810    }
811
812    // Windows WMI (fallback for non-NVIDIA GPUs)
813    #[cfg(target_os = "windows")]
814    if gpus.is_empty() {
815        if let Some(result) = probe_windows_wmi_gpu() {
816            gpus.extend(result);
817        }
818    }
819
820    // Linux lspci (fallback, no VRAM info)
821    #[cfg(target_os = "linux")]
822    if gpus.is_empty() {
823        if let Some(gpu) = probe_lspci_gpu() {
824            gpus.push(gpu);
825        }
826    }
827
828    // macOS system_profiler
829    #[cfg(target_os = "macos")]
830    if gpus.is_empty() {
831        if let Some(gpu) = probe_macos_gpu() {
832            gpus.push(gpu);
833        }
834    }
835
836    // 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)
845    if mode == ProbeMode::Full && gpus.is_empty() {
846        if let Some(gpu) = probe_vulkan_gpu() {
847            gpus.push(gpu);
848        }
849    }
850
851    if gpus.is_empty() {
852        let cc = if cfg!(feature = "rocm") {
853            "rocm"
854        } else {
855            "gpu"
856        };
857        gpus.push(GpuInfo {
858            name: String::from("cpu"),
859            vram_gb: 0.0,
860            backend: super::host::GpuBackend::Cpu,
861            compute_capability: cc.to_string(),
862            driver_version: String::from("native"),
863            pci_address: None,
864        });
865    }
866
867    gpus
868}
869
870fn detect_gpu_from_env() -> Option<GpuInfo> {
871    let name = env::var("GHOSTLINK_GPU_NAME")
872        .ok()
873        .filter(|s| !s.trim().is_empty());
874    let vram_gb = env::var("GHOSTLINK_VRAM_GB")
875        .ok()
876        .and_then(|v| v.parse::<f32>().ok());
877    let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
878        .ok()
879        .filter(|s| !s.trim().is_empty());
880    // A VRAM value of 0 (or absent) is not a meaningful GPU signal on its own —
881    // launch scripts unconditionally export GHOSTLINK_VRAM_GB=0 in CPU-only mode,
882    // and treating that as an override used to short-circuit the real hardware
883    // probes below, permanently masking true auto-discovery and falsely reporting
884    // GPU acceleration as available on CPU-only hosts.
885    let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
886    if !has_gpu_signal {
887        return None;
888    }
889
890    let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
891    let compute_capability =
892        cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
893    let backend = infer_backend(&gpu_name, &compute_capability);
894
895    Some(GpuInfo {
896        name: gpu_name,
897        vram_gb: vram_gb.unwrap_or(0.0),
898        backend,
899        compute_capability,
900        driver_version: String::from("env"),
901        pci_address: None,
902    })
903}
904
905fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
906    let output = Command::new("nvidia-smi")
907        .args([
908            "--query-gpu=index,name,memory.total,compute_cap,driver_version",
909            "--format=csv,noheader,nounits",
910        ])
911        .output()
912        .ok()?;
913    if !output.status.success() {
914        return None;
915    }
916
917    let stdout = String::from_utf8_lossy(&output.stdout);
918    let mut gpus = Vec::new();
919
920    for line in stdout.lines() {
921        let trimmed = line.trim();
922        if trimmed.is_empty() {
923            continue;
924        }
925        let mut parts = trimmed.split(',').map(str::trim);
926        let _index = parts.next()?;
927        let name = parts.next()?.to_string();
928        let vram_mib = parts
929            .next()
930            .and_then(|v| v.parse::<f32>().ok())
931            .unwrap_or(0.0);
932        let compute_cap = parts
933            .next()
934            .map(|s| s.to_string())
935            .unwrap_or_else(|| String::from("gpu"));
936        let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
937
938        gpus.push(GpuInfo {
939            name,
940            vram_gb: vram_mib / 1024.0,
941            backend: super::host::GpuBackend::Cuda,
942            compute_capability: compute_cap,
943            driver_version: driver,
944            pci_address: None,
945        });
946    }
947
948    if gpus.is_empty() {
949        None
950    } else {
951        Some(gpus)
952    }
953}
954
955#[cfg(feature = "rocm")]
956fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
957    let output = Command::new("rocm-smi")
958        .args(["--showproductname"])
959        .output()
960        .ok()?;
961    if !output.status.success() {
962        return None;
963    }
964    let stdout = String::from_utf8_lossy(&output.stdout);
965
966    let mut gpus = Vec::new();
967    let mut current_gpu = None;
968
969    for line in stdout.lines() {
970        if line.contains("GPU[") && line.contains("Card model:") {
971            let name = line
972                .split(':')
973                .nth(2)
974                .map(|s| s.trim().to_string())
975                .unwrap_or_else(|| String::from("AMD GPU"));
976            let vram = probe_rocm_vram();
977            let compute_cap = super::host::infer_compute_capability_from_name(&name);
978            gpus.push(GpuInfo {
979                name,
980                vram_gb: vram.unwrap_or(0.0),
981                backend: super::host::GpuBackend::Rocm,
982                compute_capability: compute_cap,
983                driver_version: detect_rocm_version().unwrap_or_default(),
984                pci_address: current_gpu.take(),
985            });
986        }
987    }
988
989    if gpus.is_empty() {
990        None
991    } else {
992        Some(gpus)
993    }
994}
995
996#[cfg(feature = "rocm")]
997fn probe_rocm_vram() -> Option<f32> {
998    let output = Command::new("rocm-smi")
999        .args(["--showmeminfo", "vram"])
1000        .output()
1001        .ok()?;
1002    let stdout = String::from_utf8_lossy(&output.stdout);
1003    stdout
1004        .lines()
1005        .find(|l| l.contains("Total Memory"))
1006        .and_then(|l| {
1007            let val = l.split(':').nth(1)?.trim().to_lowercase();
1008            let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1009            if val.contains("gb") {
1010                Some(num)
1011            } else if val.contains("mb") {
1012                Some(num / 1024.0)
1013            } else {
1014                None
1015            }
1016        })
1017}
1018
1019#[cfg(feature = "rocm")]
1020fn detect_rocm_version() -> Option<String> {
1021    let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1022    let stderr = String::from_utf8_lossy(&output.stderr);
1023    stderr
1024        .lines()
1025        .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1026}
1027
1028#[cfg(target_os = "windows")]
1029fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1030    // Primary: WMI query with Name, AdapterRAM, DriverVersion, PNPDeviceID
1031    let output = Command::new("powershell")
1032        .args([
1033            "-NoProfile",
1034            "-Command",
1035            "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notmatch '(?i)(Microsoft Basic Display|Remote Display|VirtualBox|VMware)' } | Select-Object Name, AdapterRAM, DriverVersion, PNPDeviceID | ConvertTo-Csv -NoTypeInformation",
1036        ])
1037        .output()
1038        .ok()?;
1039    if !output.status.success() {
1040        return None;
1041    }
1042
1043    let stdout = String::from_utf8_lossy(&output.stdout);
1044    let mut gpus: Vec<GpuInfo> = Vec::new();
1045
1046    for line in stdout.lines().skip(1) {
1047        let trimmed = line.trim();
1048        if trimmed.is_empty() || trimmed.starts_with('#') {
1049            continue;
1050        }
1051        // CSV: "Name","AdapterRAM","DriverVersion","PNPDeviceID"
1052        let mut parts: Vec<String> = Vec::new();
1053        let mut current = String::new();
1054        let mut in_quotes = false;
1055        for ch in trimmed.chars() {
1056            match ch {
1057                '"' => in_quotes = !in_quotes,
1058                ',' if !in_quotes => {
1059                    parts.push(std::mem::take(&mut current));
1060                }
1061                c => current.push(c),
1062            }
1063        }
1064        parts.push(current);
1065        if parts.len() < 3 {
1066            continue;
1067        }
1068        let name = parts[0].trim().to_string();
1069        if name.is_empty() {
1070            continue;
1071        }
1072
1073        let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1074        let vram_gb = if vram_bytes > 0.0 {
1075            (vram_bytes / 1_073_741_824.0) as f32
1076        } else {
1077            0.0
1078        };
1079        let driver = parts[2].trim().to_string();
1080        let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1081        let compute_cap = super::host::infer_compute_capability_from_name(&name);
1082
1083        // Determine backend from PNP ID or name
1084        let backend = if let Some(ref id) = pnp_id {
1085            let id_lower = id.to_ascii_lowercase();
1086            if id_lower.contains("ven_10de") {
1087                super::host::GpuBackend::Cuda
1088            } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1089                if cfg!(feature = "rocm") {
1090                    super::host::GpuBackend::Rocm
1091                } else {
1092                    super::host::GpuBackend::Directml
1093                }
1094            } else if id_lower.contains("ven_8086") {
1095                super::host::GpuBackend::Directml
1096            } else if id_lower.contains("ven_14e4") {
1097                super::host::GpuBackend::Vulkan
1098            } else {
1099                super::host::GpuBackend::Directml
1100            }
1101        } else {
1102            super::host::GpuBackend::Directml
1103        };
1104
1105        // If WMI returned 0 VRAM for a real GPU, try a secondary DXGI-based probe
1106        let actual_vram = if vram_gb <= 0.0 {
1107            probe_windows_dxgi_vram().unwrap_or(0.0)
1108        } else {
1109            vram_gb
1110        };
1111
1112        gpus.push(GpuInfo {
1113            name,
1114            vram_gb: actual_vram,
1115            backend,
1116            compute_capability: compute_cap,
1117            driver_version: driver,
1118            pci_address: pnp_id,
1119        });
1120    }
1121
1122    if gpus.is_empty() {
1123        None
1124    } else {
1125        Some(gpus)
1126    }
1127}
1128
1129/// Secondary VRAM probe used when WMI returns 0 VRAM for a detected GPU
1130/// (common for integrated GPUs, where `Win32_VideoController.AdapterRAM`'s
1131/// 32-bit field is unreliable).
1132///
1133/// Tries a plain registry read first (`HardwareInformation.qwMemorySize`,
1134/// the same technique GPU-Z uses), which needs no in-process compilation.
1135/// Falls back to the DXGI `Add-Type` C# probe — measured via `cargo bench`
1136/// to cost ~1-2s of wall-clock time compiling the inline C# shim, the
1137/// dominant cost in a Full hardware probe — only if the registry value is
1138/// absent, so drivers that don't publish it still get a VRAM reading.
1139#[cfg(target_os = "windows")]
1140fn probe_windows_dxgi_vram() -> Option<f32> {
1141    if let Some(vram) = probe_windows_registry_vram() {
1142        return Some(vram);
1143    }
1144    probe_windows_dxgi_vram_via_addtype()
1145}
1146
1147#[cfg(target_os = "windows")]
1148fn probe_windows_registry_vram() -> Option<f32> {
1149    let output = Command::new("powershell")
1150        .args([
1151            "-NoProfile",
1152            "-Command",
1153            "Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -Name 'HardwareInformation.qwMemorySize' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'HardwareInformation.qwMemorySize' -First 1",
1154        ])
1155        .output()
1156        .ok()?;
1157    if !output.status.success() {
1158        return None;
1159    }
1160    let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1161        .trim()
1162        .parse()
1163        .ok()?;
1164    if bytes > 0.0 {
1165        Some((bytes / 1_073_741_824.0) as f32)
1166    } else {
1167        None
1168    }
1169}
1170
1171#[cfg(target_os = "windows")]
1172fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1173    let script = r#"
1174Add-Type @"
1175using System;
1176using System.Runtime.InteropServices;
1177public class DXGIVram {
1178    [DllImport("dxgi.dll")]
1179    private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1180    public static float GetVRAM() {
1181        Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1182        if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1183        try {
1184            IntPtr vtable = Marshal.ReadIntPtr(factory);
1185            IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1186            var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1187            IntPtr adapter;
1188            if (enumFunc(factory, 0, out adapter) != 0) return 0;
1189            try {
1190                IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1191                IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1192                var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1193                DXGI_ADAPTER_DESC desc;
1194                if (descFunc(adapter, out desc) != 0) return 0;
1195                return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1196            } finally { Marshal.Release(adapter); }
1197        } finally { Marshal.Release(factory); }
1198    }
1199    [StructLayout(LayoutKind.Sequential)]
1200    private struct DXGI_ADAPTER_DESC {
1201        public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1202        public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1203        [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1204    }
1205    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1206    private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1207    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1208    private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1209}
1210"@
1211[DXGIVram]::GetVRAM()
1212"#;
1213    let output = Command::new("powershell")
1214        .args(["-NoProfile", "-NonInteractive", "-Command", script])
1215        .output()
1216        .ok()?;
1217    if !output.status.success() {
1218        return None;
1219    }
1220    let stdout = String::from_utf8_lossy(&output.stdout);
1221    stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1222}
1223
1224#[cfg(target_os = "linux")]
1225fn probe_lspci_gpu() -> Option<GpuInfo> {
1226    let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1227    if !output.status.success() {
1228        return None;
1229    }
1230    let stdout = String::from_utf8_lossy(&output.stdout);
1231
1232    let line = stdout
1233        .lines()
1234        .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1235    let quoted: Vec<String> = line
1236        .split('"')
1237        .enumerate()
1238        .filter_map(|(i, s)| {
1239            if i % 2 == 1 {
1240                let t = s.trim();
1241                if t.is_empty() {
1242                    None
1243                } else {
1244                    Some(t.to_string())
1245                }
1246            } else {
1247                None
1248            }
1249        })
1250        .collect();
1251    let description = quoted
1252        .get(2)
1253        .cloned()
1254        .or_else(|| quoted.last().cloned())
1255        .unwrap_or_else(|| line.trim().to_string());
1256
1257    let compute_cap = super::host::infer_compute_capability_from_name(&description);
1258    Some(GpuInfo {
1259        name: description,
1260        vram_gb: 0.0,
1261        backend: super::host::GpuBackend::Vulkan,
1262        compute_capability: compute_cap,
1263        driver_version: String::from("lspci"),
1264        pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1265    })
1266}
1267
1268#[cfg(target_os = "linux")]
1269fn probe_sysfs_gpu() -> Option<GpuInfo> {
1270    let drm_root = Path::new("/sys/class/drm");
1271    let entries = fs::read_dir(drm_root).ok()?;
1272
1273    for entry in entries.flatten() {
1274        let path = entry.path();
1275        let fname = path.file_name()?.to_string_lossy().to_string();
1276        if !fname.starts_with("card") {
1277            continue;
1278        }
1279
1280        let dev = path.join("device");
1281        if !dev.exists() {
1282            continue;
1283        }
1284
1285        let gpu_name = fs::read_to_string(dev.join("product_name"))
1286            .ok()
1287            .map(|v| v.trim().to_string())
1288            .filter(|v| !v.is_empty());
1289        let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1290            .ok()
1291            .and_then(|v| v.trim().parse::<f32>().ok());
1292        let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1293
1294        if let Some(name) = gpu_name {
1295            let compute_cap = super::host::infer_compute_capability_from_name(&name);
1296            return Some(GpuInfo {
1297                name,
1298                vram_gb: vram_gb.unwrap_or(0.0),
1299                backend: super::host::GpuBackend::Vulkan,
1300                compute_capability: compute_cap,
1301                driver_version: String::from("sysfs"),
1302                pci_address: None,
1303            });
1304        }
1305    }
1306    None
1307}
1308
1309#[cfg(target_os = "macos")]
1310fn probe_macos_gpu() -> Option<GpuInfo> {
1311    // Fast path: try `system_profiler SPDisplaysDataType -detailLevel mini` first
1312    let output = Command::new("system_profiler")
1313        .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1314        .output()
1315        .ok()?;
1316    if !output.status.success() {
1317        return None;
1318    }
1319
1320    let stdout = String::from_utf8_lossy(&output.stdout);
1321    let mut name = String::from("Apple GPU");
1322    let mut vram_gb = 0.0;
1323    let mut metal_support = String::from("metal");
1324    let mut gpu_metal_family = String::new();
1325
1326    for line in stdout.lines() {
1327        if line.contains("Chipset Model:") {
1328            if let Some(val) = line.split(':').nth(1) {
1329                name = val.trim().to_string();
1330            }
1331        }
1332        // "VRAM (Total): 16 GB" or "VRAM (Dynamic, Max): 16 GB"
1333        if line.contains("VRAM") {
1334            let val = line.split(':').nth(1).unwrap_or("").trim();
1335            if let Some(num_str) = val.split_whitespace().next() {
1336                if let Ok(num) = num_str.parse::<f32>() {
1337                    vram_gb = num;
1338                }
1339            }
1340        }
1341        if line.contains("Metal Support") {
1342            if let Some(val) = line.split(':').nth(1) {
1343                metal_support = val.trim().to_string();
1344            }
1345        }
1346        if line.contains("Metal Family") {
1347            if let Some(val) = line.split(':').nth(1) {
1348                gpu_metal_family = val.trim().to_string();
1349            }
1350        }
1351    }
1352
1353    // On Apple Silicon, shared memory wasn't exported by system_profiler;
1354    // query the IOKit registry for accurate GPU memory.
1355    if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1356        if let Ok(ioreg) = Command::new("ioreg")
1357            .args(["-r", "-c", "IOAccelerator", "-l"])
1358            .output()
1359        {
1360            let iotext = String::from_utf8_lossy(&ioreg.stdout);
1361            for line in iotext.lines() {
1362                // "gpu-core-count" or "performanceState" indicate Apple GPU
1363                if line.contains("\"gpu-core-count\"") {
1364                    // Try "memory-available" which reports shared GPU memory
1365                    if let Some(mem_line) =
1366                        iotext.lines().find(|l| l.contains("\"memory-available\""))
1367                    {
1368                        if let Some(val) = mem_line.split('=').nth(1) {
1369                            if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1370                                vram_gb = (bytes / 1_073_741_824.0) as f32;
1371                            }
1372                        }
1373                    }
1374                    break;
1375                }
1376            }
1377            // Fallback: total system memory / 2 as rough ANE+GPU shared estimate
1378            if vram_gb <= 0.0 {
1379                vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1380            }
1381        }
1382    }
1383
1384    // Build compute capability string from Metal info
1385    let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1386        metal_support
1387    } else if !gpu_metal_family.is_empty() {
1388        format!("apple_metal/{}", gpu_metal_family)
1389    } else {
1390        String::from("apple_metal")
1391    };
1392
1393    Some(GpuInfo {
1394        name,
1395        vram_gb,
1396        backend: super::host::GpuBackend::Metal,
1397        compute_capability,
1398        driver_version: String::from("metal"),
1399        pci_address: None,
1400    })
1401}
1402
1403fn probe_vulkan_gpu() -> Option<GpuInfo> {
1404    // Try `vulkaninfo` (cross-platform, part of Vulkan SDK)
1405    if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1406        if output.status.success() {
1407            let stdout = String::from_utf8_lossy(&output.stdout);
1408            for line in stdout.lines() {
1409                if line.contains("GPU id") || line.contains("deviceName") {
1410                    let name = line
1411                        .split(':')
1412                        .nth(1)
1413                        .map(|s| s.trim().to_string())
1414                        .unwrap_or_else(|| String::from("Vulkan GPU"));
1415                    let compute_cap = super::host::infer_compute_capability_from_name(&name);
1416                    return Some(GpuInfo {
1417                        name,
1418                        vram_gb: 0.0,
1419                        backend: super::host::GpuBackend::Vulkan,
1420                        compute_capability: compute_cap,
1421                        driver_version: String::from("vulkan"),
1422                        pci_address: None,
1423                    });
1424                }
1425            }
1426        }
1427    }
1428    None
1429}
1430
1431// ---------------------------------------------------------------------------
1432// NPU detection
1433// ---------------------------------------------------------------------------
1434
1435fn detect_npus() -> Vec<NpuInfo> {
1436    let mut npus = Vec::new();
1437
1438    // Windows WMI
1439    #[cfg(target_os = "windows")]
1440    {
1441        if let Ok(output) = Command::new("powershell")
1442            .args([
1443                "-NoProfile",
1444                "-Command",
1445                "Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)(NPU|Neural Processor|AI Accelerator|XDNA|Ryzen AI|Intel NPU)' } | Select-Object -ExpandProperty Name",
1446            ])
1447            .output()
1448        {
1449            if output.status.success() {
1450                let stdout = String::from_utf8_lossy(&output.stdout);
1451                for line in stdout.lines() {
1452                    let trimmed = line.trim();
1453                    if !trimmed.is_empty() {
1454                        npus.push(NpuInfo {
1455                            name: trimmed.to_string(),
1456                            memory_gb: 2.0, // typical
1457                            driver: String::from("windows"),
1458                        });
1459                    }
1460                }
1461            }
1462        }
1463    }
1464
1465    // Linux sysfs
1466    #[cfg(target_os = "linux")]
1467    {
1468        let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1469        for base in &npu_paths {
1470            if let Ok(entries) = fs::read_dir(base) {
1471                for entry in entries.flatten() {
1472                    let name = entry.file_name().to_string_lossy().to_string();
1473                    if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1474                        npus.push(NpuInfo {
1475                            name,
1476                            memory_gb: 2.0,
1477                            driver: String::from("linux"),
1478                        });
1479                    }
1480                }
1481            }
1482        }
1483        // Check for Intel NPU via /sys/class/accel
1484        if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1485            for entry in entries.flatten() {
1486                let name = entry.file_name().to_string_lossy().to_string();
1487                npus.push(NpuInfo {
1488                    name: format!("accel/{}", name),
1489                    memory_gb: 2.0,
1490                    driver: String::from("linux"),
1491                });
1492            }
1493        }
1494    }
1495
1496    // macOS: check for ANE via sysctl
1497    #[cfg(target_os = "macos")]
1498    {
1499        if super::host::is_apple_silicon() {
1500            npus.push(NpuInfo {
1501                name: String::from("Apple Neural Engine"),
1502                memory_gb: 0.0, // shared memory
1503                driver: String::from("ane"),
1504            });
1505        }
1506    }
1507
1508    // Env override
1509    if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1510        npus.push(NpuInfo {
1511            name: String::from("npu-env"),
1512            memory_gb: 2.0,
1513            driver: String::from("env"),
1514        });
1515    }
1516
1517    npus
1518}
1519
1520// ---------------------------------------------------------------------------
1521// Network detection
1522// ---------------------------------------------------------------------------
1523
1524fn detect_network_info() -> NetworkInfo {
1525    let xdp_supported = cfg!(target_os = "linux");
1526    let interfaces = detect_network_interfaces();
1527    NetworkInfo {
1528        xdp_supported,
1529        interfaces,
1530    }
1531}
1532
1533fn detect_network_interfaces() -> Vec<String> {
1534    let mut ifaces = Vec::new();
1535    if let Ok(entries) = fs::read_dir("/sys/class/net") {
1536        for entry in entries.flatten() {
1537            if let Ok(name) = entry.file_name().into_string() {
1538                if !name.starts_with("lo") {
1539                    ifaces.push(name);
1540                }
1541            }
1542        }
1543    }
1544    ifaces
1545}
1546
1547// ---------------------------------------------------------------------------
1548// Backend inference
1549// ---------------------------------------------------------------------------
1550
1551fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1552    use super::host::GpuBackend;
1553    let lowered = name.to_ascii_lowercase();
1554    let lowered_cc = compute_capability.to_ascii_lowercase();
1555
1556    if lowered_cc.contains("rocm")
1557        || lowered.contains("amd")
1558        || lowered.contains("radeon")
1559        || lowered.contains("instinct")
1560        || lowered.contains("mi250")
1561        || lowered.contains("mi300")
1562        || lowered.contains("mi350")
1563    {
1564        return GpuBackend::Rocm;
1565    }
1566
1567    if lowered_cc.contains("metal")
1568        || lowered.contains("apple")
1569        || lowered.contains("m1")
1570        || lowered.contains("m2")
1571        || lowered.contains("m3")
1572        || lowered.contains("m4")
1573    {
1574        return GpuBackend::Metal;
1575    }
1576
1577    if lowered_cc.contains("directml") || lowered.contains("directml") {
1578        return GpuBackend::Directml;
1579    }
1580
1581    if lowered_cc.contains("npu")
1582        || lowered.contains("npu")
1583        || lowered.contains("xdna")
1584        || lowered.contains("neural")
1585    {
1586        return GpuBackend::Npu;
1587    }
1588
1589    if lowered.contains("nvidia")
1590        || lowered.contains("rtx")
1591        || lowered.contains("gtx")
1592        || lowered.contains("tesla")
1593        || lowered.contains("a100")
1594        || lowered.contains("h100")
1595        || lowered.contains("b100")
1596        || lowered.contains("b200")
1597        || lowered_cc
1598            .chars()
1599            .next()
1600            .is_some_and(|c| c.is_ascii_digit())
1601    {
1602        return GpuBackend::Cuda;
1603    }
1604
1605    GpuBackend::Vulkan
1606}
1607
1608// ---------------------------------------------------------------------------
1609// Acceleration mode
1610// ---------------------------------------------------------------------------
1611
1612fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1613    use super::host::AccelerationMode;
1614
1615    // Any real GPU detected → GPU mode
1616    if gpus.iter().any(|g| {
1617        g.vram_gb > 0.0
1618            || (g.backend != super::host::GpuBackend::Cpu
1619                && g.backend != super::host::GpuBackend::Vulkan)
1620    }) {
1621        return AccelerationMode::Gpu;
1622    }
1623
1624    // Vulkan-only with pending VRAM (lspci/sysfs probe, no driver)
1625    if gpus.iter().any(|g| {
1626        g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1627    }) {
1628        return AccelerationMode::Gpu;
1629    }
1630
1631    // CPU features
1632    if cpu.features.avx_512 {
1633        return AccelerationMode::Avx512;
1634    }
1635    if cpu.features.avx2 {
1636        return AccelerationMode::Avx2;
1637    }
1638    if cpu.features.neon || cpu.features.sve {
1639        return AccelerationMode::Neon;
1640    }
1641
1642    AccelerationMode::Generic
1643}
1644
1645// ---------------------------------------------------------------------------
1646// Worker count recommendation
1647// ---------------------------------------------------------------------------
1648
1649fn compute_recommended_workers(
1650    cpu: &CpuInfo,
1651    memory: &MemoryInfo,
1652    gpus: &[GpuInfo],
1653    accel: super::host::AccelerationMode,
1654) -> usize {
1655    let cores = cpu.logical_cores.max(1);
1656    let memory_bound = if memory.total_gb >= 1.0 {
1657        (memory.total_gb / 4.0).floor() as usize
1658    } else {
1659        1
1660    }
1661    .max(1);
1662
1663    let reserved_core = cores.saturating_sub(1).max(1);
1664
1665    // Multi-GPU bonus
1666    let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1667    let accelerator_bonus = match accel {
1668        super::host::AccelerationMode::Gpu => {
1669            let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1670                2
1671            } else {
1672                1
1673            };
1674            vram_bonus + gpu_count.saturating_sub(1) // +1 per extra GPU
1675        }
1676        super::host::AccelerationMode::Avx512 => 1,
1677        _ => 0,
1678    };
1679
1680    reserved_core
1681        .min(memory_bound)
1682        .saturating_add(accelerator_bonus)
1683}
1684
1685// ---------------------------------------------------------------------------
1686// Re-export known inference helper for host.rs compat
1687// ---------------------------------------------------------------------------
1688
1689/// Re-exported for host.rs backward compatibility.
1690pub fn infer_compute_capability_from_name(name: &str) -> String {
1691    let lowered = name.to_ascii_lowercase();
1692    if lowered.contains("rtx 50")
1693        || lowered.contains("rtx50")
1694        || lowered.contains("b100")
1695        || lowered.contains("b200")
1696    {
1697        String::from("9.0")
1698    } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1699        String::from("8.9")
1700    } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1701        String::from("8.6")
1702    } else if lowered.contains("arc") {
1703        String::from("xe")
1704    } else if lowered.contains("radeon")
1705        || lowered.contains("amd")
1706        || lowered.contains("advanced micro")
1707        || lowered.contains("rx ")
1708        || lowered.contains("w7900")
1709        || lowered.contains("w6800")
1710        || lowered.contains("mi250")
1711        || lowered.contains("mi300")
1712        || lowered.contains("mi350")
1713        || lowered.contains("instinct")
1714    {
1715        if cfg!(feature = "rocm") {
1716            String::from("rocm")
1717        } else {
1718            String::from("gpu")
1719        }
1720    } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1721        String::from("xe")
1722    } else if lowered.contains("apple")
1723        || lowered.contains("m1")
1724        || lowered.contains("m2")
1725        || lowered.contains("m3")
1726        || lowered.contains("m4")
1727    {
1728        String::from("apple_metal")
1729    } else {
1730        String::from("gpu")
1731    }
1732}
1733
1734// ---------------------------------------------------------------------------
1735// Tests
1736// ---------------------------------------------------------------------------
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741    use crate::GpuBackend;
1742
1743    #[test]
1744    fn system_profile_detects_cpu() {
1745        let sp = SystemProfile::detect_fast();
1746        assert!(sp.cpu.logical_cores >= 1);
1747        assert!(!sp.cpu.arch.is_empty());
1748    }
1749
1750    #[test]
1751    fn system_profile_detects_memory() {
1752        let sp = SystemProfile::detect_fast();
1753        // Memory should be >= 0.5 GB on any real system
1754        assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
1755    }
1756
1757    #[test]
1758    fn system_profile_always_has_at_least_one_gpu_entry() {
1759        let sp = SystemProfile::detect_fast();
1760        assert!(
1761            !sp.gpus.is_empty(),
1762            "Should have at least 'cpu' fallback GPU entry"
1763        );
1764    }
1765
1766    #[test]
1767    fn system_profile_converts_to_runtime_profile() {
1768        let sp = SystemProfile::detect_fast();
1769        let rp: crate::host::RuntimeProfile = (&sp).into();
1770        assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
1771        assert_eq!(rp.recommended_workers, sp.recommended_workers);
1772    }
1773
1774    // Both scenarios below live in one test (rather than two separate #[test]
1775    // fns) because they mutate process-global env vars — Rust runs tests in
1776    // parallel by default, and a second test setting/clearing the same vars
1777    // races with this one regardless of what either asserts.
1778    #[test]
1779    fn detect_gpu_handles_env_overrides() {
1780        std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
1781        std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
1782        std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
1783
1784        let gpu = detect_gpu_from_env().expect("env probe should work");
1785        assert_eq!(gpu.name, "RTX 4090");
1786        assert!((gpu.vram_gb - 24.0).abs() < 0.1);
1787        assert_eq!(gpu.compute_capability, "8.9");
1788
1789        std::env::remove_var("GHOSTLINK_GPU_NAME");
1790        std::env::remove_var("GHOSTLINK_VRAM_GB");
1791        std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
1792
1793        // launch.sh always exports GHOSTLINK_VRAM_GB (defaulting to "0" on
1794        // CPU-only hosts). That alone must not be treated as a GPU override,
1795        // or CPU-only hosts would falsely report GPU acceleration available.
1796        std::env::set_var("GHOSTLINK_VRAM_GB", "0");
1797        assert!(detect_gpu_from_env().is_none());
1798        std::env::remove_var("GHOSTLINK_VRAM_GB");
1799    }
1800
1801    #[test]
1802    fn infer_backend_rocm() {
1803        let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
1804        assert_eq!(b, GpuBackend::Rocm);
1805    }
1806
1807    #[test]
1808    fn infer_backend_cuda() {
1809        let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
1810        assert_eq!(b, GpuBackend::Cuda);
1811    }
1812
1813    #[test]
1814    fn infer_backend_metal() {
1815        let b = infer_backend("Apple M3 Max", "apple_metal");
1816        assert_eq!(b, GpuBackend::Metal);
1817    }
1818
1819    #[test]
1820    fn compute_workers_scales_with_gpus() {
1821        let cpu = CpuInfo {
1822            logical_cores: 16,
1823            physical_cores: 8,
1824            arch: "x86_64".into(),
1825            ..Default::default()
1826        };
1827        let mem = MemoryInfo {
1828            total_gb: 64.0,
1829            available_gb: 32.0,
1830        };
1831        let gpus = vec![
1832            GpuInfo {
1833                name: "RTX 4090".into(),
1834                vram_gb: 24.0,
1835                backend: GpuBackend::Cuda,
1836                compute_capability: "8.9".into(),
1837                driver_version: "555".into(),
1838                pci_address: None,
1839            },
1840            GpuInfo {
1841                name: "RTX 3090".into(),
1842                vram_gb: 24.0,
1843                backend: GpuBackend::Cuda,
1844                compute_capability: "8.6".into(),
1845                driver_version: "555".into(),
1846                pci_address: None,
1847            },
1848        ];
1849        let accel = pick_acceleration_mode(&gpus, &cpu);
1850        let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
1851        // 15 (reserved from 16 cores) min 16 (mem/4=16) = 15, + VGPU bonus (2) + extra GPU (1) = 18
1852        assert!(workers > 0);
1853    }
1854
1855    #[test]
1856    fn physical_cores_nonzero() {
1857        let cpu = detect_cpu_info();
1858        assert!(cpu.physical_cores >= 1);
1859    }
1860
1861    #[cfg(target_os = "windows")]
1862    #[test]
1863    fn sum_windows_cim_core_counts_handles_multi_socket_output() {
1864        // Get-CimInstance emits one line per physical CPU package on
1865        // multi-socket hosts. The old code `return`ed on the first parseable
1866        // line, undercounting total physical cores on such hosts.
1867        assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
1868        assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
1869        assert_eq!(sum_windows_cim_core_counts(""), 0);
1870        // Tolerate stray blank/non-numeric lines some locales/tools emit.
1871        assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
1872    }
1873
1874    #[cfg(target_arch = "x86_64")]
1875    #[test]
1876    fn amx_tile_detection_does_not_panic() {
1877        // Exercises the raw CPUID leaf-7 path. The actual bit value is
1878        // hardware-dependent; this guards against a panic/miscount on hosts
1879        // whose CPUID max leaf is below 7.
1880        let _ = detect_amx_tile();
1881    }
1882}