use std::fmt::Write as _;
use serde::Serialize;
pub const TSV_HEADER: &str =
"s\th:m:s\tmax_rss\tmax_vms\tmax_uss\tmax_pss\tio_in\tio_out\tmean_load\tcpu_time";
pub const TSV_HEADER_FULL: &str = "s\th:m:s\tmax_rss\tmax_vms\tmax_uss\tmax_pss\t\
io_in\tio_out\tmean_load\tcpu_time\t\
major_page_faults\tminor_page_faults\t\
voluntary_ctx_switches\tinvoluntary_ctx_switches\t\
peak_n_threads\tpeak_n_procs\t\
loadavg_1m_start\tloadavg_1m_end\t\
max_swap\t\
page_cache_start\tpage_cache_end";
#[must_use]
pub fn tsv_header(mode: SchemaMode) -> &'static str {
match mode {
SchemaMode::Full => TSV_HEADER_FULL,
SchemaMode::SnakemakeStrict => TSV_HEADER,
}
}
pub const TRACE_TSV_HEADER: &str = "s\trss\tvms\tuss\tpss\tio_in\tio_out\tcpu_time\tn_procs\t\
major_page_faults\tminor_page_faults\t\
voluntary_ctx_switches\tinvoluntary_ctx_switches\t\
n_threads\tswap";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SchemaMode {
#[default]
Full,
SnakemakeStrict,
}
#[derive(Debug, Clone, Default)]
pub struct TickRecord {
pub elapsed: f64,
pub rss: f64,
pub vms: f64,
pub uss: Option<f64>,
pub pss: Option<f64>,
pub io_in: Option<f64>,
pub io_out: Option<f64>,
pub cpu_time: f64,
pub n_procs: usize,
pub major_page_faults: Option<u64>,
pub minor_page_faults: Option<u64>,
pub voluntary_ctx_switches: Option<u64>,
pub involuntary_ctx_switches: Option<u64>,
pub n_threads: Option<u64>,
pub swap: Option<f64>,
}
impl TickRecord {
#[must_use]
pub fn to_tsv_row(&self) -> String {
let mut out = String::with_capacity(128);
write!(out, "{:.4}\t{:.2}\t{:.2}", self.elapsed, self.rss, self.vms).unwrap();
for value in [self.uss, self.pss, self.io_in, self.io_out] {
out.push('\t');
out.push_str(&format_optional_float(value));
}
write!(out, "\t{:.2}\t{}", self.cpu_time, self.n_procs).unwrap();
for value in [
self.major_page_faults,
self.minor_page_faults,
self.voluntary_ctx_switches,
self.involuntary_ctx_switches,
self.n_threads,
] {
out.push('\t');
out.push_str(&format_optional_u64(value));
}
out.push('\t');
out.push_str(&format_optional_float(self.swap));
out
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BenchmarkRecord {
pub running_time: f64,
pub max_rss: Option<f64>,
pub max_vms: Option<f64>,
pub max_uss: Option<f64>,
pub max_pss: Option<f64>,
pub io_in: Option<f64>,
pub io_out: Option<f64>,
pub mean_load: f64,
pub cpu_time: f64,
pub major_page_faults: Option<u64>,
pub minor_page_faults: Option<u64>,
pub voluntary_ctx_switches: Option<u64>,
pub involuntary_ctx_switches: Option<u64>,
pub peak_n_threads: Option<u64>,
pub peak_n_procs: u64,
pub loadavg_1m_start: Option<f64>,
pub loadavg_1m_end: Option<f64>,
pub max_swap: Option<f64>,
pub page_cache_start: Option<f64>,
pub page_cache_end: Option<f64>,
pub data_collected: bool,
}
impl BenchmarkRecord {
#[must_use]
pub fn to_tsv_row(&self, mode: SchemaMode) -> String {
let mut out = String::with_capacity(128);
write!(out, "{:.4}\t{}", self.running_time, format_hms(self.running_time)).unwrap();
let extra_cols = match mode {
SchemaMode::Full => 11,
SchemaMode::SnakemakeStrict => 0,
};
if !self.data_collected {
for _ in 0..(8 + extra_cols) {
out.push_str("\tNA");
}
return out;
}
for value in
[self.max_rss, self.max_vms, self.max_uss, self.max_pss, self.io_in, self.io_out]
{
out.push('\t');
out.push_str(&format_optional_float(value));
}
write!(out, "\t{:.2}\t{:.2}", self.mean_load, self.cpu_time).unwrap();
if mode == SchemaMode::Full {
for value in [
self.major_page_faults,
self.minor_page_faults,
self.voluntary_ctx_switches,
self.involuntary_ctx_switches,
self.peak_n_threads,
] {
out.push('\t');
out.push_str(&format_optional_u64(value));
}
write!(out, "\t{}", self.peak_n_procs).unwrap();
for value in [
self.loadavg_1m_start,
self.loadavg_1m_end,
self.max_swap,
self.page_cache_start,
self.page_cache_end,
] {
out.push('\t');
out.push_str(&format_optional_float(value));
}
}
out
}
#[must_use]
pub fn to_tsv_document(&self, mode: SchemaMode) -> String {
let mut out = String::with_capacity(256);
out.push_str(tsv_header(mode));
out.push('\n');
out.push_str(&self.to_tsv_row(mode));
out.push('\n');
out
}
pub fn to_json(&self, mode: SchemaMode) -> serde_json::Result<String> {
self.to_json_string(mode, false)
}
pub fn to_json_pretty(&self, mode: SchemaMode) -> serde_json::Result<String> {
self.to_json_string(mode, true)
}
fn to_json_string(&self, mode: SchemaMode, pretty: bool) -> serde_json::Result<String> {
fn to_string<T: serde::Serialize>(value: &T, pretty: bool) -> serde_json::Result<String> {
if pretty { serde_json::to_string_pretty(value) } else { serde_json::to_string(value) }
}
match mode {
SchemaMode::Full => to_string(self, pretty),
SchemaMode::SnakemakeStrict => to_string(&SnakemakeView::from(self), pretty),
}
}
#[must_use]
pub fn to_markdown_document(&self, mode: SchemaMode) -> String {
let rows = self.markdown_rows(mode);
let metric_w = rows.iter().map(|(m, _)| m.len()).max().unwrap_or(0).max("metric".len());
let value_w = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max("value".len());
let mut out = String::with_capacity(256);
writeln!(out, "| {:<metric_w$} | {:>value_w$} |", "metric", "value").unwrap();
writeln!(out, "|:{}|{}:|", "-".repeat(metric_w + 1), "-".repeat(value_w + 1)).unwrap();
for (metric, value) in &rows {
writeln!(out, "| {metric:<metric_w$} | {value:>value_w$} |").unwrap();
}
out
}
fn markdown_rows(&self, mode: SchemaMode) -> Vec<(&'static str, String)> {
let cell = |value: Option<f64>| {
if self.data_collected { format_optional_float(value) } else { "NA".to_string() }
};
let scalar = |value: f64| {
if self.data_collected { format!("{value:.2}") } else { "NA".to_string() }
};
let int_cell = |value: Option<u64>| {
if self.data_collected { format_optional_u64(value) } else { "NA".to_string() }
};
let mut rows = vec![
("s", format!("{:.4}", self.running_time)),
("h:m:s", format_hms(self.running_time)),
("max_rss", cell(self.max_rss)),
("max_vms", cell(self.max_vms)),
("max_uss", cell(self.max_uss)),
("max_pss", cell(self.max_pss)),
("io_in", cell(self.io_in)),
("io_out", cell(self.io_out)),
("mean_load", scalar(self.mean_load)),
("cpu_time", scalar(self.cpu_time)),
];
if mode == SchemaMode::Full {
rows.push(("major_page_faults", int_cell(self.major_page_faults)));
rows.push(("minor_page_faults", int_cell(self.minor_page_faults)));
rows.push(("voluntary_ctx_switches", int_cell(self.voluntary_ctx_switches)));
rows.push(("involuntary_ctx_switches", int_cell(self.involuntary_ctx_switches)));
rows.push(("peak_n_threads", int_cell(self.peak_n_threads)));
rows.push((
"peak_n_procs",
if self.data_collected { self.peak_n_procs.to_string() } else { "NA".to_string() },
));
rows.push(("loadavg_1m_start", cell(self.loadavg_1m_start)));
rows.push(("loadavg_1m_end", cell(self.loadavg_1m_end)));
rows.push(("max_swap", cell(self.max_swap)));
rows.push(("page_cache_start", cell(self.page_cache_start)));
rows.push(("page_cache_end", cell(self.page_cache_end)));
}
rows
}
#[must_use]
pub fn summary_line(&self) -> String {
let mb = |x: Option<f64>| match x {
Some(v) => format!("{v:.1}MiB"),
None => "-".to_string(),
};
format!(
"wall={:.2}s cpu={:.2}s mean_load={:.0}% max_rss={} max_uss={} io_in={} io_out={}",
self.running_time,
self.cpu_time,
self.mean_load,
mb(self.max_rss),
mb(self.max_uss),
mb(self.io_in),
mb(self.io_out),
)
}
}
fn format_optional_float(value: Option<f64>) -> String {
match value {
Some(v) => format!("{v:.2}"),
None => "-".to_string(),
}
}
fn format_optional_u64(value: Option<u64>) -> String {
match value {
Some(v) => v.to_string(),
None => "-".to_string(),
}
}
#[derive(Serialize)]
struct SnakemakeView<'a> {
running_time: f64,
max_rss: &'a Option<f64>,
max_vms: &'a Option<f64>,
max_uss: &'a Option<f64>,
max_pss: &'a Option<f64>,
io_in: &'a Option<f64>,
io_out: &'a Option<f64>,
mean_load: f64,
cpu_time: f64,
data_collected: bool,
}
impl<'a> From<&'a BenchmarkRecord> for SnakemakeView<'a> {
fn from(r: &'a BenchmarkRecord) -> Self {
Self {
running_time: r.running_time,
max_rss: &r.max_rss,
max_vms: &r.max_vms,
max_uss: &r.max_uss,
max_pss: &r.max_pss,
io_in: &r.io_in,
io_out: &r.io_out,
mean_load: r.mean_load,
cpu_time: r.cpu_time,
data_collected: r.data_collected,
}
}
}
#[must_use]
pub fn format_hms(seconds: f64) -> String {
let total = seconds.max(0.0) as u64;
let days = total / 86_400;
let rem = total % 86_400;
let hh = rem / 3600;
let mm = (rem % 3600) / 60;
let ss = rem % 60;
let body = format!("{hh}:{mm:02}:{ss:02}");
if days == 0 {
body
} else if days == 1 {
format!("1 day, {body}")
} else {
format!("{days} days, {body}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn full_record() -> BenchmarkRecord {
BenchmarkRecord {
running_time: 12.3456,
max_rss: Some(101.5),
max_vms: Some(2048.0),
max_uss: Some(95.2),
max_pss: Some(96.0),
io_in: Some(1.25),
io_out: Some(0.5),
mean_load: 175.0,
cpu_time: 21.6,
major_page_faults: Some(42),
minor_page_faults: Some(1234),
voluntary_ctx_switches: Some(80),
involuntary_ctx_switches: Some(7),
peak_n_threads: Some(13),
peak_n_procs: 4,
loadavg_1m_start: Some(0.50),
loadavg_1m_end: Some(2.25),
max_swap: Some(64.00),
page_cache_start: Some(512.00),
page_cache_end: Some(480.25),
data_collected: true,
}
}
#[test]
fn header_matches_snakemake() {
assert_eq!(
TSV_HEADER,
"s\th:m:s\tmax_rss\tmax_vms\tmax_uss\tmax_pss\tio_in\tio_out\tmean_load\tcpu_time"
);
}
#[test]
fn header_full_appends_tricord_columns() {
assert!(TSV_HEADER_FULL.starts_with(TSV_HEADER));
let strict_cols: Vec<&str> = TSV_HEADER.split('\t').collect();
let full_cols: Vec<&str> = TSV_HEADER_FULL.split('\t').collect();
assert_eq!(&full_cols[..strict_cols.len()], &strict_cols[..]);
for col in [
"major_page_faults",
"minor_page_faults",
"voluntary_ctx_switches",
"involuntary_ctx_switches",
"peak_n_threads",
"peak_n_procs",
"page_cache_start",
"page_cache_end",
] {
assert!(full_cols.contains(&col), "missing {col} in {TSV_HEADER_FULL}");
}
}
#[test]
fn tsv_header_dispatches_on_mode() {
assert_eq!(tsv_header(SchemaMode::SnakemakeStrict), TSV_HEADER);
assert_eq!(tsv_header(SchemaMode::Full), TSV_HEADER_FULL);
}
#[test]
fn hms_zero() {
assert_eq!(format_hms(0.0), "0:00:00");
}
#[test]
fn hms_seconds() {
assert_eq!(format_hms(7.9), "0:00:07");
assert_eq!(format_hms(59.0), "0:00:59");
}
#[test]
fn hms_minutes_hours() {
assert_eq!(format_hms(60.0), "0:01:00");
assert_eq!(format_hms(3661.0), "1:01:01");
}
#[test]
fn hms_days_singular_and_plural() {
assert_eq!(format_hms(86_400.0), "1 day, 0:00:00");
assert_eq!(format_hms(86_400.0 * 2.0 + 3661.0), "2 days, 1:01:01");
}
#[test]
fn tsv_row_no_data_strict_has_10_nas() {
let record =
BenchmarkRecord { running_time: 0.1234, data_collected: false, ..Default::default() };
assert_eq!(
record.to_tsv_row(SchemaMode::SnakemakeStrict),
"0.1234\t0:00:00\tNA\tNA\tNA\tNA\tNA\tNA\tNA\tNA"
);
}
#[test]
fn tsv_row_no_data_full_has_one_na_per_full_column() {
let record =
BenchmarkRecord { running_time: 0.1234, data_collected: false, ..Default::default() };
let total_cols = TSV_HEADER_FULL.split('\t').count();
let row = record.to_tsv_row(SchemaMode::Full);
assert!(row.starts_with("0.1234\t0:00:00\t"));
assert_eq!(row.split('\t').count(), total_cols);
assert_eq!(row.split('\t').filter(|c| *c == "NA").count(), total_cols - 2);
}
#[test]
fn tsv_row_full_mode_appends_all_tricord_columns() {
assert_eq!(
full_record().to_tsv_row(SchemaMode::Full),
"12.3456\t0:00:12\t101.50\t2048.00\t95.20\t96.00\t1.25\t0.50\t175.00\t21.60\t\
42\t1234\t80\t7\t13\t4\t0.50\t2.25\t64.00\t512.00\t480.25",
);
}
#[test]
fn tsv_row_strict_mode_omits_tricord_columns() {
assert_eq!(
full_record().to_tsv_row(SchemaMode::SnakemakeStrict),
"12.3456\t0:00:12\t101.50\t2048.00\t95.20\t96.00\t1.25\t0.50\t175.00\t21.60"
);
}
#[test]
fn tsv_row_full_mode_renders_missing_tricord_metrics_as_dash() {
let record = BenchmarkRecord {
major_page_faults: None,
minor_page_faults: None,
voluntary_ctx_switches: None,
involuntary_ctx_switches: None,
peak_n_threads: None,
loadavg_1m_start: None,
loadavg_1m_end: None,
max_swap: None,
page_cache_start: None,
page_cache_end: None,
..full_record()
};
let row = record.to_tsv_row(SchemaMode::Full);
assert!(row.ends_with("\t-\t-\t-\t-\t-\t4\t-\t-\t-\t-\t-"), "row was: {row}");
}
#[test]
fn tsv_row_missing_io_renders_dash() {
let record = BenchmarkRecord {
running_time: 1.0,
max_rss: Some(10.0),
max_vms: Some(20.0),
max_uss: Some(8.0),
max_pss: None,
io_in: None,
io_out: None,
mean_load: 0.0,
cpu_time: 0.0,
data_collected: true,
..Default::default()
};
assert_eq!(
record.to_tsv_row(SchemaMode::SnakemakeStrict),
"1.0000\t0:00:01\t10.00\t20.00\t8.00\t-\t-\t-\t0.00\t0.00"
);
}
#[test]
fn tsv_document_strict_uses_snakemake_header() {
let record =
BenchmarkRecord { running_time: 0.5, data_collected: false, ..Default::default() };
let doc = record.to_tsv_document(SchemaMode::SnakemakeStrict);
let mut lines = doc.lines();
assert_eq!(lines.next(), Some(TSV_HEADER));
assert!(lines.next().is_some_and(|line| line.starts_with("0.5000\t")));
assert_eq!(lines.next(), None);
assert!(doc.ends_with('\n'));
}
#[test]
fn tsv_document_full_uses_full_header() {
let record =
BenchmarkRecord { running_time: 0.5, data_collected: false, ..Default::default() };
let doc = record.to_tsv_document(SchemaMode::Full);
let mut lines = doc.lines();
assert_eq!(lines.next(), Some(TSV_HEADER_FULL));
assert!(lines.next().is_some_and(|line| line.starts_with("0.5000\t")));
}
#[test]
fn trace_header_lists_per_tick_columns_including_page_faults() {
assert_eq!(
TRACE_TSV_HEADER,
"s\trss\tvms\tuss\tpss\tio_in\tio_out\tcpu_time\tn_procs\t\
major_page_faults\tminor_page_faults\t\
voluntary_ctx_switches\tinvoluntary_ctx_switches\t\
n_threads\tswap"
);
}
#[test]
fn tick_row_full_data() {
let tick = TickRecord {
elapsed: 0.5012,
rss: 102.30,
vms: 2048.0,
uss: Some(95.2),
pss: Some(96.0),
io_in: Some(1.25),
io_out: Some(0.5),
cpu_time: 0.75,
n_procs: 3,
major_page_faults: Some(2),
minor_page_faults: Some(150),
voluntary_ctx_switches: Some(11),
involuntary_ctx_switches: Some(3),
n_threads: Some(8),
swap: Some(16.50),
};
assert_eq!(
tick.to_tsv_row(),
"0.5012\t102.30\t2048.00\t95.20\t96.00\t1.25\t0.50\t0.75\t3\t2\t150\t11\t3\t8\t16.50"
);
}
#[test]
fn tick_row_missing_optionals_render_as_dash() {
let tick = TickRecord {
elapsed: 1.0,
rss: 10.0,
vms: 20.0,
uss: None,
pss: None,
io_in: None,
io_out: None,
cpu_time: 0.0,
n_procs: 1,
major_page_faults: None,
minor_page_faults: None,
voluntary_ctx_switches: None,
involuntary_ctx_switches: None,
n_threads: None,
swap: None,
};
assert_eq!(
tick.to_tsv_row(),
"1.0000\t10.00\t20.00\t-\t-\t-\t-\t0.00\t1\t-\t-\t-\t-\t-\t-"
);
}
#[test]
fn markdown_full_data_exact_layout() {
let expected = "\
| metric | value |
|:-------------------------|--------:|
| s | 12.3456 |
| h:m:s | 0:00:12 |
| max_rss | 101.50 |
| max_vms | 2048.00 |
| max_uss | 95.20 |
| max_pss | 96.00 |
| io_in | 1.25 |
| io_out | 0.50 |
| mean_load | 175.00 |
| cpu_time | 21.60 |
| major_page_faults | 42 |
| minor_page_faults | 1234 |
| voluntary_ctx_switches | 80 |
| involuntary_ctx_switches | 7 |
| peak_n_threads | 13 |
| peak_n_procs | 4 |
| loadavg_1m_start | 0.50 |
| loadavg_1m_end | 2.25 |
| max_swap | 64.00 |
| page_cache_start | 512.00 |
| page_cache_end | 480.25 |
";
assert_eq!(full_record().to_markdown_document(SchemaMode::Full), expected);
}
#[test]
fn markdown_strict_mode_drops_tricord_added_rows() {
let doc = full_record().to_markdown_document(SchemaMode::SnakemakeStrict);
assert!(!doc.contains("major_page_faults"), "strict doc must not list page faults: {doc}");
assert!(!doc.contains("minor_page_faults"));
assert_eq!(doc.lines().count(), 12);
}
#[test]
fn markdown_no_data_uses_na_in_resource_cells() {
let record =
BenchmarkRecord { running_time: 0.1234, data_collected: false, ..Default::default() };
let doc = record.to_markdown_document(SchemaMode::Full);
for metric in [
"max_rss",
"max_vms",
"max_uss",
"max_pss",
"io_in",
"io_out",
"mean_load",
"cpu_time",
"major_page_faults",
"minor_page_faults",
] {
let row = doc
.lines()
.find(|l| l.contains(metric))
.unwrap_or_else(|| panic!("no row for {metric}"));
assert!(row.ends_with("NA |"), "row for {metric}: {row}");
}
}
#[test]
fn markdown_missing_optionals_render_as_dash() {
let record = BenchmarkRecord {
running_time: 1.0,
max_rss: Some(10.0),
max_vms: Some(20.0),
max_uss: Some(8.0),
max_pss: None,
io_in: None,
io_out: None,
mean_load: 0.0,
cpu_time: 0.0,
major_page_faults: None,
minor_page_faults: None,
voluntary_ctx_switches: None,
involuntary_ctx_switches: None,
peak_n_threads: None,
peak_n_procs: 1,
loadavg_1m_start: None,
loadavg_1m_end: None,
max_swap: None,
page_cache_start: None,
page_cache_end: None,
data_collected: true,
};
let doc = record.to_markdown_document(SchemaMode::Full);
for metric in [
"max_pss",
"io_in",
"io_out",
"major_page_faults",
"minor_page_faults",
"voluntary_ctx_switches",
"involuntary_ctx_switches",
"peak_n_threads",
"loadavg_1m_start",
"loadavg_1m_end",
"max_swap",
"page_cache_start",
"page_cache_end",
] {
let row = doc.lines().find(|l| l.contains(metric)).expect("metric row");
assert!(row.contains(" - "), "expected dash in {metric} row: {row}");
}
}
#[test]
fn markdown_full_row_count_tracks_tsv_header_full() {
let record =
BenchmarkRecord { running_time: 0.5, data_collected: false, ..Default::default() };
let doc = record.to_markdown_document(SchemaMode::Full);
let expected_rows = 2 + TSV_HEADER_FULL.split('\t').count();
assert_eq!(doc.lines().count(), expected_rows);
}
#[test]
fn json_full_mode_preserves_fields() {
let record = BenchmarkRecord {
running_time: 1.5,
max_rss: Some(42.0),
max_pss: None,
major_page_faults: Some(7),
data_collected: true,
..Default::default()
};
let json = record.to_json(SchemaMode::Full).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["running_time"], 1.5);
assert_eq!(v["max_rss"], 42.0);
assert!(v["max_pss"].is_null());
assert_eq!(v["data_collected"], true);
assert_eq!(v["major_page_faults"], 7);
assert!(v["minor_page_faults"].is_null());
}
#[test]
fn json_strict_mode_omits_tricord_added_fields() {
let json = full_record().to_json(SchemaMode::SnakemakeStrict).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = v.as_object().expect("object");
assert!(!obj.contains_key("major_page_faults"), "strict json: {json}");
assert!(!obj.contains_key("minor_page_faults"));
assert_eq!(obj.len(), 10, "unexpected key set: {obj:?}");
}
}