mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-27 08:09:58 -07:00
global: snapshot
This commit is contained in:
251
crates/brk_bencher_visualizer/src/chart.rs
Normal file
251
crates/brk_bencher_visualizer/src/chart.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use crate::data::{DataPoint, DualRun, Result, Run};
|
||||
use crate::format;
|
||||
use plotters::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
const FONT: &str = "monospace";
|
||||
const FONT_SIZE: i32 = 20;
|
||||
const FONT_SIZE_BIG: i32 = 30;
|
||||
const SIZE: (u32, u32) = (2000, 1000);
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
const BG_COLOR: RGBColor = RGBColor(18, 18, 24);
|
||||
const TEXT_COLOR: RGBColor = RGBColor(230, 230, 240);
|
||||
const COLORS: [RGBColor; 6] = [
|
||||
RGBColor(255, 99, 132), // Pink/Red
|
||||
RGBColor(54, 162, 235), // Blue
|
||||
RGBColor(75, 192, 192), // Teal
|
||||
RGBColor(255, 206, 86), // Yellow
|
||||
RGBColor(153, 102, 255), // Purple
|
||||
RGBColor(255, 159, 64), // Orange
|
||||
];
|
||||
|
||||
pub enum YAxisFormat {
|
||||
Bytes,
|
||||
Number,
|
||||
}
|
||||
|
||||
pub struct ChartConfig<'a> {
|
||||
pub output_path: &'a Path,
|
||||
pub title: String,
|
||||
pub y_label: String,
|
||||
pub y_format: YAxisFormat,
|
||||
}
|
||||
|
||||
/// Generate a simple line chart from runs
|
||||
pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs.iter().map(|r| r.max_timestamp()).max().unwrap_or(1000) + TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
draw_series(&mut chart, &run.data, &run.id, color, time_divisor, scale_factor)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a chart with dual series per run (e.g., current + peak memory)
|
||||
pub fn generate_dual(
|
||||
config: ChartConfig,
|
||||
runs: &[DualRun],
|
||||
primary_suffix: &str,
|
||||
secondary_suffix: &str,
|
||||
) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs
|
||||
.iter()
|
||||
.flat_map(|r| r.primary.iter().chain(r.secondary.iter()))
|
||||
.map(|d| d.timestamp_ms)
|
||||
.max()
|
||||
.unwrap_or(1000)
|
||||
+ TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
|
||||
// Primary series (solid)
|
||||
draw_series(
|
||||
&mut chart,
|
||||
&run.primary,
|
||||
&format!("{} {}", run.id, primary_suffix),
|
||||
color,
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
|
||||
// Secondary series (dashed)
|
||||
draw_dashed_series(
|
||||
&mut chart,
|
||||
&run.secondary,
|
||||
&format!("{} {}", run.id, secondary_suffix),
|
||||
color.mix(0.5),
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale_y_axis(max_value: f64, base_label: &str, y_format: &YAxisFormat) -> (f64, f64, String) {
|
||||
match y_format {
|
||||
YAxisFormat::Bytes => {
|
||||
let (scaled, unit) = format::bytes(max_value);
|
||||
let factor = max_value / scaled;
|
||||
(scaled, factor, format!("{} ({})", base_label, unit))
|
||||
}
|
||||
YAxisFormat::Number => (max_value, 1.0, base_label.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate appropriate label count to avoid duplicates when rounding to integers
|
||||
fn label_count(max_value: f64) -> usize {
|
||||
let max_int = max_value.ceil() as usize;
|
||||
// Don't exceed the range, cap at 12 for readability
|
||||
max_int.clamp(2, 12)
|
||||
}
|
||||
|
||||
type Chart<'a, 'b> = ChartContext<
|
||||
'a,
|
||||
SVGBackend<'b>,
|
||||
Cartesian2d<plotters::coord::types::RangedCoordf64, plotters::coord::types::RangedCoordf64>,
|
||||
>;
|
||||
|
||||
fn configure_mesh(chart: &mut Chart, x_label: &str, y_label: &str, y_format: &YAxisFormat, x_labels: usize) -> Result<()> {
|
||||
let y_formatter: Box<dyn Fn(&f64) -> String> = match y_format {
|
||||
YAxisFormat::Bytes => Box::new(|y: &f64| {
|
||||
if y.fract() == 0.0 {
|
||||
format!("{:.0}", y)
|
||||
} else {
|
||||
format!("{:.1}", y)
|
||||
}
|
||||
}),
|
||||
YAxisFormat::Number => Box::new(|y: &f64| format::axis_number(*y)),
|
||||
};
|
||||
|
||||
chart
|
||||
.configure_mesh()
|
||||
.disable_mesh()
|
||||
.x_desc(x_label)
|
||||
.y_desc(y_label)
|
||||
.x_label_formatter(&|x| format!("{:.0}", x))
|
||||
.y_label_formatter(&y_formatter)
|
||||
.x_labels(x_labels)
|
||||
.y_labels(10)
|
||||
.x_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.y_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.axis_style(TEXT_COLOR.mix(0.3))
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points = data
|
||||
.iter()
|
||||
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor));
|
||||
|
||||
chart
|
||||
.draw_series(LineSeries::new(points, color.stroke_width(1)))?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(1)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_dashed_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBAColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points: Vec<_> = data
|
||||
.iter()
|
||||
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor))
|
||||
.collect();
|
||||
|
||||
// Draw dashed line by skipping every other segment
|
||||
chart
|
||||
.draw_series(
|
||||
points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 2 == 0)
|
||||
.map(|(_, w)| PathElement::new(vec![w[0], w[1]], color.stroke_width(2))),
|
||||
)?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 10, y), (x + 20, y)], color.stroke_width(2)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_legend<'a>(chart: &mut Chart<'a, 'a>) -> Result<()> {
|
||||
chart
|
||||
.configure_series_labels()
|
||||
.position(SeriesLabelPosition::UpperLeft)
|
||||
.label_font((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.9)))
|
||||
.background_style(BG_COLOR.mix(0.98))
|
||||
.border_style(BG_COLOR)
|
||||
.margin(10)
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
239
crates/brk_bencher_visualizer/src/data.rs
Normal file
239
crates/brk_bencher_visualizer/src/data.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataPoint {
|
||||
pub timestamp_ms: u64,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Per-run cutoff timestamps for fair comparison
|
||||
pub struct Cutoffs {
|
||||
by_id: HashMap<String, u64>,
|
||||
default: u64,
|
||||
}
|
||||
|
||||
impl Cutoffs {
|
||||
/// Calculate cutoffs from progress runs.
|
||||
/// Finds the common max progress, then returns when each run reached it.
|
||||
pub fn from_progress(progress_runs: &[Run]) -> Self {
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
if progress_runs.is_empty() {
|
||||
return Self {
|
||||
by_id: HashMap::new(),
|
||||
default: u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the minimum of max progress values (the common point all runs reached)
|
||||
let common_progress = progress_runs
|
||||
.iter()
|
||||
.map(|r| r.max_value())
|
||||
.fold(f64::MAX, f64::min);
|
||||
|
||||
let by_id: HashMap<_, _> = progress_runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
let cutoff = run
|
||||
.data
|
||||
.iter()
|
||||
.find(|d| d.value >= common_progress)
|
||||
.map(|d| d.timestamp_ms)
|
||||
.unwrap_or_else(|| run.max_timestamp())
|
||||
.saturating_add(TIME_BUFFER_MS);
|
||||
(run.id.clone(), cutoff)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default = by_id.values().copied().max().unwrap_or(u64::MAX);
|
||||
|
||||
Self { by_id, default }
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> u64 {
|
||||
self.by_id.get(id).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
pub fn trim_runs(&self, runs: &[Run]) -> Vec<Run> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
|
||||
pub fn trim_dual_runs(&self, runs: &[DualRun]) -> Vec<DualRun> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Run {
|
||||
pub id: String,
|
||||
pub data: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub fn max_timestamp(&self) -> u64 {
|
||||
self.data.iter().map(|d| d.timestamp_ms).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.data.iter().map(|d| d.value).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
data: self
|
||||
.data
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two data series from a single run (e.g., memory footprint + peak, or io read + write)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DualRun {
|
||||
pub id: String,
|
||||
pub primary: Vec<DataPoint>,
|
||||
pub secondary: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl DualRun {
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
primary: self
|
||||
.primary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
secondary: self
|
||||
.secondary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.primary
|
||||
.iter()
|
||||
.chain(self.secondary.iter())
|
||||
.map(|d| d.value)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_runs(crate_path: &Path, filename: &str) -> Result<Vec<Run>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
// Skip underscore-prefixed or numeric-only directories
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists() {
|
||||
if let Ok(data) = read_csv(&csv_path) {
|
||||
runs.push(Run { id: run_id, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn read_dual_runs(crate_path: &Path, filename: &str) -> Result<Vec<DualRun>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists() {
|
||||
if let Ok((primary, secondary)) = read_dual_csv(&csv_path) {
|
||||
runs.push(DualRun {
|
||||
id: run_id,
|
||||
primary,
|
||||
secondary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
fn read_csv(path: &Path) -> Result<Vec<DataPoint>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data = content
|
||||
.lines()
|
||||
.skip(1) // header
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split(',');
|
||||
let timestamp_ms = parts.next()?.parse().ok()?;
|
||||
let value = parts.next()?.parse().ok()?;
|
||||
Some(DataPoint {
|
||||
timestamp_ms,
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn read_dual_csv(path: &Path) -> Result<(Vec<DataPoint>, Vec<DataPoint>)> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut primary = Vec::new();
|
||||
let mut secondary = Vec::new();
|
||||
|
||||
for line in content.lines().skip(1) {
|
||||
let mut parts = line.split(',');
|
||||
if let (Some(ts), Some(v1), Some(v2)) = (parts.next(), parts.next(), parts.next()) {
|
||||
if let (Ok(timestamp_ms), Ok(val1), Ok(val2)) =
|
||||
(ts.parse(), v1.parse::<f64>(), v2.parse::<f64>())
|
||||
{
|
||||
primary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val1,
|
||||
});
|
||||
secondary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((primary, secondary))
|
||||
}
|
||||
45
crates/brk_bencher_visualizer/src/format.rs
Normal file
45
crates/brk_bencher_visualizer/src/format.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
const KIB: f64 = 1024.0;
|
||||
const MIB: f64 = KIB * 1024.0;
|
||||
const GIB: f64 = MIB * 1024.0;
|
||||
|
||||
const MINUTE: f64 = 60.0;
|
||||
const HOUR: f64 = 3600.0;
|
||||
|
||||
/// Returns (scaled_value, unit_suffix)
|
||||
pub fn bytes(bytes: f64) -> (f64, &'static str) {
|
||||
if bytes >= GIB {
|
||||
(bytes / GIB, "GiB")
|
||||
} else if bytes >= MIB {
|
||||
(bytes / MIB, "MiB")
|
||||
} else if bytes >= KIB {
|
||||
(bytes / KIB, "KiB")
|
||||
} else {
|
||||
(bytes, "bytes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (scaled_value, divisor, axis_label)
|
||||
pub fn time(seconds: f64) -> (f64, f64, &'static str) {
|
||||
if seconds >= HOUR * 2.0 {
|
||||
(seconds / HOUR, HOUR, "Time (h)")
|
||||
} else if seconds >= MINUTE * 2.0 {
|
||||
(seconds / MINUTE, MINUTE, "Time (min)")
|
||||
} else {
|
||||
(seconds, 1.0, "Time (s)")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis_number(value: f64) -> String {
|
||||
if value >= 1000.0 {
|
||||
let k = value / 1000.0;
|
||||
if k.fract() == 0.0 || k >= 100.0 {
|
||||
format!("{:.0}k", k)
|
||||
} else if k >= 10.0 {
|
||||
format!("{:.1}k", k)
|
||||
} else {
|
||||
format!("{:.2}k", k)
|
||||
}
|
||||
} else {
|
||||
format!("{:.0}", value)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user