global: snapshot

This commit is contained in:
nym21
2025-12-12 16:55:55 +01:00
parent e755f2856a
commit 3526a177fc
66 changed files with 1964 additions and 9175 deletions

View 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(())
}

View 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))
}

View 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