use std::collections::HashMap;
use std::fmt;
use crate::OperationMetrics;
#[derive(Clone, Debug, Default)]
pub struct Report {
operations: HashMap<String, ReportOperation>,
}
#[derive(Clone, Debug)]
pub struct ReportOperation {
metrics: OperationMetrics,
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct MetricStatistics {
pub slope: f64,
pub interval: Option<(f64, f64)>,
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct OperationStatistics {
pub span_count: u64,
pub bytes: MetricStatistics,
pub allocations: MetricStatistics,
}
impl Report {
#[cfg(test)]
#[must_use]
pub(crate) fn new() -> Self {
Self {
operations: HashMap::new(),
}
}
#[must_use]
pub(crate) fn from_operation_data(operation_data: &HashMap<String, OperationMetrics>) -> Self {
let report_operations = operation_data
.iter()
.map(|(name, metrics)| {
(
name.clone(),
ReportOperation {
metrics: metrics.clone(),
},
)
})
.collect();
Self {
operations: report_operations,
}
}
#[must_use]
pub fn merge(a: &Self, b: &Self) -> Self {
let mut merged_operations = a.operations.clone();
for (name, b_op) in &b.operations {
merged_operations
.entry(name.clone())
.and_modify(|a_op| a_op.metrics.merge(&b_op.metrics))
.or_insert_with(|| b_op.clone());
}
Self {
operations: merged_operations,
}
}
pub(crate) fn sorted_operations(&self) -> Vec<(&str, &ReportOperation)> {
let mut operations: Vec<(&str, &ReportOperation)> = self
.operations
.iter()
.map(|(name, op)| (name.as_str(), op))
.collect();
operations.sort_unstable_by_key(|(name, _)| *name);
operations
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(test, mutants::skip)] pub fn print_to_stdout(&self) {
if self.is_empty() {
return;
}
println!("{self}");
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.operations.is_empty() || self.operations.values().all(|op| op.metrics.is_empty())
}
pub fn operations(&self) -> impl Iterator<Item = (&str, &ReportOperation)> {
self.operations.iter().map(|(name, op)| (name.as_str(), op))
}
}
impl ReportOperation {
#[must_use]
pub fn total_bytes_allocated(&self) -> u64 {
self.metrics.total_bytes_allocated()
}
#[must_use]
pub fn total_allocations_count(&self) -> u64 {
self.metrics.total_allocations_count()
}
#[must_use]
pub fn total_iterations(&self) -> u64 {
self.metrics.total_iterations()
}
#[must_use]
pub fn bytes(&self) -> Option<f64> {
self.metrics.bytes_slope().filter(|slope| slope.is_finite())
}
#[must_use]
pub fn allocations(&self) -> Option<f64> {
self.metrics
.allocations_slope()
.filter(|slope| slope.is_finite())
}
#[must_use]
pub fn statistics(&self) -> Option<OperationStatistics> {
if self.metrics.span_count() == 0 {
return None;
}
Some(OperationStatistics {
span_count: self.metrics.span_count(),
bytes: MetricStatistics {
slope: self.metrics.bytes_slope()?,
interval: self.metrics.bytes_interval(),
},
allocations: MetricStatistics {
slope: self.metrics.allocations_slope()?,
interval: self.metrics.allocations_interval(),
},
})
}
}
pub(crate) fn format_count(value: f64) -> String {
if value.is_nan() {
return "NaN".to_owned();
}
let rounded = (value.max(0.0) * 100.0).round() / 100.0;
let mut rendered = format!("{rounded:.2}");
if rendered.contains('.') {
rendered = rendered
.trim_end_matches('0')
.trim_end_matches('.')
.to_string();
}
rendered
}
impl fmt::Display for ReportOperation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.metrics.bytes_slope(), self.metrics.allocations_slope()) {
(Some(bytes), Some(allocations)) => write!(
f,
"{} bytes/iter, {} allocations/iter",
format_count(bytes),
format_count(allocations),
),
_ => write!(f, "no measurements"),
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))] impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_empty() {
writeln!(f, "No allocation statistics captured.")?;
return Ok(());
}
writeln!(f, "Allocation statistics:")?;
writeln!(f)?;
let rows: Vec<(&str, String, String)> = self
.sorted_operations()
.into_iter()
.map(|(name, operation)| match operation.statistics() {
Some(statistics) => (
name,
format_count(statistics.bytes.slope),
format_count(statistics.allocations.slope),
),
None => (name, "n/a".to_owned(), "n/a".to_owned()),
})
.collect();
let name_header = "Operation";
let bytes_header = "Bytes/iter";
let count_header = "Allocations/iter";
let max_name_width = rows
.iter()
.map(|(name, _, _)| name.len())
.max()
.unwrap_or(0)
.max(name_header.len());
let max_bytes_width = rows
.iter()
.map(|(_, bytes, _)| bytes.len())
.max()
.unwrap_or(0)
.max(bytes_header.len());
let max_count_width = rows
.iter()
.map(|(_, _, count)| count.len())
.max()
.unwrap_or(0)
.max(count_header.len());
writeln!(
f,
"| {name_header:<max_name_width$} | {bytes_header:>max_bytes_width$} | {count_header:>max_count_width$} |",
)?;
let separator_name_width = max_name_width
.checked_add(2)
.expect("operation name width fits in memory, adding 2 cannot overflow");
let separator_bytes_width = max_bytes_width
.checked_add(2)
.expect("bytes width fits in memory, adding 2 cannot overflow");
let separator_count_width = max_count_width
.checked_add(2)
.expect("count width fits in memory, adding 2 cannot overflow");
writeln!(
f,
"|{:-<separator_name_width$}|{:-<separator_bytes_width$}|{:-<separator_count_width$}|",
"", "", "",
)?;
for (name, bytes, count) in rows {
writeln!(
f,
"| {name:<max_name_width$} | {bytes:>max_bytes_width$} | {count:>max_count_width$} |",
)?;
}
Ok(())
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
#![allow(
clippy::float_cmp,
reason = "allocation statistics are exact integer-derived values in these fixtures"
)]
use std::panic::{RefUnwindSafe, UnwindSafe};
use super::*;
use crate::Session;
use crate::allocator::register_fake_allocation;
fn report_operation(bytes_delta: u64, count_delta: u64, iterations: u64) -> ReportOperation {
let mut metrics = OperationMetrics::default();
metrics.add_iterations(bytes_delta, count_delta, iterations);
ReportOperation { metrics }
}
#[test]
fn new_report_is_empty() {
let report = Report::new();
assert!(report.is_empty());
}
#[test]
fn report_from_empty_session_is_empty() {
let session = Session::new().no_stdout().no_file();
let report = session.to_report();
assert!(report.is_empty());
}
#[test]
fn report_from_session_with_operations_is_not_empty() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("test");
let _span = operation.measure_thread().iterations(1);
register_fake_allocation(100, 1);
}
let report = session.to_report();
assert!(!report.is_empty());
}
#[test]
fn report_with_registered_but_unmeasured_operation_is_empty() {
let session = Session::new().no_stdout().no_file();
let _operation = session.operation("unmeasured");
let report = session.to_report();
assert!(report.is_empty());
}
#[test]
fn report_with_only_zero_iteration_spans_is_empty() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("failed");
let _span = operation.measure_thread().iterations(0);
register_fake_allocation(800, 8);
}
let report = session.to_report();
assert!(report.is_empty());
let operations = report.sorted_operations();
let (_name, operation) = operations.first().expect("the report has one operation");
assert!(operation.statistics().is_some());
}
#[test]
fn operations_are_sorted_by_name() {
let mut operations = HashMap::new();
operations.insert("zebra".to_owned(), report_operation(10, 1, 1));
operations.insert("alpha".to_owned(), report_operation(20, 2, 1));
let report = Report { operations };
let names: Vec<&str> = report
.sorted_operations()
.into_iter()
.map(|(name, _)| name)
.collect();
assert_eq!(names, ["alpha", "zebra"]);
}
#[test]
fn merge_empty_reports() {
let report1 = Report::new();
let report2 = Report::new();
let merged = Report::merge(&report1, &report2);
assert!(merged.is_empty());
}
#[test]
fn merge_empty_with_non_empty() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("test");
let _span = operation.measure_thread().iterations(1);
register_fake_allocation(100, 1);
}
let report1 = Report::new();
let report2 = session.to_report();
let merged1 = Report::merge(&report1, &report2);
let merged2 = Report::merge(&report2, &report1);
assert!(!merged1.is_empty());
assert!(!merged2.is_empty());
}
#[test]
fn merge_different_operations() {
let session1 = Session::new().no_stdout().no_file();
let session2 = Session::new().no_stdout().no_file();
{
let op1 = session1.operation("test1");
let _span1 = op1.measure_thread().iterations(1);
register_fake_allocation(100, 1);
}
{
let op2 = session2.operation("test2");
let _span2 = op2.measure_thread().iterations(1);
register_fake_allocation(200, 2);
}
let report1 = session1.to_report();
let report2 = session2.to_report();
let merged = Report::merge(&report1, &report2);
assert_eq!(merged.operations.len(), 2);
assert!(merged.operations.contains_key("test1"));
assert!(merged.operations.contains_key("test2"));
}
#[test]
fn merge_same_operations() {
let session1 = Session::new().no_stdout().no_file();
let session2 = Session::new().no_stdout().no_file();
{
let op1 = session1.operation("test");
let _span1 = op1.measure_thread().iterations(1);
register_fake_allocation(100, 1);
}
{
let op2 = session2.operation("test");
let _span2 = op2.measure_thread().iterations(1);
register_fake_allocation(200, 2);
}
let report1 = session1.to_report();
let report2 = session2.to_report();
let merged = Report::merge(&report1, &report2);
assert_eq!(merged.operations.len(), 1);
let merged_op = merged.operations.get("test").unwrap();
assert_eq!(merged_op.total_iterations(), 2); assert_eq!(merged_op.total_bytes_allocated(), 300); assert_eq!(merged_op.total_allocations_count(), 3); }
#[test]
fn report_clone() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("test");
let _span = operation.measure_thread().iterations(1);
register_fake_allocation(100, 1);
}
let report1 = session.to_report();
let report2 = report1.clone();
assert_eq!(report1.operations.len(), report2.operations.len());
}
#[test]
fn report_operation_total_allocations_count_zero() {
let operation = report_operation(0, 0, 1);
assert_eq!(operation.total_allocations_count(), 0);
}
#[test]
fn report_operation_total_allocations_count_multiple() {
let operation = report_operation(100, 5, 5);
assert_eq!(operation.total_allocations_count(), 25);
}
#[test]
fn report_operation_total_allocations_count_consistency_with_session() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("test_consistency");
let _span = operation.measure_thread().iterations(1);
register_fake_allocation(300, 3);
}
let report = session.to_report();
let operations: Vec<_> = report.operations().collect();
assert_eq!(operations.len(), 1);
let (_name, report_op) = operations.first().unwrap();
assert_eq!(report_op.total_allocations_count(), 3);
assert_eq!(report_op.total_bytes_allocated(), 300);
assert_eq!(report_op.total_iterations(), 1);
}
#[test]
fn statistics_are_none_without_spans() {
let session = Session::new().no_stdout().no_file();
let report = session.to_report();
assert!(report.operations().next().is_none());
}
#[test]
fn statistics_expose_both_metric_estimates() {
let operation = report_operation(200, 2, 4);
let stats = operation.statistics().unwrap();
assert_eq!(stats.span_count, 1);
assert_eq!(stats.bytes.slope, 200.0);
assert_eq!(stats.bytes.interval, None);
assert_eq!(stats.allocations.slope, 2.0);
assert_eq!(stats.allocations.interval, None);
}
#[test]
fn repeated_identical_spans_collapse_the_interval_onto_the_slope() {
let mut metrics = OperationMetrics::default();
metrics.add_iterations(200, 2, 4);
metrics.add_iterations(200, 2, 4);
let operation = ReportOperation { metrics };
let stats = operation.statistics().unwrap();
assert_eq!(stats.span_count, 2);
assert_eq!(stats.bytes.slope, 200.0);
assert_eq!(stats.bytes.interval, Some((200.0, 200.0)));
}
static_assertions::assert_impl_all!(Report: Send, Sync);
static_assertions::assert_impl_all!(ReportOperation: Send, Sync);
static_assertions::assert_impl_all!(OperationStatistics: Send, Sync);
static_assertions::assert_impl_all!(MetricStatistics: Send, Sync);
static_assertions::assert_impl_all!(Report: UnwindSafe, RefUnwindSafe);
static_assertions::assert_impl_all!(
ReportOperation: UnwindSafe, RefUnwindSafe
);
#[test]
fn report_operation_display_shows_robust_per_iteration_estimate() {
let operation = report_operation(250, 3, 4);
let display_output = operation.to_string();
assert!(
display_output.contains("bytes/iter"),
"got {display_output}"
);
assert!(display_output.contains("250"), "got {display_output}");
}
#[test]
fn report_operation_display_shows_nan_for_zero_iterations() {
let operation = report_operation(250, 3, 0);
let display_output = operation.to_string();
assert!(
display_output.contains("NaN bytes/iter"),
"got {display_output}"
);
assert!(
display_output.contains("NaN allocations/iter"),
"got {display_output}"
);
}
#[test]
fn report_operation_display_reports_no_measurements_when_empty() {
let operation = ReportOperation {
metrics: OperationMetrics::default(),
};
assert_eq!(operation.to_string(), "no measurements");
}
#[test]
fn empty_report_display_shows_no_statistics_message() {
let report = Report::new();
let display_output = report.to_string();
assert!(display_output.contains("No allocation statistics captured."));
}
}