keyhog_scanner/gpu.rs
1//! GPU-accelerated batch inference for the MoE classifier via wgpu compute shaders.
2//!
3//! Processes N feature vectors in a single GPU dispatch, achieving ~10-100x
4//! throughput over CPU for large batches. Falls back to CPU when no GPU is
5//! available or for batches smaller than the crossover threshold.
6//!
7//! Architecture mirrors ml_scorer.rs exactly:
8//! - Gate: Linear(55→6) + softmax
9//! - 6 experts: Linear(55→32)+ReLU → Linear(32→16)+ReLU → Linear(16→1)
10//! - Output: sigmoid(weighted sum of expert logits)
11//!
12//! ## Feature-gating in the lean build
13//!
14//! Every entry point that would touch wgpu / vyre-driver-wgpu directly is
15//! wrapped in `#[cfg(feature = "gpu")]`. With the `gpu` feature off (the
16//! `cargo install keyhog --no-default-features --features ci` path), the
17//! GPU drivers aren't linked at all, the probe functions report "no GPU
18//! available" without ever calling into wgpu, and the self-test functions
19//! return a "not available in this build" `Err` instead of panicking.
20//! The CPU MoE path in `ml_scorer.rs` is the entire scoring story under
21//! that profile.
22
23// Both submodules lean on the wgpu device/queue + bytemuck cast helpers.
24// They only exist in `gpu`-on builds; the public API in this module
25// short-circuits to "no GPU" via the `cfg` arms below when off.
26// Submodules live in `gpu/` (native resolution), matching the `foo.rs` + `foo/`
27// layout used across the workspace. Module names (gpu_shader/backend/policy) are
28// unchanged; only the files moved (and gpu_moe_backend.rs/gpu_env.rs were
29// renamed to match their module names).
30#[cfg(feature = "gpu")]
31mod adapter_probe;
32#[cfg(feature = "gpu")]
33mod backend;
34#[cfg(feature = "gpu")]
35pub(crate) mod gpu_shader;
36
37mod policy;
38pub use policy::*;
39mod self_test;
40pub use self_test::*;
41
42#[cfg(feature = "gpu")]
43pub(crate) use adapter_probe::{
44 gpu_adapter_device_identity, gpu_adapter_probe, is_software_adapter,
45};
46
47/// Split timers: accumulated wall time in feature extraction vs MoE scoring
48/// across all batch ML inference calls. Only the SCORING fraction is
49/// GPU-offloadable; feature extraction is inherent per-candidate CPU work. This
50/// is the data that decides whether moving the MoE to a unified GPU batch is
51/// worth the recall cost of reordering finalization.
52static MOE_FEATURE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
53static MOE_SCORE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
54
55/// Gated by the unified scanner profile switch and dumped as part of
56/// [`crate::profile_dump`].
57#[cfg(feature = "ml")]
58fn ml_split_prof_enabled() -> bool {
59 crate::scan_profile::enabled()
60}
61
62/// Print + reset the feature-vs-score split. Folded into the unified profiler:
63/// called from [`crate::profile_dump`] (early-returns when no data).
64pub(crate) fn ml_split_profile_dump() {
65 use std::sync::atomic::Ordering::Relaxed;
66 let f = MOE_FEATURE_NS.swap(0, Relaxed) as f64 / 1e6;
67 let s = MOE_SCORE_NS.swap(0, Relaxed) as f64 / 1e6;
68 if f == 0.0 && s == 0.0 {
69 return;
70 }
71 eprintln!(
72 "=== ML split: feature_extract={f:.1}ms moe_score={s:.1}ms (score = {:.1}% of ML compute; \
73only this fraction is GPU-offloadable) ===",
74 100.0 * s / (f + s).max(1e-9),
75 );
76}
77
78pub(crate) fn ml_split_profile_reset() {
79 use std::sync::atomic::Ordering::Relaxed;
80 MOE_FEATURE_NS.store(0, Relaxed);
81 MOE_SCORE_NS.store(0, Relaxed);
82}
83
84#[cfg(all(test, feature = "ml", feature = "multiline"))]
85pub(crate) fn batch_ml_inference<T: crate::ml_scorer::MlScoreInput>(
86 candidates: &[T],
87 config: &crate::types::ScannerConfig,
88) -> Vec<f64> {
89 batch_ml_inference_with_timeout(
90 candidates,
91 config,
92 std::time::Duration::from_millis(
93 crate::scanner_config::ScannerTuningConfig::GPU_MOE_TIMEOUT_MS_DEFAULT,
94 ),
95 )
96}
97
98#[cfg(feature = "ml")]
99pub(crate) fn batch_ml_inference_with_timeout<T: crate::ml_scorer::MlScoreInput>(
100 candidates: &[T],
101 config: &crate::types::ScannerConfig,
102 gpu_moe_timeout: std::time::Duration,
103) -> Vec<f64> {
104 if candidates.is_empty() {
105 return Vec::new();
106 }
107
108 #[cfg(feature = "ml")]
109 {
110 use rayon::prelude::*;
111 #[cfg(not(feature = "gpu"))]
112 let _ = gpu_moe_timeout; // LAW10: cfg-only GPU timeout marker; ML CPU scoring ignores GPU dispatch timeout by construction
113 let prof = ml_split_prof_enabled();
114
115 // Single-chunk and windowed scans commonly produce only a handful of
116 // candidates. Coalesced scans aggregate pending rows across chunks
117 // before entering here, but any batch below the measured GPU crossover
118 // still avoids rayon split/join and GPU dispatch overhead through one
119 // fused serial feature-and-score loop.
120 if candidates.len() < crate::ml_scorer::GPU_BATCH_THRESHOLD {
121 // Small-batch fused serial path (the ~99% case).
122 let t = prof.then(std::time::Instant::now);
123 let scores = crate::ml_scorer::score_input_batch_serial(candidates, config);
124 if let Some(t) = t {
125 // Fused loop: attribute the whole cost to feature+score combined
126 // under MOE_SCORE_NS (kept separate from the large-batch split).
127 MOE_SCORE_NS.fetch_add(
128 t.elapsed().as_nanos() as u64,
129 std::sync::atomic::Ordering::Relaxed,
130 );
131 }
132 return scores;
133 }
134
135 // Large batch: parallel feature extraction, then GPU (or parallel CPU).
136 let t_feat = prof.then(std::time::Instant::now);
137 let features: Vec<[f32; crate::ml_scorer::NUM_FEATURES]> = candidates
138 .par_iter()
139 .map(|candidate| candidate.ml_features(config))
140 .collect();
141 if let Some(t) = t_feat {
142 MOE_FEATURE_NS.fetch_add(
143 t.elapsed().as_nanos() as u64,
144 std::sync::atomic::Ordering::Relaxed,
145 );
146 }
147
148 let t_score = prof.then(std::time::Instant::now);
149 let score_features_on_cpu =
150 || crate::ml_scorer::score_precomputed_batch_on_cpu(candidates, &features);
151 let scores = {
152 #[cfg(feature = "gpu")]
153 {
154 match backend::batch_score_features(&features, gpu_moe_timeout) {
155 Some(mut scores) if scores.len() == candidates.len() => {
156 crate::confidence::policy::apply_empty_candidate_score_policy(
157 candidates.iter().map(|candidate| candidate.ml_text()),
158 &mut scores,
159 );
160 scores
161 }
162 Some(scores) => {
163 // Defense in depth. `batch_score_features` OWNS the length
164 // invariant (backend.rs degrades + returns `None` when the
165 // GPU readback count != batch_size == features.len()), and
166 // this caller builds `features` one-per-candidate, so a
167 // `Some` whose length differs from `candidates` cannot occur
168 // via the real backend, this arm is unreachable today. Keep
169 // it fail-LOUD instead of a silent CPU fallback (Law 10): if
170 // a future backend change ever breaks that contract, route
171 // the degrade through the SAME owner as every other MoE
172 // dispatch failure (hard-fail under --require-gpu, one-shot
173 // eprintln otherwise) rather than a second hand-rolled warn.
174 debug_assert_eq!(
175 scores.len(),
176 candidates.len(),
177 "backend::batch_score_features must return one score per input"
178 );
179 backend::moe_runtime_degrade(&format!(
180 "caller-side score count mismatch: backend returned {} scores for {} candidates",
181 scores.len(),
182 candidates.len()
183 ));
184 score_features_on_cpu()
185 }
186 // `None` here is a genuine GPU dispatch failure that
187 // `batch_score_features` ALREADY degraded loudly (below-threshold
188 // `None` cannot occur: this branch only runs for large batches).
189 None => score_features_on_cpu(),
190 }
191 }
192 #[cfg(not(feature = "gpu"))]
193 {
194 score_features_on_cpu()
195 }
196 };
197 if let Some(t) = t_score {
198 MOE_SCORE_NS.fetch_add(
199 t.elapsed().as_nanos() as u64,
200 std::sync::atomic::Ordering::Relaxed,
201 );
202 }
203 scores
204 }
205
206 #[cfg(not(feature = "ml"))]
207 {
208 let _ = candidates; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
209 let _ = config; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
210 let _ = gpu_moe_timeout; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
211 Vec::new()
212 }
213}
214
215/// Return `true` when GPU scoring support is available in this build/runtime.
216///
217/// Honors the resolved runtime policy before touching the adapter path. A
218/// caller asking after `--no-gpu` must get the same cheap "not available"
219/// answer as `gpu_probe()` instead of triggering a wgpu adapter probe.
220///
221/// # Examples
222///
223/// ```rust
224/// use keyhog_scanner::gpu::gpu_available;
225/// let _ = gpu_available();
226/// ```
227pub fn gpu_available() -> bool {
228 gpu_probe().available
229}