Skip to main content

keyhog_scanner/
scan_profile.rs

1//! Unified scan profiler: one explicit switch, one hierarchical dump.
2//!
3//! Replaces the old scattered per-pass atomic-counter hacks (each in a different
4//! file, each with its own incompatible dump) with one scanner-owned switch set
5//! explicitly by the CLI/library caller. It captures the whole pipeline in one
6//! run and emits one tree showing where every microsecond goes, including inside
7//! the phase-2 pass and how much of the cost is decode-recursion.
8//!
9//! Model: only LEAF passes are timed directly (via the [`span`] RAII guard);
10//! parent rows (scan / phase2 / phase2-capture) are SUMS of their leaves in
11//! [`dump`].
12//! Leaf passes never nest within each other (decode recursion re-enters as fresh
13//! leaf recordings that aggregate into the same leaves), so the totals are the
14//! elapsed time per pass summed across all rayon workers and all decode depths
15//! no double-counting, no per-span stack needed. Accelerator dispatch contributes
16//! the host-observed elapsed wait for that pass. Totals can exceed wall-clock
17//! because the scan is parallel; read them as proportions.
18//!
19//! Overhead when off: one cached-bool load per `span()` and a no-op `Drop`; no
20//! `Instant::now()` is taken on the hot path.
21
22use std::cell::Cell;
23use std::sync::atomic::{AtomicBool, Ordering::Relaxed};
24
25/// Leaf timing points. The ONLY spans measured directly; the hierarchy in
26/// [`dump`] derives parent totals by summing these.
27#[derive(Copy, Clone)]
28#[repr(usize)]
29pub(crate) enum P {
30    Preprocess = 0,
31    Phase1Triggers,
32    /// Accelerator-side trigger preparation and dispatch outside the shared
33    /// per-chunk phase-1 span (GPU coalescing, upload, kernel, readback, and
34    /// GPU admission). Zero for CPU-only scans.
35    BackendDispatch,
36    Hot,
37    Confirmed,
38    /// Always-active RegexSet prefilter, the anchorless detectors that run on
39    /// EVERY chunk (the cost the old label hid).
40    Phase2Prefilter,
41    /// Keyword Aho-Corasick prefilter (gates keyword-anchored phase-2 patterns).
42    Phase2KeywordAc,
43    /// Shared-anchor candidate scan (one AC over required-prefix literals).
44    Phase2SharedAc,
45    /// Anchored verification of shared-anchor candidates.
46    Phase2AnchoredVerify,
47    /// Whole-chunk extraction for active patterns with no usable anchor.
48    Phase2WholeChunk,
49    Generic,
50    Entropy,
51    Ml,
52    /// Decode pipeline: detect encoded blobs + spawn/scan decoded sub-chunks
53    /// (the recursion driver itself, excluding the sub-chunk phase-2 which lands
54    /// in the leaves above tagged at decode depth).
55    Decode,
56}
57
58const N: usize = 14;
59
60const NAMES: [&str; N] = [
61    "preprocess",
62    "phase1",
63    "backend-dispatch",
64    "hot",
65    "confirmed",
66    "phase2:prefilter",
67    "phase2:keyword-ac",
68    "phase2:shared-ac",
69    "phase2:verify",
70    "phase2:whole-chunk",
71    "generic",
72    "entropy",
73    "ml",
74    "decode",
75];
76
77static DETAILED_ENABLED: AtomicBool = AtomicBool::new(false);
78static PERF_TRACE_ENABLED: AtomicBool = AtomicBool::new(false);
79
80/// Enable or disable the scanner's detailed diagnostic profile collector.
81///
82/// This library API enables fixed stages and expensive per-pattern diagnostics
83/// together. The CLI uses it only for `--perf-trace`; `--profile` starts a
84/// [`keyhog_profile::Session`] directly so production profiling does not enable
85/// per-pattern hot-path accounting.
86pub fn set_profile_enabled(enabled: bool) {
87    DETAILED_ENABLED.store(enabled, Relaxed);
88    keyhog_profile::set_enabled(enabled);
89}
90
91/// Enable or disable low-level phase timing traces for this process.
92///
93/// This is the explicit replacement for the old ambient environment hook used by
94/// GPU/perf benches and dispatch diagnostics.
95pub fn set_perf_trace_enabled(enabled: bool) {
96    PERF_TRACE_ENABLED.store(enabled, Relaxed);
97}
98
99pub(crate) fn enabled() -> bool {
100    DETAILED_ENABLED.load(Relaxed)
101}
102
103pub(crate) fn perf_trace_enabled() -> bool {
104    PERF_TRACE_ENABLED.load(Relaxed)
105}
106
107thread_local! {
108    /// Set on the worker while it re-scans a decoded sub-chunk, so leaf times
109    /// recorded during that window are also attributed to [`NS_DECODE`].
110    static IN_DECODE: Cell<bool> = const { Cell::new(false) };
111}
112
113/// Mark/unmark the current thread as inside a decode sub-chunk rescan; returns
114/// the previous value so the caller can restore it (decode recursion nests).
115#[cfg(feature = "decode")]
116pub(crate) fn set_in_decode(on: bool) -> bool {
117    let previous = IN_DECODE.with(|cell| cell.replace(on));
118    keyhog_profile::set_attribution(if on {
119        keyhog_profile::Attribution::Decoded
120    } else {
121        keyhog_profile::Attribution::Root
122    });
123    previous
124}
125
126/// True while this worker thread is rescanning a DECODED sub-chunk (base64/hex/
127/// url/… payload sliced out of an outer chunk). This is not merely a profiling
128/// marker: it is the single-owner scan-context signal that a caller (the phase-2
129/// prefilter) reads to widen the homoglyph-ASCII skip to ALL decoded content.
130/// Homoglyph prefix variants exist to catch unicode look-alikes in SOURCE text;
131/// inside a decoded payload a non-ASCII byte run is binary noise (base64/hex of
132/// binary), and any homoglyph-variant hit there is structurally a non-credential
133/// (a real secret is ASCII/UTF-8 text and is already covered by the base pattern
134/// in the lean DB), so the ~2.8k homoglyph NFAs can be skipped on decoded chunks
135/// regardless of `is_ascii()`. Always available (returns false without the
136/// `decode` feature, where `set_in_decode` never runs and the cell stays false).
137#[inline]
138pub(crate) fn in_decode() -> bool {
139    IN_DECODE.with(Cell::get)
140}
141
142pub(crate) type Guard = keyhog_profile::Span;
143
144fn stage(point: P) -> keyhog_profile::Stage {
145    use keyhog_profile::Stage;
146    match point {
147        P::Preprocess => Stage::Preprocess,
148        P::Phase1Triggers => Stage::Phase1Triggers,
149        P::BackendDispatch => Stage::BackendDispatch,
150        P::Hot => Stage::HotPatterns,
151        P::Confirmed => Stage::ConfirmedPatterns,
152        P::Phase2Prefilter => Stage::Phase2Prefilter,
153        P::Phase2KeywordAc => Stage::Phase2KeywordAc,
154        P::Phase2SharedAc => Stage::Phase2SharedAc,
155        P::Phase2AnchoredVerify => Stage::Phase2AnchoredVerify,
156        P::Phase2WholeChunk => Stage::Phase2WholeChunk,
157        P::Generic => Stage::GenericDetection,
158        P::Entropy => Stage::Entropy,
159        P::Ml => Stage::MachineLearning,
160        P::Decode => Stage::Decode,
161    }
162}
163
164fn point_index(stage: keyhog_profile::Stage) -> Option<usize> {
165    use keyhog_profile::Stage;
166    Some(match stage {
167        Stage::Preprocess => P::Preprocess as usize,
168        Stage::Phase1Triggers => P::Phase1Triggers as usize,
169        Stage::BackendDispatch => P::BackendDispatch as usize,
170        Stage::HotPatterns => P::Hot as usize,
171        Stage::ConfirmedPatterns => P::Confirmed as usize,
172        Stage::Phase2Prefilter => P::Phase2Prefilter as usize,
173        Stage::Phase2KeywordAc => P::Phase2KeywordAc as usize,
174        Stage::Phase2SharedAc => P::Phase2SharedAc as usize,
175        Stage::Phase2AnchoredVerify => P::Phase2AnchoredVerify as usize,
176        Stage::Phase2WholeChunk => P::Phase2WholeChunk as usize,
177        Stage::GenericDetection => P::Generic as usize,
178        Stage::Entropy => P::Entropy as usize,
179        Stage::MachineLearning => P::Ml as usize,
180        Stage::Decode => P::Decode as usize,
181        Stage::SourceAcquire
182        | Stage::SourceWalk
183        | Stage::SourceRead
184        | Stage::SourceQueueWait
185        | Stage::ScannerQueueWait
186        | Stage::IncrementalLookup
187        | Stage::BackendSelect
188        | Stage::ResultMerge
189        | Stage::Suppression
190        | Stage::LiveVerification
191        | Stage::Reporting => return None,
192    })
193}
194
195/// Open a leaf span; records elapsed wall time into `point` on drop.
196#[inline]
197#[must_use]
198pub(crate) fn span(point: P) -> Guard {
199    keyhog_profile::span(stage(point))
200}
201
202/// Record the input size of a top-level scan (for the throughput line).
203pub(crate) fn add_bytes(bytes: u64) {
204    keyhog_profile::add_input_bytes(bytes);
205}
206
207/// Record a top-level file/chunk count.
208pub(crate) fn add_files(files: u64) {
209    keyhog_profile::add_input_units(files);
210}
211
212fn read_reset() -> ([u64; N], [u64; N], [u64; N], u64, u64) {
213    let mut ns = [0; N];
214    let mut calls = [0; N];
215    let mut ns_decode = [0; N];
216    for measurement in keyhog_profile::take_stage_measurements() {
217        let Some(index) = point_index(measurement.stage) else {
218            continue;
219        };
220        ns[index] = measurement.elapsed_ns;
221        calls[index] = measurement.calls;
222        ns_decode[index] = measurement.attributed_ns;
223    }
224    let (bytes, files) = keyhog_profile::take_input_totals();
225    (ns, calls, ns_decode, bytes, files)
226}
227
228/// Discard all accumulated counters without printing (warm-up between runs).
229pub fn reset() {
230    keyhog_profile::reset();
231    crate::engine::scan_inner_profile::scan_inner_profile_reset();
232    crate::engine::scan_postprocess::decode_profile_reset();
233    crate::decode::extract_profile_reset();
234    crate::decode::decoder_profile_reset();
235    crate::engine::phase2_generic::generic_profile_reset();
236    crate::engine::phase2::phase2_mark_stats_reset();
237    crate::engine::phase2::hs_mark_timing_reset();
238    crate::engine::scan_postprocess::ml_batch_profile_reset();
239    crate::gpu::ml_split_profile_reset();
240}
241
242const PHASE2_CAPTURE_LEAVES: [usize; 5] = [
243    P::Phase2Prefilter as usize,
244    P::Phase2KeywordAc as usize,
245    P::Phase2SharedAc as usize,
246    P::Phase2AnchoredVerify as usize,
247    P::Phase2WholeChunk as usize,
248];
249const PHASE2_LEAVES: [usize; 9] = [
250    P::Hot as usize,
251    P::Confirmed as usize,
252    P::Phase2Prefilter as usize,
253    P::Phase2KeywordAc as usize,
254    P::Phase2SharedAc as usize,
255    P::Phase2AnchoredVerify as usize,
256    P::Phase2WholeChunk as usize,
257    P::Generic as usize,
258    P::Entropy as usize,
259];
260// `ml` is a phase-2 leaf too, listed separately so capture sub-leaves group.
261
262/// Print and reset the unified profile tree. Safe to call when profiling was off
263/// (prints a single "disabled" line).
264pub fn dump(label: &str) {
265    if !enabled() {
266        eprintln!("[profile {label}] scanner profile switch is off; no data");
267        return;
268    }
269    let (ns, calls, ns_decode, bytes, files) = read_reset();
270    let ms = |i: usize| ns[i] as f64 / 1e6;
271    let sum = |ids: &[usize]| ids.iter().map(|&i| ns[i]).sum::<u64>();
272
273    let phase2_ns = sum(&PHASE2_LEAVES) + ns[P::Ml as usize];
274    let capture_ns = sum(&PHASE2_CAPTURE_LEAVES);
275    let scan_ns = ns[P::Preprocess as usize]
276        + ns[P::Phase1Triggers as usize]
277        + ns[P::BackendDispatch as usize]
278        + phase2_ns
279        + ns[P::Decode as usize];
280    let scan_ms = scan_ns as f64 / 1e6;
281    let pct = |part: u64, whole: u64| {
282        if whole > 0 {
283            100.0 * part as f64 / whole as f64
284        } else {
285            0.0
286        }
287    };
288
289    eprintln!("=== keyhog profile [{label}] ===");
290    let thru = if scan_ms > 0.0 {
291        (bytes as f64 / 1e6) / (scan_ms / 1000.0)
292    } else {
293        0.0
294    };
295    eprintln!(
296        "SCAN  {scan_ms:>9.1} ms   summed across workers · {} files · {:.2} MiB · {:.1} MB/s (pass-time sum)",
297        files,
298        bytes as f64 / (1024.0 * 1024.0),
299        thru
300    );
301
302    let leaf = |i: usize, parent_ns: u64, indent: &str| {
303        let c = calls[i];
304        let dec = ns_decode[i];
305        eprintln!(
306            "{indent}{:<16} {:>9.1} ms  {:>5.1}% parent  {:>6.1}% scan  calls={:<8} {:>6.0} ns/call  decode={:>4.1}%",
307            NAMES[i],
308            ms(i),
309            pct(ns[i], parent_ns),
310            pct(ns[i], scan_ns),
311            c,
312            if c > 0 { ns[i] as f64 / c as f64 } else { 0.0 },
313            pct(dec, ns[i].max(1)),
314        );
315    };
316    let parent = |name: &str, total: u64, indent: &str| {
317        eprintln!(
318            "{indent}{:<16} {:>9.1} ms  {:>5.1}% scan",
319            name,
320            total as f64 / 1e6,
321            pct(total, scan_ns),
322        );
323    };
324
325    // The prefilter call decomposition (gate-skip / HS-served / RegexSet-served)
326    // is read BEFORE its reset below so a candidate-dense vs sparse corpus is
327    // distinguishable: it answers whether the `phase2:prefilter` cost is cheap
328    // gate-skips averaged with a few brutal RegexSet passes, or uniformly heavy.
329    let mark: crate::engine::phase2::MarkSnapshot = crate::engine::phase2::phase2_mark_stats();
330    // Internal timing split of the HS-served portion (scan vs dropped host loop),
331    // read before its reset below. Only printed when HS-mark time was recorded.
332    let hs_split: crate::engine::phase2::HsMarkSplit =
333        crate::engine::phase2::hs_mark_timing_snapshot();
334
335    leaf(P::Preprocess as usize, scan_ns, "  ");
336    leaf(P::Phase1Triggers as usize, scan_ns, "  ");
337    leaf(P::BackendDispatch as usize, scan_ns, "  ");
338    parent("phase2", phase2_ns, "  ");
339    leaf(P::Hot as usize, phase2_ns, "    ");
340    leaf(P::Confirmed as usize, phase2_ns, "    ");
341    parent("phase2-capture", capture_ns, "    ");
342    for &i in &PHASE2_CAPTURE_LEAVES {
343        leaf(i, capture_ns, "      ");
344        // Attach the path decomposition directly under the prefilter leaf it
345        // describes, so the dominant scan cost is diagnosable in place.
346        if i == P::Phase2Prefilter as usize && mark.calls > 0 {
347            let line = crate::engine::phase2::format_mark_decomposition(&mark);
348            if mark.is_consistent() {
349                eprintln!("        ↳ {line}");
350            } else {
351                // Law 10: never print a mis-accounted decomposition as if it were
352                // correct. The snapshot is quiescent here (read after the scan
353                // joined), so a failed split means a `record_*` path bumped
354                // `calls` without its matching sub-counter, every percentage on
355                // this line is then wrong. Surface it loudly next to the figures.
356                eprintln!(
357                    "        ↳ {line}  ⚠ INCONSISTENT: gate-skip + hs + regexset ({}) != calls ({}), prefilter call accounting bug",
358                    mark.gate_skips + mark.served_total(),
359                    mark.calls
360                );
361            }
362            // Second layer: where the HS-served time went (scan vs dropped host
363            // loop). Only present when profiling timed at least one HS mark.
364            if hs_split.any_recorded() {
365                eprintln!(
366                    "          ↳ {}",
367                    crate::engine::phase2::format_hs_mark_split(&hs_split)
368                );
369            }
370        }
371    }
372    leaf(P::Generic as usize, phase2_ns, "    ");
373    leaf(P::Entropy as usize, phase2_ns, "    ");
374    leaf(P::Ml as usize, phase2_ns, "    ");
375    leaf(P::Decode as usize, scan_ns, "  ");
376
377    let decode_total: u64 = (0..N).map(|i| ns_decode[i]).sum();
378    eprintln!(
379        "  (of all leaf time, {:.1}% was recorded inside decode sub-chunk rescans)",
380        pct(decode_total, scan_ns),
381    );
382
383    // Fold in the auxiliary histograms recorded on the hot path. Each early-returns
384    // when its counters are empty, so an unrelated run prints nothing extra.
385    crate::engine::scan_inner_profile::scan_inner_profile_dump();
386    crate::engine::scan_postprocess::decode_profile_dump();
387    crate::decode::extract_profile_dump();
388    crate::decode::decoder_profile_dump();
389    crate::engine::phase2_generic::generic_profile_dump();
390    crate::engine::scan_postprocess::ml_batch_profile_dump();
391    crate::gpu::ml_split_profile_dump();
392
393    // Reset the prefilter call counters now that they have been reported, so the
394    // next dump reflects only its own run (the leaf NS/CALLS were already swapped
395    // out by `read_reset`; this keeps the mark counters consistent with them).
396    crate::engine::phase2::phase2_mark_stats_reset();
397    crate::engine::phase2::hs_mark_timing_reset();
398}