use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::Report;
const OUTPUT_SUBDIRECTORY: &str = "alloc_tracker";
#[derive(Serialize)]
struct OperationOutput<'a> {
operation: &'a str,
total_iterations: u64,
total_bytes_allocated: u64,
total_allocations_count: u64,
span_count: u64,
slope_bytes_per_iteration: f64,
#[serde(skip_serializing_if = "Option::is_none")]
interval_low_bytes_per_iteration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
interval_high_bytes_per_iteration: Option<f64>,
slope_allocations_per_iteration: f64,
#[serde(skip_serializing_if = "Option::is_none")]
interval_low_allocations_per_iteration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
interval_high_allocations_per_iteration: Option<f64>,
}
impl Report {
pub(crate) fn write_to_target(&self) {
let target =
folo_utils::cargo_target_directory().unwrap_or_else(|| PathBuf::from("target"));
self.write_to_directory(target.join(OUTPUT_SUBDIRECTORY));
}
pub fn write_to_directory(&self, directory: impl AsRef<Path>) {
let directory = directory.as_ref();
let mut file_names: HashMap<String, &str> = HashMap::new();
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
for (name, operation) in self.sorted_operations() {
let Some(statistics) = operation.statistics() else {
continue;
};
let file_name = format!("{}.json", folo_utils::sanitize_file_name(name));
if let Some(previous) = file_names.insert(file_name.clone(), name) {
panic!(
"operations {previous:?} and {name:?} both map to the output file name \
{file_name:?} after sanitization; rename one of them to avoid silently \
overwriting benchmark results"
);
}
let output = OperationOutput {
operation: name,
total_iterations: operation.total_iterations(),
total_bytes_allocated: operation.total_bytes_allocated(),
total_allocations_count: operation.total_allocations_count(),
span_count: statistics.span_count,
slope_bytes_per_iteration: statistics.bytes.slope,
interval_low_bytes_per_iteration: statistics.bytes.interval.map(|(low, _)| low),
interval_high_bytes_per_iteration: statistics.bytes.interval.map(|(_, high)| high),
slope_allocations_per_iteration: statistics.allocations.slope,
interval_low_allocations_per_iteration: statistics
.allocations
.interval
.map(|(low, _)| low),
interval_high_allocations_per_iteration: statistics
.allocations
.interval
.map(|(_, high)| high),
};
let json = serde_json::to_string_pretty(&output)
.expect("serializing fixed primitive fields to JSON cannot fail");
outputs.push((directory.join(file_name), json));
}
if outputs.is_empty() {
return;
}
fs::create_dir_all(directory).unwrap_or_else(|error| {
panic!(
"failed to create benchmark output directory {}: {error}",
directory.display()
)
});
for (path, json) in outputs {
fs::write(&path, json).unwrap_or_else(|error| {
panic!(
"failed to write benchmark output file {}: {error}",
path.display()
)
});
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::fs;
use std::path::Path;
use serde_json::Value;
use crate::Session;
use crate::allocator::register_fake_allocation;
fn read_json(path: &Path) -> Value {
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}
fn session_with_recorded_work(name: &str) -> Session {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation(name);
let _span = operation.measure_thread().iterations(4);
register_fake_allocation(800, 8);
}
session
}
#[test]
#[cfg_attr(miri, ignore)] fn writes_operation_statistics_as_json() {
let session = session_with_recorded_work("allocate_vec");
let directory = tempfile::tempdir().unwrap();
session.to_report().write_to_directory(directory.path());
let file = directory.path().join("allocate_vec.json");
let value = read_json(&file);
assert_eq!(
value.get("operation").and_then(Value::as_str),
Some("allocate_vec")
);
assert_eq!(
value.get("total_iterations").and_then(Value::as_u64),
Some(4)
);
assert_eq!(
value.get("total_bytes_allocated").and_then(Value::as_u64),
Some(800)
);
assert_eq!(
value.get("total_allocations_count").and_then(Value::as_u64),
Some(8)
);
assert_eq!(value.get("span_count").and_then(Value::as_u64), Some(1));
assert_eq!(
value
.get("slope_bytes_per_iteration")
.and_then(Value::as_f64),
Some(200.0)
);
assert!(value.get("interval_low_bytes_per_iteration").is_none());
assert!(value.get("interval_high_bytes_per_iteration").is_none());
assert!(value.get("mean_bytes_per_iteration").is_none());
assert!(value.get("mean_allocations_per_iteration").is_none());
assert!(value.get("std_dev_bytes_per_iteration").is_none());
assert!(value.get("min_bytes_per_iteration").is_none());
assert!(value.get("max_bytes_per_iteration").is_none());
assert_eq!(
value
.get("slope_allocations_per_iteration")
.and_then(Value::as_f64),
Some(2.0)
);
}
#[test]
#[cfg_attr(miri, ignore)] fn writes_interval_when_multiple_spans_recorded() {
let session = Session::new().no_stdout().no_file();
for _ in 0..2 {
let operation = session.operation("allocate_vec");
let _span = operation.measure_thread().iterations(4);
register_fake_allocation(800, 8);
}
let directory = tempfile::tempdir().unwrap();
session.to_report().write_to_directory(directory.path());
let value = read_json(&directory.path().join("allocate_vec.json"));
assert_eq!(value.get("span_count").and_then(Value::as_u64), Some(2));
assert_eq!(
value
.get("interval_low_bytes_per_iteration")
.and_then(Value::as_f64),
Some(200.0)
);
assert_eq!(
value
.get("interval_high_bytes_per_iteration")
.and_then(Value::as_f64),
Some(200.0)
);
}
#[test]
#[cfg_attr(miri, ignore)] fn writes_null_slopes_for_zero_iteration_operation() {
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 directory = tempfile::tempdir().unwrap();
session.to_report().write_to_directory(directory.path());
let value = read_json(&directory.path().join("failed.json"));
assert!(
value
.get("slope_bytes_per_iteration")
.expect("the bytes slope field is always present")
.is_null(),
"a zero-iteration bytes slope must serialize as null"
);
assert!(
value
.get("slope_allocations_per_iteration")
.expect("the allocations slope field is always present")
.is_null(),
"a zero-iteration allocations slope must serialize as null"
);
assert_eq!(
value.get("total_iterations").and_then(Value::as_u64),
Some(0)
);
}
#[test]
#[cfg_attr(miri, ignore)] fn sanitizes_operation_name_in_file_name() {
let session = session_with_recorded_work("group/case name");
let directory = tempfile::tempdir().unwrap();
session.to_report().write_to_directory(directory.path());
let file = directory.path().join("group_case_name.json");
assert!(file.exists());
assert_eq!(
read_json(&file).get("operation").and_then(Value::as_str),
Some("group/case name")
);
}
#[test]
#[cfg_attr(miri, ignore)] fn empty_session_writes_no_files() {
let session = Session::new().no_stdout().no_file();
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("nested");
session.to_report().write_to_directory(&target);
assert!(!target.exists());
}
#[test]
#[cfg_attr(miri, ignore)] fn skips_operations_without_iterations() {
let session = Session::new().no_stdout().no_file();
{
let operation = session.operation("measured");
let _span = operation.measure_thread().iterations(4);
register_fake_allocation(800, 8);
}
let _unmeasured = session.operation("unmeasured");
let directory = tempfile::tempdir().unwrap();
session.to_report().write_to_directory(directory.path());
assert!(directory.path().join("measured.json").exists());
assert!(!directory.path().join("unmeasured.json").exists());
}
#[test]
#[cfg_attr(miri, ignore)] fn overwrites_existing_files() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("allocate_vec.json");
fs::write(&file, "stale contents").unwrap();
let session = session_with_recorded_work("allocate_vec");
session.to_report().write_to_directory(directory.path());
let value = read_json(&file);
assert_eq!(
value.get("operation").and_then(Value::as_str),
Some("allocate_vec")
);
}
#[test]
#[cfg_attr(miri, ignore)] #[should_panic(expected = "failed to create benchmark output directory")]
fn panics_when_output_directory_cannot_be_created() {
let session = session_with_recorded_work("allocate_vec");
let directory = tempfile::tempdir().unwrap();
let blocker = directory.path().join("blocker");
fs::write(&blocker, "not a directory").unwrap();
session
.to_report()
.write_to_directory(blocker.join("nested"));
}
#[test]
#[cfg_attr(miri, ignore)] #[should_panic(expected = "failed to write benchmark output file")]
fn panics_when_output_file_cannot_be_written() {
let session = session_with_recorded_work("allocate_vec");
let directory = tempfile::tempdir().unwrap();
fs::create_dir_all(directory.path().join("allocate_vec.json")).unwrap();
session.to_report().write_to_directory(directory.path());
}
#[test]
#[should_panic(expected = "after sanitization")]
fn panics_when_operation_names_collide_after_sanitization() {
let session = Session::new().no_stdout().no_file();
for name in ["group/case", "group_case"] {
let operation = session.operation(name);
let _span = operation.measure_thread().iterations(4);
register_fake_allocation(800, 8);
}
session
.to_report()
.write_to_directory("collision_is_detected_before_writing");
}
}