global: snapshot

This commit is contained in:
nym21
2025-11-11 17:41:12 +01:00
parent 2dcbd8df99
commit 81da73bc53
17 changed files with 811 additions and 427 deletions
+367 -151
View File
@@ -6,6 +6,26 @@ use std::{
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const FONT: &str = "monospace";
macro_rules! configure_chart_mesh {
($chart:expr, $x_desc:expr, $y_desc:expr, $y_formatter:expr) => {
$chart
.configure_mesh()
.disable_mesh()
.x_desc($x_desc)
.y_desc($y_desc)
.x_label_formatter(&|x| format!("{:.0}", x))
.y_label_formatter(&$y_formatter)
.x_labels(12)
.y_labels(10)
.x_label_style((FONT, 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.y_label_style((FONT, 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.axis_style(TEXT_COLOR.mix(0.3))
.draw()?
};
}
#[derive(Debug, Clone)]
struct DataPoint {
timestamp_ms: u64,
@@ -30,6 +50,9 @@ const CHART_COLORS: [RGBColor; 6] = [
RGBColor(255, 159, 64), // Orange
];
// Time window buffer in milliseconds (5 seconds)
const TIME_BUFFER_MS: u64 = 5000;
pub struct Visualizer {
workspace_root: PathBuf,
}
@@ -50,7 +73,6 @@ impl Visualizer {
Ok(Self { workspace_root })
}
/// Generate all charts for all crates in the benches directory
pub fn generate_all_charts(&self) -> Result<()> {
let benches_dir = self.workspace_root.join("benches");
@@ -58,7 +80,6 @@ impl Visualizer {
return Err("Benches directory does not exist".into());
}
// Iterate through each crate directory
for entry in fs::read_dir(&benches_dir)? {
let entry = entry?;
let path = entry.path();
@@ -77,11 +98,10 @@ impl Visualizer {
Ok(())
}
/// Generate charts for a specific crate
fn generate_crate_charts(&self, crate_path: &Path, crate_name: &str) -> Result<()> {
// Read all benchmark runs for this crate
let disk_runs = self.read_benchmark_runs(crate_path, "disk_usage.csv")?;
let memory_runs = self.read_benchmark_runs(crate_path, "memory_footprint.csv")?;
let disk_runs = self.read_benchmark_runs(crate_path, "disk.csv")?;
let memory_runs = self.read_benchmark_runs(crate_path, "memory.csv")?;
let progress_runs = self.read_benchmark_runs(crate_path, "progress.csv")?;
if !disk_runs.is_empty() {
self.generate_disk_chart(crate_path, crate_name, &disk_runs)?;
@@ -91,10 +111,13 @@ impl Visualizer {
self.generate_memory_chart(crate_path, crate_name, &memory_runs)?;
}
if !progress_runs.is_empty() {
self.generate_progress_chart(crate_path, crate_name, &progress_runs)?;
}
Ok(())
}
/// Read all benchmark runs from subdirectories
fn read_benchmark_runs(&self, crate_path: &Path, filename: &str) -> Result<Vec<BenchmarkRun>> {
let mut runs = Vec::new();
@@ -109,10 +132,15 @@ impl Visualizer {
.ok_or("Invalid run ID")?
.to_string();
// Skip directories that start with underscore
if run_id.starts_with('_') {
continue;
}
let csv_path = run_path.join(filename);
if csv_path.exists()
&& let Ok(data) = self.read_csv(&csv_path)
&& let Ok(data) = Self::read_csv(&csv_path, 2)
{
runs.push(BenchmarkRun { run_id, data });
}
@@ -122,18 +150,17 @@ impl Visualizer {
Ok(runs)
}
/// Read a CSV file and parse data points
fn read_csv(&self, path: &Path) -> Result<Vec<DataPoint>> {
fn read_csv(path: &Path, expected_columns: usize) -> Result<Vec<DataPoint>> {
let content = fs::read_to_string(path)?;
let mut data = Vec::new();
for (i, line) in content.lines().enumerate() {
if i == 0 {
continue;
} // Skip header
}
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2
if parts.len() >= expected_columns
&& let (Ok(timestamp_ms), Ok(value)) =
(parts[0].parse::<u64>(), parts[1].parse::<f64>())
{
@@ -147,107 +174,325 @@ impl Visualizer {
Ok(data)
}
/// Generate disk usage chart
// Helper methods
fn format_bytes(bytes: f64) -> (f64, &'static str) {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
if bytes >= GIB {
(bytes / GIB, "GiB")
} else if bytes >= MIB {
(bytes / MIB, "MiB")
} else if bytes >= KIB {
(bytes / KIB, "KiB")
} else {
(bytes, "bytes")
}
}
fn format_time(time_s: f64) -> (f64, &'static str, &'static str) {
const MINUTE: f64 = 60.0;
const HOUR: f64 = 3600.0;
// Only use larger units if the value would be >= 2 (to avoid decimals)
if time_s >= HOUR * 2.0 {
(time_s / HOUR, "h", "Time (h)")
} else if time_s >= MINUTE * 2.0 {
(time_s / MINUTE, "min", "Time (min)")
} else {
(time_s, "s", "Time (s)")
}
}
fn format_axis_number(value: f64) -> String {
if value >= 1000.0 {
let k_value = value / 1000.0;
// Show decimals only if needed
if k_value.fract() == 0.0 || k_value >= 100.0 {
format!("{:.0}k", k_value)
} else if k_value >= 10.0 {
format!("{:.1}k", k_value)
} else {
format!("{:.2}k", k_value)
}
} else {
format!("{:.0}", value)
}
}
fn calculate_min_max_time(runs: &[BenchmarkRun]) -> u64 {
runs.iter()
.filter_map(|r| r.data.iter().map(|d| d.timestamp_ms).max())
.min()
.unwrap_or(1000)
}
fn calculate_max_value(runs: &[BenchmarkRun]) -> f64 {
runs.iter()
.flat_map(|r| r.data.iter().map(|d| d.value))
.fold(0.0_f64, f64::max)
}
fn trim_runs_to_time_window(runs: &[BenchmarkRun], max_time_ms: u64) -> Vec<BenchmarkRun> {
runs.iter()
.map(|run| BenchmarkRun {
run_id: run.run_id.clone(),
data: run
.data
.iter()
.filter(|d| d.timestamp_ms <= max_time_ms)
.cloned()
.collect(),
})
.collect()
}
fn draw_line_series<'a, DB: DrawingBackend, I>(
chart: &mut ChartContext<
'a,
DB,
Cartesian2d<
plotters::coord::types::RangedCoordf64,
plotters::coord::types::RangedCoordf64,
>,
>,
data: I,
label: &str,
color: RGBColor,
) -> Result<()>
where
I: Iterator<Item = (f64, f64)> + Clone,
DB::ErrorType: 'static,
{
chart
.draw_series(LineSeries::new(data, color.stroke_width(1)))?
.label(label)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(1))
});
Ok(())
}
fn configure_series_labels<'a, DB: DrawingBackend + 'a>(
chart: &mut ChartContext<
'a,
DB,
Cartesian2d<
plotters::coord::types::RangedCoordf64,
plotters::coord::types::RangedCoordf64,
>,
>,
) -> Result<()>
where
DB::ErrorType: 'static,
{
chart
.configure_series_labels()
.position(SeriesLabelPosition::UpperLeft)
.label_font((FONT, 16).into_font().color(&TEXT_COLOR.mix(0.9)))
.background_style(BG_COLOR.mix(0.98))
.border_style(BG_COLOR)
.margin(10)
.draw()?;
Ok(())
}
// Chart generation methods
fn generate_disk_chart(
&self,
crate_path: &Path,
crate_name: &str,
runs: &[BenchmarkRun],
) -> Result<()> {
let output_path = crate_path.join("disk_usage_chart.svg");
let output_path = crate_path.join("disk_chart.svg");
let root = SVGBackend::new(&output_path, (1200, 700)).into_drawing_area();
root.fill(&BG_COLOR)?;
let max_time = runs
.iter()
.flat_map(|r| r.data.iter().map(|d| d.timestamp_ms))
.max()
.unwrap_or(1000);
// Calculate time window based on shortest run + buffer
let min_max_time_ms = Self::calculate_min_max_time(runs) + TIME_BUFFER_MS;
let max_time_s = (min_max_time_ms as f64) / 1000.0;
let max_value = runs
.iter()
.flat_map(|r| r.data.iter().map(|d| d.value))
.fold(0.0_f64, f64::max);
// Trim all runs to the same time window
let trimmed_runs = Self::trim_runs_to_time_window(runs, min_max_time_ms);
// Convert to seconds and GB
let max_time_s = (max_time as f64) / 1000.0;
let max_value_gb = max_value / 1024.0;
let max_value = Self::calculate_max_value(&trimmed_runs);
let (max_value_scaled, unit) = Self::format_bytes(max_value);
let scale_factor = max_value / max_value_scaled;
// Format time based on duration
let (max_time_scaled, _time_unit, time_label) = Self::format_time(max_time_s);
let mut chart = ChartBuilder::on(&root)
.caption(
format!("{} — Disk Usage", crate_name),
("SF Mono", 24).into_font().color(&TEXT_COLOR),
(FONT, 24).into_font().color(&TEXT_COLOR),
)
.margin(20)
.x_label_area_size(55)
.y_label_area_size(75)
.build_cartesian_2d(0.0..max_time_s * 1.05, 0.0..max_value_gb * 1.1)?;
.x_label_area_size(50)
.margin_left(50)
.right_y_label_area_size(75)
.build_cartesian_2d(0.0..max_time_scaled * 1.025, 0.0..max_value_scaled * 1.1)?;
chart
.configure_mesh()
.disable_mesh()
.x_desc("Time (s)")
.y_desc("Disk Usage (GB)")
.x_label_offset(10)
.y_label_offset(10)
.x_label_formatter(&|x| format!("{:.1}", x))
.y_label_formatter(&|y| format!("{:.2}", y))
.x_labels(8)
.y_labels(6)
.x_label_style(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.y_label_style(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.axis_style(TEXT_COLOR.mix(0.3))
.draw()?;
configure_chart_mesh!(
chart,
time_label,
format!("Disk Usage ({})", unit),
|y: &f64| format!("{:.2}", y)
);
for (idx, run) in runs.iter().enumerate() {
for (idx, run) in trimmed_runs.iter().enumerate() {
let color = CHART_COLORS[idx % CHART_COLORS.len()];
chart
.draw_series(LineSeries::new(
run.data
.iter()
.map(|d| (d.timestamp_ms as f64 / 1000.0, d.value / 1024.0)),
color.stroke_width(2),
))?
.label(&run.run_id)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
let time_divisor = max_time_s / max_time_scaled;
Self::draw_line_series(
&mut chart,
run.data.iter().map(|d| {
(
d.timestamp_ms as f64 / 1000.0 / time_divisor,
d.value / scale_factor,
)
}),
&run.run_id,
color,
)?;
}
chart
.configure_series_labels()
.position(SeriesLabelPosition::UpperLeft)
.label_font(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.9)))
.background_style(BG_COLOR.mix(0.98))
.border_style(BG_COLOR)
.margin(10)
.draw()?;
Self::configure_series_labels(&mut chart)?;
root.present()?;
println!("Generated: {}", output_path.display());
Ok(())
}
/// Generate memory footprint chart
fn generate_memory_chart(
&self,
crate_path: &Path,
crate_name: &str,
runs: &[BenchmarkRun],
) -> Result<()> {
let output_path = crate_path.join("memory_footprint_chart.svg");
let output_path = crate_path.join("memory_chart.svg");
let root = SVGBackend::new(&output_path, (1200, 700)).into_drawing_area();
root.fill(&BG_COLOR)?;
// Calculate time window based on shortest run + buffer
let min_max_time_ms = Self::calculate_min_max_time(runs) + TIME_BUFFER_MS;
let max_time_s = (min_max_time_ms as f64) / 1000.0;
// Read memory CSV files which have 3 columns: timestamp, footprint, peak
let enhanced_runs = self.read_memory_data(crate_path, runs)?;
// Trim enhanced runs to the same time window
let trimmed_enhanced_runs: Vec<_> = enhanced_runs
.into_iter()
.map(|(run_id, footprint, peak)| {
let trimmed_footprint: Vec<_> = footprint
.into_iter()
.filter(|d| d.timestamp_ms <= min_max_time_ms)
.collect();
let trimmed_peak: Vec<_> = peak
.into_iter()
.filter(|d| d.timestamp_ms <= min_max_time_ms)
.collect();
(run_id, trimmed_footprint, trimmed_peak)
})
.collect();
let max_value = trimmed_enhanced_runs
.iter()
.flat_map(|(_, f, p)| f.iter().chain(p.iter()).map(|d| d.value))
.fold(0.0_f64, f64::max);
let (max_value_scaled, unit) = Self::format_bytes(max_value);
let scale_factor = max_value / max_value_scaled;
// Format time based on duration
let (max_time_scaled, _time_unit, time_label) = Self::format_time(max_time_s);
let mut chart = ChartBuilder::on(&root)
.caption(
format!("{} — Memory", crate_name),
(FONT, 24).into_font().color(&TEXT_COLOR),
)
.margin(20)
.x_label_area_size(50)
.margin_left(50)
.right_y_label_area_size(75)
.build_cartesian_2d(0.0..max_time_scaled * 1.025, 0.0..max_value_scaled * 1.1)?;
configure_chart_mesh!(
chart,
time_label,
format!("Memory ({})", unit),
|y: &f64| format!("{:.2}", y)
);
let time_divisor = max_time_s / max_time_scaled;
for (idx, (run_id, footprint_data, peak_data)) in trimmed_enhanced_runs.iter().enumerate() {
let color = CHART_COLORS[idx % CHART_COLORS.len()];
Self::draw_line_series(
&mut chart,
footprint_data.iter().map(|d| {
(
d.timestamp_ms as f64 / 1000.0 / time_divisor,
d.value / scale_factor,
)
}),
&format!("{} (current)", run_id),
color,
)?;
// Draw peak line (dashed) - inline to handle time_divisor
let dashed_color = color.mix(0.5);
chart
.draw_series(
peak_data
.iter()
.map(|d| {
(
d.timestamp_ms as f64 / 1000.0 / time_divisor,
d.value / scale_factor,
)
})
.zip(peak_data.iter().skip(1).map(|d| {
(
d.timestamp_ms as f64 / 1000.0 / time_divisor,
d.value / scale_factor,
)
}))
.enumerate()
.filter(|(i, _)| i % 2 == 0)
.map(|(_, (p1, p2))| {
PathElement::new(vec![p1, p2], dashed_color.stroke_width(2))
}),
)?
.label(format!("{} (peak)", run_id))
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 10, y), (x + 20, y)],
dashed_color.stroke_width(2),
)
});
}
Self::configure_series_labels(&mut chart)?;
root.present()?;
println!("Generated: {}", output_path.display());
Ok(())
}
#[allow(clippy::type_complexity)]
fn read_memory_data(
&self,
crate_path: &Path,
runs: &[BenchmarkRun],
) -> Result<Vec<(String, Vec<DataPoint>, Vec<DataPoint>)>> {
let mut enhanced_runs = Vec::new();
for run in runs {
// Re-read the CSV to get both footprint and peak values
let csv_path = crate_path.join(&run.run_id).join("memory_footprint.csv");
let csv_path = crate_path.join(&run.run_id).join("memory.csv");
if let Ok(content) = fs::read_to_string(&csv_path) {
let mut footprint_data = Vec::new();
let mut peak_data = Vec::new();
@@ -255,7 +500,7 @@ impl Visualizer {
for (i, line) in content.lines().enumerate() {
if i == 0 {
continue;
} // Skip header
}
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 3
@@ -280,100 +525,71 @@ impl Visualizer {
}
}
let max_time = enhanced_runs
.iter()
.flat_map(|(_, f, p)| f.iter().chain(p.iter()).map(|d| d.timestamp_ms))
.max()
.unwrap_or(1000);
Ok(enhanced_runs)
}
let max_value = enhanced_runs
.iter()
.flat_map(|(_, f, p)| f.iter().chain(p.iter()).map(|d| d.value))
.fold(0.0_f64, f64::max);
fn generate_progress_chart(
&self,
crate_path: &Path,
crate_name: &str,
runs: &[BenchmarkRun],
) -> Result<()> {
let output_path = crate_path.join("progress_chart.svg");
let root = SVGBackend::new(&output_path, (1200, 700)).into_drawing_area();
root.fill(&BG_COLOR)?;
// Convert to seconds and GB
let max_time_s = (max_time as f64) / 1000.0;
let max_value_gb = max_value / 1024.0;
// Calculate time window based on shortest run + buffer
let min_max_time_ms = Self::calculate_min_max_time(runs) + TIME_BUFFER_MS;
let max_time_s = (min_max_time_ms as f64) / 1000.0;
// Trim all runs to the same time window
let trimmed_runs = Self::trim_runs_to_time_window(runs, min_max_time_ms);
let max_block = Self::calculate_max_value(&trimmed_runs);
// Format time based on duration
let (max_time_scaled, _time_unit, time_label) = Self::format_time(max_time_s);
let mut chart = ChartBuilder::on(&root)
.caption(
format!("{}Memory Footprint", crate_name),
("SF Mono", 24).into_font().color(&TEXT_COLOR),
format!("{}Progress", crate_name),
(FONT, 24).into_font().color(&TEXT_COLOR),
)
.margin(20)
.x_label_area_size(55)
.y_label_area_size(75)
.build_cartesian_2d(0.0..max_time_s * 1.05, 0.0..max_value_gb * 1.1)?;
.x_label_area_size(50)
.margin_left(50)
.right_y_label_area_size(75)
.build_cartesian_2d(0.0..max_time_scaled * 1.025, 0.0..max_block * 1.1)?;
chart
.configure_mesh()
.disable_mesh()
.x_desc("Time (s)")
.y_desc("Memory (GB)")
.x_label_offset(10)
.y_label_offset(10)
.x_label_formatter(&|x| format!("{:.1}", x))
.y_label_formatter(&|y| format!("{:.2}", y))
.x_labels(8)
.y_labels(6)
.x_label_style(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.y_label_style(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.x_desc(time_label)
.y_desc("Block Number")
.x_label_formatter(&|x| Self::format_axis_number(*x))
.y_label_formatter(&|y| Self::format_axis_number(*y))
.x_labels(12)
.y_labels(10)
.x_label_style((FONT, 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.y_label_style((FONT, 16).into_font().color(&TEXT_COLOR.mix(0.7)))
.axis_style(TEXT_COLOR.mix(0.3))
.draw()?;
for (idx, (run_id, footprint_data, peak_data)) in enhanced_runs.iter().enumerate() {
let time_divisor = max_time_s / max_time_scaled;
for (idx, run) in trimmed_runs.iter().enumerate() {
let color = CHART_COLORS[idx % CHART_COLORS.len()];
// Draw footprint line (solid)
chart
.draw_series(LineSeries::new(
footprint_data
.iter()
.map(|d| (d.timestamp_ms as f64 / 1000.0, d.value / 1024.0)),
color.stroke_width(2),
))?
.label(format!("{} (current)", run_id))
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
// Draw peak line (dashed, slightly transparent)
let dashed_color = color.mix(0.5);
chart
.draw_series(
peak_data
.iter()
.map(|d| (d.timestamp_ms as f64 / 1000.0, d.value / 1024.0))
.zip(
peak_data
.iter()
.skip(1)
.map(|d| (d.timestamp_ms as f64 / 1000.0, d.value / 1024.0)),
)
.enumerate()
.filter(|(i, _)| i % 2 == 0) // Create dashed effect
.map(|(_, (p1, p2))| {
PathElement::new(vec![p1, p2], dashed_color.stroke_width(2))
}),
)?
.label(format!("{} (peak)", run_id))
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 10, y), (x + 20, y)],
dashed_color.stroke_width(2),
)
});
Self::draw_line_series(
&mut chart,
run.data
.iter()
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value)),
&run.run_id,
color,
)?;
}
chart
.configure_series_labels()
.position(SeriesLabelPosition::UpperLeft)
.label_font(("SF Mono", 16).into_font().color(&TEXT_COLOR.mix(0.9)))
.background_style(BG_COLOR.mix(0.98))
.border_style(BG_COLOR)
.margin(10)
.draw()?;
Self::configure_series_labels(&mut chart)?;
root.present()?;
println!("Generated: {}", output_path.display());
Ok(())