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 160 chars).
15    pub fingerprint: String,
16    /// Whether the native executor or Spareval fallback handled this query.
17    pub executor: ExecutorKind,
18    /// How many execution batches or fallback probes invoked this query.
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/// Storage work counted only while profiling is enabled. Counts are structural
66/// evidence; wall-clock costs are reported separately.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct StorageRecord {
69    /// Time spent compiling data-independent read/write demand.
70    pub access_catalog_us: u64,
71    pub store_builds: u64,
72    pub graph_union_builds: u64,
73    pub graph_union_rows: u64,
74    pub graph_projection_builds: u64,
75    pub graph_projection_rows: u64,
76    pub source_builds: u64,
77    pub source_rows: u64,
78    pub dataset_builds: u64,
79    pub encoded_rows: u64,
80    pub committed_rows: u64,
81    pub commit_batches: u64,
82    pub commit_us: u64,
83    pub index_builds: u64,
84    pub index_bytes: u64,
85    pub index_declines: u64,
86    /// Allocated PSO pair-buffer capacity, excluding map nodes and dictionaries.
87    pub source_primary_bytes: u64,
88    pub session_primary_bytes: u64,
89    /// Time spent encoding the lazily shared source storage.
90    pub source_encode_us: u64,
91    /// Time spent encoding session datasets from data graphs.
92    pub session_encode_us: u64,
93}
94
95/// One optional-index admission decision. The base PSO index is mandatory and
96/// therefore does not appear here.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct IndexRecord {
99    pub scope: &'static str,
100    pub kind: &'static str,
101    pub predicate: Option<String>,
102    pub reason: &'static str,
103    pub rows: usize,
104    pub estimated_bytes: usize,
105    pub actual_bytes: usize,
106    pub budget_bytes: usize,
107    pub build_us: u64,
108    pub accepted: bool,
109}
110
111/// Candidate rows visited by one scan pattern. The mask uses S=1, P=2, O=4.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct ScanRecord {
114    pub calls: u64,
115    pub candidate_rows: u64,
116}
117
118/// Aggregate bounded reachability-cache telemetry for one profiling session.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct ReachCacheRecord {
121    pub hits: u64,
122    pub misses: u64,
123    pub insertions: u64,
124    pub declined_ids: u64,
125}
126
127/// One evaluator's cache counters, merged into [`ShapeCacheRecord`] on drop.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
129pub(crate) struct ShapeCacheSample {
130    pub hits: u64,
131    pub misses: u64,
132    pub insertions: u64,
133    pub recursion_back_edges: u64,
134    pub non_cacheable_results: u64,
135    pub entries: usize,
136    pub estimated_bytes: usize,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ExecutorKind {
141    /// Spareval fallback; carries the capability-analysis reason if known.
142    Fallback {
143        reason: Option<String>,
144    },
145    Native,
146}
147
148#[derive(Debug, Default)]
149pub struct ProfileCollector {
150    records: Vec<QueryRecord>,
151    shape_records: Vec<ShapeRecord>,
152    shape_cache: ShapeCacheRecord,
153    storage: StorageRecord,
154    indexes: Vec<IndexRecord>,
155    scans: [[ScanRecord; 8]; 2],
156    reach_cache: ReachCacheRecord,
157}
158
159impl ProfileCollector {
160    pub fn new() -> Self {
161        ProfileCollector::default()
162    }
163
164    pub fn record_invocation(&mut self, fingerprint: &str, exec_us: u64, executor: ExecutorKind) {
165        if let Some(r) = self
166            .records
167            .iter_mut()
168            .find(|r| r.fingerprint == fingerprint)
169        {
170            r.invocations += 1;
171            r.total_exec_us += exec_us;
172        } else {
173            self.records.push(QueryRecord {
174                fingerprint: fingerprint.to_string(),
175                executor,
176                invocations: 1,
177                total_exec_us: exec_us,
178            });
179        }
180    }
181
182    pub fn record_shape_invocation(&mut self, label: &str, exec_us: u64) {
183        self.record_shape_work(label, exec_us, 0);
184    }
185
186    /// Record one evaluation together with the evidence nodes it visited.
187    pub fn record_shape_work(&mut self, label: &str, exec_us: u64, visits: u64) {
188        if let Some(r) = self.shape_records.iter_mut().find(|r| r.label == label) {
189            r.invocations += 1;
190            r.total_us += exec_us;
191            r.visits += visits;
192        } else {
193            self.shape_records.push(ShapeRecord {
194                label: label.to_string(),
195                invocations: 1,
196                total_us: exec_us,
197                visits,
198            });
199        }
200    }
201
202    pub(crate) fn record_shape_cache(&mut self, sample: ShapeCacheSample) {
203        self.shape_cache.evaluators += 1;
204        self.shape_cache.hits += sample.hits;
205        self.shape_cache.misses += sample.misses;
206        self.shape_cache.insertions += sample.insertions;
207        self.shape_cache.recursion_back_edges += sample.recursion_back_edges;
208        self.shape_cache.non_cacheable_results += sample.non_cacheable_results;
209        self.shape_cache.peak_entries = self.shape_cache.peak_entries.max(sample.entries);
210        self.shape_cache.estimated_peak_bytes = self
211            .shape_cache
212            .estimated_peak_bytes
213            .max(sample.estimated_bytes);
214    }
215
216    pub fn records(&self) -> &[QueryRecord] {
217        &self.records
218    }
219
220    pub fn shape_records(&self) -> &[ShapeRecord] {
221        &self.shape_records
222    }
223
224    pub fn shape_cache(&self) -> &ShapeCacheRecord {
225        &self.shape_cache
226    }
227
228    pub fn storage(&self) -> &StorageRecord {
229        &self.storage
230    }
231
232    pub fn indexes(&self) -> &[IndexRecord] {
233        &self.indexes
234    }
235
236    pub fn scans(&self) -> &[[ScanRecord; 8]; 2] {
237        &self.scans
238    }
239
240    pub fn reach_cache(&self) -> &ReachCacheRecord {
241        &self.reach_cache
242    }
243
244    pub fn print_summary(&self) {
245        if self.storage.store_builds + self.storage.dataset_builds + self.storage.source_builds > 0
246        {
247            println!(
248                "profile: storage: {} Store build(s), {} source build(s) / {} row(s), {} dataset build(s), {} encoded row(s), {} committed row(s), {} index build(s) / {} byte(s), {} declined",
249                self.storage.store_builds,
250                self.storage.source_builds,
251                self.storage.source_rows,
252                self.storage.dataset_builds,
253                self.storage.encoded_rows,
254                self.storage.committed_rows,
255                self.storage.index_builds,
256                self.storage.index_bytes,
257                self.storage.index_declines,
258            );
259            println!(
260                "profile: storage time: {} µs access catalog, {} µs shared source encode, {} µs session encode, {} µs across {} commit batch(es)",
261                self.storage.access_catalog_us,
262                self.storage.source_encode_us,
263                self.storage.session_encode_us,
264                self.storage.commit_us,
265                self.storage.commit_batches,
266            );
267            println!(
268                "profile: primary pair buffers: {} source byte(s), {} session byte(s)",
269                self.storage.source_primary_bytes, self.storage.session_primary_bytes,
270            );
271            println!(
272                "profile: graph materialization: {} union build(s) / {} row(s), {} compatibility projection(s) / {} row(s)",
273                self.storage.graph_union_builds,
274                self.storage.graph_union_rows,
275                self.storage.graph_projection_builds,
276                self.storage.graph_projection_rows,
277            );
278        }
279        for index in &self.indexes {
280            let predicate = index
281                .predicate
282                .as_deref()
283                .map_or(String::new(), |predicate| format!(" <{predicate}>"));
284            println!(
285                "profile: index: {} {}{}: {}, {} row(s), {} estimated byte(s), {} allocated byte(s), {} byte budget, {} µs build, {}",
286                index.scope,
287                index.kind,
288                predicate,
289                index.reason,
290                index.rows,
291                index.estimated_bytes,
292                index.actual_bytes,
293                index.budget_bytes,
294                index.build_us,
295                if index.accepted { "built" } else { "declined" },
296            );
297        }
298        for (scope, patterns) in ["source", "session"].into_iter().zip(&self.scans) {
299            for (mask, scan) in patterns.iter().enumerate() {
300                if scan.calls == 0 {
301                    continue;
302                }
303                let pattern = [
304                    if mask & 1 != 0 { 'S' } else { '-' },
305                    if mask & 2 != 0 { 'P' } else { '-' },
306                    if mask & 4 != 0 { 'O' } else { '-' },
307                ];
308                println!(
309                    "profile: scan: {scope} {}{}{}: {} call(s), {} candidate row(s)",
310                    pattern[0], pattern[1], pattern[2], scan.calls, scan.candidate_rows,
311                );
312            }
313        }
314        if self.reach_cache.hits + self.reach_cache.misses > 0 {
315            println!(
316                "profile: reach cache: {} hit(s), {} miss(es), {} insertion(s), {} declined result id(s)",
317                self.reach_cache.hits,
318                self.reach_cache.misses,
319                self.reach_cache.insertions,
320                self.reach_cache.declined_ids,
321            );
322        }
323        if !self.shape_records.is_empty() {
324            println!(
325                "profile: {} distinct shape(s)/rule(s)",
326                self.shape_records.len()
327            );
328            let mut sorted = self.shape_records.to_vec();
329            sorted.sort_by_key(|b| std::cmp::Reverse(b.total_us));
330            for r in &sorted {
331                let avg_us = r.total_us.checked_div(r.invocations).unwrap_or(0);
332                println!(
333                    "  {}: {} call(s), {}µs total, {}µs avg",
334                    r.label, r.invocations, r.total_us, avg_us,
335                );
336            }
337        }
338        if self.shape_cache.evaluators > 0 {
339            let lookups = self.shape_cache.hits + self.shape_cache.misses;
340            let hit_rate = if lookups == 0 {
341                0.0
342            } else {
343                self.shape_cache.hits as f64 * 100.0 / lookups as f64
344            };
345            println!(
346                "profile: shape cache: {} evaluator(s), {} hit(s), {} miss(es), \
347                 {hit_rate:.1}% hit rate",
348                self.shape_cache.evaluators, self.shape_cache.hits, self.shape_cache.misses,
349            );
350            println!(
351                "  {} insertion(s), {} recursion back-edge(s), {} non-cacheable result(s)",
352                self.shape_cache.insertions,
353                self.shape_cache.recursion_back_edges,
354                self.shape_cache.non_cacheable_results,
355            );
356            println!(
357                "  peak: {} entries, ~{} bytes",
358                self.shape_cache.peak_entries, self.shape_cache.estimated_peak_bytes,
359            );
360        }
361        if self.records.is_empty() {
362            if self.shape_records.is_empty() && self.shape_cache.evaluators == 0 {
363                println!("profile: no data collected");
364            }
365            return;
366        }
367        println!(
368            "profile: {} distinct SPARQL query/queries",
369            self.records.len()
370        );
371        let mut sorted = self.records.to_vec();
372        sorted.sort_by_key(|b| std::cmp::Reverse(b.total_exec_us));
373        for r in &sorted {
374            let exec_str = match &r.executor {
375                ExecutorKind::Fallback { reason: None } => "fallback".to_string(),
376                ExecutorKind::Fallback { reason: Some(s) } => format!("fallback({s})"),
377                ExecutorKind::Native => "native".to_string(),
378            };
379            let avg_us = r.total_exec_us.checked_div(r.invocations).unwrap_or(0);
380            println!(
381                "  [{exec_str}] {}: {} call(s), {}µs total, {}µs avg",
382                r.fingerprint, r.invocations, r.total_exec_us, avg_us,
383            );
384        }
385    }
386}
387
388thread_local! {
389    static PROFILER: RefCell<Option<ProfileCollector>> = const { RefCell::new(None) };
390}
391
392/// Enable profiling for the current thread. Resets any previous collector.
393pub fn enable() {
394    PROFILER.with(|p| *p.borrow_mut() = Some(ProfileCollector::new()));
395}
396
397/// Disable profiling and return the collected data, if any.
398pub fn take() -> Option<ProfileCollector> {
399    PROFILER.with(|p| p.borrow_mut().take())
400}
401
402/// Whether telemetry is enabled for the current thread.
403pub(crate) fn is_enabled() -> bool {
404    PROFILER.with(|p| p.borrow().is_some())
405}
406
407/// Record one query invocation. No-op when profiling is disabled.
408pub fn record(fingerprint: &str, exec_us: u64, executor: ExecutorKind) {
409    PROFILER.with(|p| {
410        if let Some(col) = p.borrow_mut().as_mut() {
411            col.record_invocation(fingerprint, exec_us, executor);
412        }
413    });
414}
415
416/// Record one shape/rule evaluation. No-op when profiling is disabled.
417pub fn record_shape(label: &str, exec_us: u64) {
418    PROFILER.with(|p| {
419        if let Some(col) = p.borrow_mut().as_mut() {
420            col.record_shape_invocation(label, exec_us);
421        }
422    });
423}
424
425pub(crate) fn record_store_build() {
426    PROFILER.with(|p| {
427        if let Some(col) = p.borrow_mut().as_mut() {
428            col.storage.store_builds += 1;
429        }
430    });
431}
432
433pub(crate) fn record_graph_union(rows: usize) {
434    PROFILER.with(|p| {
435        if let Some(col) = p.borrow_mut().as_mut() {
436            col.storage.graph_union_builds += 1;
437            col.storage.graph_union_rows += rows as u64;
438        }
439    });
440}
441
442pub(crate) fn record_graph_projection(rows: usize) {
443    PROFILER.with(|p| {
444        if let Some(col) = p.borrow_mut().as_mut() {
445            col.storage.graph_projection_builds += 1;
446            col.storage.graph_projection_rows += rows as u64;
447        }
448    });
449}
450
451pub(crate) fn record_source_build(rows: usize) {
452    PROFILER.with(|p| {
453        if let Some(col) = p.borrow_mut().as_mut() {
454            col.storage.source_builds += 1;
455            col.storage.source_rows += rows as u64;
456        }
457    });
458}
459
460pub(crate) fn record_source_encode_time(us: u64) {
461    PROFILER.with(|p| {
462        if let Some(col) = p.borrow_mut().as_mut() {
463            col.storage.source_encode_us += us;
464        }
465    });
466}
467
468pub(crate) fn record_access_catalog_time(us: u64) {
469    PROFILER.with(|p| {
470        if let Some(col) = p.borrow_mut().as_mut() {
471            col.storage.access_catalog_us = col.storage.access_catalog_us.saturating_add(us);
472        }
473    });
474}
475
476pub(crate) fn record_dataset_build(rows: usize) {
477    PROFILER.with(|p| {
478        if let Some(col) = p.borrow_mut().as_mut() {
479            col.storage.dataset_builds += 1;
480            col.storage.encoded_rows += rows as u64;
481        }
482    });
483}
484
485pub(crate) fn record_session_encode_time(us: u64) {
486    PROFILER.with(|p| {
487        if let Some(col) = p.borrow_mut().as_mut() {
488            col.storage.session_encode_us += us;
489        }
490    });
491}
492
493pub(crate) fn record_dataset_commit(rows: usize, us: u64) {
494    PROFILER.with(|p| {
495        if let Some(col) = p.borrow_mut().as_mut() {
496            col.storage.committed_rows += rows as u64;
497            col.storage.commit_batches += 1;
498            col.storage.commit_us += us;
499        }
500    });
501}
502
503pub(crate) fn record_index_decision(record: IndexRecord) {
504    PROFILER.with(|p| {
505        if let Some(col) = p.borrow_mut().as_mut() {
506            if record.accepted {
507                col.storage.index_builds += 1;
508                col.storage.index_bytes += record.actual_bytes as u64;
509            } else {
510                col.storage.index_declines += 1;
511            }
512            col.indexes.push(record);
513        }
514    });
515}
516
517pub(crate) fn record_primary_index_bytes(scope: &'static str, old: usize, new: usize) {
518    PROFILER.with(|p| {
519        if let Some(col) = p.borrow_mut().as_mut() {
520            let bytes = if scope == "source" {
521                &mut col.storage.source_primary_bytes
522            } else {
523                &mut col.storage.session_primary_bytes
524            };
525            *bytes = bytes.saturating_sub(old as u64) + new as u64;
526        }
527    });
528}
529
530pub(crate) fn observe_shared_source_primary_bytes(bytes: usize) {
531    PROFILER.with(|p| {
532        if let Some(col) = p.borrow_mut().as_mut() {
533            col.storage.source_primary_bytes = col.storage.source_primary_bytes.max(bytes as u64);
534        }
535    });
536}
537
538pub(crate) fn record_scan(scope: &'static str, mask: usize, candidates: usize) {
539    PROFILER.with(|p| {
540        if let Some(col) = p.borrow_mut().as_mut() {
541            let scope = usize::from(scope == "session");
542            let scan = &mut col.scans[scope][mask];
543            scan.calls += 1;
544            scan.candidate_rows += candidates as u64;
545        }
546    });
547}
548
549pub(crate) fn record_reach_cache_lookup(hit: bool) {
550    PROFILER.with(|p| {
551        if let Some(col) = p.borrow_mut().as_mut() {
552            if hit {
553                col.reach_cache.hits += 1;
554            } else {
555                col.reach_cache.misses += 1;
556            }
557        }
558    });
559}
560
561pub(crate) fn record_reach_cache_admission(inserted: bool, result_ids: usize) {
562    PROFILER.with(|p| {
563        if let Some(col) = p.borrow_mut().as_mut() {
564            if inserted {
565                col.reach_cache.insertions += 1;
566            } else {
567                col.reach_cache.declined_ids += result_ids as u64;
568            }
569        }
570    });
571}
572
573/// Record one evaluation plus its evidence-node visits. No-op when profiling
574/// is disabled.
575pub(crate) fn record_shape_work(label: &str, exec_us: u64, visits: u64) {
576    PROFILER.with(|p| {
577        if let Some(col) = p.borrow_mut().as_mut() {
578            col.record_shape_work(label, exec_us, visits);
579        }
580    });
581}
582
583thread_local! {
584    static EVIDENCE_VISITS: Cell<u64> = const { Cell::new(0) };
585}
586
587/// One evidence-materialization node visit.
588///
589/// Unlike the counters above this is always on: it is a `Cell` increment beside
590/// a function that already clones a `Shape`, and it is the only way to see how
591/// far the shared shape DAG expands into a per-pair evidence tree. Visits count
592/// nodes *entered*, including those later pruned from the retained evidence.
593pub(crate) fn record_evidence_visit() {
594    EVIDENCE_VISITS.with(|visits| visits.set(visits.get().wrapping_add(1)));
595}
596
597/// Read and reset the evidence-visit counter for this thread.
598pub fn take_evidence_visits() -> u64 {
599    EVIDENCE_VISITS.with(|visits| visits.replace(0))
600}
601
602/// Read the evidence-visit counter without resetting it, for deltas around a
603/// region of work.
604pub fn evidence_visits() -> u64 {
605    EVIDENCE_VISITS.with(Cell::get)
606}
607
608thread_local! {
609    static PATH_PROBES: Cell<u64> = const { Cell::new(0) };
610}
611
612/// One `path_support` probe. Each probe re-derives a full successor set from
613/// the backend to answer a single membership question, so probe count is the
614/// multiplier on path work that evidence pays and conformance does not.
615pub(crate) fn record_path_probe() {
616    PATH_PROBES.with(|probes| probes.set(probes.get().wrapping_add(1)));
617}
618
619/// Read and reset the path-probe counter for this thread.
620pub fn take_path_probes() -> u64 {
621    PATH_PROBES.with(|probes| probes.replace(0))
622}
623
624/// Merge one evaluator's shape-cache telemetry. No-op when profiling is
625/// disabled.
626pub(crate) fn record_shape_cache(sample: ShapeCacheSample) {
627    PROFILER.with(|p| {
628        if let Some(col) = p.borrow_mut().as_mut() {
629            col.record_shape_cache(sample);
630        }
631    });
632}
633
634/// Helper: measure `f` and record the result under `fingerprint`. Returns the
635/// value produced by `f`.
636pub fn timed<T>(fingerprint: &str, f: impl FnOnce() -> T) -> T {
637    let start = Instant::now();
638    let result = f();
639    let us = start.elapsed().as_micros() as u64;
640    record(fingerprint, us, ExecutorKind::Fallback { reason: None });
641    result
642}
643
644/// Derive a short fingerprint from a canonical query string.
645pub fn fingerprint(query: &str) -> String {
646    let trimmed = query.trim();
647    let preview: String = trimmed.chars().take(160).collect();
648    // Replace newlines/runs of whitespace with a single space for readability.
649    preview.split_whitespace().collect::<Vec<_>>().join(" ")
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn aggregates_shape_cache_samples() {
658        let mut collector = ProfileCollector::new();
659        collector.record_shape_cache(ShapeCacheSample {
660            hits: 3,
661            misses: 5,
662            insertions: 4,
663            recursion_back_edges: 1,
664            non_cacheable_results: 2,
665            entries: 4,
666            estimated_bytes: 400,
667        });
668        collector.record_shape_cache(ShapeCacheSample {
669            hits: 7,
670            misses: 2,
671            insertions: 2,
672            recursion_back_edges: 0,
673            non_cacheable_results: 0,
674            entries: 2,
675            estimated_bytes: 250,
676        });
677
678        assert_eq!(
679            collector.shape_cache(),
680            &ShapeCacheRecord {
681                evaluators: 2,
682                hits: 10,
683                misses: 7,
684                insertions: 6,
685                recursion_back_edges: 1,
686                non_cacheable_results: 2,
687                peak_entries: 4,
688                estimated_peak_bytes: 400,
689            }
690        );
691    }
692}