Skip to main content

blz_core/
profiling.rs

1#![allow(clippy::cast_possible_wrap)] // Wrapping is acceptable for memory delta calculations
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{Duration, Instant};
7use sysinfo::System;
8use tracing::{Level, debug, info, span};
9
10use crate::numeric::{duration_to_micros_saturating, safe_average, u64_to_f64_lossy, usize_to_u64};
11
12/// Global performance metrics collector
13#[derive(Debug, Clone)]
14pub struct PerformanceMetrics {
15    /// Number of search operations performed
16    pub search_count: Arc<AtomicU64>,
17    /// Total time spent in search operations (in microseconds)
18    pub total_search_time: Arc<AtomicU64>,
19    /// Number of index build operations performed
20    pub index_build_count: Arc<AtomicU64>,
21    /// Total time spent in index build operations (in microseconds)
22    pub total_index_time: Arc<AtomicU64>,
23    /// Total bytes processed during indexing
24    pub bytes_processed: Arc<AtomicU64>,
25    /// Total lines searched across all operations
26    pub lines_searched: Arc<AtomicU64>,
27}
28
29impl Default for PerformanceMetrics {
30    fn default() -> Self {
31        Self {
32            search_count: Arc::new(AtomicU64::new(0)),
33            total_search_time: Arc::new(AtomicU64::new(0)),
34            index_build_count: Arc::new(AtomicU64::new(0)),
35            total_index_time: Arc::new(AtomicU64::new(0)),
36            bytes_processed: Arc::new(AtomicU64::new(0)),
37            lines_searched: Arc::new(AtomicU64::new(0)),
38        }
39    }
40}
41
42impl PerformanceMetrics {
43    /// Record a search operation
44    pub fn record_search(&self, duration: Duration, lines_count: usize) {
45        self.search_count.fetch_add(1, Ordering::Relaxed);
46        let inc = duration_to_micros_saturating(duration);
47        let _ = self
48            .total_search_time
49            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
50                Some(cur.saturating_add(inc))
51            });
52        self.lines_searched
53            .fetch_add(usize_to_u64(lines_count), Ordering::Relaxed);
54    }
55
56    /// Record an index build operation
57    pub fn record_index_build(&self, duration: Duration, bytes_count: usize) {
58        self.index_build_count.fetch_add(1, Ordering::Relaxed);
59        let inc = duration_to_micros_saturating(duration);
60        let _ = self
61            .total_index_time
62            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
63                Some(cur.saturating_add(inc))
64            });
65        self.bytes_processed
66            .fetch_add(usize_to_u64(bytes_count), Ordering::Relaxed);
67    }
68
69    /// Get average search time in microseconds
70    #[must_use]
71    pub fn avg_search_time_micros(&self) -> f64 {
72        let count = self.search_count.load(Ordering::Relaxed);
73        let total = self.total_search_time.load(Ordering::Relaxed);
74        safe_average(total, count)
75    }
76
77    /// Get average index build time in milliseconds
78    #[must_use]
79    pub fn avg_index_time_millis(&self) -> f64 {
80        let count = self.index_build_count.load(Ordering::Relaxed);
81        let total = self.total_index_time.load(Ordering::Relaxed);
82        safe_average(total, count) / 1000.0
83    }
84
85    /// Get throughput in lines per second for search operations
86    #[must_use]
87    pub fn search_throughput_lines_per_sec(&self) -> f64 {
88        let lines = self.lines_searched.load(Ordering::Relaxed);
89        let time_seconds =
90            u64_to_f64_lossy(self.total_search_time.load(Ordering::Relaxed)) / 1_000_000.0;
91        if time_seconds == 0.0 {
92            0.0
93        } else {
94            u64_to_f64_lossy(lines) / time_seconds
95        }
96    }
97
98    /// Get processing throughput in MB/s for indexing operations
99    #[must_use]
100    pub fn index_throughput_mbps(&self) -> f64 {
101        let bytes = self.bytes_processed.load(Ordering::Relaxed);
102        let time_seconds =
103            u64_to_f64_lossy(self.total_index_time.load(Ordering::Relaxed)) / 1_000_000.0;
104        if time_seconds == 0.0 {
105            0.0
106        } else {
107            (u64_to_f64_lossy(bytes) / (1024.0 * 1024.0)) / time_seconds
108        }
109    }
110
111    /// Print performance summary
112    pub fn print_summary(&self) {
113        let searches = self.search_count.load(Ordering::Relaxed);
114        let indexes = self.index_build_count.load(Ordering::Relaxed);
115
116        println!("\n{}", "Performance Summary".bold());
117        println!("{}", "===================".bold());
118
119        if searches > 0 {
120            println!("Search Operations:");
121            println!("  Total searches: {searches}");
122            println!(
123                "  Average time: {:.2}ms",
124                self.avg_search_time_micros() / 1000.0
125            );
126            println!(
127                "  Total lines searched: {}",
128                self.lines_searched.load(Ordering::Relaxed)
129            );
130            println!(
131                "  Throughput: {:.0} lines/sec",
132                self.search_throughput_lines_per_sec()
133            );
134        }
135
136        if indexes > 0 {
137            println!("Index Operations:");
138            println!("  Total builds: {indexes}");
139            println!("  Average time: {:.2}ms", self.avg_index_time_millis());
140            println!(
141                "  Total bytes processed: {}",
142                format_bytes(self.bytes_processed.load(Ordering::Relaxed))
143            );
144            println!("  Throughput: {:.2} MB/s", self.index_throughput_mbps());
145        }
146    }
147}
148
149/// Timer for measuring operation duration with automatic metrics recording
150pub struct OperationTimer {
151    /// Start time of the operation
152    start: Instant,
153    /// Name or description of the operation being timed
154    operation: String,
155    /// Optional metrics collector for recording results
156    metrics: Option<PerformanceMetrics>,
157}
158
159impl OperationTimer {
160    /// Creates a new operation timer with basic logging
161    pub fn new(operation: &str) -> Self {
162        info!("Starting operation: {}", operation);
163        Self {
164            start: Instant::now(),
165            operation: operation.to_string(),
166            metrics: None,
167        }
168    }
169
170    /// Creates a new operation timer with metrics collection
171    pub fn with_metrics(operation: &str, metrics: PerformanceMetrics) -> Self {
172        info!("Starting operation with metrics: {}", operation);
173        Self {
174            start: Instant::now(),
175            operation: operation.to_string(),
176            metrics: Some(metrics),
177        }
178    }
179
180    /// Finish timing and optionally record metrics
181    pub fn finish(self) -> Duration {
182        let duration = self.start.elapsed();
183        info!(
184            "Completed {}: {:.2}ms",
185            self.operation,
186            duration.as_millis()
187        );
188        duration
189    }
190
191    /// Finish timing a search operation with line count
192    pub fn finish_search(self, lines_count: usize) -> Duration {
193        let duration = self.start.elapsed();
194        info!(
195            "Completed {} search: {:.2}ms ({} lines)",
196            self.operation,
197            duration.as_millis(),
198            lines_count
199        );
200
201        if let Some(metrics) = &self.metrics {
202            metrics.record_search(duration, lines_count);
203        }
204        duration
205    }
206
207    /// Finish timing an index operation with byte count
208    pub fn finish_index(self, bytes_count: usize) -> Duration {
209        let duration = self.start.elapsed();
210        info!(
211            "Completed {} indexing: {:.2}ms ({} bytes)",
212            self.operation,
213            duration.as_millis(),
214            bytes_count
215        );
216
217        if let Some(metrics) = &self.metrics {
218            metrics.record_index_build(duration, bytes_count);
219        }
220        duration
221    }
222}
223
224/// Component-level timing breakdown for detailed analysis
225#[derive(Debug, Default)]
226pub struct ComponentTimings {
227    /// Map of component names to their cumulative durations
228    timings: HashMap<String, Duration>,
229}
230
231impl ComponentTimings {
232    /// Creates a new component timings tracker
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Times an operation and records it under the given component name
239    pub fn time<T, F>(&mut self, component: &str, operation: F) -> T
240    where
241        F: FnOnce() -> T,
242    {
243        let _span = span!(Level::DEBUG, "component_timing", component = component);
244        let start = Instant::now();
245        let result = operation();
246        let duration = start.elapsed();
247
248        self.timings.insert(
249            component.to_string(),
250            self.timings.get(component).copied().unwrap_or_default() + duration,
251        );
252
253        debug!("Component {}: {:.2}ms", component, duration.as_millis());
254        result
255    }
256
257    /// Gets the cumulative timing for a specific component
258    #[must_use]
259    pub fn get_timing(&self, component: &str) -> Option<Duration> {
260        self.timings.get(component).copied()
261    }
262
263    /// Calculates the total time across all components
264    #[must_use]
265    pub fn total_time(&self) -> Duration {
266        self.timings.values().sum()
267    }
268
269    /// Prints a formatted breakdown of component timings to stdout
270    pub fn print_breakdown(&self) {
271        if self.timings.is_empty() {
272            return;
273        }
274
275        let total = self.total_time();
276        println!("\n{}", "Component Breakdown".bold());
277        println!("{}", "==================".bold());
278
279        let mut sorted_timings: Vec<_> = self.timings.iter().collect();
280        sorted_timings.sort_by(|a, b| b.1.cmp(a.1));
281
282        for (component, duration) in sorted_timings {
283            #[allow(clippy::cast_precision_loss)] // Display-only percentage calculation
284            let percentage = if total.as_micros() > 0 {
285                (duration.as_micros() as f64 / total.as_micros() as f64) * 100.0
286            } else {
287                0.0
288            };
289
290            println!(
291                "  {:<20}: {:>8.2}ms ({:>5.1}%)",
292                component,
293                duration.as_millis(),
294                percentage
295            );
296        }
297
298        println!("  {:<20}: {:>8.2}ms", "TOTAL", total.as_millis());
299    }
300
301    /// Prints a formatted breakdown of component timings to stderr.
302    ///
303    /// This is useful for performance analysis without interfering with JSON output on stdout.
304    /// The output is buffered and written atomically to prevent interleaving when multiple
305    /// sources are searched in parallel.
306    ///
307    /// # Arguments
308    ///
309    /// * `source` - Optional source alias to identify which source this breakdown is for.
310    ///   When provided, the header will show `[alias] Component Breakdown`.
311    pub fn print_breakdown_stderr(&self, source: Option<&str>) {
312        use std::fmt::Write as FmtWrite;
313        use std::io::Write as IoWrite;
314
315        if self.timings.is_empty() {
316            return;
317        }
318
319        let total = self.total_time();
320
321        // Build the entire breakdown string first
322        let mut output = String::new();
323
324        // Header with optional source name
325        output.push('\n');
326        if let Some(alias) = source {
327            let _ = writeln!(output, "[{alias}] Component Breakdown");
328        } else {
329            output.push_str("Component Breakdown\n");
330        }
331        output.push_str("==================\n");
332
333        let mut sorted_timings: Vec<_> = self.timings.iter().collect();
334        sorted_timings.sort_by(|a, b| b.1.cmp(a.1));
335
336        for (component, duration) in sorted_timings {
337            #[allow(clippy::cast_precision_loss)] // Display-only percentage calculation
338            let percentage = if total.as_micros() > 0 {
339                (duration.as_micros() as f64 / total.as_micros() as f64) * 100.0
340            } else {
341                0.0
342            };
343
344            let _ = writeln!(
345                output,
346                "  {:<20}: {:>8}ms ({:>5.1}%)",
347                component,
348                duration.as_millis(),
349                percentage
350            );
351        }
352
353        let _ = writeln!(output, "  {:<20}: {:>8}ms", "TOTAL", total.as_millis());
354
355        // Write atomically with a single stderr lock
356        let stderr = std::io::stderr();
357        let mut handle = stderr.lock();
358        // Ignore write errors for diagnostic output
359        let _ = handle.write_all(output.as_bytes());
360    }
361}
362
363/// System resource monitor for memory and CPU usage
364pub struct ResourceMonitor {
365    /// System information collector from sysinfo crate
366    system: System,
367    /// Process ID of the current process
368    pid: u32,
369    /// Memory usage at monitor creation time (in bytes)
370    initial_memory: u64,
371}
372
373impl Default for ResourceMonitor {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl ResourceMonitor {
380    /// Creates a new resource monitor and captures initial state
381    #[must_use]
382    pub fn new() -> Self {
383        let mut system = System::new_all();
384        system.refresh_all();
385        let pid = std::process::id();
386
387        let initial_memory = system
388            .process(sysinfo::Pid::from(pid as usize))
389            .map_or(0, sysinfo::Process::memory);
390
391        Self {
392            system,
393            pid,
394            initial_memory,
395        }
396    }
397
398    /// Refreshes system information
399    pub fn refresh(&mut self) {
400        self.system.refresh_all();
401    }
402
403    /// Gets the current memory usage in megabytes
404    #[allow(clippy::cast_precision_loss)] // Memory values far below f64 precision limit
405    pub fn current_memory_mb(&mut self) -> f64 {
406        self.refresh();
407        self.system
408            .process(sysinfo::Pid::from(self.pid as usize))
409            .map_or(0.0, |process| process.memory() as f64 / (1024.0 * 1024.0))
410    }
411
412    /// Gets the memory usage change since initialization in megabytes
413    #[allow(clippy::cast_precision_loss)] // Memory values far below f64 precision limit
414    pub fn memory_delta_mb(&mut self) -> f64 {
415        self.refresh();
416        if let Some(process) = self.system.process(sysinfo::Pid::from(self.pid as usize)) {
417            let current = process.memory();
418            (current as i64 - self.initial_memory as i64) as f64 / (1024.0 * 1024.0)
419        } else {
420            0.0
421        }
422    }
423
424    /// Gets the current CPU usage percentage for this process
425    pub fn cpu_usage(&mut self) -> f32 {
426        self.refresh();
427        self.system
428            .process(sysinfo::Pid::from(self.pid as usize))
429            .map_or(0.0, sysinfo::Process::cpu_usage)
430    }
431
432    /// Prints formatted resource usage information
433    pub fn print_resource_usage(&mut self) {
434        println!("\n{}", "Resource Usage".bold());
435        println!("{}", "==============".bold());
436        println!(
437            "Memory: {:.1} MB (Δ{:+.1} MB)",
438            self.current_memory_mb(),
439            self.memory_delta_mb()
440        );
441        println!("CPU: {:.1}%", self.cpu_usage());
442    }
443}
444
445/// Start CPU profiling (requires --features=flamegraph).
446///
447/// # Errors
448///
449/// Returns an error if the profiler cannot be initialized.
450#[cfg(feature = "flamegraph")]
451pub fn start_profiling() -> Result<pprof::ProfilerGuard<'static>, Box<dyn std::error::Error>> {
452    let guard = pprof::ProfilerGuardBuilder::default()
453        .frequency(1000) // 1kHz sampling
454        .blocklist(&["libc", "libgcc", "pthread", "vdso"])
455        .build()?;
456    Ok(guard)
457}
458
459/// Stop profiling and generate flamegraph.
460///
461/// # Errors
462///
463/// Returns an error if the report cannot be generated or written.
464#[cfg(feature = "flamegraph")]
465pub fn stop_profiling_and_report(
466    guard: &pprof::ProfilerGuard,
467) -> Result<(), Box<dyn std::error::Error>> {
468    match guard.report().build() {
469        Ok(report) => {
470            // Note: Protobuf output temporarily disabled due to API changes
471            // TODO: Re-enable once pprof protobuf API is clarified
472
473            // Generate flamegraph if possible
474            let file = std::fs::File::create("flamegraph.svg")?;
475            report.flamegraph(file)?;
476            println!("Flamegraph saved to flamegraph.svg");
477        },
478        Err(e) => {
479            eprintln!("Failed to generate profile report: {e}");
480        },
481    }
482    Ok(())
483}
484
485/// Fallback profiling stubs when flamegraph feature is disabled.
486///
487/// # Errors
488///
489/// Returns `Ok(())` because profiling is disabled.
490#[cfg(not(feature = "flamegraph"))]
491#[allow(clippy::unnecessary_wraps)] // Need to match the API of the feature-enabled version
492pub fn start_profiling() -> Result<(), Box<dyn std::error::Error>> {
493    debug!("CPU profiling not available (flamegraph feature not enabled)");
494    Ok(())
495}
496
497/// Stops CPU profiling and generates a flamegraph report (no-op when flamegraph feature is disabled).
498///
499/// # Errors
500///
501/// Returns `Ok(())` because profiling is disabled.
502#[cfg(not(feature = "flamegraph"))]
503#[allow(clippy::unnecessary_wraps)] // Need to match the API of the feature-enabled version
504pub fn stop_profiling_and_report(_guard: ()) -> Result<(), Box<dyn std::error::Error>> {
505    debug!("CPU profiling not available (flamegraph feature not enabled)");
506    Ok(())
507}
508
509/// Format bytes in human-readable format
510fn format_bytes(bytes: u64) -> String {
511    use crate::numeric::u64_to_f64_lossy;
512    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
513    let mut size = u64_to_f64_lossy(bytes);
514    let mut unit_index = 0;
515
516    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
517        size /= 1024.0;
518        unit_index += 1;
519    }
520
521    if unit_index == 0 {
522        format!("{} {}", bytes, UNITS[unit_index])
523    } else {
524        format!("{:.1} {}", size, UNITS[unit_index])
525    }
526}
527
528// Extension trait to add bold formatting (simple implementation for this example)
529trait BoldFormat {
530    fn bold(&self) -> &Self;
531}
532
533impl BoldFormat for str {
534    fn bold(&self) -> &Self {
535        // In a real implementation, you might use colored crate or similar
536        // For now, just return the string as-is
537        self
538    }
539}
540
541#[cfg(test)]
542#[allow(clippy::unwrap_used)]
543mod tests {
544    use super::*;
545    use std::thread;
546
547    #[test]
548    fn test_performance_metrics() {
549        let metrics = PerformanceMetrics::default();
550
551        // Record some search operations
552        metrics.record_search(Duration::from_millis(5), 1000);
553        metrics.record_search(Duration::from_millis(7), 1500);
554
555        assert_eq!(metrics.search_count.load(Ordering::Relaxed), 2);
556        assert_eq!(metrics.lines_searched.load(Ordering::Relaxed), 2500);
557
558        // Average should be 6ms = 6000 microseconds
559        assert!((metrics.avg_search_time_micros() - 6000.0).abs() < 1.0);
560    }
561
562    #[test]
563    fn test_operation_timer() {
564        let timer = OperationTimer::new("test_operation");
565        thread::sleep(Duration::from_millis(1));
566        let duration = timer.finish();
567
568        assert!(duration >= Duration::from_millis(1));
569    }
570
571    #[test]
572    fn test_component_timings() {
573        let mut timings = ComponentTimings::new();
574
575        timings.time("parsing", || {
576            thread::sleep(Duration::from_millis(2));
577            "parsed"
578        });
579
580        timings.time("indexing", || {
581            thread::sleep(Duration::from_millis(3));
582            "indexed"
583        });
584
585        let parsing_time = timings.get_timing("parsing").unwrap();
586        let indexing_time = timings.get_timing("indexing").unwrap();
587
588        assert!(parsing_time >= Duration::from_millis(2));
589        assert!(indexing_time >= Duration::from_millis(3));
590        assert!(timings.total_time() >= Duration::from_millis(5));
591    }
592
593    #[test]
594    fn test_resource_monitor() {
595        let mut monitor = ResourceMonitor::new();
596        let memory = monitor.current_memory_mb();
597        let _cpu = monitor.cpu_usage();
598
599        assert!(memory > 0.0, "Should report some memory usage");
600    }
601
602    #[test]
603    fn test_format_bytes() {
604        assert_eq!(format_bytes(500), "500 B");
605        assert_eq!(format_bytes(1536), "1.5 KB");
606        assert_eq!(format_bytes(1_048_576), "1.0 MB");
607        assert_eq!(format_bytes(2_097_152), "2.0 MB");
608    }
609}