Skip to main content

shifty_engine/
profile.rs

1//! Opt-in execution telemetry (doc §269). Collected via a thread-local so no
2//! validation API signatures change. Enable with `enable()`, consume with
3//! `take()`.
4//!
5//! Shape-cache counters are accumulated locally by each evaluator and published
6//! once when it is dropped, avoiding a thread-local operation per lookup.
7
8use std::cell::{Cell, RefCell};
9use web_time::Instant;
10
11/// Per-query performance record.
12#[derive(Debug, Clone)]
13pub struct QueryRecord {
14    /// Stable fingerprint derived from the canonical query text (first 64 chars).
15    pub fingerprint: String,
16    /// Whether the native executor handled this query (always Fallback in stage 1).
17    pub executor: ExecutorKind,
18    /// How many times this query was invoked (one per focus node in stage 1).
19    pub invocations: u64,
20    /// Total wall-clock execution time across all invocations, in microseconds.
21    pub total_exec_us: u64,
22}
23
24/// Per-shape or per-rule wall-clock record. One entry per distinct label
25/// (shape IRI, `@N` slot id, or `rule[N]`).
26#[derive(Debug, Clone)]
27pub struct ShapeRecord {
28    /// Shape IRI (named shapes), `@N` arena slot (blank-node shapes), or
29    /// `rule[N]` (inference rules).
30    pub label: String,
31    /// Number of evaluation calls (one per focus node for validation, one per
32    /// rule firing for inference).
33    pub invocations: u64,
34    /// Total wall-clock time across all invocations, in microseconds.
35    pub total_us: u64,
36    /// Evidence-materialization node visits attributed to this label, when the
37    /// caller reported them. Zero for conformance-only and inference records.
38    pub visits: u64,
39}
40
41/// Aggregate shape-cache telemetry for one profiling session.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct ShapeCacheRecord {
44    /// Number of per-snapshot evaluators that reported cache activity.
45    pub evaluators: u64,
46    /// Lookups served from an existing memo entry.
47    pub hits: u64,
48    /// Lookups without an existing memo entry, including recursion back-edges.
49    pub misses: u64,
50    /// Completed results admitted to the memo.
51    pub insertions: u64,
52    /// Lookups that encountered the same `(ShapeId, Term)` on the active stack.
53    pub recursion_back_edges: u64,
54    /// Completed results not admitted because they depended on a back-edge.
55    pub non_cacheable_results: u64,
56    /// Largest final entry count reported by any one evaluator.
57    pub peak_entries: usize,
58    /// Approximate maximum bytes retained by any one evaluator.
59    ///
60    /// Includes hash-table bucket storage and owned RDF-term string payloads,
61    /// but not allocator metadata.
62    pub estimated_peak_bytes: usize,
63}
64
65/// One evaluator's cache counters, merged into [`ShapeCacheRecord`] on drop.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
67pub(crate) struct ShapeCacheSample {
68    pub hits: u64,
69    pub misses: u64,
70    pub insertions: u64,
71    pub recursion_back_edges: u64,
72    pub non_cacheable_results: u64,
73    pub entries: usize,
74    pub estimated_bytes: usize,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ExecutorKind {
79    /// Spareval fallback; carries the capability-analysis reason if known.
80    Fallback {
81        reason: Option<String>,
82    },
83    Native,
84}
85
86#[derive(Debug, Default)]
87pub struct ProfileCollector {
88    records: Vec<QueryRecord>,
89    shape_records: Vec<ShapeRecord>,
90    shape_cache: ShapeCacheRecord,
91}
92
93impl ProfileCollector {
94    pub fn new() -> Self {
95        ProfileCollector::default()
96    }
97
98    pub fn record_invocation(&mut self, fingerprint: &str, exec_us: u64, executor: ExecutorKind) {
99        if let Some(r) = self
100            .records
101            .iter_mut()
102            .find(|r| r.fingerprint == fingerprint)
103        {
104            r.invocations += 1;
105            r.total_exec_us += exec_us;
106        } else {
107            self.records.push(QueryRecord {
108                fingerprint: fingerprint.to_string(),
109                executor,
110                invocations: 1,
111                total_exec_us: exec_us,
112            });
113        }
114    }
115
116    pub fn record_shape_invocation(&mut self, label: &str, exec_us: u64) {
117        self.record_shape_work(label, exec_us, 0);
118    }
119
120    /// Record one evaluation together with the evidence nodes it visited.
121    pub fn record_shape_work(&mut self, label: &str, exec_us: u64, visits: u64) {
122        if let Some(r) = self.shape_records.iter_mut().find(|r| r.label == label) {
123            r.invocations += 1;
124            r.total_us += exec_us;
125            r.visits += visits;
126        } else {
127            self.shape_records.push(ShapeRecord {
128                label: label.to_string(),
129                invocations: 1,
130                total_us: exec_us,
131                visits,
132            });
133        }
134    }
135
136    pub(crate) fn record_shape_cache(&mut self, sample: ShapeCacheSample) {
137        self.shape_cache.evaluators += 1;
138        self.shape_cache.hits += sample.hits;
139        self.shape_cache.misses += sample.misses;
140        self.shape_cache.insertions += sample.insertions;
141        self.shape_cache.recursion_back_edges += sample.recursion_back_edges;
142        self.shape_cache.non_cacheable_results += sample.non_cacheable_results;
143        self.shape_cache.peak_entries = self.shape_cache.peak_entries.max(sample.entries);
144        self.shape_cache.estimated_peak_bytes = self
145            .shape_cache
146            .estimated_peak_bytes
147            .max(sample.estimated_bytes);
148    }
149
150    pub fn records(&self) -> &[QueryRecord] {
151        &self.records
152    }
153
154    pub fn shape_records(&self) -> &[ShapeRecord] {
155        &self.shape_records
156    }
157
158    pub fn shape_cache(&self) -> &ShapeCacheRecord {
159        &self.shape_cache
160    }
161
162    pub fn print_summary(&self) {
163        if !self.shape_records.is_empty() {
164            println!(
165                "profile: {} distinct shape(s)/rule(s)",
166                self.shape_records.len()
167            );
168            let mut sorted = self.shape_records.to_vec();
169            sorted.sort_by_key(|b| std::cmp::Reverse(b.total_us));
170            for r in &sorted {
171                let avg_us = r.total_us.checked_div(r.invocations).unwrap_or(0);
172                println!(
173                    "  {}: {} call(s), {}µs total, {}µs avg",
174                    r.label, r.invocations, r.total_us, avg_us,
175                );
176            }
177        }
178        if self.shape_cache.evaluators > 0 {
179            let lookups = self.shape_cache.hits + self.shape_cache.misses;
180            let hit_rate = if lookups == 0 {
181                0.0
182            } else {
183                self.shape_cache.hits as f64 * 100.0 / lookups as f64
184            };
185            println!(
186                "profile: shape cache: {} evaluator(s), {} hit(s), {} miss(es), \
187                 {hit_rate:.1}% hit rate",
188                self.shape_cache.evaluators, self.shape_cache.hits, self.shape_cache.misses,
189            );
190            println!(
191                "  {} insertion(s), {} recursion back-edge(s), {} non-cacheable result(s)",
192                self.shape_cache.insertions,
193                self.shape_cache.recursion_back_edges,
194                self.shape_cache.non_cacheable_results,
195            );
196            println!(
197                "  peak: {} entries, ~{} bytes",
198                self.shape_cache.peak_entries, self.shape_cache.estimated_peak_bytes,
199            );
200        }
201        if self.records.is_empty() {
202            if self.shape_records.is_empty() && self.shape_cache.evaluators == 0 {
203                println!("profile: no data collected");
204            }
205            return;
206        }
207        println!(
208            "profile: {} distinct SPARQL query/queries",
209            self.records.len()
210        );
211        let mut sorted = self.records.to_vec();
212        sorted.sort_by_key(|b| std::cmp::Reverse(b.total_exec_us));
213        for r in &sorted {
214            let exec_str = match &r.executor {
215                ExecutorKind::Fallback { reason: None } => "fallback".to_string(),
216                ExecutorKind::Fallback { reason: Some(s) } => format!("fallback({s})"),
217                ExecutorKind::Native => "native".to_string(),
218            };
219            let avg_us = r.total_exec_us.checked_div(r.invocations).unwrap_or(0);
220            println!(
221                "  [{exec_str}] {}: {} call(s), {}µs total, {}µs avg",
222                r.fingerprint, r.invocations, r.total_exec_us, avg_us,
223            );
224        }
225    }
226}
227
228thread_local! {
229    static PROFILER: RefCell<Option<ProfileCollector>> = const { RefCell::new(None) };
230}
231
232/// Enable profiling for the current thread. Resets any previous collector.
233pub fn enable() {
234    PROFILER.with(|p| *p.borrow_mut() = Some(ProfileCollector::new()));
235}
236
237/// Disable profiling and return the collected data, if any.
238pub fn take() -> Option<ProfileCollector> {
239    PROFILER.with(|p| p.borrow_mut().take())
240}
241
242/// Whether telemetry is enabled for the current thread.
243pub(crate) fn is_enabled() -> bool {
244    PROFILER.with(|p| p.borrow().is_some())
245}
246
247/// Record one query invocation. No-op when profiling is disabled.
248pub fn record(fingerprint: &str, exec_us: u64, executor: ExecutorKind) {
249    PROFILER.with(|p| {
250        if let Some(col) = p.borrow_mut().as_mut() {
251            col.record_invocation(fingerprint, exec_us, executor);
252        }
253    });
254}
255
256/// Record one shape/rule evaluation. No-op when profiling is disabled.
257pub fn record_shape(label: &str, exec_us: u64) {
258    PROFILER.with(|p| {
259        if let Some(col) = p.borrow_mut().as_mut() {
260            col.record_shape_invocation(label, exec_us);
261        }
262    });
263}
264
265/// Record one evaluation plus its evidence-node visits. No-op when profiling
266/// is disabled.
267pub(crate) fn record_shape_work(label: &str, exec_us: u64, visits: u64) {
268    PROFILER.with(|p| {
269        if let Some(col) = p.borrow_mut().as_mut() {
270            col.record_shape_work(label, exec_us, visits);
271        }
272    });
273}
274
275thread_local! {
276    static EVIDENCE_VISITS: Cell<u64> = const { Cell::new(0) };
277}
278
279/// One evidence-materialization node visit.
280///
281/// Unlike the counters above this is always on: it is a `Cell` increment beside
282/// a function that already clones a `Shape`, and it is the only way to see how
283/// far the shared shape DAG expands into a per-pair evidence tree. Visits count
284/// nodes *entered*, including those later pruned from the retained evidence.
285pub(crate) fn record_evidence_visit() {
286    EVIDENCE_VISITS.with(|visits| visits.set(visits.get().wrapping_add(1)));
287}
288
289/// Read and reset the evidence-visit counter for this thread.
290pub fn take_evidence_visits() -> u64 {
291    EVIDENCE_VISITS.with(|visits| visits.replace(0))
292}
293
294/// Read the evidence-visit counter without resetting it, for deltas around a
295/// region of work.
296pub fn evidence_visits() -> u64 {
297    EVIDENCE_VISITS.with(Cell::get)
298}
299
300thread_local! {
301    static PATH_PROBES: Cell<u64> = const { Cell::new(0) };
302}
303
304/// One `path_support` probe. Each probe re-derives a full successor set from
305/// the backend to answer a single membership question, so probe count is the
306/// multiplier on path work that evidence pays and conformance does not.
307pub(crate) fn record_path_probe() {
308    PATH_PROBES.with(|probes| probes.set(probes.get().wrapping_add(1)));
309}
310
311/// Read and reset the path-probe counter for this thread.
312pub fn take_path_probes() -> u64 {
313    PATH_PROBES.with(|probes| probes.replace(0))
314}
315
316/// Merge one evaluator's shape-cache telemetry. No-op when profiling is
317/// disabled.
318pub(crate) fn record_shape_cache(sample: ShapeCacheSample) {
319    PROFILER.with(|p| {
320        if let Some(col) = p.borrow_mut().as_mut() {
321            col.record_shape_cache(sample);
322        }
323    });
324}
325
326/// Helper: measure `f` and record the result under `fingerprint`. Returns the
327/// value produced by `f`.
328pub fn timed<T>(fingerprint: &str, f: impl FnOnce() -> T) -> T {
329    let start = Instant::now();
330    let result = f();
331    let us = start.elapsed().as_micros() as u64;
332    record(fingerprint, us, ExecutorKind::Fallback { reason: None });
333    result
334}
335
336/// Derive a short fingerprint from a canonical query string.
337pub fn fingerprint(query: &str) -> String {
338    let trimmed = query.trim();
339    let preview: String = trimmed.chars().take(60).collect();
340    // Replace newlines/runs of whitespace with a single space for readability.
341    preview.split_whitespace().collect::<Vec<_>>().join(" ")
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn aggregates_shape_cache_samples() {
350        let mut collector = ProfileCollector::new();
351        collector.record_shape_cache(ShapeCacheSample {
352            hits: 3,
353            misses: 5,
354            insertions: 4,
355            recursion_back_edges: 1,
356            non_cacheable_results: 2,
357            entries: 4,
358            estimated_bytes: 400,
359        });
360        collector.record_shape_cache(ShapeCacheSample {
361            hits: 7,
362            misses: 2,
363            insertions: 2,
364            recursion_back_edges: 0,
365            non_cacheable_results: 0,
366            entries: 2,
367            estimated_bytes: 250,
368        });
369
370        assert_eq!(
371            collector.shape_cache(),
372            &ShapeCacheRecord {
373                evaluators: 2,
374                hits: 10,
375                misses: 7,
376                insertions: 6,
377                recursion_back_edges: 1,
378                non_cacheable_results: 2,
379                peak_entries: 4,
380                estimated_peak_bytes: 400,
381            }
382        );
383    }
384}