keyhog_scanner/hw_probe/mod.rs
1//! Hardware capability probing with once-cached results.
2//!
3//! Detects CPU features (AVX-512, AVX2, NEON), GPU compute (wgpu/Vulkan),
4//! Hyperscan availability, io_uring support, memory, and core counts.
5//! All detection is done once at startup and cached for the process
6//! lifetime.
7//!
8//! Split into focused submodules by hardware-probe responsibility:
9//!
10//! * `thresholds` - GPU routing crossover constants consumed through
11//! the public tier lookup functions.
12//! * [`tier`] - GPU adapter classification + tier threshold profiles.
13//! * [`select`] - [`select_backend`] routing logic + env-override
14//! parsing.
15//! * [`banner`] - `startup_banner` formatter for the CLI header.
16//! * [`platform`] - per-OS detection of physical cores, memory,
17//! and io_uring availability.
18
19use std::sync::OnceLock;
20
21mod banner;
22pub(crate) mod platform;
23pub(crate) mod select;
24mod tier;
25
26pub(crate) mod thresholds;
27
28pub use banner::startup_banner;
29pub use select::{
30 gpu_could_engage, parse_backend_str, select_backend, select_backend_verdict,
31 BackendRoutingReason, BackendRoutingVerdict, BACKEND_OVERRIDE_VALUES,
32};
33pub use tier::{gpu_routing_profile, gpu_routing_profiles, GpuRoutingProfile};
34
35/// Scan execution backend selected for a given workload.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub enum ScanBackend {
39 /// GPU region-presence phase 1 through VYRE's CUDA driver.
40 GpuCuda,
41 /// GPU region-presence phase 1 through VYRE's WGPU driver.
42 GpuWgpu,
43 /// Hyperscan NFA multi-pattern matching + SIMD prefilter.
44 /// This is the primary high-throughput path on all platforms.
45 SimdCpu,
46 /// Pure CPU: Aho-Corasick literals plus Rust regex extraction. No
47 /// Hyperscan or GPU execution.
48 CpuFallback,
49}
50
51impl ScanBackend {
52 /// Stable label for logs and CLI startup banner.
53 #[must_use]
54 pub fn label(self) -> &'static str {
55 match self {
56 Self::GpuCuda => "gpu-cuda-region-presence",
57 Self::GpuWgpu => "gpu-wgpu-region-presence",
58 Self::SimdCpu => "simd-regex",
59 Self::CpuFallback => "cpu-fallback",
60 }
61 }
62
63 /// Whether this route executes on a physical GPU.
64 #[must_use]
65 pub const fn is_gpu(self) -> bool {
66 matches!(self, Self::GpuCuda | Self::GpuWgpu)
67 }
68}
69
70/// True when this build compiled a scan backend beyond the always-present scalar
71/// [`ScanBackend::CpuFallback`]. Hyperscan (`simd`) and/or the GPU stack (`gpu`).
72///
73/// When this is `false` there is no routing choice: every scan can only run
74/// `CpuFallback`, so autoroute calibration is neither needed nor possible and the
75/// caller resolves the lone backend directly instead of failing closed. This is a
76/// COMPILE-time fact, owned here in the scanner where the `simd`/`gpu` feature gates
77/// actually live: a consumer's own feature flags can diverge (e.g. the CLI's
78/// `ci-lean` enables `keyhog-scanner/simd` without the CLI's own `simd` feature), so
79/// consumers MUST ask the scanner rather than checking their own `cfg!`.
80#[must_use]
81pub const fn multiple_backends_compiled() -> bool {
82 simd_backend_compiled() || gpu_backend_compiled()
83}
84
85/// True when this scanner crate was compiled with the Hyperscan/SIMD backend.
86/// Consumers must query this owner instead of their own Cargo feature namespace:
87/// workspace feature unification can enable a dependency backend without
88/// enabling a same-named feature on the consuming crate.
89#[must_use]
90pub const fn simd_backend_compiled() -> bool {
91 cfg!(feature = "simd")
92}
93
94/// Runtime identity of the dynamically linked Hyperscan/Vectorscan library.
95///
96/// Autoroute persistence must bind SIMD measurements to the library that
97/// actually executed them, not only to the fact that SIMD support compiled.
98#[must_use]
99pub fn hyperscan_runtime_identity() -> Option<String> {
100 #[cfg(feature = "simd")]
101 {
102 Some(hyperscan::version().to_string())
103 }
104 #[cfg(not(feature = "simd"))]
105 {
106 None
107 }
108}
109
110/// True when this scanner crate was compiled with the GPU backend stack.
111/// Autoroute host identity and persisted build evidence use this dependency-owned
112/// fact so a GPU-selected calibration can never be stored without GPU identity.
113#[must_use]
114pub const fn gpu_backend_compiled() -> bool {
115 cfg!(feature = "gpu")
116}
117
118/// Single owner of the SIMD-tier label precedence chain.
119///
120/// The label reported by the startup banner, `keyhog backend`, `keyhog doctor`,
121/// and the backend store must always agree, so the `"AVX-512" > "AVX2" > "NEON"
122/// > "scalar"` precedence lives in exactly ONE place. Callers pass the three
123/// probed CPU-feature booleans (typically `caps.has_avx512`, `caps.has_avx2`,
124/// `caps.has_neon`) and receive the highest-priority label that is available.
125///
126/// Precedence is strict and independent of the lower bits: if `has_avx512` is
127/// true the result is `"AVX-512"` regardless of the other two, and so on down
128/// to `"scalar"` when none are present.
129#[must_use]
130pub const fn simd_label(has_avx512: bool, has_avx2: bool, has_neon: bool) -> &'static str {
131 if has_avx512 {
132 "AVX-512"
133 } else if has_avx2 {
134 "AVX2"
135 } else if has_neon {
136 "NEON"
137 } else {
138 "scalar"
139 }
140}
141
142/// Hardware capabilities detected at startup.
143#[derive(Debug, Clone)]
144pub struct HardwareCaps {
145 pub physical_cores: usize,
146 pub logical_cores: usize,
147 pub has_avx2: bool,
148 pub has_avx512: bool,
149 pub has_neon: bool,
150 pub gpu_available: bool,
151 pub gpu_name: Option<String>,
152 /// WGPU's portable `max_buffer_size`, expressed in MiB and capped by
153 /// KeyHog. The historical field name does not mean physical VRAM; WGPU can
154 /// expose a virtual buffer limit larger than installed device memory.
155 pub gpu_vram_mb: Option<u64>,
156 pub gpu_runtime_identity: Option<String>,
157 /// True when the GPU is a software renderer (llvmpipe/lavapipe) - always slower than CPU.
158 pub gpu_is_software: bool,
159 pub total_memory_mb: Option<u64>,
160 pub io_uring_available: bool,
161 /// True when the `simd` feature is compiled in AND Hyperscan initialized.
162 pub hyperscan_available: bool,
163 /// Runtime identity of the Hyperscan/Vectorscan library that produced these
164 /// capabilities. `None` when `hyperscan_available` is false or the identity
165 /// has not been probed yet.
166 pub hyperscan_runtime_identity: Option<String>,
167}
168
169static HW_PROBE: OnceLock<HardwareCaps> = OnceLock::new();
170
171/// Probe hardware once and cache the result.
172pub fn probe_hardware() -> &'static HardwareCaps {
173 HW_PROBE.get_or_init(|| {
174 let logical_cores = std::thread::available_parallelism()
175 .map(|n| n.get())
176 .unwrap_or(1); // LAW10: host/OS hardware probe parse failure => None/conservative default; perf-only, recall-irrelevant
177 let physical_cores = platform::physical_core_count().unwrap_or(logical_cores); // LAW10: host/OS hardware probe parse failure => None/conservative default; perf-only, recall-irrelevant
178
179 #[cfg(target_arch = "x86_64")]
180 let (has_avx2, has_avx512, has_neon) = (
181 std::arch::is_x86_feature_detected!("avx2"),
182 std::arch::is_x86_feature_detected!("avx512f"),
183 false,
184 );
185 #[cfg(target_arch = "aarch64")]
186 let (has_avx2, has_avx512, has_neon) = (false, false, true);
187 #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
188 let (has_avx2, has_avx512, has_neon) = (false, false, false);
189
190 let gpu_probe = crate::gpu::gpu_probe();
191 let gpu_available = gpu_probe.available;
192 let gpu_name = gpu_probe.name;
193 let gpu_vram_mb = gpu_probe.buffer_limit_mb;
194 let gpu_runtime_identity = gpu_probe.runtime_identity;
195 let gpu_is_software = gpu_probe.is_software;
196 if gpu_is_software {
197 tracing::warn!(
198 gpu = ?gpu_name,
199 "Software GPU detected: GPU scanning disabled (slower than CPU)"
200 );
201 }
202
203 let hyperscan_available = cfg!(feature = "simd");
204 let hyperscan_runtime_identity = hyperscan_available
205 .then(hyperscan_runtime_identity)
206 .flatten();
207 let total_memory_mb = platform::detect_total_memory_mb();
208 let io_uring_available = platform::detect_io_uring();
209
210 let caps = HardwareCaps {
211 physical_cores,
212 logical_cores,
213 has_avx2,
214 has_avx512,
215 has_neon,
216 gpu_available,
217 gpu_name: gpu_name.clone(),
218 gpu_vram_mb,
219 gpu_runtime_identity,
220 gpu_is_software,
221 total_memory_mb,
222 io_uring_available,
223 hyperscan_available,
224 hyperscan_runtime_identity,
225 };
226
227 tracing::info!(
228 physical_cores,
229 logical_cores,
230 gpu_available,
231 gpu_name = ?gpu_name,
232 has_avx512 = caps.has_avx512,
233 has_avx2 = caps.has_avx2,
234 has_neon = caps.has_neon,
235 hyperscan = hyperscan_available,
236 io_uring = io_uring_available,
237 "hardware probe complete"
238 );
239
240 caps
241 })
242}
243
244#[cfg(test)]
245#[doc(hidden)]
246pub mod testing {
247 pub use super::{
248 gpu_could_engage, parse_backend_str, probe_hardware, select_backend,
249 select_backend_verdict, startup_banner, BackendRoutingReason, BackendRoutingVerdict,
250 HardwareCaps, ScanBackend,
251 };
252
253 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
254 pub enum GpuTier {
255 High,
256 Mid,
257 Low,
258 }
259
260 fn from_inner(tier: super::tier::GpuTier) -> GpuTier {
261 match tier {
262 super::tier::GpuTier::High => GpuTier::High,
263 super::tier::GpuTier::Mid => GpuTier::Mid,
264 super::tier::GpuTier::Low => GpuTier::Low,
265 }
266 }
267
268 fn to_inner(tier: GpuTier) -> super::tier::GpuTier {
269 match tier {
270 GpuTier::High => super::tier::GpuTier::High,
271 GpuTier::Mid => super::tier::GpuTier::Mid,
272 GpuTier::Low => super::tier::GpuTier::Low,
273 }
274 }
275
276 /// Select the CPU scan backend (SIMD/scalar tier) for the given hardware
277 /// capabilities, ignoring any GPU. Delegates to [`super::select`].
278 pub fn cpu_tier_backend(caps: &HardwareCaps) -> ScanBackend {
279 super::select::cpu_tier_backend(caps)
280 }
281
282 /// Classify a GPU adapter name into a routing [`GpuTier`] (High/Mid/Low).
283 /// `None` (no adapter) classifies to the lowest tier.
284 pub fn classify_gpu_tier(adapter_name: Option<&str>) -> GpuTier {
285 from_inner(super::tier::classify_gpu_tier(adapter_name))
286 }
287
288 /// Minimum workload size (bytes) at which the GPU backend is allowed to
289 /// engage at all for this tier (below it, CPU always wins).
290 pub fn gpu_min_bytes_for_tier(tier: GpuTier) -> u64 {
291 super::tier::gpu_min_bytes_for_tier(to_inner(tier))
292 }
293
294 /// Minimum workload size (bytes) at which the GPU runs *solo* (no CPU
295 /// co-scan) for this tier.
296 pub fn gpu_solo_bytes_for_tier(tier: GpuTier) -> u64 {
297 super::tier::gpu_solo_bytes_for_tier(to_inner(tier))
298 }
299
300 /// Pattern-count break-even above which GPU scanning beats CPU at this tier.
301 pub fn gpu_pattern_breakeven_for_tier(tier: GpuTier) -> usize {
302 super::tier::gpu_pattern_breakeven_for_tier(to_inner(tier))
303 }
304
305 /// Choose the scan backend for a batch from hardware caps, total workload
306 /// size, pattern count, and the largest single-chunk size.
307 pub fn select_backend_for_batch(
308 caps: &HardwareCaps,
309 workload_bytes: u64,
310 pattern_count: usize,
311 large_chunk_bytes: u64,
312 ) -> ScanBackend {
313 super::select::select_backend_for_batch(
314 caps,
315 workload_bytes,
316 pattern_count,
317 large_chunk_bytes,
318 )
319 }
320
321 /// Like [`select_backend_for_batch`] but returns the full
322 /// [`BackendRoutingVerdict`] (the chosen backend plus the inputs and reason
323 /// behind the decision) for diagnostics/telemetry.
324 pub fn select_backend_for_batch_verdict(
325 caps: &HardwareCaps,
326 workload_bytes: u64,
327 pattern_count: usize,
328 large_chunk_bytes: u64,
329 ) -> BackendRoutingVerdict {
330 super::select::select_backend_for_batch_verdict(
331 caps,
332 workload_bytes,
333 pattern_count,
334 large_chunk_bytes,
335 )
336 }
337
338 /// Test-only forced backend override (from the `KEYHOG_*` routing env), or
339 /// `None` when routing is not overridden.
340 pub fn forced_backend_override_for_test() -> Option<ScanBackend> {
341 super::select::forced_backend_override_for_test()
342 }
343
344 /// Parse the physical CPU core count from `/proc/cpuinfo` contents (Linux).
345 #[cfg(target_os = "linux")]
346 pub fn linux_physical_cores_from_cpuinfo(content: &str) -> Option<usize> {
347 super::platform::linux_physical_cores_from_cpuinfo(content)
348 }
349
350 /// Parse total system memory (MiB) from `/proc/meminfo` contents (Linux).
351 #[cfg(target_os = "linux")]
352 pub fn linux_total_memory_mb_from_meminfo(content: &str) -> Option<u64> {
353 super::platform::linux_total_memory_mb_from_meminfo(content)
354 }
355}