1use std::cell::{Cell, RefCell};
9use web_time::Instant;
10
11#[derive(Debug, Clone)]
13pub struct QueryRecord {
14 pub fingerprint: String,
16 pub executor: ExecutorKind,
18 pub invocations: u64,
20 pub total_exec_us: u64,
22}
23
24#[derive(Debug, Clone)]
27pub struct ShapeRecord {
28 pub label: String,
31 pub invocations: u64,
34 pub total_us: u64,
36 pub visits: u64,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct ShapeCacheRecord {
44 pub evaluators: u64,
46 pub hits: u64,
48 pub misses: u64,
50 pub insertions: u64,
52 pub recursion_back_edges: u64,
54 pub non_cacheable_results: u64,
56 pub peak_entries: usize,
58 pub estimated_peak_bytes: usize,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct StorageRecord {
69 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 pub source_primary_bytes: u64,
88 pub session_primary_bytes: u64,
89 pub source_encode_us: u64,
91 pub session_encode_us: u64,
93}
94
95#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct ScanRecord {
114 pub calls: u64,
115 pub candidate_rows: u64,
116}
117
118#[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#[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 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 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
392pub fn enable() {
394 PROFILER.with(|p| *p.borrow_mut() = Some(ProfileCollector::new()));
395}
396
397pub fn take() -> Option<ProfileCollector> {
399 PROFILER.with(|p| p.borrow_mut().take())
400}
401
402pub(crate) fn is_enabled() -> bool {
404 PROFILER.with(|p| p.borrow().is_some())
405}
406
407pub 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
416pub 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
573pub(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
587pub(crate) fn record_evidence_visit() {
594 EVIDENCE_VISITS.with(|visits| visits.set(visits.get().wrapping_add(1)));
595}
596
597pub fn take_evidence_visits() -> u64 {
599 EVIDENCE_VISITS.with(|visits| visits.replace(0))
600}
601
602pub 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
612pub(crate) fn record_path_probe() {
616 PATH_PROBES.with(|probes| probes.set(probes.get().wrapping_add(1)));
617}
618
619pub fn take_path_probes() -> u64 {
621 PATH_PROBES.with(|probes| probes.replace(0))
622}
623
624pub(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
634pub 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
644pub fn fingerprint(query: &str) -> String {
646 let trimmed = query.trim();
647 let preview: String = trimmed.chars().take(160).collect();
648 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}