Skip to main content

linkrs_query/executor/streaming/runtime/
profile.rs

1//! Per-operator execution profiling.
2//!
3//! [`OperatorProfile`] is the serializable snapshot, [`ProfileEntry`] holds
4//! lock-free atomic counters for the hot path, [`ProfileBoard`] is the
5//! per-query store, and [`ProfileCollector`] aggregates profiles for
6//! EXPLAIN / PROFILE output.
7
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering};
10use std::sync::Arc;
11use std::time::Instant;
12
13use parking_lot::Mutex;
14
15use parking_lot::RwLock;
16
17use crate::executor::streaming::plan::types::PhysicalOperatorId;
18
19/// Per-operator profile snapshot
20#[derive(Debug, Clone, Default)]
21pub struct OperatorProfile {
22    pub physical_operator_id: PhysicalOperatorId,
23    pub node_id: i64,
24    pub partition_id: Option<usize>,
25    pub name: String,
26    pub open_time_us: u64,
27    pub next_time_us: u64,
28    pub close_time_us: u64,
29    pub output_rows: u64,
30    /// Rows consumed from the input (visible rows pulled from the child).
31    /// Recorded only by operators whose feedback needs an input/output
32    /// pair (currently Filter); zero means unmeasured, not empty input.
33    pub input_rows: u64,
34    /// Whether this operator instance ran to exhaustion (`advance`
35    /// returned `None`). Operators starved by an upstream Limit or by
36    /// early termination never exhaust; their partial row counts must
37    /// not train the feedback loop.
38    pub exhausted: bool,
39    /// Number of chunks produced (advances).  Used as the per-operator
40    /// execution loop count in the feedback path.
41    pub advance_count: u64,
42    pub peak_memory: u64,
43    pub peak_memory_bytes: u64,
44    pub spilled_bytes: u64,
45    pub spill_count: u64,
46    /// Logical rows written to spill runs. Displayed next to the static
47    /// `spill_threshold` config so PROFILE shows configured vs actual.
48    pub spilled_rows: u64,
49}
50
51/// Identifies an operator instance in a partitioned executor tree.
52///
53/// A physical operator occurs once per local partition. Logical node IDs may
54/// be shared by multiple physical operators, so they are display metadata and
55/// cannot identify a profile entry. Global and gather operators use `None`.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub struct OperatorProfileKey {
58    pub physical_operator_id: PhysicalOperatorId,
59    pub partition_id: Option<usize>,
60}
61
62impl OperatorProfileKey {
63    pub const fn new(
64        physical_operator_id: PhysicalOperatorId,
65        partition_id: Option<usize>,
66    ) -> Self {
67        Self {
68            physical_operator_id,
69            partition_id,
70        }
71    }
72}
73
74/// Per-operator atomic profile counters for lock-free hot-path updates.
75///
76/// Each operator registers a [`ProfileEntry`] during `open()`.  Subsequent
77/// timing and row-count updates use atomic operations with no lock held,
78/// eliminating mutex contention on the per-advance hot path.
79#[derive(Debug)]
80pub struct ProfileEntry {
81    pub physical_operator_id: PhysicalOperatorId,
82    pub node_id: AtomicI64,
83    pub partition_id: Option<usize>,
84    pub name: parking_lot::Mutex<String>,
85    pub open_time_us: AtomicU64,
86    pub next_time_us: AtomicU64,
87    pub close_time_us: AtomicU64,
88    pub output_rows: AtomicU64,
89    pub input_rows: AtomicU64,
90    pub exhausted: AtomicBool,
91    pub advance_count: AtomicU64,
92    pub peak_memory_bytes: AtomicU64,
93    pub spilled_bytes: AtomicU64,
94    pub spill_count: AtomicU64,
95    pub spilled_rows: AtomicU64,
96}
97
98impl ProfileEntry {
99    pub fn new(profile: &OperatorProfile) -> Self {
100        Self {
101            physical_operator_id: profile.physical_operator_id,
102            node_id: AtomicI64::new(profile.node_id),
103            partition_id: profile.partition_id,
104            name: parking_lot::Mutex::new(profile.name.clone()),
105            open_time_us: AtomicU64::new(profile.open_time_us),
106            next_time_us: AtomicU64::new(profile.next_time_us),
107            close_time_us: AtomicU64::new(profile.close_time_us),
108            output_rows: AtomicU64::new(profile.output_rows),
109            input_rows: AtomicU64::new(profile.input_rows),
110            exhausted: AtomicBool::new(profile.exhausted),
111            advance_count: AtomicU64::new(profile.advance_count),
112            peak_memory_bytes: AtomicU64::new(profile.peak_memory_bytes),
113            spilled_bytes: AtomicU64::new(profile.spilled_bytes),
114            spill_count: AtomicU64::new(profile.spill_count),
115            spilled_rows: AtomicU64::new(profile.spilled_rows),
116        }
117    }
118
119    /// Snapshot current atomics into an [`OperatorProfile`] for reporting.
120    pub fn snapshot(&self) -> OperatorProfile {
121        OperatorProfile {
122            physical_operator_id: self.physical_operator_id,
123            node_id: self.node_id.load(Ordering::Relaxed),
124            partition_id: self.partition_id,
125            name: self.name.lock().clone(),
126            open_time_us: self.open_time_us.load(Ordering::Relaxed),
127            next_time_us: self.next_time_us.load(Ordering::Relaxed),
128            close_time_us: self.close_time_us.load(Ordering::Relaxed),
129            output_rows: self.output_rows.load(Ordering::Relaxed),
130            input_rows: self.input_rows.load(Ordering::Relaxed),
131            exhausted: self.exhausted.load(Ordering::Relaxed),
132            advance_count: self.advance_count.load(Ordering::Relaxed),
133            peak_memory: self.peak_memory_bytes.load(Ordering::Relaxed),
134            peak_memory_bytes: self.peak_memory_bytes.load(Ordering::Relaxed),
135            spilled_bytes: self.spilled_bytes.load(Ordering::Relaxed),
136            spill_count: self.spill_count.load(Ordering::Relaxed),
137            spilled_rows: self.spilled_rows.load(Ordering::Relaxed),
138        }
139    }
140}
141
142/// Lock-free profile store for hot-path operator timing and row-count updates.
143///
144/// Uses `RwLock` for structural operations (first-access entry creation) and
145/// `AtomicU64` counters so that per-advance increments never block.
146///
147/// Design:
148/// - `register_operator()` — called during `open()`, pre-creates entries
149/// - `record_timing()` / `record_rows()` — lock-free fast path
150/// - `flush_to_collector()` — aggregate into a [`ProfileCollector`] at end
151#[derive(Debug)]
152pub struct ProfileBoard {
153    entries: RwLock<HashMap<OperatorProfileKey, Arc<ProfileEntry>>>,
154    pub total_rows: AtomicU64,
155    pub total_time_us: AtomicU64,
156    start_time: Mutex<Option<Instant>>,
157    end_time: Mutex<Option<Instant>>,
158    pub parallel_wall_time_us: AtomicU64,
159    pub parallel_work_time_us: AtomicU64,
160    pub parallel_workers: AtomicUsize,
161    pub parallel_buffered_chunks_peak: AtomicUsize,
162    pub parallel_buffered_bytes_peak: AtomicUsize,
163}
164
165impl Default for ProfileBoard {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl ProfileBoard {
172    pub fn new() -> Self {
173        Self {
174            entries: RwLock::new(HashMap::new()),
175            total_rows: AtomicU64::new(0),
176            total_time_us: AtomicU64::new(0),
177            start_time: Mutex::new(None),
178            end_time: Mutex::new(None),
179            parallel_wall_time_us: AtomicU64::new(0),
180            parallel_work_time_us: AtomicU64::new(0),
181            parallel_workers: AtomicUsize::new(0),
182            parallel_buffered_chunks_peak: AtomicUsize::new(0),
183            parallel_buffered_bytes_peak: AtomicUsize::new(0),
184        }
185    }
186
187    pub fn record_start(&self) {
188        *self.start_time.lock() = Some(Instant::now());
189    }
190
191    pub fn record_end(&self) {
192        let elapsed = self
193            .start_time
194            .lock()
195            .map(|t| t.elapsed().as_micros() as u64)
196            .unwrap_or(0);
197        self.total_time_us.store(elapsed, Ordering::Relaxed);
198        *self.end_time.lock() = Some(Instant::now());
199    }
200
201    /// Register (or update) a per-operator profile entry from a snapshot.
202    ///
203    /// Called during `open()` to pre-populate entries so the hot-path
204    /// access in `advance()` never needs to write-lock.
205    pub fn register_operator(&self, profile: &OperatorProfile) -> Arc<ProfileEntry> {
206        let key = OperatorProfileKey::new(profile.physical_operator_id, profile.partition_id);
207        let entry = Arc::new(ProfileEntry::new(profile));
208        self.entries.write().insert(key, entry.clone());
209        entry
210    }
211
212    /// Find an entry by key (read-lock, hot-path friendly).
213    pub fn get_entry(&self, key: &OperatorProfileKey) -> Option<Arc<ProfileEntry>> {
214        self.entries.read().get(key).cloned()
215    }
216
217    /// Aggregate all entries into a [`ProfileCollector`] for EXPLAIN output.
218    pub fn flush_to_collector(&self) -> ProfileCollector {
219        let mut collector = ProfileCollector::new();
220        let guard = self.entries.read();
221        for entry in guard.values() {
222            collector.operators.insert(
223                OperatorProfileKey::new(entry.physical_operator_id, entry.partition_id),
224                entry.snapshot(),
225            );
226        }
227        drop(guard);
228        collector.total_rows = self.total_rows.load(Ordering::Relaxed);
229        collector.total_time_us = self.total_time_us.load(Ordering::Relaxed);
230        collector.start_time = *self.start_time.lock();
231        collector.end_time = *self.end_time.lock();
232        collector.parallel_wall_time_us = self.parallel_wall_time_us.load(Ordering::Relaxed);
233        collector.parallel_work_time_us = self.parallel_work_time_us.load(Ordering::Relaxed);
234        collector.parallel_workers = self.parallel_workers.load(Ordering::Relaxed);
235        collector.parallel_buffered_chunks_peak =
236            self.parallel_buffered_chunks_peak.load(Ordering::Relaxed);
237        collector.parallel_buffered_bytes_peak =
238            self.parallel_buffered_bytes_peak.load(Ordering::Relaxed);
239        collector
240    }
241}
242
243/// Collects execution profile data across all operators (for EXPLAIN output).
244#[derive(Debug, Default)]
245pub struct ProfileCollector {
246    pub operators: HashMap<OperatorProfileKey, OperatorProfile>,
247    pub total_rows: u64,
248    pub total_time_us: u64,
249    pub start_time: Option<Instant>,
250    pub end_time: Option<Instant>,
251    /// Wall-clock time spent in parallel partition execution.
252    pub parallel_wall_time_us: u64,
253    /// Sum of per-worker execution time (may exceed wall time).
254    pub parallel_work_time_us: u64,
255    /// Maximum number of workers used by any coordinator in this query.
256    pub parallel_workers: usize,
257    /// Peak number of chunks retained in output queues.
258    pub parallel_buffered_chunks_peak: usize,
259    /// Peak accounted bytes retained in output queues.
260    pub parallel_buffered_bytes_peak: usize,
261}
262
263impl ProfileCollector {
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    pub fn record_start(&mut self) {
269        self.start_time = Some(Instant::now());
270    }
271
272    pub fn record_end(&mut self) {
273        self.end_time = Some(Instant::now());
274        if let Some(start) = self.start_time {
275            self.total_time_us = start.elapsed().as_micros() as u64;
276        }
277    }
278
279    pub fn add_rows(&mut self, count: u64) {
280        self.total_rows += count;
281    }
282
283    pub fn record_operator_profile(&mut self, profile: OperatorProfile) {
284        let key = OperatorProfileKey::new(profile.physical_operator_id, profile.partition_id);
285        self.operators.insert(key, profile);
286    }
287
288    /// Return a snapshot of the parallel profile fields for
289    /// EXPLAIN / PROFILE output.
290    pub fn parallel_profile(&self) -> (u64, u64, usize, usize, usize) {
291        (
292            self.parallel_wall_time_us,
293            self.parallel_work_time_us,
294            self.parallel_workers,
295            self.parallel_buffered_chunks_peak,
296            self.parallel_buffered_bytes_peak,
297        )
298    }
299
300    /// Aggregate profiles from partition execution into this collector.
301    ///
302    /// For each operator node_id, sums timing/output_rows and takes the max
303    /// of peak_memory_bytes across partitions.
304    pub fn aggregate_partition_profiles(&mut self, partition_profiles: &[ProfileCollector]) {
305        for pp in partition_profiles {
306            for (key, op) in &pp.operators {
307                let entry = self
308                    .operators
309                    .entry(*key)
310                    .or_insert_with(|| OperatorProfile {
311                        physical_operator_id: key.physical_operator_id,
312                        node_id: op.node_id,
313                        partition_id: key.partition_id,
314                        name: op.name.clone(),
315                        // AND-fold below: start exhausted so the first
316                        // partition decides instead of a false default.
317                        exhausted: true,
318                        ..OperatorProfile::default()
319                    });
320                entry.open_time_us += op.open_time_us;
321                entry.next_time_us += op.next_time_us;
322                entry.close_time_us += op.close_time_us;
323                entry.output_rows += op.output_rows;
324                entry.input_rows += op.input_rows;
325                // One starved partition truncates the whole operator sample.
326                entry.exhausted = entry.exhausted && op.exhausted;
327                entry.advance_count += op.advance_count;
328                entry.peak_memory_bytes = entry.peak_memory_bytes.max(op.peak_memory_bytes);
329                entry.spill_count += op.spill_count;
330                entry.spilled_bytes += op.spilled_bytes;
331                entry.spilled_rows += op.spilled_rows;
332            }
333            self.total_rows += pp.total_rows;
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_profile_collector() {
344        let mut pc = ProfileCollector::new();
345        pc.record_start();
346        std::thread::sleep(std::time::Duration::from_micros(100));
347        pc.record_end();
348        assert!(pc.total_time_us > 0);
349    }
350
351    #[test]
352    fn test_partition_profile_aggregation_preserves_partition_identity() {
353        let mut first = ProfileCollector::new();
354        first.record_operator_profile(OperatorProfile {
355            physical_operator_id: PhysicalOperatorId(7),
356            node_id: 7,
357            partition_id: Some(0),
358            name: "ScanVertices".to_string(),
359            output_rows: 2,
360            peak_memory_bytes: 10,
361            ..OperatorProfile::default()
362        });
363        let mut second = ProfileCollector::new();
364        second.record_operator_profile(OperatorProfile {
365            physical_operator_id: PhysicalOperatorId(7),
366            node_id: 7,
367            partition_id: Some(1),
368            name: "ScanVertices".to_string(),
369            output_rows: 3,
370            peak_memory_bytes: 20,
371            ..OperatorProfile::default()
372        });
373
374        let mut aggregate = ProfileCollector::new();
375        aggregate.aggregate_partition_profiles(&[first, second]);
376
377        assert_eq!(aggregate.operators.len(), 2);
378        assert_eq!(
379            aggregate
380                .operators
381                .get(&OperatorProfileKey::new(PhysicalOperatorId(7), Some(0)))
382                .expect("partition zero profile")
383                .output_rows,
384            2
385        );
386        assert_eq!(
387            aggregate
388                .operators
389                .get(&OperatorProfileKey::new(PhysicalOperatorId(7), Some(1)))
390                .expect("partition one profile")
391                .peak_memory_bytes,
392            20
393        );
394    }
395
396    #[test]
397    fn test_partition_profile_aggregation_sums_spill_rows() {
398        let mut first = ProfileCollector::new();
399        first.record_operator_profile(OperatorProfile {
400            physical_operator_id: PhysicalOperatorId(9),
401            node_id: 9,
402            partition_id: Some(0),
403            name: "Sort".to_string(),
404            spilled_rows: 100,
405            spilled_bytes: 2048,
406            spill_count: 1,
407            ..OperatorProfile::default()
408        });
409        let mut second = ProfileCollector::new();
410        second.record_operator_profile(OperatorProfile {
411            physical_operator_id: PhysicalOperatorId(9),
412            node_id: 9,
413            partition_id: Some(1),
414            name: "Sort".to_string(),
415            spilled_rows: 50,
416            spilled_bytes: 1024,
417            spill_count: 1,
418            ..OperatorProfile::default()
419        });
420
421        let mut aggregate = ProfileCollector::new();
422        aggregate.aggregate_partition_profiles(&[first, second]);
423        let entry = aggregate
424            .operators
425            .get(&OperatorProfileKey::new(PhysicalOperatorId(9), Some(0)))
426            .expect("partition zero profile");
427        assert_eq!(entry.spilled_rows, 100);
428        let entry = aggregate
429            .operators
430            .get(&OperatorProfileKey::new(PhysicalOperatorId(9), Some(1)))
431            .expect("partition one profile");
432        assert_eq!(entry.spilled_rows, 50);
433    }
434
435    #[test]
436    fn test_partition_profile_aggregation_sums_input_and_ands_exhausted() {
437        let mut first = ProfileCollector::new();
438        first.record_operator_profile(OperatorProfile {
439            physical_operator_id: PhysicalOperatorId(11),
440            node_id: 11,
441            partition_id: Some(0),
442            name: "Filter".to_string(),
443            output_rows: 800,
444            input_rows: 1000,
445            exhausted: true,
446            ..OperatorProfile::default()
447        });
448        let mut second = ProfileCollector::new();
449        second.record_operator_profile(OperatorProfile {
450            physical_operator_id: PhysicalOperatorId(11),
451            node_id: 11,
452            partition_id: Some(1),
453            name: "Filter".to_string(),
454            output_rows: 0,
455            input_rows: 1000,
456            exhausted: false,
457            ..OperatorProfile::default()
458        });
459
460        let mut aggregate = ProfileCollector::new();
461        aggregate.aggregate_partition_profiles(&[first, second]);
462
463        // Partition identity is preserved for EXPLAIN output ...
464        assert_eq!(aggregate.operators.len(), 2);
465        // ... but totals are available for one-sample-per-operator
466        // feedback: inputs sum, and one starved partition truncates
467        // the whole operator sample.
468        let total_input: u64 = aggregate.operators.values().map(|op| op.input_rows).sum();
469        assert_eq!(total_input, 2000);
470        assert!(
471            aggregate.operators.values().any(|op| !op.exhausted),
472            "a starved partition must stay visible to the feedback fold"
473        );
474    }
475}