Skip to content

feat(cli): make benchmark reports comparable across runs and machines - #596

Merged
MegaRedHand merged 2 commits into
mainfrom
feat/benchmark-comparable-reports
Sep 1, 2026
Merged

feat(cli): make benchmark reports comparable across runs and machines#596
MegaRedHand merged 2 commits into
mainfrom
feat/benchmark-comparable-reports

Conversation

@pablodeymo

@pablodeymo pablodeymo commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

Per-iteration rows show what one build cost. Comparing an optimization against a baseline
needs three more things, and this adds them: aggregate statistics, build provenance, and
machine-readable output.

Third of three (design doc → harness → this).

Stacked on the harness PR. Its diff here is additive — the per-iteration rows stay,
the summary is appended below them.

  iter           compact  select_payloads     stf_simulate   overhead       wall         root
  1              0.000ms          0.002ms          0.015ms    0.068ms    0.085ms   0x7282cc99
  2              0.000ms          0.002ms          0.015ms    0.066ms    0.083ms   0xb9065af0
  3              0.000ms          0.002ms          0.015ms    0.064ms    0.081ms   0x303f6b0f

  phase              count        min       mean        p50        p90        max
  compact                3    0.000ms    0.000ms    0.000ms    0.000ms    0.000ms
  select_payloads        3    0.002ms    0.002ms    0.002ms    0.002ms    0.002ms
  stf_simulate           3    0.015ms    0.015ms    0.015ms    0.015ms    0.015ms
  overhead               3    0.064ms    0.066ms    0.066ms    0.068ms    0.068ms
  wall                   3    0.081ms    0.083ms    0.083ms    0.085ms    0.085ms

What Changed

File Change
bin/ethlambda/src/benchmark/report.rs Stats/Summary plus stats(), percentile() and the aggregate table: count, min, mean, p50, p90, max per phase, and a CV flagged above 10%. schema_version + to_json(). Environment gains the two resolved crypto revisions
bin/ethlambda/build.rs Resolve the leansig and leanVM revisions from Cargo.lock into rustc-env vars. The per-[[package]] parse collects name and source before extracting the rev, so it does not depend on TOML field order
bin/ethlambda/src/benchmark/mod.rs --format human|json and --output <path>
bin/ethlambda/Cargo.toml, Cargo.lock serde_json
.github/workflows/ci.yml Seconds-fast mock smoke step in the Test job asserting the JSON contract

Correctness / Behavior Guarantees

  • Nearest-rank percentiles, no interpolation. Sample counts are small, so an exact
    observed value beats a blend of two neighbours.
  • Outliers are never discarded and the raw per-iteration rows stay above the summary,
    so a heavy tail stays visible instead of being averaged away. A CV above 10% is flagged
    so a noisy run is not read as a result.
  • Provenance is a comparability guard, not decoration. leansig is pinned to a moving
    branch and leanVM does the signature aggregation, so either revision moving moves the
    measured crypto. Two reports that disagree on them are not comparable, and without this
    the report cannot say so.
  • The JSON shape is pinned by CI, so a change to the report contract cannot land
    unnoticed. Logs already go to stderr, so the JSON pipes straight into jq.
  • Node behavior is untouched; build.rs only adds env vars consumed by the report.

Tests Added / Run

  • report.rs: percentile on a single sample and on odd/even lengths; stats() against a
    known set whose population stddev gives CV = 0.4; stats() on empty input is zeroed.
  • Verified by hand: the CI assertion
    jq -e '.schema_version == 1 and (.samples | length == 3)' passes, and reports carry
    both resolved revisions.
  • make fmt, make lint, make test (580 tests, 30 suites) — all clean.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (cargo test --workspace --profile release-fast) — all passing

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

bin/ethlambda/build.rs

  • Lines 70–89: Manual TOML parsing of Cargo.lock is fragile. The format is stable but using toml or cargo-lock crates would be more robust against whitespace variations and future field reordering. If keeping the manual parser, add a comment noting the dependency on the specific lockfile format.
  • Line 76: The relative path ../../Cargo.lock assumes a fixed workspace layout. Consider using CARGO_WORKSPACE_DIR (Cargo 1.74+) or traversing upward to find the lockfile to survive crate restructuring.
  • Line 89: rsplit_once('#') correctly handles the git revision fragment, but note that if the URL itself contains a # (e.g., in a branch name), this will split incorrectly. Branch names with # are rare but valid; consider documenting this limitation.

bin/ethlambda/src/benchmark/mod.rs

  • Lines 227–234: If both --format json and --output are specified, report.to_json() is called twice (once for stdout, once for the file). For large reports, serialize once to a String and reuse it.
  • Line 232: Writing to the file uses ? but the stdout print on line 229 uses println!. Consider handling the JSON serialization error consistently (though both ultimately return eyre::Result).

bin/ethlambda/src/benchmark/report.rs

  • Lines 105–106: The percentile function uses round() which can be sensitive to floating-point precision. Given the small sample sizes typical in benchmarks, the nearest-rank method is appropriate, but consider documenting why interpolation was avoided.
  • Line 134: The CV warning threshold of 10% is reasonable, but consider making it configurable via CLI for noisy CI environments.
  • Lines 186–187: Good practice embedding the crypto library revisions in the report; this prevents benchmark result misinterpretation when dependencies move.
  • Line 230: Hardcoded schema_version: 1 is good for future compatibility. Document the schema evolution policy (e.g., "bump on breaking JSON structure changes").

General

  • CI workflow: The smoke test using jq to validate JSON structure is a good integration check.
  • Tests: The unit tests for percentile and stats cover edge cases (empty input, single sample, odd/even lengths). Consider adding a test for the CV calculation when mean is zero to prevent regression of the NaN-guard.

Security/Memory: No unsafe code introduced. File I/O in build.rs is limited to reading the lockfile; paths are constructed from CARGO_MANIFEST_DIR which is controlled by Cargo.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. bin/ethlambda/src/benchmark/report.rs lines 296-298 implement percentile() as round((len - 1) * q), but the comment says “nearest-rank percentile.” That is not nearest-rank and it biases even-sized samples upward: for 4 values, p50 becomes the 3rd element instead of the 2nd nearest-rank value. Because this summary is now part of the JSON contract, consumers will get systematically wrong p50/p90 values. Either implement true nearest-rank (ceil(n*q) - 1, clamped) or rename the function/docs/tests to the estimator you actually want.

  2. bin/ethlambda/build.rs lines 41-45 silently fall back to "unknown" when the lockfile path changes or the ad hoc parser stops matching. For benchmark provenance, that is a correctness problem: reports remain “valid” JSON but lose the crypto revision data that this PR is trying to preserve, and CI only checks schema_version plus sample count. I would at least emit a warning when either targeted package is missing, and preferably fail the build or switch to a real lockfile parser so provenance regressions are not silent.

No consensus-path, fork-choice, attestation-validation, SSZ, or XMSS-processing logic is touched here, so I did not find consensus/security regressions in the Ethereum client code itself.

I could not run the Rust tests in this sandbox because Cargo/rustup attempted to write under read-only ~/.cargo / ~/.rustup.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 596 — benchmark report comparability (stats, provenance, JSON)

This is benchmarking-tool code, not consensus-critical logic, so the risk profile is low, but there's one finding that materially affects CI cost/time and directly contradicts the PR's own claims.

1. CI smoke step does not reuse the previous build, contrary to its comment (.github/workflows/ci.yml:79-85)

# Reuses the release build from the test step; validates the benchmark
# harness end-to-end and its JSON output contract in a few seconds.
- name: Benchmark smoke (mock crypto)
  run: |
    cargo run --release --bin ethlambda -- benchmark synthetic --mock-crypto \

The preceding "Run fixture-based tests" step runs make test, which builds under --profile release-fast (Makefile:15, no LTO, 16 codegen units, incremental — see Cargo.toml:48-53). This new step invokes cargo run --release, which is [profile.release]: lto = "fat", codegen-units = 1 (Cargo.toml:38-41). These are two entirely separate profile directories under target/; nothing is reused. The result is a from-scratch fat-LTO build of the full binary and its dependency graph (including the XMSS/leanVM crypto stack), which is exactly the slow-build case the release-fast profile exists to avoid, per this repo's own CLAUDE.md note ("rebuilds are much faster than --release").

  • The comment and PR description's "seconds-fast" framing are false — this will add a full LTO release build to CI (likely minutes, not seconds).
  • Fix: use cargo run --profile release-fast (matching the test job's build) so it genuinely reuses cached artifacts from the preceding step, or run it as an additional command inside make test/the fixture-tests composite action rather than a separate --release invocation.

This is worth confirming/fixing before merge since it's a recurring CI cost on every PR and push to main.

Minor nits

  • bin/ethlambda/src/benchmark/mod.rs:226-231: when both --format json and --output <path> are passed, report.to_json() is called twice, redoing the serialization. Cheap in absolute terms (small struct, once per benchmark run) but trivially avoidable by binding it to a local once.
  • bin/ethlambda/build.rs lockfile_git_revs(): hand-rolled line-based TOML parsing instead of a proper parser. It's scoped narrowly enough (only name = / source = lines, only within [[package]] blocks) to be safe for a machine-generated Cargo.lock today, but it's a second implicit TOML parser to keep in sync if the lockfile format ever changes shape. Given this only affects benchmark provenance metadata (falls back to "unknown" on any parse miss, not a hard failure), this is acceptable as-is — just flagging as a maintainability tradeoff, not a blocker.
  • .github/workflows/ci.yml:82-85: the new step's run: block omits set -euo pipefail (used by the other custom steps in this same action, e.g. run-fixture-tests/action.yml:18). In practice jq -e will fail on non-JSON/empty input if cargo run fails, but adding set -euo pipefail would make that failure mode explicit and consistent with the rest of the workflow.

What looks solid

  • stats()/percentile(): population-variance CV, nearest-rank percentiles, empty-input zeroing, and the single/odd/even-length edge cases are all correctly handled and covered by the added unit tests (report.rs:295-345). The sorted.len() - 1 in percentile can't underflow since it's only reached after the is_empty() guard in stats().
  • build.rs package-name-to-env-var mapping (leansig, lean-multisig) matches the actual Cargo.lock entries and crates/common/crypto/Cargo.toml dependency names — verified directly against the lockfile.
  • rustc-env fallback to "unknown" plus the single cargo:rerun-if-changed for the lockfile is the right call: env!() in report.rs can't fail to compile, and since the git2/rustc Emitter instructions already suppress Cargo's default whole-package rerun-if-changed, explicitly watching Cargo.lock here is actually necessary, not redundant.
  • No consensus-layer code paths are touched — build.rs and benchmark/ are excluded from block/attestation/state-transition logic, so there's no fork-choice, justification, or signature-verification risk surface in this diff.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@pablodeymo
pablodeymo force-pushed the feat/benchmark-comparable-reports branch from 605531f to 254cf06 Compare August 26, 2026 20:23
@pablodeymo
pablodeymo force-pushed the feat/benchmark-comparable-reports branch from 254cf06 to 9226605 Compare August 26, 2026 20:56
@pablodeymo
pablodeymo force-pushed the feat/benchmark-comparable-reports branch from 9226605 to e1bea8a Compare August 26, 2026 21:51
@pablodeymo
pablodeymo force-pushed the feat/benchmark-comparable-reports branch 2 times, most recently from 9df5f5f to 3d98b58 Compare August 31, 2026 17:28
Base automatically changed from feat/benchmark-harness-core to main August 31, 2026 17:48
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.
@pablodeymo
pablodeymo force-pushed the feat/benchmark-comparable-reports branch from 3d98b58 to 0b049b6 Compare September 1, 2026 15:13
Comment thread .github/workflows/ci.yml Outdated
@MegaRedHand
MegaRedHand added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 563d9e1 Sep 1, 2026
4 checks passed
@MegaRedHand
MegaRedHand deleted the feat/benchmark-comparable-reports branch September 1, 2026 16:00
Sahilgill24 pushed a commit to Sahilgill24/ethlambda that referenced this pull request Sep 1, 2026
…ass#591)

## 🗒️ Description / Motivation

The binary has only ever run the node, so an invocation is a bare list
of node flags.
The offline block-building benchmark adds a second entry point, which
means the node
first needs a name of its own.

`node` is an ordinary clap sub-command on a top-level parser that owns
the binary's name,
version and about. `NodeOptions` (renamed from `CliOptions`) becomes a
plain `clap::Args`
group and keeps every field
exactly as it is — no `Option<T>`, no `required = true`, no unwrap
helper on the node
path, which is what the review of lambdaclass#497 objected to.

The flat `ethlambda --genesis ...` form keeps working, because that is
what the
Dockerfile, lean-quickstart, the hive shim and the devnet skills all
pass. clap has no
`default_subcommand`, so exactly one thing sits in front of the parser:
a command line
that names no sub-command gets `node` inserted.

## What Changed

| File | Change |
|------|--------|
| `bin/ethlambda/src/command.rs` | New. Top-level `Cli` parser +
`Command` sub-command enum, and `default_subcommand`, which inserts
`node` unless the first token is a sub-command,
`-h/--help/-V/--version`, or clap's generated `help` |
| `bin/ethlambda/src/cli.rs` | `clap::Parser` → `clap::Args`, and
`CliOptions` renamed to `NodeOptions`; the `#[command(...)]` attribute
moves to the top-level parser. No field changes |
| `bin/ethlambda/src/main.rs` | Parses through `command::parse()` and
matches on `Command` |

`command.rs` also carries a test-only `parse_node_options` helper.
Merging `main` brought
lambdaclass#579's `cli.rs` tests, which called `CliOptions::parse_from` — a
`clap::Parser` method the
group lost when it became `clap::Args`. Git merged both sides cleanly,
so nothing flagged
it; the test build was broken until `a3d7e52`, and both test modules now
parse a node
command line through the real dispatch.

## Correctness / Behavior Guarantees

- **Every existing invocation keeps working**, and clap owns everything
a reader should
not have to trust us for: `--help` lists the sub-commands itself, usage
lines name the
sub-command, and an unknown sub-command produces clap's error rather
than a
  stray-positional one.
- `NodeOptions` declares no positional arguments, so the first token
after the program
name is either a flag or a sub-command — a flag *value* never lands
there and is never
  mistaken for one. A leading flag therefore means the flat node form.
- **`--version` after node flags still works and still prints the same
string.** It used
to live on the node options, so it was accepted anywhere;
`propagate_version` keeps that,
and `display_name = "ethlambda"` keeps the output byte-identical rather
than
  `ethlambda-node`. All three forms are asserted equal.
- **One deliberate change:** a bare `ethlambda` now prints clap's
top-level help, listing
the sub-commands, instead of a missing-argument list. It still exits
non-zero, and the
  test asserts both.

## Tests Added / Run

Unit tests in `command.rs` pin: the flat parse; the two forms agreeing
field for field; a
`--node-id` value that is literally `node`; a trailing `node` token
still rejected; a
second `node` token rejected; missing required flags in both forms; the
bare invocation's
error kind and non-zero exit; `--help`/`--version` staying top-level;
`--version` printing
one identical string across all three forms; and `--help` listing the
sub-commands.

`make fmt`, `make lint`, `make test` (574 tests, 30 suites) — all clean.

## Related Issues / PRs

- Replaces the CLI approach reviewed in the now-closed lambdaclass#497
- Design doc in lambdaclass#594; the benchmark stacks on this in lambdaclass#595lambdaclass#596
- Related to lambdaclass#465

## ✅ Verification Checklist

- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [x] Ran `make test` (`cargo test --workspace --profile release-fast`)
— all passing

---------

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
Sahilgill24 pushed a commit to Sahilgill24/ethlambda that referenced this pull request Sep 1, 2026
## 🗒️ Description / Motivation

Documents the block-building benchmark: what it measures, how to run it,
how to read a
report, and what it cannot do yet.

This replaces the design plan this PR originally carried. Per review, a
`docs/plans/`
file written as "this is how the benchmark was originally designed" goes
stale the moment
the benchmark changes and then actively misleads, so the page now
describes the tool
rather than a schedule for building it. The plan served its purpose — it
was the shared
reference while lambdaclass#595 and lambdaclass#596 were reviewed — and is not something the
tree should keep.

> **Restacked.** Now based on lambdaclass#596, so the page documents behaviour that
actually exists
> rather than behaviour that is still in review. Base moves to `main`
once the two land.

## What Changed

| File | Change |
|------|--------|
| `docs/benchmarking.md` | New. Running it (flag table with defaults),
what the measured span includes and deliberately excludes, how phase
times come from the existing histogram, reading the per-iteration and
summary tables, comparing two runs, current limitations, the CI smoke
step |
| `docs/SUMMARY.md` | Listed under Development |
| `docs/plans/block-building-benchmark.md` | Removed |
| `bin/ethlambda/src/benchmark/mod.rs` | Module doc points at the new
page |

## Correctness / Behavior Guarantees

Documentation only — the one code change is a doc-comment path.

Two things the plan file never stated, both of which a reader needs:

- **When two reports may not be compared at all.** The header carries
the resolved
leanSig and leanVM revisions plus the machine fields; leanSig tracks a
moving branch
and leanVM performs the aggregation, so either one moving changes the
measured crypto.
- **What is not supported yet** — real crypto, the seal phase, replay
from a datadir —
written as current limitations rather than as milestones, so the page
does not promise
  a schedule it cannot keep.

## Tests Added / Run

- `make docs` builds the site with the new page in place; no dangling
references to the
  removed plan file anywhere in the tree.
- `make fmt`, `make lint`, `make test` (622 tests) — all clean.

## Related Issues / PRs

- Stacked on lambdaclass#596, which stacks on lambdaclass#595
- Related to lambdaclass#465

## ✅ Verification Checklist

- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [x] Ran `make test` (`cargo test --workspace --profile release-fast`)
— all passing

---------

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
MegaRedHand added a commit that referenced this pull request Sep 1, 2026
…er the main merge

Merging main brought in the offline block-building benchmark (#595, #596),
which was written against the old 52-byte pubkey. It is not a textual
conflict, so the merge landed silently and only broke at compile time:

    error[E0308]: mismatched types
      --> bin/ethlambda/src/benchmark/corpus.rs:127:5
       | expected an array with a size of 32, found one with a size of 52

`synthetic_pubkey` now sizes its buffer from `PUBLIC_KEY_SIZE`, so the next
scheme change moves it rather than breaking the build again.

The same merge left three references to a dependency this branch removed:

- The benchmark report embedded a resolved `leansig` revision read from
  Cargo.lock. leanVM internalized XMSS, so no `leansig` package resolves any
  more and every report would have printed `leansig=unknown`. The header now
  carries the single leanVM revision that pins the whole signature stack.
- `rand` was a dependency of `ethlambda-crypto` and a dev-dependency of
  `ethlambda-blockchain` and `ethlambda-storage` only for leanSig keygen in
  tests this branch rewrote. Dropped from all three; `ethlambda-types` keeps
  its own, which was already unused before this branch.
- CLAUDE.md still described 52-byte keys, 2536-byte signatures, and a
  `leansig` dependency.

Also corrects the `SingleMessageAggregate` / `MultiMessageAggregate` doc
comments, which claimed the proof bytes are leanVM's `to_bytes()` form with
participant pubkeys embedded. Every producer and consumer uses
`to_bytes_without_pubkeys()`: pubkeys stay off the wire and the verifier
rebuilds the set from the aggregation bits, which is the property that makes
the framing match main.

Fixture-driven spec tests still fail (122 forkchoice, 73 stf, 7 ssz, 3
signature), all at deserialization: leanSpec's latest released fixtures are
still on the 52-byte scheme. Every other test in the workspace passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants