Skip to main content

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;
32mod backend;
33#[cfg(all(test, feature = "gpu", target_os = "linux"))]
34pub(crate) use backend::load_dynamic_library;
35#[cfg(all(feature = "gpu", target_os = "linux"))]
36pub(crate) use backend::probe_cuda_peer;
37#[cfg(all(test, feature = "gpu"))]
38pub(crate) use backend::with_test_resident_dispatch_failure;
39pub use backend::GpuBackendAvailability;
40#[cfg(feature = "gpu")]
41pub(crate) use backend::{scan_gpu_literal_evidence_by_region_resident, GpuResidentLiteralSlot};
42pub(crate) use backend::{GpuBackendAcquisitionFailure, GpuBackendPeers};
43type RecoveryReceiptCounter = std::sync::Arc<std::sync::atomic::AtomicU64>;
44
45thread_local! {
46    static RECOVERY_RECEIPT_COUNTER: std::cell::RefCell<Option<RecoveryReceiptCounter>> =
47        const { std::cell::RefCell::new(None) };
48}
49
50struct RecoveryReceiptCounterGuard {
51    previous: Option<RecoveryReceiptCounter>,
52}
53
54impl Drop for RecoveryReceiptCounterGuard {
55    fn drop(&mut self) {
56        let previous = self.previous.take();
57        RECOVERY_RECEIPT_COUNTER.with_borrow_mut(|counter| {
58            *counter = previous;
59        });
60    }
61}
62
63pub(crate) fn capture_recovery_receipts() -> Option<RecoveryReceiptCounter> {
64    RECOVERY_RECEIPT_COUNTER.with_borrow(|counter| counter.clone())
65}
66
67pub(crate) fn with_captured_recovery_receipts<T>(
68    counter: Option<&RecoveryReceiptCounter>,
69    operation: impl FnOnce() -> T,
70) -> T {
71    let previous = RECOVERY_RECEIPT_COUNTER
72        .with_borrow_mut(|current| std::mem::replace(&mut *current, counter.cloned()));
73    let _guard = RecoveryReceiptCounterGuard { previous };
74    operation()
75}
76
77pub(crate) fn with_recovery_receipt_scope<T>(operation: impl FnOnce() -> T) -> (T, u64) {
78    let counter = RecoveryReceiptCounter::new(std::sync::atomic::AtomicU64::new(0));
79    let result = with_captured_recovery_receipts(Some(&counter), operation);
80    let receipts = counter.load(std::sync::atomic::Ordering::Relaxed);
81    (result, receipts)
82}
83
84pub(crate) fn record_recovery_receipt() {
85    RECOVERY_RECEIPT_COUNTER.with_borrow(|counter| {
86        if let Some(counter) = counter {
87            match counter.fetch_update(
88                std::sync::atomic::Ordering::Relaxed,
89                std::sync::atomic::Ordering::Relaxed,
90                |receipts| Some(receipts.saturating_add(1)),
91            ) {
92                Ok(_) => {}
93                // LAW10: impossible unconditional update rejection is surfaced loudly to
94                // stderr and tracing; no recovery receipt is silently dropped.
95                Err(_) => {
96                    eprintln!(
97                        "keyhog: recovery receipt counter rejected an unconditional saturating update"
98                    );
99                    tracing::error!(
100                        target: "keyhog::gpu",
101                        "recovery receipt counter rejected an unconditional saturating update"
102                    );
103                }
104            }
105        }
106    });
107}
108#[cfg(feature = "gpu")]
109pub(crate) mod gpu_shader;
110
111mod policy;
112pub use policy::*;
113mod self_test;
114pub use self_test::*;
115
116#[cfg(feature = "gpu")]
117pub(crate) use adapter_probe::{
118    gpu_adapter_device_identity, gpu_adapter_probe, is_software_adapter,
119};
120
121/// Split timers: accumulated wall time in feature extraction vs MoE scoring
122/// across all batch ML inference calls. Only the SCORING fraction is
123/// GPU-offloadable; feature extraction is inherent per-candidate CPU work. This
124/// is the data that decides whether moving the MoE to a unified GPU batch is
125/// worth the recall cost of reordering finalization.
126static MOE_FEATURE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
127static MOE_SCORE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
128
129/// Gated by the unified scanner profile switch and dumped as part of
130/// [`crate::profile_dump`].
131#[cfg(feature = "ml")]
132fn ml_split_prof_enabled() -> bool {
133    crate::scan_profile::enabled()
134}
135
136/// Print + reset the feature-vs-score split. Folded into the unified profiler:
137/// called from [`crate::profile_dump`] (early-returns when no data).
138pub(crate) fn ml_split_profile_dump() {
139    use std::sync::atomic::Ordering::Relaxed;
140    let f = MOE_FEATURE_NS.swap(0, Relaxed) as f64 / 1e6;
141    let s = MOE_SCORE_NS.swap(0, Relaxed) as f64 / 1e6;
142    if f == 0.0 && s == 0.0 {
143        return;
144    }
145    eprintln!(
146        "=== ML split: feature_extract={f:.1}ms moe_score={s:.1}ms (score = {:.1}% of ML compute; \
147only this fraction is GPU-offloadable) ===",
148        100.0 * s / (f + s).max(1e-9),
149    );
150}
151
152pub(crate) fn ml_split_profile_reset() {
153    use std::sync::atomic::Ordering::Relaxed;
154    MOE_FEATURE_NS.store(0, Relaxed);
155    MOE_SCORE_NS.store(0, Relaxed);
156}
157
158#[cfg(all(test, feature = "ml", feature = "multiline"))]
159pub(crate) fn batch_ml_inference<T: crate::ml_scorer::MlScoreInput>(
160    candidates: &[T],
161    config: &crate::types::ScannerConfig,
162) -> Vec<f64> {
163    match batch_ml_inference_with_timeout(
164        candidates,
165        config,
166        std::time::Duration::from_millis(
167            crate::scanner_config::ScannerTuningConfig::GPU_MOE_TIMEOUT_MS_DEFAULT,
168        ),
169    ) {
170        Ok(scores) => scores,
171        Err(error) => panic!("test GPU ML inference failed: {error}"),
172    }
173}
174
175#[cfg(feature = "ml")]
176pub(crate) fn batch_ml_inference_with_timeout<T: crate::ml_scorer::MlScoreInput>(
177    candidates: &[T],
178    config: &crate::types::ScannerConfig,
179    gpu_moe_timeout: std::time::Duration,
180) -> crate::error::Result<Vec<f64>> {
181    if candidates.is_empty() {
182        return Ok(Vec::new());
183    }
184
185    #[cfg(feature = "ml")]
186    {
187        use rayon::prelude::*;
188        #[cfg(not(feature = "gpu"))]
189        let _ = gpu_moe_timeout; // LAW10: cfg-only GPU timeout marker; ML CPU scoring ignores GPU dispatch timeout by construction
190        let prof = ml_split_prof_enabled();
191
192        // Single-chunk and windowed scans commonly produce only a handful of
193        // candidates. Coalesced scans aggregate pending rows across chunks
194        // before entering here, but any batch below the measured GPU crossover
195        // still avoids rayon split/join and GPU dispatch overhead through one
196        // fused serial feature-and-score loop.
197        if candidates.len() < crate::ml_scorer::GPU_BATCH_THRESHOLD {
198            // Small-batch fused serial path (the ~99% case).
199            let t = prof.then(std::time::Instant::now);
200            let scores = crate::ml_scorer::score_input_batch_serial(candidates, config);
201            if let Some(t) = t {
202                // Fused loop: attribute the whole cost to feature+score combined
203                // under MOE_SCORE_NS (kept separate from the large-batch split).
204                MOE_SCORE_NS.fetch_add(
205                    t.elapsed().as_nanos() as u64,
206                    std::sync::atomic::Ordering::Relaxed,
207                );
208            }
209            return Ok(scores);
210        }
211
212        // Large batch: parallel feature extraction, then GPU (or parallel CPU).
213        let t_feat = prof.then(std::time::Instant::now);
214        let features: Vec<[f32; crate::ml_scorer::NUM_FEATURES]> = candidates
215            .par_iter()
216            .map(|candidate| candidate.ml_features(config))
217            .collect();
218        if let Some(t) = t_feat {
219            MOE_FEATURE_NS.fetch_add(
220                t.elapsed().as_nanos() as u64,
221                std::sync::atomic::Ordering::Relaxed,
222            );
223        }
224
225        let t_score = prof.then(std::time::Instant::now);
226        let score_features_on_cpu =
227            || crate::ml_scorer::score_precomputed_batch_on_cpu(candidates, &features);
228        let scores = {
229            #[cfg(feature = "gpu")]
230            {
231                match backend::batch_score_features(&features, gpu_moe_timeout) {
232                    Ok(Some(mut scores)) if scores.len() == candidates.len() => {
233                        crate::confidence::policy::apply_empty_candidate_score_policy(
234                            candidates.iter().map(|candidate| candidate.ml_text()),
235                            &mut scores,
236                        );
237                        scores
238                    }
239                    Ok(Some(scores)) => {
240                        debug_assert_eq!(
241                            scores.len(),
242                            candidates.len(),
243                            "backend::batch_score_features must return one score per input"
244                        );
245                        backend::moe_runtime_degrade(&format!(
246                            "caller-side score count mismatch: backend returned {} scores for {} candidates",
247                            scores.len(),
248                            candidates.len()
249                        ))
250                        .map_err(|error| crate::error::ScanError::Gpu(error.to_string()))?;
251                        score_features_on_cpu()
252                    }
253                    Ok(None) => score_features_on_cpu(),
254                    Err(error) => {
255                        return Err(crate::error::ScanError::Gpu(error.to_string()));
256                    }
257                }
258            }
259            #[cfg(not(feature = "gpu"))]
260            {
261                score_features_on_cpu()
262            }
263        };
264        if let Some(t) = t_score {
265            MOE_SCORE_NS.fetch_add(
266                t.elapsed().as_nanos() as u64,
267                std::sync::atomic::Ordering::Relaxed,
268            );
269        }
270        Ok(scores)
271    }
272
273    #[cfg(not(feature = "ml"))]
274    {
275        let _ = candidates; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
276        let _ = config; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
277        let _ = gpu_moe_timeout; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
278        Ok(Vec::new())
279    }
280}
281
282/// Return `true` when GPU scoring support is available in this build/runtime.
283///
284/// Honors the resolved runtime policy before touching the adapter path. A
285/// caller asking after `--no-gpu` must get the same cheap "not available"
286/// answer as `gpu_probe()` instead of triggering a wgpu adapter probe.
287///
288/// # Examples
289///
290/// ```rust
291/// use keyhog_scanner::gpu::gpu_available;
292/// let _ = gpu_available();
293/// ```
294pub fn gpu_available() -> bool {
295    gpu_probe().available
296}