Skip to content

Commit 605531f

Browse files
committed
feat(cli): make benchmark reports comparable across runs and machines
Per-iteration rows show what one build cost; comparing an optimization against a baseline needs three more things, which this adds. Aggregate statistics per phase — count, min, mean, p50, p90, max, and a coefficient of variation flagged above 10% so a noisy run is not read as a result. Percentiles are nearest-rank, without interpolation: sample counts are small, so an exact observed value beats a blend of two. Outliers are never discarded, and the raw per-iteration rows stay above the summary. Build provenance — build.rs resolves the leansig and leanVM revisions from Cargo.lock into the report. leansig is pinned to a moving branch and leanVM does the signature aggregation, so either one moves the measured crypto; two reports that disagree on them are not comparable, and without this the report cannot say so. The per-[[package]] parse collects `name` and `source` before extracting the rev, so it does not depend on TOML field order. Machine-readable output — `--format json` with a schema_version, and `--output <path>` to write it alongside a human-readable run. Logs already go to stderr, so the JSON pipes straight into jq. CI gains a seconds-fast mock smoke step that asserts the contract, so a change to the report shape cannot land unnoticed.
1 parent 233cc94 commit 605531f

6 files changed

Lines changed: 305 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,11 @@ jobs:
7575

7676
- name: Run fixture-based tests
7777
uses: ./.github/actions/run-fixture-tests
78+
79+
# Reuses the release build from the test step; validates the benchmark
80+
# harness end-to-end and its JSON output contract in a few seconds.
81+
- name: Benchmark smoke (mock crypto)
82+
run: |
83+
cargo run --release --bin ethlambda -- benchmark synthetic --mock-crypto \
84+
--num-validators 4 --warmup-slots 4 --iterations 3 --format json \
85+
| jq -e '.schema_version == 1 and (.samples | length == 3)'

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/ethlambda/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ tracing.workspace = true
3838
tracing-subscriber = "0.3"
3939

4040
serde.workspace = true
41+
serde_json.workspace = true
4142
serde_yaml_ng.workspace = true
4243
hex.workspace = true
4344

bin/ethlambda/build.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
use std::path::PathBuf;
2+
13
use vergen_git2::{Emitter, Git2Builder, RustcBuilder};
24

5+
/// Crate names whose resolved git revision is embedded in the binary, one per
6+
/// upstream crypto repository: `leansig` for leanSig, `lean-multisig` for
7+
/// leanVM (the direct dependency `ethlambda-crypto` builds against).
8+
const LEANSIG_PACKAGE: &str = "leansig";
9+
const LEANVM_PACKAGE: &str = "lean-multisig";
10+
311
fn main() -> Result<(), Box<dyn std::error::Error>> {
412
let git2 = Git2Builder::default().branch(true).sha(true).build()?;
513
let rustc = RustcBuilder::default()
@@ -12,5 +20,70 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
1220
.add_instructions(&git2)?
1321
.emit()?;
1422

23+
emit_crypto_revs();
24+
1525
Ok(())
1626
}
27+
28+
/// Embed the resolved leanSig and leanVM git revisions from the workspace
29+
/// Cargo.lock.
30+
///
31+
/// The crypto dependencies are pinned upstream (leansig to a moving branch,
32+
/// leanVM to a rev), so a `cargo update` or a rev bump changes the measured
33+
/// crypto with little or no ethlambda diff; benchmark reports embed these
34+
/// revisions to keep results interpretable across lock bumps.
35+
fn emit_crypto_revs() {
36+
let revs = lockfile_git_revs();
37+
for (package, env_var) in [
38+
(LEANSIG_PACKAGE, "ETHLAMBDA_LEANSIG_REV"),
39+
(LEANVM_PACKAGE, "ETHLAMBDA_LEANVM_REV"),
40+
] {
41+
let rev = revs
42+
.as_ref()
43+
.and_then(|revs| revs.get(package).cloned())
44+
.unwrap_or_else(|| "unknown".to_string());
45+
println!("cargo:rustc-env={env_var}={rev}");
46+
}
47+
if let Some(lockfile) = workspace_lockfile() {
48+
println!("cargo:rerun-if-changed={}", lockfile.display());
49+
}
50+
}
51+
52+
fn workspace_lockfile() -> Option<PathBuf> {
53+
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
54+
Some(PathBuf::from(manifest_dir).join("../../Cargo.lock"))
55+
}
56+
57+
/// Map each git-sourced package in the lockfile to its resolved revision.
58+
///
59+
/// Both fields of a `[[package]]` block are collected before the revision is
60+
/// extracted, so the result does not depend on TOML field order within the
61+
/// table (a lock-file reformatter emitting `source` before `name` would
62+
/// otherwise silently yield "unknown").
63+
fn lockfile_git_revs() -> Option<std::collections::HashMap<String, String>> {
64+
let lockfile = std::fs::read_to_string(workspace_lockfile()?).ok()?;
65+
let mut revs = std::collections::HashMap::new();
66+
// A lockfile is a flat sequence of `[[package]]` blocks; splitting on the
67+
// header gives one chunk per package (the first chunk is the file preamble,
68+
// which has no `name` and is skipped).
69+
for block in lockfile.split("[[package]]") {
70+
let mut name = None;
71+
let mut source = None;
72+
for line in block.lines() {
73+
let line = line.trim();
74+
if let Some(value) = line.strip_prefix("name = ") {
75+
name = Some(value.trim_matches('"').to_string());
76+
} else if let Some(value) = line.strip_prefix("source = ") {
77+
source = Some(value.trim_matches('"').to_string());
78+
}
79+
}
80+
// source = "git+https://github.com/leanEthereum/leanSig?branch=devnet4#<rev>"
81+
let (Some(name), Some(source)) = (name, source) else {
82+
continue;
83+
};
84+
if let Some(rev) = source.strip_prefix("git+").and_then(|s| s.rsplit_once('#')) {
85+
revs.insert(name, rev.1.to_string());
86+
}
87+
}
88+
Some(revs)
89+
}

bin/ethlambda/src/benchmark/mod.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod corpus;
1313
mod report;
1414

1515
use std::collections::{BTreeMap, HashMap};
16+
use std::path::PathBuf;
1617
use std::time::Instant;
1718

1819
use ethlambda_blockchain::block_builder::ProposerConfig;
@@ -84,6 +85,19 @@ struct CommonOptions {
8485
/// Mirrors the node flag: distinct AttestationData cap per built block.
8586
#[arg(long, default_value = "3")]
8687
max_attestations_per_block: usize,
88+
/// Report format printed to stdout. Logs go to stderr, so JSON output can
89+
/// be piped directly (e.g. into jq).
90+
#[arg(long, value_enum, default_value_t = OutputFormat::Human)]
91+
format: OutputFormat,
92+
/// Also write the JSON report to this file.
93+
#[arg(long)]
94+
output: Option<PathBuf>,
95+
}
96+
97+
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
98+
enum OutputFormat {
99+
Human,
100+
Json,
87101
}
88102

89103
pub(crate) fn run(options: BenchmarkOptions) -> eyre::Result<()> {
@@ -207,7 +221,16 @@ fn run_synthetic(options: SyntheticOptions) -> eyre::Result<()> {
207221
max_attestations_per_block: common.max_attestations_per_block,
208222
};
209223
let report = Report::new(Environment::collect(), params, samples);
210-
println!("{}", report.human_table());
224+
225+
match common.format {
226+
OutputFormat::Human => println!("{}", report.human_table()),
227+
OutputFormat::Json => println!("{}", report.to_json()?),
228+
}
229+
if let Some(path) = &common.output {
230+
std::fs::write(path, report.to_json()?)
231+
.wrap_err_with(|| format!("failed to write report to {}", path.display()))?;
232+
eprintln!("report written to {}", path.display());
233+
}
211234

212235
Ok(())
213236
}

0 commit comments

Comments
 (0)