use std::collections::HashMap;
use std::{fmt, iter};
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,
pub peak_outstanding_bytes: Option<MetricStatistics>,
}
impl Report {
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))] #[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 peak_outstanding_bytes(&self) -> Option<f64> {
self.metrics
.peak_outstanding_bytes()
.filter(|peak| peak.is_finite())
}
#[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(),
},
peak_outstanding_bytes: self.peak_outstanding_bytes().map(|slope| MetricStatistics {
slope,
interval: self.metrics.peak_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('.') {
let trimmed_len = rendered.trim_end_matches('0').trim_end_matches('.').len();
rendered.truncate(trimmed_len);
}
rendered
}
#[cfg_attr(coverage_nightly, coverage(off))]
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 headers = ["Operation", "Bytes/iter", "Allocations/iter", "Peak bytes"];
let rows: Vec<TableRow<'_>> = self
.sorted_operations()
.into_iter()
.map(|(name, operation)| {
let figures = match operation.statistics() {
Some(statistics) => [
format_count(statistics.bytes.slope),
format_count(statistics.allocations.slope),
statistics.peak_outstanding_bytes.map_or_else(
|| NOT_AVAILABLE.to_owned(),
|peak| format_count(peak.slope),
),
],
None => [
NOT_AVAILABLE.to_owned(),
NOT_AVAILABLE.to_owned(),
NOT_AVAILABLE.to_owned(),
],
};
TableRow { name, figures }
})
.collect();
let mut widths = headers.map(str::len);
for row in &rows {
for (width, cell) in widths.iter_mut().zip(row.cells()) {
*width = (*width).max(cell.len());
}
}
write_table_row(f, headers.iter().copied(), widths)?;
for width in widths {
let dashes = width
.checked_add(TABLE_CELL_PADDING)
.expect("column width fits in memory, adding the padding cannot overflow");
write!(f, "|{:-<dashes$}", "")?;
}
writeln!(f, "|")?;
for row in &rows {
write_table_row(f, row.cells(), widths)?;
}
Ok(())
}
}
const TABLE_COLUMNS: usize = 4;
const TABLE_FIGURE_COLUMNS: usize = TABLE_COLUMNS - 1;
const TABLE_CELL_PADDING: usize = 2;
const NOT_AVAILABLE: &str = "n/a";
struct TableRow<'a> {
name: &'a str,
figures: [String; TABLE_FIGURE_COLUMNS],
}
impl TableRow<'_> {
fn cells(&self) -> impl Iterator<Item = &str> {
iter::once(self.name).chain(self.figures.iter().map(String::as_str))
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn write_table_row<'a>(
f: &mut fmt::Formatter<'_>,
cells: impl Iterator<Item = &'a str>,
widths: [usize; TABLE_COLUMNS],
) -> fmt::Result {
let mut cells = cells.zip(widths);
if let Some((name, width)) = cells.next() {
write!(f, "| {name:<width$} |")?;
}
for (cell, width) in cells {
write!(f, " {cell:>width$} |")?;
}
writeln!(f)
}
#[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::counters::register_fake_allocation;
use crate::span_measurement::SpanMeasurement;
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 zero_iteration_spans_withhold_the_peak_from_statistics() {
let mut metrics = OperationMetrics::default();
metrics.add_iterations(800, 8, 0);
let operation = ReportOperation { metrics };
assert_eq!(operation.peak_outstanding_bytes(), None);
assert!(
operation
.statistics()
.unwrap()
.peak_outstanding_bytes
.is_none()
);
}
#[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 per_iteration_accessors_withhold_unavailable_rates() {
let mut metrics = OperationMetrics::default();
let operation = ReportOperation {
metrics: metrics.clone(),
};
assert_eq!(operation.bytes(), None);
assert_eq!(operation.allocations(), None);
metrics.add_span(SpanMeasurement {
iterations: 0,
bytes: 37,
count: 7,
peak_outstanding_bytes: None,
});
let operation = ReportOperation { metrics };
assert_eq!(operation.bytes(), None);
assert_eq!(operation.allocations(), None);
assert_eq!(operation.total_bytes_allocated(), 37);
assert_eq!(operation.total_allocations_count(), 7);
assert_eq!(operation.total_iterations(), 0);
}
#[test]
fn per_iteration_accessors_report_measured_zero() {
let operation = report_operation(0, 0, 3);
assert_eq!(operation.bytes(), Some(0.0));
assert_eq!(operation.allocations(), Some(0.0));
}
#[test]
fn per_iteration_accessors_preserve_fractional_rates() {
let mut metrics = OperationMetrics::default();
metrics.add_span(SpanMeasurement {
iterations: 4,
bytes: 27,
count: 10,
peak_outstanding_bytes: None,
});
let operation = ReportOperation { metrics };
assert_eq!(operation.bytes(), Some(6.75));
assert_eq!(operation.allocations(), Some(2.5));
assert_eq!(operation.total_bytes_allocated(), 27);
assert_eq!(operation.total_allocations_count(), 10);
assert_eq!(operation.total_iterations(), 4);
}
#[test]
fn per_iteration_accessors_preserve_weighting_across_report_merges() {
let spans = [
SpanMeasurement {
iterations: 1,
bytes: 11,
count: 1,
peak_outstanding_bytes: None,
},
SpanMeasurement {
iterations: 3,
bytes: 13,
count: 8,
peak_outstanding_bytes: None,
},
];
let mut combined = OperationMetrics::default();
let reports = spans.map(|span| {
let mut metrics = OperationMetrics::default();
metrics.add_span(span);
combined.add_span(span);
Report::from_operation_data(&HashMap::from([("work".to_owned(), metrics)]))
});
let merged = Report::merge(&reports[0], &reports[1]);
let combined = Report::from_operation_data(&HashMap::from([("work".to_owned(), combined)]));
for report in [&combined, &merged] {
let operation = report.operations.get("work").unwrap();
assert_eq!(operation.bytes(), Some(5.0));
assert_eq!(operation.allocations(), Some(2.5));
assert_eq!(operation.total_bytes_allocated(), 24);
assert_eq!(operation.total_allocations_count(), 9);
assert_eq!(operation.total_iterations(), 4);
}
}
#[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_byte_and_allocation_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("250 bytes/iter"));
assert!(display_output.contains("3 allocations/iter"));
}
#[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."));
}
#[test]
fn report_display_renders_each_peak_according_to_its_availability() {
const ITERATIONS: u64 = 4;
const PEAK_PER_ITERATION: u64 = 700;
let mut measured = OperationMetrics::default();
measured.add_span(SpanMeasurement {
iterations: ITERATIONS,
bytes: 250 * ITERATIONS,
count: 3 * ITERATIONS,
peak_outstanding_bytes: Some(PEAK_PER_ITERATION),
});
let mut process_measured = OperationMetrics::default();
process_measured.add_span(SpanMeasurement {
iterations: ITERATIONS,
bytes: 250 * ITERATIONS,
count: 3 * ITERATIONS,
peak_outstanding_bytes: None,
});
let mut zero_iterations = OperationMetrics::default();
zero_iterations.add_iterations(250, 3, 0);
let mut operations = HashMap::new();
operations.insert("thread".to_owned(), ReportOperation { metrics: measured });
operations.insert(
"process".to_owned(),
ReportOperation {
metrics: process_measured,
},
);
operations.insert(
"nothing".to_owned(),
ReportOperation {
metrics: zero_iterations,
},
);
let report = Report { operations };
let display_output = report.to_string();
let peak_cell = |name: &str| {
let row = display_output
.lines()
.find(|line| line.contains(name))
.unwrap_or_else(|| panic!("the table has a row for {name}, got {display_output}"));
let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect();
assert_eq!(cells.len(), TABLE_COLUMNS);
cells.last().copied().unwrap().to_owned()
};
assert_eq!(peak_cell("thread"), PEAK_PER_ITERATION.to_string());
assert_eq!(peak_cell("process"), NOT_AVAILABLE);
assert_eq!(peak_cell("nothing"), NOT_AVAILABLE);
}
}