Skip to main content

xlog_runtime/
profiler.rs

1//! Performance profiler for execution statistics
2//!
3//! This module provides [`Profiler`] for tracking per-operation and per-stratum
4//! statistics during query execution. It can be used to identify performance
5//! bottlenecks and understand resource usage patterns.
6//!
7//! # Example
8//!
9//! ```
10//! use xlog_runtime::profiler::{Profiler, OpStats};
11//!
12//! let mut profiler = Profiler::new(true);
13//!
14//! // Record operation statistics
15//! profiler.record(OpStats {
16//!     op_name: "hash_join".to_string(),
17//!     input_rows: 1000,
18//!     output_rows: 500,
19//!     duration_us: 1500,
20//!     memory_bytes: 4096,
21//! });
22//!
23//! // Get summary
24//! println!("{}", profiler.summary());
25//! ```
26
27use std::collections::HashMap;
28use std::time::Instant;
29
30/// Statistics for a single operation
31///
32/// Tracks the name, row counts, duration, and memory usage for an operation.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct OpStats {
35    /// Name of the operation (e.g., "hash_join", "filter", "scan")
36    pub op_name: String,
37    /// Number of input rows processed
38    pub input_rows: u64,
39    /// Number of output rows produced
40    pub output_rows: u64,
41    /// Duration in microseconds
42    pub duration_us: u64,
43    /// Memory used in bytes
44    pub memory_bytes: u64,
45}
46
47impl OpStats {
48    /// Create a new OpStats with all fields
49    pub fn new(
50        op_name: impl Into<String>,
51        input_rows: u64,
52        output_rows: u64,
53        duration_us: u64,
54        memory_bytes: u64,
55    ) -> Self {
56        Self {
57            op_name: op_name.into(),
58            input_rows,
59            output_rows,
60            duration_us,
61            memory_bytes,
62        }
63    }
64
65    /// Create OpStats for an operation with no memory tracking
66    pub fn timed(
67        op_name: impl Into<String>,
68        input_rows: u64,
69        output_rows: u64,
70        duration_us: u64,
71    ) -> Self {
72        Self {
73            op_name: op_name.into(),
74            input_rows,
75            output_rows,
76            duration_us,
77            memory_bytes: 0,
78        }
79    }
80}
81
82/// Statistics for a single stratum
83#[derive(Debug, Clone, Default)]
84#[non_exhaustive]
85pub struct StratumStats {
86    /// Stratum index (0-based)
87    pub stratum_id: usize,
88    /// Number of rules in this stratum
89    pub num_rules: usize,
90    /// Whether this stratum contains recursive rules
91    pub is_recursive: bool,
92    /// Number of iterations (1 for non-recursive, N for fixpoint)
93    pub iterations: usize,
94    /// Total duration in microseconds
95    pub duration_us: u64,
96    /// Operations within this stratum
97    pub ops: Vec<OpStats>,
98}
99
100impl StratumStats {
101    /// Create a new StratumStats
102    pub fn new(stratum_id: usize, num_rules: usize, is_recursive: bool) -> Self {
103        Self {
104            stratum_id,
105            num_rules,
106            is_recursive,
107            iterations: if is_recursive { 0 } else { 1 },
108            duration_us: 0,
109            ops: Vec::new(),
110        }
111    }
112
113    /// Get aggregated operation counts by operation name
114    pub fn op_summary(&self) -> HashMap<String, (usize, u64)> {
115        let mut summary: HashMap<String, (usize, u64)> = HashMap::new();
116        for op in &self.ops {
117            let entry = summary.entry(op.op_name.clone()).or_insert((0, 0));
118            entry.0 += 1;
119            entry.1 += op.duration_us;
120        }
121        summary
122    }
123}
124
125/// Counts of actual fallbacks taken after a WCOJ-family dispatch attempt.
126///
127/// These counters are recorded at the execution boundary, not at individual
128/// eligibility checks. One attempted route therefore contributes at most one
129/// fallback count before the ordinary execution path runs.
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131pub struct WcojFallbackStats {
132    /// Two-relation chain specialization declined.
133    pub chain: u64,
134    /// A dedicated triangle, cycle, or clique route declined.
135    pub dedicated_multiway: u64,
136    /// A general Free Join route declined.
137    pub free_join: u64,
138    /// The planner explicitly selected the captured hash route.
139    pub planned_hash: u64,
140    /// Factorized recursive-delta execution declined to the ordinary delta path.
141    pub factorized_delta: u64,
142    /// Fused group-by execution declined to materialize-then-aggregate.
143    pub groupby_fusion: u64,
144}
145
146impl WcojFallbackStats {
147    /// Total number of WCOJ-family fallbacks actually executed.
148    pub fn total(self) -> u64 {
149        self.chain
150            .saturating_add(self.dedicated_multiway)
151            .saturating_add(self.free_join)
152            .saturating_add(self.planned_hash)
153            .saturating_add(self.factorized_delta)
154            .saturating_add(self.groupby_fusion)
155    }
156
157    /// Accumulate another execution's counters without overflowing.
158    pub fn saturating_add_assign(&mut self, other: Self) {
159        self.chain = self.chain.saturating_add(other.chain);
160        self.dedicated_multiway = self
161            .dedicated_multiway
162            .saturating_add(other.dedicated_multiway);
163        self.free_join = self.free_join.saturating_add(other.free_join);
164        self.planned_hash = self.planned_hash.saturating_add(other.planned_hash);
165        self.factorized_delta = self.factorized_delta.saturating_add(other.factorized_delta);
166        self.groupby_fusion = self.groupby_fusion.saturating_add(other.groupby_fusion);
167    }
168}
169
170/// Final execution statistics returned to CLI
171#[derive(Debug, Clone, Default)]
172#[non_exhaustive]
173pub struct ExecutionStats {
174    /// Total execution duration in microseconds
175    pub total_duration_us: u64,
176    /// Per-stratum statistics
177    pub strata: Vec<StratumStats>,
178    /// Peak memory usage in bytes
179    pub peak_memory_bytes: u64,
180    /// Memory budget in bytes
181    pub memory_budget_bytes: u64,
182    /// Total output rows across all queries
183    pub total_output_rows: u64,
184    /// WCOJ triangle-hook dispatches that installed a result (vs. silently
185    /// falling back to the binary-join path). A value > 0 is the proof a
186    /// run actually used the WCOJ triangle kernel.
187    pub wcoj_triangle_dispatch_count: u64,
188    /// WCOJ 4-cycle-hook dispatches that installed a result.
189    pub wcoj_4cycle_dispatch_count: u64,
190    /// Aggregate-fused group-by-root WCOJ dispatches (count without
191    /// materializing the join rows).
192    pub wcoj_groupby_fusion_dispatch_count: u64,
193    /// Generalized Free Join dispatches installed via the multiway plan.
194    pub free_join_dispatch_count: u64,
195    /// Factorized recursive-delta dispatches installed in the semi-naive
196    /// fixpoint.
197    pub factorized_delta_dispatch_count: u64,
198    /// Scan operations in embedded binary fallbacks that are logically
199    /// equivalent to successful ChainJoin specializations. These operations
200    /// did not execute and are therefore excluded from `strata[*].ops`.
201    pub chain_fallback_scan_equivalents: u64,
202    /// Filter operations in embedded binary fallbacks that are logically
203    /// equivalent to successful ChainJoin specializations. These operations
204    /// did not execute and are therefore excluded from `strata[*].ops`.
205    pub chain_fallback_filter_equivalents: u64,
206    /// WCOJ pipeline errors converted into binary-join declines. 0 is
207    /// healthy; a nonzero value signals a regressed WCOJ pipeline hiding
208    /// behind the silent-fallback contract.
209    pub wcoj_error_decline_count: u64,
210    /// Exhaustive route-level telemetry for WCOJ-family fallbacks that ran.
211    pub wcoj_fallback: WcojFallbackStats,
212    /// Selection, transfer, and device-timing evidence for an attempted
213    /// resident conditional-graph execution.
214    pub resident_graph: Option<crate::resident_graph::ResidentGraphExecutionStats>,
215}
216
217impl ExecutionStats {
218    /// Format stats as human-readable string
219    pub fn format_human(&self) -> String {
220        let total_secs = self.total_duration_us as f64 / 1_000_000.0;
221        let mut output = String::new();
222
223        output.push_str(&format!("Execution completed in {:.2}s\n\n", total_secs));
224
225        for stratum in &self.strata {
226            let stratum_secs = stratum.duration_us as f64 / 1_000_000.0;
227            let recursive_info = if stratum.is_recursive {
228                format!(", recursive, {} iterations", stratum.iterations)
229            } else {
230                String::new()
231            };
232
233            output.push_str(&format!(
234                "Stratum {}: {:.2}s ({} rules{})\n",
235                stratum.stratum_id, stratum_secs, stratum.num_rules, recursive_info
236            ));
237
238            // Aggregate operations by name
239            let op_summary = stratum.op_summary();
240            let mut ops: Vec<_> = op_summary.into_iter().collect();
241            ops.sort_by_key(|op| std::cmp::Reverse(op.1 .1)); // Sort by duration descending
242
243            for (op_name, (count, duration_us)) in ops {
244                let op_secs = duration_us as f64 / 1_000_000.0;
245                output.push_str(&format!(
246                    "  - {}: {:.2}s ({} calls)\n",
247                    op_name, op_secs, count
248                ));
249            }
250        }
251
252        let peak_mb = self.peak_memory_bytes as f64 / (1024.0 * 1024.0);
253        let budget_mb = self.memory_budget_bytes as f64 / (1024.0 * 1024.0);
254        output.push_str(&format!(
255            "\nMemory: {:.0} MB peak / {:.0} MB budget\n",
256            peak_mb, budget_mb
257        ));
258        output.push_str(&format!(
259            "Output: {} rows\n",
260            format_rows(self.total_output_rows)
261        ));
262        output.push_str(&format!(
263            "WCOJ dispatch: triangle {}, 4-cycle {}, groupby-fusion {}, free-join {}, factorized-delta {}, fallbacks {}, pipeline-errors {}\n",
264            self.wcoj_triangle_dispatch_count,
265            self.wcoj_4cycle_dispatch_count,
266            self.wcoj_groupby_fusion_dispatch_count,
267            self.free_join_dispatch_count,
268            self.factorized_delta_dispatch_count,
269            self.wcoj_fallback.total(),
270            self.wcoj_error_decline_count,
271        ));
272        output.push_str(&format!(
273            "chain fallback equivalents: scans {}, filters {}\n",
274            self.chain_fallback_scan_equivalents, self.chain_fallback_filter_equivalents
275        ));
276
277        output
278    }
279
280    /// Format stats as JSON string
281    pub fn format_json(&self) -> String {
282        let strata = self
283            .strata
284            .iter()
285            .map(|stratum| {
286                let mut operations: Vec<_> = stratum.op_summary().into_iter().collect();
287                operations.sort_unstable_by(|left, right| left.0.cmp(&right.0));
288                let operations = operations
289                    .into_iter()
290                    .map(|(name, (calls, duration_us))| {
291                        serde_json::json!({
292                            "op": name,
293                            "calls": calls,
294                            "duration_ms": duration_us / 1000,
295                        })
296                    })
297                    .collect::<Vec<_>>();
298                serde_json::json!({
299                    "stratum": stratum.stratum_id,
300                    "rules": stratum.num_rules,
301                    "recursive": stratum.is_recursive,
302                    "iterations": stratum.iterations,
303                    "duration_ms": stratum.duration_us / 1000,
304                    "ops": operations,
305                })
306            })
307            .collect::<Vec<_>>();
308
309        let mut json = serde_json::json!({
310            "total_ms": self.total_duration_us / 1000,
311            "strata": strata,
312            "peak_memory_mb": self.peak_memory_bytes / (1024 * 1024),
313            "budget_memory_mb": self.memory_budget_bytes / (1024 * 1024),
314            "output_rows": self.total_output_rows,
315            "wcoj": {
316                "triangle_dispatch": self.wcoj_triangle_dispatch_count,
317                "four_cycle_dispatch": self.wcoj_4cycle_dispatch_count,
318                "groupby_fusion_dispatch": self.wcoj_groupby_fusion_dispatch_count,
319                "free_join_dispatch": self.free_join_dispatch_count,
320                "factorized_delta_dispatch": self.factorized_delta_dispatch_count,
321                "chain_fallback_scan_equivalents": self.chain_fallback_scan_equivalents,
322                "chain_fallback_filter_equivalents": self.chain_fallback_filter_equivalents,
323                "error_decline": self.wcoj_error_decline_count,
324                "fallback": {
325                    "total": self.wcoj_fallback.total(),
326                    "chain": self.wcoj_fallback.chain,
327                    "dedicated_multiway": self.wcoj_fallback.dedicated_multiway,
328                    "free_join": self.wcoj_fallback.free_join,
329                    "planned_hash": self.wcoj_fallback.planned_hash,
330                    "factorized_delta": self.wcoj_fallback.factorized_delta,
331                    "groupby_fusion": self.wcoj_fallback.groupby_fusion,
332                },
333            },
334        });
335        if let Some(resident) = &self.resident_graph {
336            let selection = match resident.selection {
337                crate::resident_graph::ResidentGraphSelectionKind::ExistingGpu => "existing_gpu",
338                crate::resident_graph::ResidentGraphSelectionKind::ResidentConditionalGraph => {
339                    "resident_conditional_graph"
340                }
341            };
342            let mut resident_json = serde_json::json!({
343                "selection": selection,
344                "conditional_graph_launches": resident.conditional_graph_launches,
345                "terminal_synchronizations": resident.terminal_synchronizations,
346                "host_iterations": resident.host_iterations,
347                "host_allocations": resident.host_allocations,
348                "host_status_injections": resident.host_status_injections,
349                "deterministic_d2h_violations": resident.deterministic_d2h_violations,
350                "host_dispatched_scan_ops": resident.host_dispatched_scan_ops,
351                "host_dispatched_filter_ops": resident.host_dispatched_filter_ops,
352                "device_scan_invocations": resident.device_scan_invocations,
353                "device_filter_invocations": resident.device_filter_invocations,
354                "semantic_scan_invocations": resident.semantic_scan_invocations,
355                "semantic_filter_invocations": resident.semantic_filter_invocations,
356                "staged_store_mutations": resident.staged_store_mutations,
357                "deferred_profile": {
358                    "timed_scan_filter_invocations": resident.deferred_profile.timed_scan_filter_invocations,
359                    "device_elapsed_ns": resident.deferred_profile.device_elapsed_ns,
360                    "final_sync_misattributed_ns": resident.deferred_profile.final_sync_misattributed_ns,
361                },
362                "core_transfers": {
363                    "tracked_htod_calls": resident.core_transfers.tracked_htod_calls,
364                    "tracked_htod_bytes": resident.core_transfers.tracked_htod_bytes,
365                    "tracked_dtoh_calls": resident.core_transfers.tracked_dtoh_calls,
366                    "tracked_dtoh_bytes": resident.core_transfers.tracked_dtoh_bytes,
367                    "provider_dtoh_calls": resident.core_transfers.provider_dtoh_calls,
368                    "untracked_metadata_dtoh_calls": resident.core_transfers.untracked_metadata_dtoh_calls,
369                },
370                "final_observation": {
371                    "dtoh_calls": resident.final_observation.dtoh_calls,
372                    "dtoh_bytes": resident.final_observation.dtoh_bytes,
373                    "pinned_receipts": resident.final_observation.pinned_receipts,
374                },
375            });
376            if let Some(reason) = &resident.decline {
377                resident_json["resident_graph_declined"] =
378                    serde_json::Value::String(format!("{reason:?}"));
379            }
380            json["resident_graph"] = resident_json;
381        }
382        json.to_string()
383    }
384}
385
386/// Format row count with commas for readability
387fn format_rows(rows: u64) -> String {
388    let s = rows.to_string();
389    let mut result = String::new();
390    for (i, c) in s.chars().rev().enumerate() {
391        if i > 0 && i % 3 == 0 {
392            result.insert(0, ',');
393        }
394        result.insert(0, c);
395    }
396    result
397}
398
399/// Execution profiler for tracking operation statistics
400///
401/// The profiler collects statistics for each operation during query execution.
402/// It can be enabled or disabled; when disabled, `record` is a no-op for
403/// minimal overhead.
404///
405/// # Thread Safety
406///
407/// This implementation is NOT thread-safe. It is designed for single-threaded
408/// execution in the MVP.
409///
410/// # Example
411///
412/// ```
413/// use xlog_runtime::profiler::{Profiler, OpStats};
414///
415/// // Create an enabled profiler
416/// let mut profiler = Profiler::new(true);
417///
418/// // Record some stats
419/// profiler.record(OpStats::timed("scan", 0, 1000, 100));
420/// profiler.record(OpStats::timed("filter", 1000, 500, 200));
421///
422/// // Check totals
423/// assert_eq!(profiler.total_duration_us(), 300);
424///
425/// // Get summary
426/// println!("{}", profiler.summary());
427/// ```
428pub struct Profiler {
429    /// Whether profiling is enabled
430    enabled: bool,
431    /// Collected operation statistics (flat list for backward compatibility)
432    stats: Vec<OpStats>,
433    /// Per-stratum statistics
434    strata: Vec<StratumStats>,
435    /// Currently active stratum index
436    current_stratum: Option<usize>,
437    /// Stratum start time
438    stratum_start: Option<Instant>,
439    /// Peak memory observed during execution
440    peak_memory_bytes: u64,
441    /// Memory budget
442    memory_budget_bytes: u64,
443}
444
445impl Profiler {
446    /// Create a new profiler
447    ///
448    /// # Arguments
449    /// * `enabled` - Whether to collect statistics. When disabled, `record` is a no-op.
450    pub fn new(enabled: bool) -> Self {
451        Self {
452            enabled,
453            stats: Vec::new(),
454            strata: Vec::new(),
455            current_stratum: None,
456            stratum_start: None,
457            peak_memory_bytes: 0,
458            memory_budget_bytes: 0,
459        }
460    }
461
462    /// Set memory budget for reporting
463    pub fn set_memory_budget(&mut self, budget_bytes: u64) {
464        self.memory_budget_bytes = budget_bytes;
465    }
466
467    /// Begin timing a stratum
468    ///
469    /// # Arguments
470    /// * `stratum_id` - The stratum index
471    /// * `num_rules` - Number of rules in the stratum
472    /// * `is_recursive` - Whether the stratum is recursive
473    pub fn begin_stratum(&mut self, stratum_id: usize, num_rules: usize, is_recursive: bool) {
474        if !self.enabled {
475            return;
476        }
477        self.current_stratum = Some(stratum_id);
478        self.stratum_start = Some(Instant::now());
479        self.strata
480            .push(StratumStats::new(stratum_id, num_rules, is_recursive));
481    }
482
483    /// End timing the current stratum
484    pub fn end_stratum(&mut self) {
485        if !self.enabled {
486            return;
487        }
488        if let (Some(start), Some(_idx)) = (self.stratum_start.take(), self.current_stratum.take())
489        {
490            let duration = start.elapsed();
491            if let Some(stratum) = self.strata.last_mut() {
492                stratum.duration_us = duration.as_micros() as u64;
493            }
494        }
495    }
496
497    /// Record fixpoint iteration count for the current stratum
498    pub fn record_iterations(&mut self, iterations: usize) {
499        if !self.enabled {
500            return;
501        }
502        if let Some(stratum) = self.strata.last_mut() {
503            stratum.iterations = iterations;
504        }
505    }
506
507    /// Record an operation with timing
508    ///
509    /// This is a convenience method that calculates duration from a start time.
510    ///
511    /// # Arguments
512    /// * `op_name` - Name of the operation (e.g., "join", "filter", "scan")
513    /// * `input_rows` - Number of input rows
514    /// * `output_rows` - Number of output rows
515    /// * `start` - The instant when the operation started
516    /// * `memory_bytes` - Memory used by the operation
517    pub fn record_op(
518        &mut self,
519        op_name: impl Into<String>,
520        input_rows: u64,
521        output_rows: u64,
522        start: Instant,
523        memory_bytes: u64,
524    ) {
525        if !self.enabled {
526            return;
527        }
528        let duration = start.elapsed();
529        self.record(OpStats {
530            op_name: op_name.into(),
531            input_rows,
532            output_rows,
533            duration_us: duration.as_micros() as u64,
534            memory_bytes,
535        });
536    }
537
538    /// Start timing an operation
539    ///
540    /// Returns the current instant if profiling is enabled, None otherwise.
541    /// This allows zero-overhead timing when profiling is disabled.
542    #[inline]
543    pub fn start_op(&self) -> Option<Instant> {
544        if self.enabled {
545            Some(Instant::now())
546        } else {
547            None
548        }
549    }
550
551    /// Record peak memory observation
552    pub fn record_peak_memory(&mut self, memory_bytes: u64) {
553        if !self.enabled {
554            return;
555        }
556        if memory_bytes > self.peak_memory_bytes {
557            self.peak_memory_bytes = memory_bytes;
558        }
559    }
560
561    /// Get execution stats for CLI output
562    pub fn execution_stats(&self, total_output_rows: u64) -> ExecutionStats {
563        ExecutionStats {
564            total_duration_us: self.strata.iter().map(|s| s.duration_us).sum(),
565            strata: self.strata.clone(),
566            peak_memory_bytes: self.peak_memory_bytes,
567            memory_budget_bytes: self.memory_budget_bytes,
568            total_output_rows,
569            // WCOJ dispatch counters live on the executor, not the profiler;
570            // `Executor::execution_stats` fills them in after this call.
571            ..Default::default()
572        }
573    }
574
575    /// Check if profiling is enabled
576    pub fn is_enabled(&self) -> bool {
577        self.enabled
578    }
579
580    /// Record operation statistics
581    ///
582    /// If the profiler is disabled, this is a no-op.
583    /// If a stratum is active, the operation is also recorded in the stratum.
584    ///
585    /// # Arguments
586    /// * `stats` - The operation statistics to record
587    pub fn record(&mut self, stats: OpStats) {
588        if self.enabled {
589            // Also add to current stratum if one is active
590            if self.current_stratum.is_some() {
591                if let Some(stratum) = self.strata.last_mut() {
592                    stratum.ops.push(stats.clone());
593                }
594            }
595            self.stats.push(stats);
596        }
597    }
598
599    /// Get all recorded statistics
600    ///
601    /// Returns a slice of all operation statistics collected so far.
602    pub fn stats(&self) -> &[OpStats] {
603        &self.stats
604    }
605
606    /// Clear all recorded statistics
607    ///
608    /// Removes all collected statistics but keeps the profiler enabled/disabled state.
609    pub fn clear(&mut self) {
610        self.stats.clear();
611        self.strata.clear();
612        self.current_stratum = None;
613        self.stratum_start = None;
614        self.peak_memory_bytes = 0;
615    }
616
617    /// Get total duration across all operations in microseconds
618    pub fn total_duration_us(&self) -> u64 {
619        self.stats.iter().map(|s| s.duration_us).sum()
620    }
621
622    /// Get total memory usage across all operations in bytes
623    ///
624    /// Note: This is the sum of memory reported by each operation, which may
625    /// include overlapping allocations. It represents total memory activity
626    /// rather than peak memory usage.
627    pub fn total_memory_bytes(&self) -> u64 {
628        self.stats.iter().map(|s| s.memory_bytes).sum()
629    }
630
631    /// Get peak memory usage across all operations in bytes
632    ///
633    /// Returns the maximum memory_bytes value across all recorded operations.
634    /// Returns 0 if no operations have been recorded.
635    pub fn peak_memory_bytes(&self) -> u64 {
636        self.stats.iter().map(|s| s.memory_bytes).max().unwrap_or(0)
637    }
638
639    /// Get the number of recorded operations
640    pub fn operation_count(&self) -> usize {
641        self.stats.len()
642    }
643
644    /// Generate a human-readable summary of the profiling data
645    ///
646    /// The summary includes:
647    /// - Total operation count
648    /// - Total duration in milliseconds
649    /// - Total memory usage
650    /// - Per-operation breakdown with timing and row counts
651    pub fn summary(&self) -> String {
652        if self.stats.is_empty() {
653            return "Profiler: No operations recorded".to_string();
654        }
655
656        let total_duration_us = self.total_duration_us();
657        let total_duration_ms = total_duration_us as f64 / 1000.0;
658        let total_memory = self.total_memory_bytes();
659        let peak_memory = self.peak_memory_bytes();
660
661        let mut output = String::new();
662        output.push_str("=== Execution Profile ===\n");
663        output.push_str(&format!("Operations: {}\n", self.stats.len()));
664        output.push_str(&format!(
665            "Total duration: {:.3} ms ({} us)\n",
666            total_duration_ms, total_duration_us
667        ));
668        output.push_str(&format!("Total memory: {} bytes\n", total_memory));
669        output.push_str(&format!("Peak memory: {} bytes\n", peak_memory));
670        output.push_str("\n--- Operations ---\n");
671
672        for (i, stat) in self.stats.iter().enumerate() {
673            let duration_ms = stat.duration_us as f64 / 1000.0;
674            let percentage = if total_duration_us > 0 {
675                (stat.duration_us as f64 / total_duration_us as f64) * 100.0
676            } else {
677                0.0
678            };
679
680            output.push_str(&format!(
681                "{:3}. {:<20} | {:>10} -> {:>10} rows | {:>8.3} ms ({:>5.1}%) | {:>10} bytes\n",
682                i + 1,
683                truncate_name(&stat.op_name, 20),
684                stat.input_rows,
685                stat.output_rows,
686                duration_ms,
687                percentage,
688                stat.memory_bytes
689            ));
690        }
691
692        output
693    }
694
695    /// Enable or disable the profiler
696    ///
697    /// When disabled, `record` becomes a no-op. Existing stats are preserved.
698    pub fn set_enabled(&mut self, enabled: bool) {
699        self.enabled = enabled;
700    }
701}
702
703impl Default for Profiler {
704    /// Creates a disabled profiler by default
705    fn default() -> Self {
706        Self {
707            enabled: false,
708            stats: Vec::new(),
709            strata: Vec::new(),
710            current_stratum: None,
711            stratum_start: None,
712            peak_memory_bytes: 0,
713            memory_budget_bytes: 0,
714        }
715    }
716}
717
718/// RAII guard for measuring operation timing
719///
720/// Records the operation duration when dropped.
721pub struct MeasureGuard<'a> {
722    profiler: &'a mut Profiler,
723    op_name: String,
724    input_rows: u64,
725    start: Instant,
726    output_rows: Option<u64>,
727}
728
729impl<'a> MeasureGuard<'a> {
730    /// Create a new measure guard
731    pub fn new(profiler: &'a mut Profiler, op_name: impl Into<String>, input_rows: u64) -> Self {
732        Self {
733            profiler,
734            op_name: op_name.into(),
735            input_rows,
736            start: Instant::now(),
737            output_rows: None,
738        }
739    }
740
741    /// Set the output row count and finish timing
742    pub fn finish(mut self, output_rows: u64) {
743        self.output_rows = Some(output_rows);
744        // Drop will record the stats
745    }
746}
747
748impl<'a> Drop for MeasureGuard<'a> {
749    fn drop(&mut self) {
750        if self.profiler.is_enabled() {
751            let duration = self.start.elapsed();
752            self.profiler.record(OpStats {
753                op_name: std::mem::take(&mut self.op_name),
754                input_rows: self.input_rows,
755                output_rows: self.output_rows.unwrap_or(0),
756                duration_us: duration.as_micros() as u64,
757                memory_bytes: 0,
758            });
759        }
760    }
761}
762
763/// Truncate a name to fit within max_len characters
764fn truncate_name(name: &str, max_len: usize) -> String {
765    if name.len() <= max_len {
766        name.to_string()
767    } else {
768        format!("{}...", &name[..max_len.saturating_sub(3)])
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    #[test]
777    fn explicit_chain_equivalents_and_resident_semantic_counts_are_serialized_separately() {
778        let mut resident = crate::resident_graph::ResidentGraphExecutionStats::declined(
779            crate::resident_graph::ResidentGraphDeclineReason::SourceSetUncertified {
780                relation: "quoted\"relation\\name".to_string(),
781            },
782        );
783        resident.device_scan_invocations = 17;
784        resident.device_filter_invocations = 19;
785        resident.semantic_scan_invocations = 13;
786        resident.semantic_filter_invocations = 11;
787        let stats = ExecutionStats {
788            chain_fallback_scan_equivalents: 2,
789            chain_fallback_filter_equivalents: 3,
790            wcoj_fallback: WcojFallbackStats {
791                chain: 5,
792                dedicated_multiway: 7,
793                free_join: 11,
794                planned_hash: 13,
795                factorized_delta: 17,
796                groupby_fusion: 19,
797            },
798            resident_graph: Some(resident),
799            ..ExecutionStats::default()
800        };
801
802        let human = stats.format_human();
803        assert!(
804            human.contains("chain fallback equivalents: scans 2, filters 3"),
805            "{human}"
806        );
807        let json = stats.format_json();
808        let parsed: serde_json::Value =
809            serde_json::from_str(&json).expect("execution stats must be valid JSON");
810        assert_eq!(parsed["wcoj"]["chain_fallback_scan_equivalents"], 2);
811        assert_eq!(parsed["wcoj"]["chain_fallback_filter_equivalents"], 3);
812        assert_eq!(parsed["wcoj"]["fallback"]["total"], 72);
813        assert_eq!(parsed["wcoj"]["fallback"]["chain"], 5);
814        assert_eq!(parsed["wcoj"]["fallback"]["dedicated_multiway"], 7);
815        assert_eq!(parsed["wcoj"]["fallback"]["free_join"], 11);
816        assert_eq!(parsed["wcoj"]["fallback"]["planned_hash"], 13);
817        assert_eq!(parsed["wcoj"]["fallback"]["factorized_delta"], 17);
818        assert_eq!(parsed["wcoj"]["fallback"]["groupby_fusion"], 19);
819        assert_eq!(parsed["resident_graph"]["device_scan_invocations"], 17);
820        assert_eq!(parsed["resident_graph"]["device_filter_invocations"], 19);
821        assert_eq!(parsed["resident_graph"]["semantic_scan_invocations"], 13);
822        assert_eq!(parsed["resident_graph"]["semantic_filter_invocations"], 11);
823        assert_eq!(
824            parsed["resident_graph"]["resident_graph_declined"],
825            r#"SourceSetUncertified { relation: "quoted\"relation\\name" }"#
826        );
827        assert!(
828            json.contains("\"chain_fallback_scan_equivalents\":2"),
829            "{json}"
830        );
831        assert!(
832            json.contains("\"chain_fallback_filter_equivalents\":3"),
833            "{json}"
834        );
835        assert!(json.contains("\"device_scan_invocations\":17"), "{json}");
836        assert!(json.contains("\"device_filter_invocations\":19"), "{json}");
837        assert!(json.contains("\"semantic_scan_invocations\":13"), "{json}");
838        assert!(
839            json.contains("\"semantic_filter_invocations\":11"),
840            "{json}"
841        );
842    }
843
844    // ============== OpStats Tests ==============
845
846    #[test]
847    fn test_opstats_new() {
848        let stats = OpStats::new("hash_join", 1000, 500, 1500, 4096);
849
850        assert_eq!(stats.op_name, "hash_join");
851        assert_eq!(stats.input_rows, 1000);
852        assert_eq!(stats.output_rows, 500);
853        assert_eq!(stats.duration_us, 1500);
854        assert_eq!(stats.memory_bytes, 4096);
855    }
856
857    #[test]
858    fn test_opstats_timed() {
859        let stats = OpStats::timed("filter", 1000, 800, 200);
860
861        assert_eq!(stats.op_name, "filter");
862        assert_eq!(stats.input_rows, 1000);
863        assert_eq!(stats.output_rows, 800);
864        assert_eq!(stats.duration_us, 200);
865        assert_eq!(stats.memory_bytes, 0);
866    }
867
868    #[test]
869    fn test_opstats_default() {
870        let stats = OpStats::default();
871
872        assert_eq!(stats.op_name, "");
873        assert_eq!(stats.input_rows, 0);
874        assert_eq!(stats.output_rows, 0);
875        assert_eq!(stats.duration_us, 0);
876        assert_eq!(stats.memory_bytes, 0);
877    }
878
879    #[test]
880    fn test_opstats_clone() {
881        let stats = OpStats::new("scan", 0, 1000, 100, 2048);
882        let cloned = stats.clone();
883
884        assert_eq!(stats, cloned);
885    }
886
887    #[test]
888    fn test_opstats_debug() {
889        let stats = OpStats::new("test_op", 100, 50, 10, 1024);
890        let debug_str = format!("{:?}", stats);
891
892        assert!(debug_str.contains("test_op"));
893        assert!(debug_str.contains("100"));
894        assert!(debug_str.contains("50"));
895    }
896
897    // ============== Profiler Creation Tests ==============
898
899    #[test]
900    fn test_profiler_new_enabled() {
901        let profiler = Profiler::new(true);
902
903        assert!(profiler.is_enabled());
904        assert!(profiler.stats().is_empty());
905    }
906
907    #[test]
908    fn test_profiler_new_disabled() {
909        let profiler = Profiler::new(false);
910
911        assert!(!profiler.is_enabled());
912        assert!(profiler.stats().is_empty());
913    }
914
915    #[test]
916    fn test_profiler_default() {
917        let profiler = Profiler::default();
918
919        assert!(!profiler.is_enabled());
920        assert!(profiler.stats().is_empty());
921    }
922
923    // ============== Profiler Recording Tests ==============
924
925    #[test]
926    fn test_profiler_record_when_enabled() {
927        let mut profiler = Profiler::new(true);
928
929        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
930        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
931
932        assert_eq!(profiler.stats().len(), 2);
933        assert_eq!(profiler.stats()[0].op_name, "op1");
934        assert_eq!(profiler.stats()[1].op_name, "op2");
935    }
936
937    #[test]
938    fn test_profiler_record_when_disabled() {
939        let mut profiler = Profiler::new(false);
940
941        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
942        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
943
944        assert!(profiler.stats().is_empty());
945    }
946
947    #[test]
948    fn test_profiler_set_enabled() {
949        let mut profiler = Profiler::new(false);
950
951        // Initially disabled, record should be no-op
952        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
953        assert!(profiler.stats().is_empty());
954
955        // Enable and record
956        profiler.set_enabled(true);
957        assert!(profiler.is_enabled());
958        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
959        assert_eq!(profiler.stats().len(), 1);
960        assert_eq!(profiler.stats()[0].op_name, "op2");
961
962        // Disable again
963        profiler.set_enabled(false);
964        assert!(!profiler.is_enabled());
965        profiler.record(OpStats::new("op3", 25, 10, 2, 256));
966        assert_eq!(profiler.stats().len(), 1); // Still only op2
967    }
968
969    // ============== Profiler Clear Tests ==============
970
971    #[test]
972    fn test_profiler_clear() {
973        let mut profiler = Profiler::new(true);
974
975        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
976        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
977        assert_eq!(profiler.stats().len(), 2);
978
979        profiler.clear();
980
981        assert!(profiler.stats().is_empty());
982        assert!(profiler.is_enabled()); // Enabled state preserved
983    }
984
985    #[test]
986    fn test_profiler_clear_preserves_enabled_state() {
987        let mut profiler = Profiler::new(true);
988        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
989        profiler.clear();
990
991        assert!(profiler.is_enabled());
992
993        profiler.set_enabled(false);
994        profiler.clear();
995
996        assert!(!profiler.is_enabled());
997    }
998
999    // ============== Profiler Aggregation Tests ==============
1000
1001    #[test]
1002    fn test_total_duration_us() {
1003        let mut profiler = Profiler::new(true);
1004
1005        profiler.record(OpStats::new("op1", 100, 50, 100, 0));
1006        profiler.record(OpStats::new("op2", 50, 25, 200, 0));
1007        profiler.record(OpStats::new("op3", 25, 10, 150, 0));
1008
1009        assert_eq!(profiler.total_duration_us(), 450);
1010    }
1011
1012    #[test]
1013    fn test_total_duration_us_empty() {
1014        let profiler = Profiler::new(true);
1015
1016        assert_eq!(profiler.total_duration_us(), 0);
1017    }
1018
1019    #[test]
1020    fn test_total_memory_bytes() {
1021        let mut profiler = Profiler::new(true);
1022
1023        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
1024        profiler.record(OpStats::new("op2", 50, 25, 5, 2048));
1025        profiler.record(OpStats::new("op3", 25, 10, 2, 512));
1026
1027        assert_eq!(profiler.total_memory_bytes(), 3584);
1028    }
1029
1030    #[test]
1031    fn test_total_memory_bytes_empty() {
1032        let profiler = Profiler::new(true);
1033
1034        assert_eq!(profiler.total_memory_bytes(), 0);
1035    }
1036
1037    #[test]
1038    fn test_peak_memory_bytes() {
1039        let mut profiler = Profiler::new(true);
1040
1041        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
1042        profiler.record(OpStats::new("op2", 50, 25, 5, 4096));
1043        profiler.record(OpStats::new("op3", 25, 10, 2, 2048));
1044
1045        assert_eq!(profiler.peak_memory_bytes(), 4096);
1046    }
1047
1048    #[test]
1049    fn test_peak_memory_bytes_empty() {
1050        let profiler = Profiler::new(true);
1051
1052        assert_eq!(profiler.peak_memory_bytes(), 0);
1053    }
1054
1055    #[test]
1056    fn test_operation_count() {
1057        let mut profiler = Profiler::new(true);
1058
1059        assert_eq!(profiler.operation_count(), 0);
1060
1061        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
1062        assert_eq!(profiler.operation_count(), 1);
1063
1064        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
1065        assert_eq!(profiler.operation_count(), 2);
1066
1067        profiler.clear();
1068        assert_eq!(profiler.operation_count(), 0);
1069    }
1070
1071    // ============== Profiler Summary Tests ==============
1072
1073    #[test]
1074    fn test_summary_empty() {
1075        let profiler = Profiler::new(true);
1076        let summary = profiler.summary();
1077
1078        assert!(summary.contains("No operations recorded"));
1079    }
1080
1081    #[test]
1082    fn test_summary_with_operations() {
1083        let mut profiler = Profiler::new(true);
1084
1085        profiler.record(OpStats::new("scan", 0, 1000, 100, 4096));
1086        profiler.record(OpStats::new("filter", 1000, 500, 200, 2048));
1087        profiler.record(OpStats::new("hash_join", 500, 250, 500, 8192));
1088
1089        let summary = profiler.summary();
1090
1091        // Check header
1092        assert!(summary.contains("=== Execution Profile ==="));
1093        assert!(summary.contains("Operations: 3"));
1094
1095        // Check timing
1096        assert!(summary.contains("Total duration:"));
1097        assert!(summary.contains("800 us"));
1098
1099        // Check memory
1100        assert!(summary.contains("Total memory: 14336 bytes"));
1101        assert!(summary.contains("Peak memory: 8192 bytes"));
1102
1103        // Check operations listed
1104        assert!(summary.contains("scan"));
1105        assert!(summary.contains("filter"));
1106        assert!(summary.contains("hash_join"));
1107
1108        // Check row counts are present
1109        assert!(summary.contains("1000"));
1110        assert!(summary.contains("500"));
1111        assert!(summary.contains("250"));
1112    }
1113
1114    #[test]
1115    fn test_summary_percentages() {
1116        let mut profiler = Profiler::new(true);
1117
1118        // Two operations with known durations for percentage calculation
1119        profiler.record(OpStats::new("fast_op", 100, 50, 250, 0));
1120        profiler.record(OpStats::new("slow_op", 100, 50, 750, 0));
1121
1122        let summary = profiler.summary();
1123
1124        // fast_op should be 25%, slow_op should be 75%
1125        assert!(summary.contains("25.0%") || summary.contains("25."));
1126        assert!(summary.contains("75.0%") || summary.contains("75."));
1127    }
1128
1129    // ============== Truncate Name Tests ==============
1130
1131    #[test]
1132    fn test_truncate_name_short() {
1133        let result = truncate_name("short", 20);
1134        assert_eq!(result, "short");
1135    }
1136
1137    #[test]
1138    fn test_truncate_name_exact() {
1139        let name = "exactly_twenty_chars"; // 20 chars
1140        let result = truncate_name(name, 20);
1141        assert_eq!(result, name);
1142    }
1143
1144    #[test]
1145    fn test_truncate_name_long() {
1146        let name = "this_is_a_very_long_operation_name";
1147        let result = truncate_name(name, 20);
1148        assert_eq!(result.len(), 20);
1149        assert!(result.ends_with("..."));
1150    }
1151
1152    // ============== Integration Tests ==============
1153
1154    #[test]
1155    fn test_profiler_full_workflow() {
1156        // Simulate a typical profiling workflow
1157        let mut profiler = Profiler::new(true);
1158
1159        // Simulate query execution
1160        profiler.record(OpStats::new("scan_edge", 0, 10000, 500, 40000));
1161        profiler.record(OpStats::new("scan_node", 0, 1000, 100, 4000));
1162        profiler.record(OpStats::new("hash_join", 11000, 5000, 2000, 100000));
1163        profiler.record(OpStats::new("filter", 5000, 2000, 300, 20000));
1164        profiler.record(OpStats::new("project", 2000, 2000, 50, 8000));
1165        profiler.record(OpStats::new("dedup", 2000, 1500, 400, 12000));
1166
1167        // Verify stats
1168        assert_eq!(profiler.operation_count(), 6);
1169        assert_eq!(profiler.total_duration_us(), 3350);
1170        assert_eq!(profiler.total_memory_bytes(), 184000);
1171        assert_eq!(profiler.peak_memory_bytes(), 100000);
1172
1173        // Generate summary
1174        let summary = profiler.summary();
1175        assert!(summary.contains("6"));
1176        assert!(summary.contains("scan_edge"));
1177        assert!(summary.contains("hash_join"));
1178        assert!(summary.contains("dedup"));
1179
1180        // Clear and verify
1181        profiler.clear();
1182        assert_eq!(profiler.operation_count(), 0);
1183        assert!(profiler.is_enabled());
1184    }
1185
1186    #[test]
1187    fn test_profiler_disabled_has_zero_overhead() {
1188        // When disabled, nothing should be stored
1189        let mut profiler = Profiler::new(false);
1190
1191        for i in 0..1000 {
1192            profiler.record(OpStats::new(
1193                format!("op_{}", i),
1194                i as u64,
1195                i as u64,
1196                i as u64,
1197                i as u64,
1198            ));
1199        }
1200
1201        // Should have zero stats
1202        assert_eq!(profiler.operation_count(), 0);
1203        assert_eq!(profiler.total_duration_us(), 0);
1204        assert_eq!(profiler.total_memory_bytes(), 0);
1205    }
1206
1207    #[test]
1208    fn test_profiler_stats_immutable_reference() {
1209        let mut profiler = Profiler::new(true);
1210
1211        profiler.record(OpStats::new("op1", 100, 50, 10, 1024));
1212
1213        // Get immutable reference
1214        let stats = profiler.stats();
1215        assert_eq!(stats.len(), 1);
1216        assert_eq!(stats[0].op_name, "op1");
1217
1218        // Can still record after getting immutable reference (in separate scope)
1219        profiler.record(OpStats::new("op2", 50, 25, 5, 512));
1220        assert_eq!(profiler.stats().len(), 2);
1221    }
1222
1223    #[test]
1224    fn test_opstats_equality() {
1225        let stats1 = OpStats::new("op", 100, 50, 10, 1024);
1226        let stats2 = OpStats::new("op", 100, 50, 10, 1024);
1227        let stats3 = OpStats::new("op", 100, 50, 10, 2048); // Different memory
1228
1229        assert_eq!(stats1, stats2);
1230        assert_ne!(stats1, stats3);
1231    }
1232}