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    /// Performance-core count on a hybrid CPU (e.g. Intel 12th gen+
38    /// P-cores). `None` when undetectable (non-Windows currently — see
39    /// `detect_hybrid_core_counts`) or when the CPU has no real P/E split
40    /// (every core reporting the same `EfficiencyClass`, which is the
41    /// common case, including this repo's AMD Zen reference hardware).
42    pub performance_cores: Option<usize>,
43    /// Efficiency-core count on a hybrid CPU. Same availability rules as
44    /// `performance_cores`.
45    pub efficiency_cores: Option<usize>,
46}
47
48/// CPU SIMD / accelerator features.
49#[derive(Clone, Debug, Default, Hash)]
50pub struct CpuFeatures {
51    pub avx2: bool,
52    pub avx_512: bool,
53    pub neon: bool,
54    pub sve: bool,
55    pub amx: bool,
56    pub sme: bool,
57}
58
59/// System memory information.
60#[derive(Clone, Debug, Default)]
61pub struct MemoryInfo {
62    /// Total physical RAM in GB.
63    pub total_gb: f32,
64    /// Approximate available RAM in GB (cached/free).
65    pub available_gb: f32,
66}
67
68/// A single GPU detected on the system.
69#[derive(Clone, Debug)]
70pub struct GpuInfo {
71    /// Human-readable name (e.g. "NVIDIA GeForce RTX 4090").
72    pub name: String,
73    /// VRAM in GB.
74    pub vram_gb: f32,
75    /// Likely backend technology.
76    pub backend: super::host::GpuBackend,
77    /// Compute capability string (e.g. "8.9", "rocm", "xe", "apple_metal").
78    pub compute_capability: String,
79    /// Driver version when available.
80    pub driver_version: String,
81    /// PCI domain:bus:device.function (when available).
82    pub pci_address: Option<String>,
83}
84
85/// A Neural Processing Unit detected on the system.
86#[derive(Clone, Debug)]
87pub struct NpuInfo {
88    pub name: String,
89    pub memory_gb: f32,
90    pub driver: String,
91}
92
93/// Network interface information relevant to cluster tuning.
94#[derive(Clone, Debug, Default, Hash)]
95pub struct NetworkInfo {
96    pub xdp_supported: bool,
97    pub interfaces: Vec<String>,
98}
99
100// ---------------------------------------------------------------------------
101// SystemProfile
102// ---------------------------------------------------------------------------
103
104/// Complete, platform-agnostic description of the local host.
105#[derive(Clone, Debug, Default)]
106pub struct SystemProfile {
107    pub cpu: CpuInfo,
108    pub memory: MemoryInfo,
109    pub gpus: Vec<GpuInfo>,
110    pub npus: Vec<NpuInfo>,
111    pub network: NetworkInfo,
112    /// Derived acceleration mode (best available).
113    pub acceleration_mode: super::host::AccelerationMode,
114    /// Recommended worker count for scheduling / pipelines.
115    pub recommended_workers: usize,
116    /// Platform-specific hostname / node identifier.
117    pub system_id: String,
118    /// Best-effort description of how detection was performed.
119    pub detection_source: String,
120}
121
122// ---------------------------------------------------------------------------
123// Cache helpers (re-use existing cache infra from host.rs)
124// ---------------------------------------------------------------------------
125
126static SYSTEM_PROFILE_CACHE: OnceLock<Mutex<Option<CachedSystemProfile>>> = OnceLock::new();
127const SYSTEM_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30);
128
129struct CachedSystemProfile {
130    captured_at: Instant,
131    profile: SystemProfile,
132}
133
134// ---------------------------------------------------------------------------
135// Public API
136// ---------------------------------------------------------------------------
137
138impl SystemProfile {
139    /// Run full system detection and return the unified profile.
140    ///
141    /// Uses a short-lived cache (30 s) so repeated calls are cheap.
142    pub fn detect() -> Self {
143        Self::detect_with_mode(ProbeMode::Full)
144    }
145
146    /// Fast path: skip slow external commands, use env + sysfs + cached data.
147    pub fn detect_fast() -> Self {
148        Self::detect_with_mode(ProbeMode::Fast)
149    }
150
151    fn detect_with_mode(mode: ProbeMode) -> Self {
152        if mode == ProbeMode::Fast {
153            let cache = SYSTEM_PROFILE_CACHE.get_or_init(|| Mutex::new(None));
154            // Hold the lock across the whole check-compute-populate sequence
155            // instead of just the check. Only checking (and releasing) the
156            // lock here would let every concurrent cache-miss caller within
157            // the TTL window race into its own full multi-subprocess
158            // detection at once — observed in practice when several
159            // Fast-mode callers start near-simultaneously (e.g. concurrently
160            // running tests), each spawning ~6 external processes and
161            // causing severe CPU/process-table contention. Holding the lock
162            // makes followers wait for the leader's result instead of
163            // duplicating the work.
164            let mut guard = match cache.lock() {
165                Ok(guard) => guard,
166                Err(poisoned) => poisoned.into_inner(),
167            };
168            if let Some(entry) = guard.as_ref() {
169                if entry.captured_at.elapsed() < SYSTEM_PROFILE_CACHE_TTL {
170                    return entry.profile.clone();
171                }
172            }
173            let profile = Self::detect_uncached(mode);
174            *guard = Some(CachedSystemProfile {
175                captured_at: Instant::now(),
176                profile: profile.clone(),
177            });
178            return profile;
179        }
180
181        Self::detect_uncached(mode)
182    }
183
184    fn detect_uncached(mode: ProbeMode) -> Self {
185        // Each of these probes independently spawns 0-N external processes
186        // (wmic/powershell on Windows, lscpu/sysctl on Linux/macOS, nvidia-smi
187        // everywhere) with no shared mutable state between them. Sequentially
188        // these can sum to multiple seconds of wall-clock time (measured via
189        // `cargo bench`: ~4s for a Full probe); running them concurrently
190        // bounds total latency to the single slowest probe instead of the sum.
191        let (system_id, cpu, memory, gpus, npus, network) = std::thread::scope(|scope| {
192            let system_id = scope.spawn(|| hostname().unwrap_or_else(|| "unknown".into()));
193            let cpu = scope.spawn(detect_cpu_info);
194            let memory = scope.spawn(detect_memory_info);
195            let gpus = scope.spawn(move || detect_gpus(mode));
196            let npus = scope.spawn(detect_npus);
197            let network = scope.spawn(detect_network_info);
198
199            (
200                system_id.join().unwrap(),
201                cpu.join().unwrap(),
202                memory.join().unwrap(),
203                gpus.join().unwrap(),
204                npus.join().unwrap(),
205                network.join().unwrap(),
206            )
207        });
208        let acceleration_mode = pick_acceleration_mode(&gpus, &cpu);
209        let recommended_workers =
210            compute_recommended_workers(&cpu, &memory, &gpus, acceleration_mode);
211        let detection_source = gpus
212            .first()
213            .and_then(|g| {
214                if g.name == "cpu" || g.name.is_empty() {
215                    None
216                } else {
217                    Some(g.name.clone())
218                }
219            })
220            .unwrap_or_else(|| "cpu".into());
221
222        Self {
223            cpu,
224            memory,
225            gpus,
226            npus,
227            network,
228            acceleration_mode,
229            recommended_workers,
230            system_id,
231            detection_source,
232        }
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Conversion to legacy types
238// ---------------------------------------------------------------------------
239
240impl From<&SystemProfile> for super::host::RuntimeProfile {
241    fn from(sp: &SystemProfile) -> Self {
242        let primary_gpu = sp.gpus.first();
243        let gpu_backend = primary_gpu
244            .map(|g| g.backend)
245            .unwrap_or(super::host::GpuBackend::Cpu);
246        let vram_gb = primary_gpu.map(|g| g.vram_gb).unwrap_or(0.0);
247        let gpu_name = primary_gpu.map(|g| g.name.clone());
248        let compute_cap = primary_gpu
249            .map(|g| g.compute_capability.clone())
250            .unwrap_or_else(|| String::from("cpu"));
251
252        let node_resources = NodeResources::new(
253            sp.system_id.clone(),
254            vram_gb,
255            sp.memory.total_gb,
256            compute_cap,
257            gpu_name,
258        );
259
260        super::host::RuntimeProfile {
261            node_resources,
262            logical_cores: sp.cpu.logical_cores,
263            recommended_workers: sp.recommended_workers,
264            acceleration_mode: sp.acceleration_mode,
265            gpu_backend,
266            xdp_supported: sp.network.xdp_supported,
267            detection_source: sp.detection_source.clone(),
268            probe_mode: super::host::ProbeMode::Fast,
269        }
270    }
271}
272
273impl From<SystemProfile> for super::host::RuntimeProfile {
274    fn from(sp: SystemProfile) -> Self {
275        Self::from(&sp)
276    }
277}
278
279impl SystemProfile {
280    /// Convenience: build a `NodeResources` for cluster advertisement.
281    pub fn to_node_resources(&self) -> NodeResources {
282        let primary = self.gpus.first();
283        NodeResources::new(
284            self.system_id.clone(),
285            primary.map(|g| g.vram_gb).unwrap_or(0.0),
286            self.memory.total_gb,
287            primary
288                .map(|g| g.compute_capability.clone())
289                .unwrap_or_else(|| String::from("cpu")),
290            primary.map(|g| g.name.clone()),
291        )
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Probe utilities
297// ---------------------------------------------------------------------------
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300enum ProbeMode {
301    Fast,
302    Full,
303}
304
305#[cfg(unix)]
306fn hostname() -> Option<String> {
307    let mut buf = [0u8; 512];
308    let res = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
309    if res == 0 {
310        let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
311        if let Ok(s) = std::str::from_utf8(&buf[..len]) {
312            let trimmed = s.trim();
313            if !trimmed.is_empty() {
314                return Some(trimmed.to_string());
315            }
316        }
317    }
318    // Fallback to spawning hostname command
319    let output = Command::new("hostname").output().ok()?;
320    if !output.status.success() {
321        return None;
322    }
323    let name = String::from_utf8_lossy(&output.stdout);
324    let trimmed = name.trim();
325    if trimmed.is_empty() {
326        None
327    } else {
328        Some(trimmed.to_string())
329    }
330}
331
332#[cfg(not(unix))]
333fn hostname() -> Option<String> {
334    if let Ok(name) = std::env::var("COMPUTERNAME") {
335        let trimmed = name.trim();
336        if !trimmed.is_empty() {
337            return Some(trimmed.to_string());
338        }
339    }
340    let output = Command::new("hostname").output().ok()?;
341    if !output.status.success() {
342        return None;
343    }
344    let name = String::from_utf8_lossy(&output.stdout);
345    let trimmed = name.trim();
346    if trimmed.is_empty() {
347        None
348    } else {
349        Some(trimmed.to_string())
350    }
351}
352
353// ---------------------------------------------------------------------------
354// CPU detection
355// ---------------------------------------------------------------------------
356
357fn detect_cpu_info() -> CpuInfo {
358    let logical_cores = std::thread::available_parallelism()
359        .map(usize::from)
360        .unwrap_or(1);
361
362    let arch = std::env::consts::ARCH.to_string();
363
364    // SIMD features are compile-time/CPUID-only (no subprocess), cheap to run inline.
365    let features = detect_cpu_features(&arch);
366
367    // Brand string and physical-core count are independent external-process
368    // probes (e.g. on Windows, two separate `Get-CimInstance Win32_Processor`
369    // PowerShell invocations). Measured via `cargo bench`: ~1-1.5s each,
370    // ~2.8s combined when run sequentially. Running them concurrently bounds
371    // this to the slower of the two.
372    let (brand, physical_cores) = std::thread::scope(|scope| {
373        let brand = scope.spawn(detect_cpu_brand);
374        let physical_cores = scope.spawn(detect_physical_cores);
375        (brand.join().unwrap(), physical_cores.join().unwrap())
376    });
377    let physical_cores = physical_cores.unwrap_or(logical_cores / 2).max(1);
378    let (performance_cores, efficiency_cores) = detect_hybrid_core_counts();
379
380    CpuInfo {
381        brand,
382        logical_cores,
383        physical_cores,
384        arch,
385        features,
386        performance_cores,
387        efficiency_cores,
388    }
389}
390
391/// Detects a hybrid CPU's performance/efficiency core split.
392///
393/// Windows only, via `GetLogicalProcessorInformationEx(RelationProcessorCore, ...)`
394/// — the only OS-level API that reports the per-core `EfficiencyClass` Intel
395/// Thread Director / AMD's equivalent uses. There's no Linux/macOS
396/// equivalent probed here yet: Linux exposes core type through
397/// `/sys/devices/cpu_core`/`cpu_atom` on very new kernels (6.x+) or
398/// `cpufreq` base-frequency comparison, and macOS deliberately doesn't
399/// expose thread-level affinity/topology like this at all — both left as
400/// `(None, None)` rather than guessing from an unverified heuristic.
401#[cfg(target_os = "windows")]
402fn detect_hybrid_core_counts() -> (Option<usize>, Option<usize>) {
403    use windows_sys::Win32::System::SystemInformation::{
404        GetLogicalProcessorInformationEx, RelationProcessorCore,
405    };
406
407    let mut len: u32 = 0;
408    // First call with a null buffer: fails by design, populates `len` with
409    // the required buffer size. Return code/GetLastError aren't checked
410    // here because the only signal that matters is whether `len` came back
411    // non-zero — if it didn't, there's nothing to parse either way.
412    unsafe {
413        GetLogicalProcessorInformationEx(RelationProcessorCore, std::ptr::null_mut(), &mut len);
414    }
415    if len == 0 {
416        return (None, None);
417    }
418
419    let mut buf = vec![0u8; len as usize];
420    let ok = unsafe {
421        GetLogicalProcessorInformationEx(RelationProcessorCore, buf.as_mut_ptr().cast(), &mut len)
422    };
423    if ok == 0 {
424        return (None, None);
425    }
426    buf.truncate(len as usize);
427    parse_processor_core_relationships(&buf)
428}
429
430#[cfg(not(target_os = "windows"))]
431fn detect_hybrid_core_counts() -> (Option<usize>, Option<usize>) {
432    (None, None)
433}
434
435/// Parses a `GetLogicalProcessorInformationEx(RelationProcessorCore, ...)`
436/// result buffer into `(performance_cores, efficiency_cores)`.
437///
438/// Pure and platform-independent (no `windows-sys` types) specifically so
439/// it can be unit-tested with hand-built synthetic buffers — this repo has
440/// no real hybrid P/E-core hardware to test `detect_hybrid_core_counts`
441/// itself against, so correctness has to come from testing this parsing
442/// logic directly against the documented struct layout instead.
443///
444/// Each variable-length `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX` record
445/// (winnt.h):
446///   offset 0: `Relationship` (u32) — `RelationProcessorCore` == 0
447///   offset 4: `Size` (u32) — total record length, used to advance to the
448///             next record; a request for `RelationProcessorCore` only
449///             returns records with this relationship, so no other variant
450///             needs to be understood here
451///   offset 8: `PROCESSOR_RELATIONSHIP.Flags` (u8)
452///   offset 9: `PROCESSOR_RELATIONSHIP.EfficiencyClass` (u8) — higher value
453///             = more performant; a uniform value across every core means
454///             no real hybrid split (true for the overwhelming majority of
455///             CPUs, hybrid or not, that this ever runs against)
456///
457/// Returns `(None, None)` if the buffer is empty, too short to contain any
458/// full record, or every core reports the same `EfficiencyClass`. A
459/// malformed trailing record (`Size` of 0, or claiming to extend past the
460/// buffer) stops parsing at that point rather than reading out of bounds —
461/// records already parsed are still used.
462///
463/// `#[cfg(any(windows, test))]`: the only real caller
464/// (`detect_hybrid_core_counts`) is itself Windows-only, so on a non-Windows
465/// *non-test* build this function is genuinely unused — without the `test`
466/// half of this gate, `cargo build`/`clippy` on Linux/macOS fail outright on
467/// `-D warnings`'s dead-code lint. The `test` half keeps it (and its
468/// `#[cfg(test)] mod tests` coverage below) compiled on every CI platform,
469/// which is the whole reason this parser was written pure and
470/// platform-independent in the first place — see the doc comment above.
471#[cfg(any(windows, test))]
472fn parse_processor_core_relationships(buf: &[u8]) -> (Option<usize>, Option<usize>) {
473    const RELATION_PROCESSOR_CORE: u32 = 0;
474
475    let mut classes: Vec<u8> = Vec::new();
476    let mut offset = 0usize;
477    while offset + 8 <= buf.len() {
478        let relationship = u32::from_ne_bytes(buf[offset..offset + 4].try_into().unwrap());
479        let size = u32::from_ne_bytes(buf[offset + 4..offset + 8].try_into().unwrap()) as usize;
480        if size < 8 || offset + size > buf.len() {
481            break;
482        }
483        if relationship == RELATION_PROCESSOR_CORE && offset + 10 <= buf.len() {
484            classes.push(buf[offset + 9]);
485        }
486        offset += size;
487    }
488
489    if classes.is_empty() {
490        return (None, None);
491    }
492    let max_class = *classes.iter().max().expect("classes is non-empty");
493    if classes.iter().all(|&c| c == max_class) {
494        return (None, None);
495    }
496    let performance = classes.iter().filter(|&&c| c == max_class).count();
497    let efficiency = classes.len() - performance;
498    (Some(performance), Some(efficiency))
499}
500
501fn detect_cpu_brand() -> String {
502    // Linux
503    #[cfg(target_os = "linux")]
504    {
505        if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
506            for line in content.lines() {
507                if line.starts_with("model name") || line.starts_with("Model name") {
508                    if let Some(name) = line.split(':').nth(1) {
509                        let trimmed = name.trim();
510                        if !trimmed.is_empty() {
511                            return trimmed.to_string();
512                        }
513                    }
514                }
515            }
516        }
517        if let Ok(output) = Command::new("lscpu").output() {
518            let text = String::from_utf8_lossy(&output.stdout);
519            if let Some(line) = text.lines().find(|l| l.contains("Model name")) {
520                if let Some(name) = line.split(':').nth(1) {
521                    return name.trim().to_string();
522                }
523            }
524        }
525    }
526
527    // macOS
528    #[cfg(target_os = "macos")]
529    {
530        if let Ok(output) = Command::new("sysctl")
531            .args(["-n", "machdep.cpu.brand_string"])
532            .output()
533        {
534            let name = String::from_utf8_lossy(&output.stdout);
535            let trimmed = name.trim();
536            if !trimmed.is_empty() {
537                return trimmed.to_string();
538            }
539        }
540        // Apple Silicon fallback
541        if let Ok(output) = Command::new("sysctl")
542            .args(["-n", "machdep.cpu.brand_string"])
543            .output()
544        {
545            let name = String::from_utf8_lossy(&output.stdout);
546            let trimmed = name.trim();
547            if !trimmed.is_empty() {
548                return trimmed.to_string();
549            }
550        }
551        // For Apple Silicon, sysctl may not return a brand string
552        if let Ok(output) = Command::new("sysctl").args(["-n", "hw.model"]).output() {
553            let model = String::from_utf8_lossy(&output.stdout);
554            let trimmed = model.trim();
555            if !trimmed.is_empty() {
556                return format!("Apple {}", trimmed);
557            }
558        }
559    }
560
561    // Windows
562    #[cfg(target_os = "windows")]
563    {
564        if let Ok(output) = Command::new("wmic")
565            .args(["cpu", "get", "name", "/format:csv"])
566            .output()
567        {
568            let text = String::from_utf8_lossy(&output.stdout);
569            for line in text.lines().skip(1) {
570                let trimmed = line.trim();
571                if !trimmed.is_empty() && !trimmed.contains("Node") {
572                    // CSV format: Node,Name
573                    if let Some(name) = trimmed.split(',').nth(1) {
574                        let n = name.trim();
575                        if !n.is_empty() {
576                            return n.to_string();
577                        }
578                    }
579                }
580            }
581        }
582        // Fallback: Win32_Processor via PowerShell
583        if let Ok(output) = Command::new("powershell")
584            .args([
585                "-NoProfile",
586                "-Command",
587                "(Get-CimInstance Win32_Processor).Name",
588            ])
589            .output()
590        {
591            let name = String::from_utf8_lossy(&output.stdout);
592            let trimmed = name.trim();
593            if !trimmed.is_empty() {
594                return trimmed.to_string();
595            }
596        }
597    }
598
599    String::from("Unknown CPU")
600}
601
602fn detect_cpu_features(arch: &str) -> CpuFeatures {
603    match arch {
604        "x86_64" | "x86" => detect_x86_features(),
605        "aarch64" | "arm" => {
606            let neon = true; // all ARMv8+ have NEON
607            let sve = detect_sve();
608            CpuFeatures {
609                neon,
610                sve,
611                ..Default::default()
612            }
613        }
614        _ => CpuFeatures::default(),
615    }
616}
617
618#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
619fn detect_x86_features() -> CpuFeatures {
620    let avx2 = is_x86_feature_detected!("avx2");
621    let avx_512 = is_x86_feature_detected!("avx512f");
622    let amx = detect_amx_tile();
623    CpuFeatures {
624        avx2,
625        avx_512,
626        amx,
627        ..Default::default()
628    }
629}
630
631/// Runtime AMX-TILE detection via raw CPUID.
632///
633/// `is_x86_feature_detected!("amx-tile")` is gated behind the unstable
634/// `x86_amx_intrinsics` feature on current stable Rust, so it can't be used
635/// here. The previous implementation substituted
636/// `cfg!(target_feature = "amx-tile")`, a *compile-time* check that reflects
637/// only whether this binary was built with `-C target-feature=+amx-tile`
638/// (true for essentially no real-world build) — so `amx` silently reported
639/// `false` on every AMX-capable host (e.g. Sapphire Rapids) regardless of
640/// actual hardware support. CPUID leaf 7, sub-leaf 0, EDX bit 24 is the
641/// architectural AMX-TILE feature bit.
642// `__cpuid`/`__cpuid_count` require an explicit `unsafe` block on this
643// project's MSRV toolchain, but a newer compiler considers that block
644// unnecessary (their signature became safe) and denies it under
645// `-D warnings` instead. `#[allow(unused_unsafe)]` keeps this function
646// compiling on both: required for MSRV, a silenced no-op warning on newer
647// rustc.
648#[cfg(target_arch = "x86_64")]
649#[allow(unused_unsafe)]
650fn detect_amx_tile() -> bool {
651    // Safety: `__cpuid`/`__cpuid_count` require the CPUID instruction to be
652    // available, which is guaranteed on every x86_64 CPU (part of the
653    // baseline x86_64 architecture, unlike 32-bit x86 where it can be
654    // absent on pre-Pentium hardware) — this cfg-gates to x86_64 only.
655    let max_leaf = unsafe { core::arch::x86_64::__cpuid(0) }.eax;
656    if max_leaf < 7 {
657        return false;
658    }
659    let leaf7 = unsafe { core::arch::x86_64::__cpuid_count(7, 0) };
660    (leaf7.edx & (1 << 24)) != 0
661}
662
663// AMX only exists on 64-bit server-class parts; no known 32-bit x86 target
664// implements it, so avoid the extra unsafe surface for that arch.
665#[cfg(all(target_arch = "x86", not(target_arch = "x86_64")))]
666fn detect_amx_tile() -> bool {
667    false
668}
669
670#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
671fn detect_x86_features() -> CpuFeatures {
672    CpuFeatures::default()
673}
674
675fn detect_sve() -> bool {
676    #[cfg(target_os = "linux")]
677    {
678        // Check /proc/cpuinfo for "sve" feature flag
679        if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
680            if content.contains("sve") || content.contains("SVE") {
681                return true;
682            }
683            // Also check /proc/self/auxv for HWCAP_SVE
684        }
685    }
686    false
687}
688
689/// Sum `NumberOfCores` values across all lines of CIM/WMI output, one line
690/// per physical CPU package on multi-socket hosts.
691#[cfg(target_os = "windows")]
692fn sum_windows_cim_core_counts(text: &str) -> usize {
693    text.lines()
694        .filter_map(|line| line.trim().parse::<usize>().ok())
695        .sum()
696}
697
698fn detect_physical_cores() -> Option<usize> {
699    // Linux
700    #[cfg(target_os = "linux")]
701    {
702        // Count unique core IDs across all CPU packages
703        if let Ok(content) = fs::read_to_string("/proc/cpuinfo") {
704            let mut cores = std::collections::BTreeSet::new();
705            let mut current_package = None;
706            for line in content.lines() {
707                if let Some(val) = line.strip_prefix("physical id\t: ") {
708                    current_package = Some(val.trim().to_string());
709                }
710                if let Some(val) = line.strip_prefix("core id\t\t: ") {
711                    if let Some(ref pkg) = current_package {
712                        cores.insert(format!("{}-{}", pkg, val.trim()));
713                    }
714                }
715            }
716            if !cores.is_empty() {
717                return Some(cores.len());
718            }
719        }
720    }
721
722    // macOS
723    #[cfg(target_os = "macos")]
724    {
725        if let Ok(output) = Command::new("sysctl")
726            .args(["-n", "hw.physicalcpu"])
727            .output()
728        {
729            if let Ok(count) = String::from_utf8_lossy(&output.stdout)
730                .trim()
731                .parse::<usize>()
732            {
733                return Some(count);
734            }
735        }
736    }
737
738    // Windows
739    #[cfg(target_os = "windows")]
740    {
741        if let Ok(output) = Command::new("powershell")
742            .args([
743                "-NoProfile",
744                "-Command",
745                "(Get-CimInstance Win32_Processor).NumberOfCores",
746            ])
747            .output()
748        {
749            let text = String::from_utf8_lossy(&output.stdout);
750            // Get-CimInstance without -ErrorAction/indexing returns one line per
751            // physical CPU package on multi-socket hosts. The previous code
752            // `return`ed on the first parseable line, silently reporting only
753            // one socket's core count (e.g. 16 instead of 32 on a dual-socket
754            // Xeon box) and skewing worker/tuning decisions that divide by
755            // physical core count. Sum across all reported packages instead.
756            let total = sum_windows_cim_core_counts(&text);
757            if total > 0 {
758                return Some(total);
759            }
760        }
761    }
762
763    None
764}
765
766// ---------------------------------------------------------------------------
767// Memory detection
768// ---------------------------------------------------------------------------
769
770fn detect_memory_info() -> MemoryInfo {
771    // Total and available memory are independent external-process probes
772    // (e.g. two separate PowerShell/CIM calls on Windows); run them
773    // concurrently rather than paying both costs sequentially.
774    let (total, available) = std::thread::scope(|scope| {
775        let total = scope.spawn(detect_system_memory_gb);
776        let available = scope.spawn(detect_available_memory_gb);
777        (total.join().unwrap(), available.join().unwrap())
778    });
779    let total_gb = total.unwrap_or(0.0);
780    let available_gb = available.unwrap_or(total_gb * 0.5);
781    MemoryInfo {
782        total_gb,
783        available_gb,
784    }
785}
786
787fn detect_system_memory_gb() -> Option<f32> {
788    // Env override (cross-platform)
789    if let Ok(val) = env::var("GHOSTLINK_SYSTEM_MEMORY_GB") {
790        if let Ok(gb) = val.parse::<f32>() {
791            return Some(gb.max(0.0));
792        }
793    }
794
795    platform_detect_system_memory_gb()
796}
797
798#[cfg(target_os = "linux")]
799fn platform_detect_system_memory_gb() -> Option<f32> {
800    let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
801    let line = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?;
802    let kb = line
803        .split_whitespace()
804        .nth(1)
805        .and_then(|v| v.parse::<f32>().ok())?;
806    Some(kb / 1024.0 / 1024.0)
807}
808
809#[cfg(target_os = "macos")]
810fn platform_detect_system_memory_gb() -> Option<f32> {
811    if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() {
812        if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
813            .trim()
814            .parse::<f64>()
815        {
816            return Some((bytes / 1_073_741_824.0) as f32);
817        }
818    }
819    None
820}
821
822#[cfg(target_os = "windows")]
823fn platform_detect_system_memory_gb() -> Option<f32> {
824    if let Ok(output) = Command::new("powershell")
825        .args([
826            "-NoProfile",
827            "-Command",
828            "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
829        ])
830        .output()
831    {
832        if let Ok(bytes) = String::from_utf8_lossy(&output.stdout)
833            .trim()
834            .parse::<f64>()
835        {
836            if bytes > 0.0 {
837                return Some((bytes / 1_073_741_824.0) as f32);
838            }
839        }
840    }
841    // Fallback: wmic
842    if let Ok(output) = Command::new("wmic")
843        .args([
844            "ComputerSystem",
845            "get",
846            "TotalPhysicalMemory",
847            "/format:csv",
848        ])
849        .output()
850    {
851        let text = String::from_utf8_lossy(&output.stdout);
852        for line in text.lines().skip(1) {
853            if let Some(val) = line.split(',').nth(1) {
854                if let Ok(bytes) = val.trim().parse::<f64>() {
855                    if bytes > 0.0 {
856                        return Some((bytes / 1_073_741_824.0) as f32);
857                    }
858                }
859            }
860        }
861    }
862    None
863}
864
865#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
866fn platform_detect_system_memory_gb() -> Option<f32> {
867    None
868}
869
870fn detect_available_memory_gb() -> Option<f32> {
871    platform_detect_available_memory_gb()
872}
873
874#[cfg(target_os = "linux")]
875fn platform_detect_available_memory_gb() -> Option<f32> {
876    let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
877    if let Some(line) = meminfo.lines().find(|l| l.starts_with("MemAvailable:")) {
878        let kb = line
879            .split_whitespace()
880            .nth(1)
881            .and_then(|v| v.parse::<f32>().ok())?;
882        return Some(kb / 1024.0 / 1024.0);
883    }
884    let free = meminfo
885        .lines()
886        .find(|l| l.starts_with("MemFree:"))
887        .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
888        .unwrap_or(0.0);
889    let cached = meminfo
890        .lines()
891        .find(|l| l.starts_with("Cached:"))
892        .and_then(|l| l.split_whitespace().nth(1)?.parse::<f32>().ok())
893        .unwrap_or(0.0);
894    Some((free + cached) / 1024.0 / 1024.0)
895}
896
897#[cfg(target_os = "macos")]
898fn platform_detect_available_memory_gb() -> Option<f32> {
899    if let Ok(output) = Command::new("vm_stat").output() {
900        let text = String::from_utf8_lossy(&output.stdout);
901        let mut free_pages = 0.0f64;
902        let mut inactive_pages = 0.0f64;
903        let mut page_size = 16384.0f64;
904        for line in text.lines() {
905            if let Some(val) = line.strip_prefix("Mach Virtual Memory Statistics: (page size of ") {
906                if let Ok(size) = val.trim_end_matches(" bytes)").parse::<f64>() {
907                    page_size = size;
908                }
909            }
910            if line.contains("pages free") {
911                free_pages = line
912                    .split(':')
913                    .nth(1)
914                    .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
915                    .unwrap_or(0.0);
916            }
917            if line.contains("pages inactive") {
918                inactive_pages = line
919                    .split(':')
920                    .nth(1)
921                    .and_then(|s| s.trim().replace('.', "").parse::<f64>().ok())
922                    .unwrap_or(0.0);
923            }
924        }
925        let available_bytes = (free_pages + inactive_pages) * page_size;
926        return Some((available_bytes / 1_073_741_824.0) as f32);
927    }
928    None
929}
930
931#[cfg(target_os = "windows")]
932fn platform_detect_available_memory_gb() -> Option<f32> {
933    if let Ok(output) = Command::new("powershell")
934        .args([
935            "-NoProfile",
936            "-Command",
937            "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory",
938        ])
939        .output()
940    {
941        if let Ok(kb) = String::from_utf8_lossy(&output.stdout)
942            .trim()
943            .parse::<f64>()
944        {
945            return Some((kb / 1024.0 / 1024.0) as f32);
946        }
947    }
948    None
949}
950
951#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
952fn platform_detect_available_memory_gb() -> Option<f32> {
953    None
954}
955
956// ---------------------------------------------------------------------------
957// GPU detection
958// ---------------------------------------------------------------------------
959
960fn detect_gpus(mode: ProbeMode) -> Vec<GpuInfo> {
961    // Try env override first (single GPU)
962    if let Some(gpu) = detect_gpu_from_env() {
963        return vec![gpu];
964    }
965
966    // Platform-specific probes with timeout for subprocess calls
967    let mut gpus = Vec::new();
968
969    // nvidia-smi (cross-platform, with timeout)
970    if let Some(result) = probe_nvidia_smi_with_timeout() {
971        gpus.extend(result);
972    }
973
974    // rocm-smi (Linux, feature-gated, with timeout)
975    #[cfg(feature = "rocm")]
976    if let Some(result) = probe_rocm_smi_with_timeout() {
977        gpus.extend(result);
978    }
979
980    // Windows WMI (fallback for non-NVIDIA GPUs, with timeout)
981    #[cfg(target_os = "windows")]
982    if gpus.is_empty() {
983        if let Some(result) = probe_windows_wmi_gpu_with_timeout() {
984            gpus.extend(result);
985        }
986    }
987
988    // Linux lspci (fallback, no VRAM info, with timeout)
989    #[cfg(target_os = "linux")]
990    if gpus.is_empty() {
991        if let Some(gpu) = probe_lspci_gpu_with_timeout() {
992            gpus.push(gpu);
993        }
994    }
995
996    // macOS system_profiler (with timeout)
997    #[cfg(target_os = "macos")]
998    if gpus.is_empty() {
999        if let Some(gpu) = probe_macos_gpu_with_timeout() {
1000            gpus.push(gpu);
1001        }
1002    }
1003
1004    // Linux sysfs (VRAM info for AMD + Intel)
1005    #[cfg(target_os = "linux")]
1006    if gpus.is_empty() {
1007        if let Some(gpu) = probe_sysfs_gpu() {
1008            gpus.push(gpu);
1009        }
1010    }
1011
1012    // Vulkan probe (cross-platform, full mode only to avoid slowdown, with timeout)
1013    if mode == ProbeMode::Full && gpus.is_empty() {
1014        if let Some(gpu) = probe_vulkan_gpu_with_timeout() {
1015            gpus.push(gpu);
1016        }
1017    }
1018
1019    if gpus.is_empty() {
1020        let cc = if cfg!(feature = "rocm") {
1021            "rocm"
1022        } else {
1023            "gpu"
1024        };
1025        gpus.push(GpuInfo {
1026            name: String::from("cpu"),
1027            vram_gb: 0.0,
1028            backend: super::host::GpuBackend::Cpu,
1029            compute_capability: cc.to_string(),
1030            driver_version: String::from("native"),
1031            pci_address: None,
1032        });
1033    }
1034
1035    gpus
1036}
1037
1038/// Runs `probe` on a detached background thread and waits up to `timeout`
1039/// for a result, returning as soon as it's ready rather than always blocking
1040/// for the full duration — `std::thread::scope` can't do this: it implicitly
1041/// joins every spawned thread before returning, so wrapping a probe in a
1042/// scope and then sleeping for the timeout duration blocks for that full
1043/// duration on *every* call, fast or slow, and still hangs forever on a
1044/// genuinely stuck probe (the earlier bug this replaces). Rust has no way to
1045/// forcibly kill a thread, so a probe that's still running past `timeout` is
1046/// abandoned: the background thread runs to completion on its own time and
1047/// its late result is silently dropped when `rx` goes out of scope.
1048fn probe_with_timeout<T, F>(timeout: Duration, probe: F) -> Option<T>
1049where
1050    T: Send + 'static,
1051    F: FnOnce() -> Option<T> + Send + 'static,
1052{
1053    let (tx, rx) = std::sync::mpsc::channel();
1054    std::thread::spawn(move || {
1055        let _ = tx.send(probe());
1056    });
1057    rx.recv_timeout(timeout).ok().flatten()
1058}
1059
1060/// Timeout-wrapped GPU probe: nvidia-smi with 5-second timeout
1061fn probe_nvidia_smi_with_timeout() -> Option<Vec<GpuInfo>> {
1062    probe_with_timeout(Duration::from_millis(5000), probe_nvidia_smi)
1063}
1064
1065/// Timeout-wrapped GPU probe: rocm-smi with 5-second timeout
1066#[cfg(feature = "rocm")]
1067fn probe_rocm_smi_with_timeout() -> Option<Vec<GpuInfo>> {
1068    probe_with_timeout(Duration::from_millis(5000), probe_rocm_smi)
1069}
1070
1071/// Timeout-wrapped GPU probe: Windows WMI with 5-second timeout
1072#[cfg(target_os = "windows")]
1073fn probe_windows_wmi_gpu_with_timeout() -> Option<Vec<GpuInfo>> {
1074    probe_with_timeout(Duration::from_millis(5000), probe_windows_wmi_gpu)
1075}
1076
1077/// Timeout-wrapped GPU probe: lspci with 5-second timeout
1078#[cfg(target_os = "linux")]
1079fn probe_lspci_gpu_with_timeout() -> Option<GpuInfo> {
1080    probe_with_timeout(Duration::from_millis(5000), probe_lspci_gpu)
1081}
1082
1083/// Timeout-wrapped GPU probe: macOS system_profiler with 5-second timeout
1084#[cfg(target_os = "macos")]
1085fn probe_macos_gpu_with_timeout() -> Option<GpuInfo> {
1086    probe_with_timeout(Duration::from_millis(5000), probe_macos_gpu)
1087}
1088
1089/// Timeout-wrapped GPU probe: Vulkan with 10-second timeout (slower probe)
1090fn probe_vulkan_gpu_with_timeout() -> Option<GpuInfo> {
1091    probe_with_timeout(Duration::from_millis(10000), probe_vulkan_gpu)
1092}
1093
1094fn detect_gpu_from_env() -> Option<GpuInfo> {
1095    let name = env::var("GHOSTLINK_GPU_NAME")
1096        .ok()
1097        .filter(|s| !s.trim().is_empty());
1098    let vram_gb = env::var("GHOSTLINK_VRAM_GB")
1099        .ok()
1100        .and_then(|v| v.parse::<f32>().ok());
1101    let cc = env::var("GHOSTLINK_COMPUTE_CAPABILITY")
1102        .ok()
1103        .filter(|s| !s.trim().is_empty());
1104    // A VRAM value of 0 (or absent) is not a meaningful GPU signal on its own —
1105    // launch scripts unconditionally export GHOSTLINK_VRAM_GB=0 in CPU-only mode,
1106    // and treating that as an override used to short-circuit the real hardware
1107    // probes below, permanently masking true auto-discovery and falsely reporting
1108    // GPU acceleration as available on CPU-only hosts.
1109    let has_gpu_signal = name.is_some() || cc.is_some() || vram_gb.is_some_and(|v| v > 0.0);
1110    if !has_gpu_signal {
1111        return None;
1112    }
1113
1114    let gpu_name = name.unwrap_or_else(|| String::from("env-gpu"));
1115    let compute_capability =
1116        cc.unwrap_or_else(|| super::host::infer_compute_capability_from_name(&gpu_name));
1117    let backend = infer_backend(&gpu_name, &compute_capability);
1118
1119    Some(GpuInfo {
1120        name: gpu_name,
1121        vram_gb: vram_gb.unwrap_or(0.0),
1122        backend,
1123        compute_capability,
1124        driver_version: String::from("env"),
1125        pci_address: None,
1126    })
1127}
1128
1129fn probe_nvidia_smi() -> Option<Vec<GpuInfo>> {
1130    let output = Command::new("nvidia-smi")
1131        .args([
1132            "--query-gpu=index,name,memory.total,compute_cap,driver_version",
1133            "--format=csv,noheader,nounits",
1134        ])
1135        .output()
1136        .ok()?;
1137    if !output.status.success() {
1138        return None;
1139    }
1140
1141    let stdout = String::from_utf8_lossy(&output.stdout);
1142    let mut gpus = Vec::new();
1143
1144    for line in stdout.lines() {
1145        let trimmed = line.trim();
1146        if trimmed.is_empty() {
1147            continue;
1148        }
1149        let mut parts = trimmed.split(',').map(str::trim);
1150        let _index = parts.next()?;
1151        let name = parts.next()?.to_string();
1152        let vram_mib = parts
1153            .next()
1154            .and_then(|v| v.parse::<f32>().ok())
1155            .unwrap_or(0.0);
1156        let compute_cap = parts
1157            .next()
1158            .map(|s| s.to_string())
1159            .unwrap_or_else(|| String::from("gpu"));
1160        let driver = parts.next().map(|s| s.to_string()).unwrap_or_default();
1161
1162        gpus.push(GpuInfo {
1163            name,
1164            vram_gb: vram_mib / 1024.0,
1165            backend: super::host::GpuBackend::Cuda,
1166            compute_capability: compute_cap,
1167            driver_version: driver,
1168            pci_address: None,
1169        });
1170    }
1171
1172    if gpus.is_empty() {
1173        None
1174    } else {
1175        Some(gpus)
1176    }
1177}
1178
1179#[cfg(feature = "rocm")]
1180fn probe_rocm_smi() -> Option<Vec<GpuInfo>> {
1181    let output = Command::new("rocm-smi")
1182        .args(["--showproductname"])
1183        .output()
1184        .ok()?;
1185    if !output.status.success() {
1186        return None;
1187    }
1188    let stdout = String::from_utf8_lossy(&output.stdout);
1189
1190    let mut gpus = Vec::new();
1191    let mut current_gpu = None;
1192
1193    for line in stdout.lines() {
1194        if line.contains("GPU[") && line.contains("Card model:") {
1195            let name = line
1196                .split(':')
1197                .nth(2)
1198                .map(|s| s.trim().to_string())
1199                .unwrap_or_else(|| String::from("AMD GPU"));
1200            let vram = probe_rocm_vram();
1201            let compute_cap = super::host::infer_compute_capability_from_name(&name);
1202            gpus.push(GpuInfo {
1203                name,
1204                vram_gb: vram.unwrap_or(0.0),
1205                backend: super::host::GpuBackend::Rocm,
1206                compute_capability: compute_cap,
1207                driver_version: detect_rocm_version().unwrap_or_default(),
1208                pci_address: current_gpu.take(),
1209            });
1210        }
1211    }
1212
1213    if gpus.is_empty() {
1214        None
1215    } else {
1216        Some(gpus)
1217    }
1218}
1219
1220#[cfg(feature = "rocm")]
1221fn probe_rocm_vram() -> Option<f32> {
1222    let output = Command::new("rocm-smi")
1223        .args(["--showmeminfo", "vram"])
1224        .output()
1225        .ok()?;
1226    let stdout = String::from_utf8_lossy(&output.stdout);
1227    stdout
1228        .lines()
1229        .find(|l| l.contains("Total Memory"))
1230        .and_then(|l| {
1231            let val = l.split(':').nth(1)?.trim().to_lowercase();
1232            let num: f32 = val.split_whitespace().next()?.parse().ok()?;
1233            if val.contains("gb") {
1234                Some(num)
1235            } else if val.contains("mb") {
1236                Some(num / 1024.0)
1237            } else {
1238                None
1239            }
1240        })
1241}
1242
1243#[cfg(feature = "rocm")]
1244fn detect_rocm_version() -> Option<String> {
1245    let output = Command::new("rocm-smi").args(["--version"]).output().ok()?;
1246    let stderr = String::from_utf8_lossy(&output.stderr);
1247    stderr
1248        .lines()
1249        .find_map(|l| l.split_whitespace().last().map(|s| s.to_string()))
1250}
1251
1252#[cfg(target_os = "windows")]
1253fn probe_windows_wmi_gpu() -> Option<Vec<GpuInfo>> {
1254    // Primary: WMI query with Name, AdapterRAM, DriverVersion, PNPDeviceID
1255    let output = Command::new("powershell")
1256        .args([
1257            "-NoProfile",
1258            "-Command",
1259            "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notmatch '(?i)(Microsoft Basic Display|Remote Display|VirtualBox|VMware)' } | Select-Object Name, AdapterRAM, DriverVersion, PNPDeviceID | ConvertTo-Csv -NoTypeInformation",
1260        ])
1261        .output()
1262        .ok()?;
1263    if !output.status.success() {
1264        return None;
1265    }
1266
1267    let stdout = String::from_utf8_lossy(&output.stdout);
1268    let mut gpus: Vec<GpuInfo> = Vec::new();
1269
1270    for line in stdout.lines().skip(1) {
1271        let trimmed = line.trim();
1272        if trimmed.is_empty() || trimmed.starts_with('#') {
1273            continue;
1274        }
1275        // CSV: "Name","AdapterRAM","DriverVersion","PNPDeviceID"
1276        let mut parts: Vec<String> = Vec::new();
1277        let mut current = String::new();
1278        let mut in_quotes = false;
1279        for ch in trimmed.chars() {
1280            match ch {
1281                '"' => in_quotes = !in_quotes,
1282                ',' if !in_quotes => {
1283                    parts.push(std::mem::take(&mut current));
1284                }
1285                c => current.push(c),
1286            }
1287        }
1288        parts.push(current);
1289        if parts.len() < 3 {
1290            continue;
1291        }
1292        let name = parts[0].trim().to_string();
1293        if name.is_empty() {
1294            continue;
1295        }
1296
1297        let vram_bytes: f64 = parts[1].trim().parse().unwrap_or(0.0);
1298        let vram_gb = if vram_bytes > 0.0 {
1299            (vram_bytes / 1_073_741_824.0) as f32
1300        } else {
1301            0.0
1302        };
1303        let driver = parts[2].trim().to_string();
1304        let pnp_id = parts.get(3).map(|s| s.trim().to_string());
1305        let compute_cap = super::host::infer_compute_capability_from_name(&name);
1306
1307        // Determine backend from PNP ID or name
1308        let backend = if let Some(ref id) = pnp_id {
1309            let id_lower = id.to_ascii_lowercase();
1310            if id_lower.contains("ven_10de") {
1311                super::host::GpuBackend::Cuda
1312            } else if id_lower.contains("ven_1002") || id_lower.contains("ven_1022") {
1313                if cfg!(feature = "rocm") {
1314                    super::host::GpuBackend::Rocm
1315                } else {
1316                    super::host::GpuBackend::Directml
1317                }
1318            } else if id_lower.contains("ven_8086") {
1319                super::host::GpuBackend::Directml
1320            } else if id_lower.contains("ven_14e4") {
1321                super::host::GpuBackend::Vulkan
1322            } else {
1323                super::host::GpuBackend::Directml
1324            }
1325        } else {
1326            super::host::GpuBackend::Directml
1327        };
1328
1329        // If WMI returned 0 VRAM for a real GPU, try a secondary DXGI-based probe
1330        let actual_vram = if vram_gb <= 0.0 {
1331            probe_windows_dxgi_vram().unwrap_or(0.0)
1332        } else {
1333            vram_gb
1334        };
1335
1336        gpus.push(GpuInfo {
1337            name,
1338            vram_gb: actual_vram,
1339            backend,
1340            compute_capability: compute_cap,
1341            driver_version: driver,
1342            pci_address: pnp_id,
1343        });
1344    }
1345
1346    if gpus.is_empty() {
1347        None
1348    } else {
1349        Some(gpus)
1350    }
1351}
1352
1353/// Secondary VRAM probe used when WMI returns 0 VRAM for a detected GPU
1354/// (common for integrated GPUs, where `Win32_VideoController.AdapterRAM`'s
1355/// 32-bit field is unreliable).
1356///
1357/// Tries a plain registry read first (`HardwareInformation.qwMemorySize`,
1358/// the same technique GPU-Z uses), which needs no in-process compilation.
1359/// Falls back to the DXGI `Add-Type` C# probe — measured via `cargo bench`
1360/// to cost ~1-2s of wall-clock time compiling the inline C# shim, the
1361/// dominant cost in a Full hardware probe — only if the registry value is
1362/// absent, so drivers that don't publish it still get a VRAM reading.
1363#[cfg(target_os = "windows")]
1364fn probe_windows_dxgi_vram() -> Option<f32> {
1365    if let Some(vram) = probe_windows_registry_vram() {
1366        return Some(vram);
1367    }
1368    probe_windows_dxgi_vram_via_addtype()
1369}
1370
1371#[cfg(target_os = "windows")]
1372fn probe_windows_registry_vram() -> Option<f32> {
1373    let output = Command::new("powershell")
1374        .args([
1375            "-NoProfile",
1376            "-Command",
1377            "Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -Name 'HardwareInformation.qwMemorySize' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'HardwareInformation.qwMemorySize' -First 1",
1378        ])
1379        .output()
1380        .ok()?;
1381    if !output.status.success() {
1382        return None;
1383    }
1384    let bytes: f64 = String::from_utf8_lossy(&output.stdout)
1385        .trim()
1386        .parse()
1387        .ok()?;
1388    if bytes > 0.0 {
1389        Some((bytes / 1_073_741_824.0) as f32)
1390    } else {
1391        None
1392    }
1393}
1394
1395#[cfg(target_os = "windows")]
1396fn probe_windows_dxgi_vram_via_addtype() -> Option<f32> {
1397    let script = r#"
1398Add-Type @"
1399using System;
1400using System.Runtime.InteropServices;
1401public class DXGIVram {
1402    [DllImport("dxgi.dll")]
1403    private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr ppFactory);
1404    public static float GetVRAM() {
1405        Guid iid = new Guid("7706bb9b-3be9-49f2-9fac-271a00c72f78");
1406        if (CreateDXGIFactory1(ref iid, out IntPtr factory) != 0) return 0;
1407        try {
1408            IntPtr vtable = Marshal.ReadIntPtr(factory);
1409            IntPtr enumAdapters = Marshal.ReadIntPtr(vtable, 9 * IntPtr.Size);
1410            var enumFunc = Marshal.GetDelegateForFunctionPointer<EnumAdaptersDelegate>(enumAdapters);
1411            IntPtr adapter;
1412            if (enumFunc(factory, 0, out adapter) != 0) return 0;
1413            try {
1414                IntPtr adapterVtable = Marshal.ReadIntPtr(adapter);
1415                IntPtr descPtr = Marshal.ReadIntPtr(adapterVtable, 4 * IntPtr.Size);
1416                var descFunc = Marshal.GetDelegateForFunctionPointer<GetDescDelegate>(descPtr);
1417                DXGI_ADAPTER_DESC desc;
1418                if (descFunc(adapter, out desc) != 0) return 0;
1419                return (float)(desc.DedicatedVideoMemory / (1024.0 * 1024.0 * 1024.0));
1420            } finally { Marshal.Release(adapter); }
1421        } finally { Marshal.Release(factory); }
1422    }
1423    [StructLayout(LayoutKind.Sequential)]
1424    private struct DXGI_ADAPTER_DESC {
1425        public uint VendorId; public uint DeviceId; public uint SubSysId; public uint Revision;
1426        public ulong DedicatedVideoMemory; public ulong DedicatedSystemMemory; public ulong SharedSystemMemory;
1427        [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string Description;
1428    }
1429    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1430    private delegate int EnumAdaptersDelegate(IntPtr factory, uint index, out IntPtr adapter);
1431    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
1432    private delegate int GetDescDelegate(IntPtr adapter, out DXGI_ADAPTER_DESC desc);
1433}
1434"@
1435[DXGIVram]::GetVRAM()
1436"#;
1437    let output = Command::new("powershell")
1438        .args(["-NoProfile", "-NonInteractive", "-Command", script])
1439        .output()
1440        .ok()?;
1441    if !output.status.success() {
1442        return None;
1443    }
1444    let stdout = String::from_utf8_lossy(&output.stdout);
1445    stdout.trim().parse::<f32>().ok().filter(|&v| v > 0.0)
1446}
1447
1448#[cfg(target_os = "linux")]
1449fn probe_lspci_gpu() -> Option<GpuInfo> {
1450    let output = Command::new("lspci").args(["-mm"]).output().ok()?;
1451    if !output.status.success() {
1452        return None;
1453    }
1454    let stdout = String::from_utf8_lossy(&output.stdout);
1455
1456    let line = stdout
1457        .lines()
1458        .find(|l| l.contains("VGA compatible controller") || l.contains("3D controller"))?;
1459    let quoted: Vec<String> = line
1460        .split('"')
1461        .enumerate()
1462        .filter_map(|(i, s)| {
1463            if i % 2 == 1 {
1464                let t = s.trim();
1465                if t.is_empty() {
1466                    None
1467                } else {
1468                    Some(t.to_string())
1469                }
1470            } else {
1471                None
1472            }
1473        })
1474        .collect();
1475    let description = quoted
1476        .get(2)
1477        .cloned()
1478        .or_else(|| quoted.last().cloned())
1479        .unwrap_or_else(|| line.trim().to_string());
1480
1481    let compute_cap = super::host::infer_compute_capability_from_name(&description);
1482    Some(GpuInfo {
1483        name: description,
1484        vram_gb: 0.0,
1485        backend: super::host::GpuBackend::Vulkan,
1486        compute_capability: compute_cap,
1487        driver_version: String::from("lspci"),
1488        pci_address: line.split(' ').next().map(|s| s.trim().to_string()),
1489    })
1490}
1491
1492#[cfg(target_os = "linux")]
1493fn probe_sysfs_gpu() -> Option<GpuInfo> {
1494    let drm_root = Path::new("/sys/class/drm");
1495    let entries = fs::read_dir(drm_root).ok()?;
1496
1497    for entry in entries.flatten() {
1498        let path = entry.path();
1499        let fname = path.file_name()?.to_string_lossy().to_string();
1500        if !fname.starts_with("card") {
1501            continue;
1502        }
1503
1504        let dev = path.join("device");
1505        if !dev.exists() {
1506            continue;
1507        }
1508
1509        let gpu_name = fs::read_to_string(dev.join("product_name"))
1510            .ok()
1511            .map(|v| v.trim().to_string())
1512            .filter(|v| !v.is_empty());
1513        let vram_bytes = fs::read_to_string(dev.join("mem_info_vram_total"))
1514            .ok()
1515            .and_then(|v| v.trim().parse::<f32>().ok());
1516        let vram_gb = vram_bytes.map(|b| b / 1024.0 / 1024.0 / 1024.0);
1517
1518        if let Some(name) = gpu_name {
1519            let compute_cap = super::host::infer_compute_capability_from_name(&name);
1520            return Some(GpuInfo {
1521                name,
1522                vram_gb: vram_gb.unwrap_or(0.0),
1523                backend: super::host::GpuBackend::Vulkan,
1524                compute_capability: compute_cap,
1525                driver_version: String::from("sysfs"),
1526                pci_address: None,
1527            });
1528        }
1529    }
1530    None
1531}
1532
1533#[cfg(target_os = "macos")]
1534fn probe_macos_gpu() -> Option<GpuInfo> {
1535    // Fast path: try `system_profiler SPDisplaysDataType -detailLevel mini` first
1536    let output = Command::new("system_profiler")
1537        .args(["SPDisplaysDataType", "-detailLevel", "mini"])
1538        .output()
1539        .ok()?;
1540    if !output.status.success() {
1541        return None;
1542    }
1543
1544    let stdout = String::from_utf8_lossy(&output.stdout);
1545    let mut name = String::from("Apple GPU");
1546    let mut vram_gb = 0.0;
1547    let mut metal_support = String::from("metal");
1548    let mut gpu_metal_family = String::new();
1549
1550    for line in stdout.lines() {
1551        if line.contains("Chipset Model:") {
1552            if let Some(val) = line.split(':').nth(1) {
1553                name = val.trim().to_string();
1554            }
1555        }
1556        // "VRAM (Total): 16 GB" or "VRAM (Dynamic, Max): 16 GB"
1557        if line.contains("VRAM") {
1558            let val = line.split(':').nth(1).unwrap_or("").trim();
1559            if let Some(num_str) = val.split_whitespace().next() {
1560                if let Ok(num) = num_str.parse::<f32>() {
1561                    vram_gb = num;
1562                }
1563            }
1564        }
1565        if line.contains("Metal Support") {
1566            if let Some(val) = line.split(':').nth(1) {
1567                metal_support = val.trim().to_string();
1568            }
1569        }
1570        if line.contains("Metal Family") {
1571            if let Some(val) = line.split(':').nth(1) {
1572                gpu_metal_family = val.trim().to_string();
1573            }
1574        }
1575    }
1576
1577    // On Apple Silicon, shared memory wasn't exported by system_profiler;
1578    // query the IOKit registry for accurate GPU memory.
1579    if vram_gb <= 0.0 && super::host::is_apple_silicon() {
1580        if let Ok(ioreg) = Command::new("ioreg")
1581            .args(["-r", "-c", "IOAccelerator", "-l"])
1582            .output()
1583        {
1584            let iotext = String::from_utf8_lossy(&ioreg.stdout);
1585            for line in iotext.lines() {
1586                // "gpu-core-count" or "performanceState" indicate Apple GPU
1587                if line.contains("\"gpu-core-count\"") {
1588                    // Try "memory-available" which reports shared GPU memory
1589                    if let Some(mem_line) =
1590                        iotext.lines().find(|l| l.contains("\"memory-available\""))
1591                    {
1592                        if let Some(val) = mem_line.split('=').nth(1) {
1593                            if let Ok(bytes) = val.trim().trim_matches('"').parse::<f64>() {
1594                                vram_gb = (bytes / 1_073_741_824.0) as f32;
1595                            }
1596                        }
1597                    }
1598                    break;
1599                }
1600            }
1601            // Fallback: total system memory / 2 as rough ANE+GPU shared estimate
1602            if vram_gb <= 0.0 {
1603                vram_gb = detect_system_memory_gb().unwrap_or(16.0) * 0.5;
1604            }
1605        }
1606    }
1607
1608    // Build compute capability string from Metal info
1609    let compute_capability = if !metal_support.is_empty() && metal_support != "metal" {
1610        metal_support
1611    } else if !gpu_metal_family.is_empty() {
1612        format!("apple_metal/{}", gpu_metal_family)
1613    } else {
1614        String::from("apple_metal")
1615    };
1616
1617    Some(GpuInfo {
1618        name,
1619        vram_gb,
1620        backend: super::host::GpuBackend::Metal,
1621        compute_capability,
1622        driver_version: String::from("metal"),
1623        pci_address: None,
1624    })
1625}
1626
1627fn probe_vulkan_gpu() -> Option<GpuInfo> {
1628    // Try `vulkaninfo` (cross-platform, part of Vulkan SDK)
1629    if let Ok(output) = Command::new("vulkaninfo").args(["--summary"]).output() {
1630        if output.status.success() {
1631            let stdout = String::from_utf8_lossy(&output.stdout);
1632            for line in stdout.lines() {
1633                if line.contains("GPU id") || line.contains("deviceName") {
1634                    let name = line
1635                        .split(':')
1636                        .nth(1)
1637                        .map(|s| s.trim().to_string())
1638                        .unwrap_or_else(|| String::from("Vulkan GPU"));
1639                    let compute_cap = super::host::infer_compute_capability_from_name(&name);
1640                    return Some(GpuInfo {
1641                        name,
1642                        vram_gb: 0.0,
1643                        backend: super::host::GpuBackend::Vulkan,
1644                        compute_capability: compute_cap,
1645                        driver_version: String::from("vulkan"),
1646                        pci_address: None,
1647                    });
1648                }
1649            }
1650        }
1651    }
1652    None
1653}
1654
1655// ---------------------------------------------------------------------------
1656// NPU detection
1657// ---------------------------------------------------------------------------
1658
1659fn detect_npus() -> Vec<NpuInfo> {
1660    let mut npus = Vec::new();
1661
1662    // Windows WMI
1663    #[cfg(target_os = "windows")]
1664    {
1665        if let Ok(output) = Command::new("powershell")
1666            .args([
1667                "-NoProfile",
1668                "-Command",
1669                "Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)(NPU|Neural Processor|AI Accelerator|XDNA|Ryzen AI|Intel NPU)' } | Select-Object -ExpandProperty Name",
1670            ])
1671            .output()
1672        {
1673            if output.status.success() {
1674                let stdout = String::from_utf8_lossy(&output.stdout);
1675                for line in stdout.lines() {
1676                    let trimmed = line.trim();
1677                    if !trimmed.is_empty() {
1678                        npus.push(NpuInfo {
1679                            name: trimmed.to_string(),
1680                            memory_gb: 2.0, // typical
1681                            driver: String::from("windows"),
1682                        });
1683                    }
1684                }
1685            }
1686        }
1687    }
1688
1689    // Linux sysfs
1690    #[cfg(target_os = "linux")]
1691    {
1692        let npu_paths = ["/sys/devices/virtual/npu", "/sys/bus/auxiliary/devices"];
1693        for base in &npu_paths {
1694            if let Ok(entries) = fs::read_dir(base) {
1695                for entry in entries.flatten() {
1696                    let name = entry.file_name().to_string_lossy().to_string();
1697                    if name.contains("npu") || name.contains("xdna") || name.contains("ivpu") {
1698                        npus.push(NpuInfo {
1699                            name,
1700                            memory_gb: 2.0,
1701                            driver: String::from("linux"),
1702                        });
1703                    }
1704                }
1705            }
1706        }
1707        // Check for Intel NPU via /sys/class/accel
1708        if let Ok(entries) = fs::read_dir("/sys/class/accel") {
1709            for entry in entries.flatten() {
1710                let name = entry.file_name().to_string_lossy().to_string();
1711                npus.push(NpuInfo {
1712                    name: format!("accel/{}", name),
1713                    memory_gb: 2.0,
1714                    driver: String::from("linux"),
1715                });
1716            }
1717        }
1718    }
1719
1720    // macOS: check for ANE via sysctl
1721    #[cfg(target_os = "macos")]
1722    {
1723        if super::host::is_apple_silicon() {
1724            npus.push(NpuInfo {
1725                name: String::from("Apple Neural Engine"),
1726                memory_gb: 0.0, // shared memory
1727                driver: String::from("ane"),
1728            });
1729        }
1730    }
1731
1732    // Env override
1733    if (env::var("NPU_DEVICE").is_ok() || env::var("QUALCOMM_NPU").is_ok()) && npus.is_empty() {
1734        npus.push(NpuInfo {
1735            name: String::from("npu-env"),
1736            memory_gb: 2.0,
1737            driver: String::from("env"),
1738        });
1739    }
1740
1741    npus
1742}
1743
1744// ---------------------------------------------------------------------------
1745// Network detection
1746// ---------------------------------------------------------------------------
1747
1748fn detect_network_info() -> NetworkInfo {
1749    let xdp_supported = cfg!(target_os = "linux");
1750    let interfaces = detect_network_interfaces();
1751    NetworkInfo {
1752        xdp_supported,
1753        interfaces,
1754    }
1755}
1756
1757fn detect_network_interfaces() -> Vec<String> {
1758    let mut ifaces = Vec::new();
1759    if let Ok(entries) = fs::read_dir("/sys/class/net") {
1760        for entry in entries.flatten() {
1761            if let Ok(name) = entry.file_name().into_string() {
1762                if !name.starts_with("lo") {
1763                    ifaces.push(name);
1764                }
1765            }
1766        }
1767    }
1768    ifaces
1769}
1770
1771// ---------------------------------------------------------------------------
1772// Backend inference
1773// ---------------------------------------------------------------------------
1774
1775fn infer_backend(name: &str, compute_capability: &str) -> super::host::GpuBackend {
1776    use super::host::GpuBackend;
1777    let lowered = name.to_ascii_lowercase();
1778    let lowered_cc = compute_capability.to_ascii_lowercase();
1779
1780    if lowered_cc.contains("rocm")
1781        || lowered.contains("amd")
1782        || lowered.contains("radeon")
1783        || lowered.contains("instinct")
1784        || lowered.contains("mi250")
1785        || lowered.contains("mi300")
1786        || lowered.contains("mi350")
1787    {
1788        return GpuBackend::Rocm;
1789    }
1790
1791    if lowered_cc.contains("metal")
1792        || lowered.contains("apple")
1793        || lowered.contains("m1")
1794        || lowered.contains("m2")
1795        || lowered.contains("m3")
1796        || lowered.contains("m4")
1797    {
1798        return GpuBackend::Metal;
1799    }
1800
1801    if lowered_cc.contains("directml") || lowered.contains("directml") {
1802        return GpuBackend::Directml;
1803    }
1804
1805    if lowered_cc.contains("npu")
1806        || lowered.contains("npu")
1807        || lowered.contains("xdna")
1808        || lowered.contains("neural")
1809    {
1810        return GpuBackend::Npu;
1811    }
1812
1813    if lowered.contains("nvidia")
1814        || lowered.contains("rtx")
1815        || lowered.contains("gtx")
1816        || lowered.contains("tesla")
1817        || lowered.contains("a100")
1818        || lowered.contains("h100")
1819        || lowered.contains("b100")
1820        || lowered.contains("b200")
1821        || lowered_cc
1822            .chars()
1823            .next()
1824            .is_some_and(|c| c.is_ascii_digit())
1825    {
1826        return GpuBackend::Cuda;
1827    }
1828
1829    GpuBackend::Vulkan
1830}
1831
1832// ---------------------------------------------------------------------------
1833// Acceleration mode
1834// ---------------------------------------------------------------------------
1835
1836fn pick_acceleration_mode(gpus: &[GpuInfo], cpu: &CpuInfo) -> super::host::AccelerationMode {
1837    use super::host::AccelerationMode;
1838
1839    // Any real GPU detected → GPU mode
1840    if gpus.iter().any(|g| {
1841        g.vram_gb > 0.0
1842            || (g.backend != super::host::GpuBackend::Cpu
1843                && g.backend != super::host::GpuBackend::Vulkan)
1844    }) {
1845        return AccelerationMode::Gpu;
1846    }
1847
1848    // Vulkan-only with pending VRAM (lspci/sysfs probe, no driver)
1849    if gpus.iter().any(|g| {
1850        g.backend == super::host::GpuBackend::Vulkan && !g.name.is_empty() && g.name != "cpu"
1851    }) {
1852        return AccelerationMode::Gpu;
1853    }
1854
1855    // CPU features
1856    if cpu.features.avx_512 {
1857        return AccelerationMode::Avx512;
1858    }
1859    if cpu.features.avx2 {
1860        return AccelerationMode::Avx2;
1861    }
1862    if cpu.features.neon || cpu.features.sve {
1863        return AccelerationMode::Neon;
1864    }
1865
1866    AccelerationMode::Generic
1867}
1868
1869// ---------------------------------------------------------------------------
1870// Worker count recommendation
1871// ---------------------------------------------------------------------------
1872
1873fn compute_recommended_workers(
1874    cpu: &CpuInfo,
1875    memory: &MemoryInfo,
1876    gpus: &[GpuInfo],
1877    accel: super::host::AccelerationMode,
1878) -> usize {
1879    let cores = cpu.logical_cores.max(1);
1880    let memory_bound = if memory.total_gb >= 1.0 {
1881        (memory.total_gb / 4.0).floor() as usize
1882    } else {
1883        1
1884    }
1885    .max(1);
1886
1887    let reserved_core = cores.saturating_sub(1).max(1);
1888
1889    // Multi-GPU bonus
1890    let gpu_count = gpus.iter().filter(|g| g.vram_gb > 0.0).count();
1891    let accelerator_bonus = match accel {
1892        super::host::AccelerationMode::Gpu => {
1893            let vram_bonus = if gpus.iter().any(|g| g.vram_gb >= 16.0) {
1894                2
1895            } else {
1896                1
1897            };
1898            vram_bonus + gpu_count.saturating_sub(1) // +1 per extra GPU
1899        }
1900        super::host::AccelerationMode::Avx512 => 1,
1901        _ => 0,
1902    };
1903
1904    reserved_core
1905        .min(memory_bound)
1906        .saturating_add(accelerator_bonus)
1907}
1908
1909// ---------------------------------------------------------------------------
1910// Re-export known inference helper for host.rs compat
1911// ---------------------------------------------------------------------------
1912
1913/// Re-exported for host.rs backward compatibility.
1914pub fn infer_compute_capability_from_name(name: &str) -> String {
1915    let lowered = name.to_ascii_lowercase();
1916    if lowered.contains("rtx 50")
1917        || lowered.contains("rtx50")
1918        || lowered.contains("b100")
1919        || lowered.contains("b200")
1920    {
1921        String::from("9.0")
1922    } else if lowered.contains("rtx 40") || lowered.contains("rtx40") {
1923        String::from("8.9")
1924    } else if lowered.contains("rtx 30") || lowered.contains("rtx30") {
1925        String::from("8.6")
1926    } else if lowered.contains("arc") {
1927        String::from("xe")
1928    } else if lowered.contains("radeon")
1929        || lowered.contains("amd")
1930        || lowered.contains("advanced micro")
1931        || lowered.contains("rx ")
1932        || lowered.contains("w7900")
1933        || lowered.contains("w6800")
1934        || lowered.contains("mi250")
1935        || lowered.contains("mi300")
1936        || lowered.contains("mi350")
1937        || lowered.contains("instinct")
1938    {
1939        if cfg!(feature = "rocm") {
1940            String::from("rocm")
1941        } else {
1942            String::from("gpu")
1943        }
1944    } else if lowered.contains("intel") || lowered.contains("iris") || lowered.contains("uhd") {
1945        String::from("xe")
1946    } else if lowered.contains("apple")
1947        || lowered.contains("m1")
1948        || lowered.contains("m2")
1949        || lowered.contains("m3")
1950        || lowered.contains("m4")
1951    {
1952        String::from("apple_metal")
1953    } else {
1954        String::from("gpu")
1955    }
1956}
1957
1958// ---------------------------------------------------------------------------
1959// Tests
1960// ---------------------------------------------------------------------------
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965    use crate::GpuBackend;
1966
1967    #[test]
1968    fn system_profile_detects_cpu() {
1969        let sp = SystemProfile::detect_fast();
1970        assert!(sp.cpu.logical_cores >= 1);
1971        assert!(!sp.cpu.arch.is_empty());
1972    }
1973
1974    /// Builds one synthetic `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX`
1975    /// record with `Relationship = RelationProcessorCore` (0) and the given
1976    /// `EfficiencyClass`. The exact record length doesn't need to match a
1977    /// real Windows-supplied buffer byte-for-byte — `parse_processor_core_relationships`
1978    /// only ever trusts each record's own `Size` field to advance, never an
1979    /// assumption about a fixed struct size, so any self-consistent length
1980    /// here exercises the real parsing logic faithfully.
1981    fn synthetic_core_record(efficiency_class: u8) -> Vec<u8> {
1982        let total_size: u32 = 48;
1983        let mut rec = vec![0u8; total_size as usize];
1984        rec[0..4].copy_from_slice(&0u32.to_ne_bytes()); // RelationProcessorCore
1985        rec[4..8].copy_from_slice(&total_size.to_ne_bytes());
1986        rec[8] = 0; // PROCESSOR_RELATIONSHIP.Flags
1987        rec[9] = efficiency_class;
1988        rec
1989    }
1990
1991    fn synthetic_non_core_record(relationship: u32, size: u32) -> Vec<u8> {
1992        let mut rec = vec![0u8; size as usize];
1993        rec[0..4].copy_from_slice(&relationship.to_ne_bytes());
1994        rec[4..8].copy_from_slice(&size.to_ne_bytes());
1995        rec
1996    }
1997
1998    #[test]
1999    fn parse_processor_core_relationships_detects_hybrid_split() {
2000        let mut buf = Vec::new();
2001        for _ in 0..6 {
2002            buf.extend(synthetic_core_record(1)); // performance cores
2003        }
2004        for _ in 0..8 {
2005            buf.extend(synthetic_core_record(0)); // efficiency cores
2006        }
2007        assert_eq!(parse_processor_core_relationships(&buf), (Some(6), Some(8)));
2008    }
2009
2010    #[test]
2011    fn parse_processor_core_relationships_returns_none_for_uniform_cpu() {
2012        let mut buf = Vec::new();
2013        for _ in 0..8 {
2014            buf.extend(synthetic_core_record(0));
2015        }
2016        assert_eq!(parse_processor_core_relationships(&buf), (None, None));
2017    }
2018
2019    #[test]
2020    fn parse_processor_core_relationships_handles_empty_buffer() {
2021        assert_eq!(parse_processor_core_relationships(&[]), (None, None));
2022    }
2023
2024    #[test]
2025    fn parse_processor_core_relationships_stops_at_malformed_record_without_panicking() {
2026        let mut buf = Vec::new();
2027        buf.extend(synthetic_core_record(1));
2028        buf.extend(synthetic_core_record(0));
2029        // Malformed trailing record: a full 8-byte header claiming
2030        // Relationship = RelationProcessorCore and Size = 100, but no
2031        // payload actually follows in the buffer. Must not read out of
2032        // bounds or panic — the two valid records already parsed should
2033        // still be used.
2034        let mut bad_tail = vec![0u8; 8];
2035        bad_tail[0..4].copy_from_slice(&0u32.to_ne_bytes());
2036        bad_tail[4..8].copy_from_slice(&100u32.to_ne_bytes());
2037        buf.extend(bad_tail);
2038        assert_eq!(parse_processor_core_relationships(&buf), (Some(1), Some(1)));
2039    }
2040
2041    #[test]
2042    fn parse_processor_core_relationships_ignores_non_core_relationships() {
2043        let mut buf = Vec::new();
2044        buf.extend(synthetic_non_core_record(1, 16)); // RelationNumaNode, irrelevant
2045        buf.extend(synthetic_core_record(1));
2046        buf.extend(synthetic_core_record(0));
2047        assert_eq!(parse_processor_core_relationships(&buf), (Some(1), Some(1)));
2048    }
2049
2050    #[test]
2051    fn system_profile_detects_memory() {
2052        let sp = SystemProfile::detect_fast();
2053        // Memory should be >= 0.5 GB on any real system
2054        assert!(sp.memory.total_gb >= 0.5 || sp.memory.total_gb == 0.0);
2055    }
2056
2057    #[test]
2058    fn system_profile_always_has_at_least_one_gpu_entry() {
2059        let sp = SystemProfile::detect_fast();
2060        assert!(
2061            !sp.gpus.is_empty(),
2062            "Should have at least 'cpu' fallback GPU entry"
2063        );
2064    }
2065
2066    #[test]
2067    fn system_profile_converts_to_runtime_profile() {
2068        let sp = SystemProfile::detect_fast();
2069        let rp: crate::host::RuntimeProfile = (&sp).into();
2070        assert_eq!(rp.logical_cores, sp.cpu.logical_cores);
2071        assert_eq!(rp.recommended_workers, sp.recommended_workers);
2072    }
2073
2074    // Both scenarios below live in one test (rather than two separate #[test]
2075    // fns) because they mutate process-global env vars — Rust runs tests in
2076    // parallel by default, and a second test setting/clearing the same vars
2077    // races with this one regardless of what either asserts.
2078    #[test]
2079    fn detect_gpu_handles_env_overrides() {
2080        std::env::set_var("GHOSTLINK_GPU_NAME", "RTX 4090");
2081        std::env::set_var("GHOSTLINK_VRAM_GB", "24.0");
2082        std::env::set_var("GHOSTLINK_COMPUTE_CAPABILITY", "8.9");
2083
2084        let gpu = detect_gpu_from_env().expect("env probe should work");
2085        assert_eq!(gpu.name, "RTX 4090");
2086        assert!((gpu.vram_gb - 24.0).abs() < 0.1);
2087        assert_eq!(gpu.compute_capability, "8.9");
2088
2089        std::env::remove_var("GHOSTLINK_GPU_NAME");
2090        std::env::remove_var("GHOSTLINK_VRAM_GB");
2091        std::env::remove_var("GHOSTLINK_COMPUTE_CAPABILITY");
2092
2093        // launch.sh always exports GHOSTLINK_VRAM_GB (defaulting to "0" on
2094        // CPU-only hosts). That alone must not be treated as a GPU override,
2095        // or CPU-only hosts would falsely report GPU acceleration available.
2096        std::env::set_var("GHOSTLINK_VRAM_GB", "0");
2097        assert!(detect_gpu_from_env().is_none());
2098        std::env::remove_var("GHOSTLINK_VRAM_GB");
2099    }
2100
2101    #[test]
2102    fn infer_backend_rocm() {
2103        let b = infer_backend("AMD Radeon RX 7900 XTX", "rocm");
2104        assert_eq!(b, GpuBackend::Rocm);
2105    }
2106
2107    #[test]
2108    fn infer_backend_cuda() {
2109        let b = infer_backend("NVIDIA GeForce RTX 4090", "8.9");
2110        assert_eq!(b, GpuBackend::Cuda);
2111    }
2112
2113    #[test]
2114    fn infer_backend_metal() {
2115        let b = infer_backend("Apple M3 Max", "apple_metal");
2116        assert_eq!(b, GpuBackend::Metal);
2117    }
2118
2119    #[test]
2120    fn compute_workers_scales_with_gpus() {
2121        let cpu = CpuInfo {
2122            logical_cores: 16,
2123            physical_cores: 8,
2124            arch: "x86_64".into(),
2125            ..Default::default()
2126        };
2127        let mem = MemoryInfo {
2128            total_gb: 64.0,
2129            available_gb: 32.0,
2130        };
2131        let gpus = vec![
2132            GpuInfo {
2133                name: "RTX 4090".into(),
2134                vram_gb: 24.0,
2135                backend: GpuBackend::Cuda,
2136                compute_capability: "8.9".into(),
2137                driver_version: "555".into(),
2138                pci_address: None,
2139            },
2140            GpuInfo {
2141                name: "RTX 3090".into(),
2142                vram_gb: 24.0,
2143                backend: GpuBackend::Cuda,
2144                compute_capability: "8.6".into(),
2145                driver_version: "555".into(),
2146                pci_address: None,
2147            },
2148        ];
2149        let accel = pick_acceleration_mode(&gpus, &cpu);
2150        let workers = compute_recommended_workers(&cpu, &mem, &gpus, accel);
2151        // 15 (reserved from 16 cores) min 16 (mem/4=16) = 15, + VGPU bonus (2) + extra GPU (1) = 18
2152        assert!(workers > 0);
2153    }
2154
2155    #[test]
2156    fn physical_cores_nonzero() {
2157        let cpu = detect_cpu_info();
2158        assert!(cpu.physical_cores >= 1);
2159    }
2160
2161    #[cfg(target_os = "windows")]
2162    #[test]
2163    fn sum_windows_cim_core_counts_handles_multi_socket_output() {
2164        // Get-CimInstance emits one line per physical CPU package on
2165        // multi-socket hosts. The old code `return`ed on the first parseable
2166        // line, undercounting total physical cores on such hosts.
2167        assert_eq!(sum_windows_cim_core_counts("16\n16\n"), 32);
2168        assert_eq!(sum_windows_cim_core_counts("24\n"), 24);
2169        assert_eq!(sum_windows_cim_core_counts(""), 0);
2170        // Tolerate stray blank/non-numeric lines some locales/tools emit.
2171        assert_eq!(sum_windows_cim_core_counts("8\n\n8\n"), 16);
2172    }
2173
2174    #[cfg(target_arch = "x86_64")]
2175    #[test]
2176    fn amx_tile_detection_does_not_panic() {
2177        // Exercises the raw CPUID leaf-7 path. The actual bit value is
2178        // hardware-dependent; this guards against a panic/miscount on hosts
2179        // whose CPUID max leaf is below 7.
2180        let _ = detect_amx_tile();
2181    }
2182}