Perf profiling and some low hanging perf improvements in text rendering

This commit is contained in:
Will Temple
2026-05-16 19:07:44 -04:00
parent 4347232b98
commit e5ae067bc2
13 changed files with 1010 additions and 117 deletions

8
lib/perf/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "ruin_perf"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

226
lib/perf/src/lib.rs Normal file
View File

@@ -0,0 +1,226 @@
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunMetadata {
pub scenario: String,
pub frames: u64,
pub profile: String,
pub target_os: String,
pub target_arch: String,
pub started_unix_ms: u128,
}
impl RunMetadata {
pub fn new(scenario: impl Into<String>, frames: u64) -> Self {
Self {
scenario: scenario.into(),
frames,
profile: if cfg!(debug_assertions) {
"debug".into()
} else {
"release".into()
},
target_os: std::env::consts::OS.into(),
target_arch: std::env::consts::ARCH.into(),
started_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SampleSeries {
pub name: String,
pub unit: String,
pub samples: Vec<f64>,
}
impl SampleSeries {
pub fn new(name: impl Into<String>, unit: impl Into<String>) -> Self {
Self {
name: name.into(),
unit: unit.into(),
samples: Vec::new(),
}
}
pub fn push(&mut self, sample: f64) {
if sample.is_finite() {
self.samples.push(sample);
}
}
pub fn summary(&self) -> SampleSummary {
SampleSummary::from_samples(&self.name, &self.unit, &self.samples)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SampleSummary {
pub name: String,
pub unit: String,
pub count: usize,
pub avg: f64,
pub p50: f64,
pub p95: f64,
pub max: f64,
}
impl SampleSummary {
fn from_samples(name: &str, unit: &str, samples: &[f64]) -> Self {
if samples.is_empty() {
return Self {
name: name.into(),
unit: unit.into(),
count: 0,
avg: 0.0,
p50: 0.0,
p95: 0.0,
max: 0.0,
};
}
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let sum = sorted.iter().sum::<f64>();
Self {
name: name.into(),
unit: unit.into(),
count: sorted.len(),
avg: sum / sorted.len() as f64,
p50: percentile(&sorted, 0.50),
p95: percentile(&sorted, 0.95),
max: *sorted.last().unwrap_or(&0.0),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PerfReport {
pub metadata: RunMetadata,
pub summaries: Vec<SampleSummary>,
pub counters: BTreeMap<String, f64>,
pub notes: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct PerfReportBuilder {
metadata: RunMetadata,
series: BTreeMap<String, SampleSeries>,
counters: BTreeMap<String, f64>,
notes: Vec<String>,
}
impl PerfReportBuilder {
pub fn new(metadata: RunMetadata) -> Self {
Self {
metadata,
series: BTreeMap::new(),
counters: BTreeMap::new(),
notes: Vec::new(),
}
}
pub fn record_ms(&mut self, name: impl Into<String>, value: f64) {
self.record(name, "ms", value);
}
pub fn record(&mut self, name: impl Into<String>, unit: impl Into<String>, value: f64) {
let name = name.into();
let unit = unit.into();
self.series
.entry(name.clone())
.or_insert_with(|| SampleSeries::new(name, unit))
.push(value);
}
pub fn add_counter(&mut self, name: impl Into<String>, value: impl Into<f64>) {
*self.counters.entry(name.into()).or_insert(0.0) += value.into();
}
pub fn set_counter(&mut self, name: impl Into<String>, value: impl Into<f64>) {
self.counters.insert(name.into(), value.into());
}
pub fn note(&mut self, note: impl Into<String>) {
self.notes.push(note.into());
}
pub fn finish(self) -> PerfReport {
PerfReport {
metadata: self.metadata,
summaries: self.series.values().map(SampleSeries::summary).collect(),
counters: self.counters,
notes: self.notes,
}
}
}
impl PerfReport {
pub fn write_json(&self, path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(path, json)?;
Ok(())
}
pub fn default_output_path(&self) -> PathBuf {
PathBuf::from("target")
.join("ruin-perf")
.join(format!("{}.json", self.metadata.scenario))
}
pub fn print_summary(&self) {
println!(
"scenario={} frames={} profile={} target={}/{}",
self.metadata.scenario,
self.metadata.frames,
self.metadata.profile,
self.metadata.target_os,
self.metadata.target_arch
);
println!(
"{:<32} {:>8} {:>10} {:>10} {:>10} {:>10}",
"metric", "count", "avg", "p50", "p95", "max"
);
for summary in &self.summaries {
println!(
"{:<32} {:>8} {:>10.3} {:>10.3} {:>10.3} {:>10.3} {}",
summary.name,
summary.count,
summary.avg,
summary.p50,
summary.p95,
summary.max,
summary.unit
);
}
if !self.counters.is_empty() {
println!();
println!("{:<32} {:>12}", "counter", "value");
for (name, value) in &self.counters {
println!("{name:<32} {value:>12.3}");
}
}
}
}
fn percentile(sorted: &[f64], percentile: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let index = ((sorted.len() - 1) as f64 * percentile).round() as usize;
sorted[index.min(sorted.len() - 1)]
}