feat(cli): add offline block-building benchmark sub-command - #593
feat(cli): add offline block-building benchmark sub-command#593pablodeymo wants to merge 1 commit into
Conversation
`ethlambda benchmark synthetic` measures block building exactly as executed when the node proposes, against a reproducible synthetic workload, with no devnet required. "Optimize block building" (#465) is the top roadmap item, but the only observability today is Prometheus histograms on a live devnet: noisy, not reproducible, and unable to compare an optimization against a baseline. The harness drives the production proposer entry point (`produce_block_with_signatures`) over a seeded in-memory chain, seeding the pending pool per slot and letting the proposal tick promote it, as on a live node. Phases come from the existing `lean_block_proposal_attestation_build_phase_seconds` histogram: the per-label sample sums are deltaed between iterations, so attribution is exact and the hot path is untouched. Each iteration records its block root, so a baseline-vs-optimized diff proves an optimization changed only speed and not attestation selection. This is milestone M1 (mock crypto) of docs/plans/block-building-benchmark.md; M2 adds real XMSS/leanVM pools and the seal phase, M3 replay-from-datadir. Dispatch goes through the `benchmark` token that command.rs already strips, parsed by a `clap::Parser` of its own, so the harness arguments never enter CliOptions and the node parser keeps the exact shape it has today. That replaces the earlier `subcommand_negates_reqs` approach, which forced all seven node-required arguments to `Option<T>` and an unwrap helper on the node path for an invariant clap already enforced. `main` is now synchronous: it dispatches, and only the node path enters the tokio runtime — the benchmark is synchronous CPU-bound work and would otherwise park a worker thread for its whole run. Benchmark logs go to stderr at WARN so the report owns stdout and stays pipe-clean for `--format json | jq`. NEW_PAYLOAD_CAP becomes public so the harness can reject a --proofs-per-data batch the pending pool would evict whole. build.rs embeds the resolved leansig and leanVM revisions from Cargo.lock into reports, since both move the measured crypto. `make bench` runs it, and CI adds a seconds-fast mock smoke step asserting the JSON contract.
🤖 Kimi Code ReviewOverall Assessment: Well-structured, secure benchmark harness with proper isolation between node and benchmark code paths. No critical consensus bugs or security vulnerabilities. Minor maintainability issues noted below. build.rs (bin/ethlambda)Issue: Fragile manual TOML parsing of
Recommendation: Use the benchmark/corpus.rsIssue: Hardcoded pubkey size may drift from type definition.
Recommendation: Use benchmark/mod.rsIssue: Hardcoded metric name coupling.
Recommendation: Export the metric name constant from Issue: Potential silent precision loss in timing.
Recommendation: Log a warning if benchmark/report.rsIssue: Population vs. sample standard deviation.
Note: Document that CV uses population stddev if this is intentional. command.rsCode Quality: Excellent backward compatibility handling. The main.rsSecurity/Isolation: Good separation of concerns.
Consensus & State Transition CorrectnessValidations:
Note on Security
CI Integration (ci.yml)Line 78-84: The smoke test validates JSON schema version and sample count. Consider adding a determinism check: # Run twice with same seed and compare block roots
cargo run ... --format json > run1.json
cargo run ... --format json > run2.json
jq -e '.samples[].block_root' run
---
*Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt* |
🤖 Codex Code Review
Aside from those benchmark/reporting issues, I did not see a new consensus-rule regression in fork choice, attestation validation, STF, XMSS, or SSZ handling from this PR’s direct code changes. I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
|
Split into three smaller PRs, each with a goal of its own:
#595 + #596 reconstruct what was reviewed here, plus a per-iteration table (#595 would otherwise collect the phase breakdown without ever showing it). Each of the three was verified independently: Closing in favour of that stack. |
…ass#595) ## 🗒️ Description / Motivation Adds `ethlambda benchmark synthetic` — an offline harness that measures block building **exactly as executed when the node proposes**, against a reproducible synthetic workload, with no devnet required. Second of three (design doc → **this** → comparable reports). What lands here is the smallest thing that runs: the real proposer path, driven deterministically, reporting one row per measured iteration. Aggregate statistics, build provenance and machine-readable output follow in the next PR, so this one can be reviewed for *what it measures* rather than how it formats. > **Stacked on lambdaclass#591**, which adds the `node`/`benchmark` token dispatch this uses. Review > that first; the base moves to `main` once it lands. ``` $ make bench Block-building benchmark — synthetic workload (mock crypto) validators=8 warmup_slots=8 iterations=10 proofs_per_data=1 seed=42 enable_proposer_aggregation=false max_attestations_per_block=3 ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 os=macos arch=aarch64 threads=14 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 ``` ## How to read this In this order — each piece is understandable without the next: 1. **`corpus.rs`** — the workload. Deterministic validators, a genesis store over `InMemoryBackend`, and `seed_pool`, which fills the pending pool for one slot and reports how many entries the next build will see. 2. **`build_one_slot`** (`mod.rs`) — one slot end to end: seed, time the build, import the block. Warmup and measured slots run this same path; only whether the sample is kept differs, so there is no "am I warming up?" branching inside. 3. **`PhaseTimer`** (`mod.rs`) — `start()` before the build, `finish()` after. Two readings of the existing phase histogram; the difference between their sample *sums* is the build's phase time, so nothing is added to the hot path. 4. **`run_synthetic`** (`mod.rs`) — validate, set up, loop over slots, report. 46 lines. 5. **`report.rs`** — the types and the per-iteration table. ## What Changed | File | Change | |------|--------| | `bin/ethlambda/src/benchmark/mod.rs` | Harness driver: clap options + validation, `run_synthetic`'s slot loop, `build_one_slot` for one slot's work, and `PhaseTimer` for per-phase attribution | | `bin/ethlambda/src/benchmark/corpus.rs` | Seeded synthetic corpus: genesis store over `InMemoryBackend`, deterministic pubkeys via splitmix64, and per-slot pool seeding in fixed insertion order, which also rejects a batch the pool would evict whole | | `bin/ethlambda/src/benchmark/report.rs` | Params/Environment/Sample types and the human-readable per-iteration table | | `bin/ethlambda/src/command.rs` | `benchmark` joins `node` as a second clap sub-command, so clap lists it in `--help` and names it in its own usage lines. The node payload becomes `Box<CliOptions>` now that a much smaller variant sits beside it | | `bin/ethlambda/src/main.rs` | `main` becomes synchronous and dispatches; only the node path enters the tokio runtime (`run_node` carries the `#[tokio::main]` attributes). Benchmark logs go to stderr at WARN so the report owns stdout | | `crates/storage/{lib,store}.rs` | Export `NEW_PAYLOAD_CAP` so the harness rejects a `--proofs-per-data` batch the pending pool would evict whole | | `Makefile` | `make bench` (override `BENCH_ARGS` to customize) | ## Correctness / Behavior Guarantees - **`cli.rs` is not touched by this PR and the node runtime is unchanged.** The harness arguments live in their own `Args` group; the node's stay plain `PathBuf`/`String`, so clap keeps emitting its own missing-argument errors. - **It measures the production path**, not a copy: the harness enters through `produce_block_with_signatures`, the same function `BlockChainServer::propose_block` calls, and seeds the *pending* pool so the proposal tick promotes it exactly as on a live node. - **Determinism:** same seed + params → identical per-iteration block roots. Verified across repeated runs; the roots are printed so a baseline-vs-optimized diff proves an optimization changed only speed, not attestation selection. The harness never reads the wall clock into results. - **Exact phase attribution with zero hot-path changes:** per-iteration `select_payloads`/`compact`/`stf_simulate` come from the sample sums of the existing `lean_block_proposal_attestation_build_phase_seconds` histogram, deltaed between iterations, with a per-phase assertion that the count advanced by exactly one. `overhead` is the clamped remainder of wall minus the phases. - The benchmark never starts the tokio runtime, so it cannot park a worker thread for the duration of a CPU-bound run. ## Tests Added / Run - `corpus.rs`: participant groups partition every validator; synthetic pubkeys are deterministic for a seed. - `command.rs`: the benchmark token parses with no node argument, rejects node flags, and its usage line names the sub-command. - Verified by hand: `make bench`; identical block-root sequences across two runs at the same seed; `ethlambda --genesis config.yaml` still failing with clap's own missing-argument list. - `make fmt`, `make lint`, `make test` (576 tests, 30 suites) — all clean. ## Related Issues / PRs - Stacked on lambdaclass#591; design doc in the accompanying docs PR - Followed by the comparable-reports PR - Splits the now-closed lambdaclass#497 / lambdaclass#593 - 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
…lambdaclass#596) ## 🗒️ 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 - Stacked on the harness PR; design doc in the accompanying docs PR - Splits the now-closed lambdaclass#497 / lambdaclass#593 - 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>
🗒️ Description / Motivation
Adds
ethlambda benchmark synthetic— an offline harness that measures block buildingexactly as executed when the node proposes, against a reproducible synthetic
workload, with no devnet required.
"Optimize block building" (#465) is the top roadmap item, but the only observability
today is Prometheus histograms on a live devnet: noisy, not reproducible, and unable to
compare an optimization against a baseline. This is milestone M1 (mock-crypto mode) of
the design in
docs/plans/block-building-benchmark.md; M2 adds real XMSS/leanVM poolsand post-build seal-phase measurement, M3 adds replay-from-datadir.
What Changed
bin/ethlambda/src/benchmark/{mod,corpus,report}.rsInMemoryBackend, per-slot pool seeding into the pending pool (promoted by the proposal tick, as in production), iteration loop drivingproduce_block_with_signatures+ block import; stats + human/JSON reportbin/ethlambda/src/command.rsbenchmarkjoinsnodeas a dispatched token, parsed by aclap::Parserof its own so harness arguments never enterCliOptions; argv[0] is rewritten toethlambda benchmarkso usage lines name the sub-command that owns thembin/ethlambda/src/main.rsmainis synchronous: it dispatches, and only the node path enters the tokio runtime (run_nodecarries the#[tokio::main]attributes). Benchmark logs go to stderr at WARN so the report owns stdoutcrates/storage/{lib,store}.rsNEW_PAYLOAD_CAPso the harness rejects--proofs-per-databatches the pending pool would evict wholebin/ethlambda/build.rsCargo.lockinto reports (leansig tracks the movingdevnet4branch; leanVM does the aggregation, so its pinned rev moves the measured crypto too). The per-[[package]]parse collectsnameandsourcebefore extracting the rev, so it does not depend on TOML field orderMakefile,.github/workflows/ci.ymlmake bench; seconds-fast mock smoke step in the Test job validating the JSON contractdocs/plans/block-building-benchmark.mdCorrectness / Behavior Guarantees
cli.rsis not touched, and the node runtime is unchanged. The seven node-requiredarguments stay plain
PathBuf/String, so clap keeps emitting its ownmissing-argument errors and there is no
Option<T>to unwrap on the node path. Thatreplaces feat(cli): add offline block-building benchmark subcommand #497's
subcommand_negates_reqsapproach, which needed both.the JSON, so a baseline-vs-optimized diff proves an optimization changed only speed,
not attestation selection). Verified across repeated runs. The harness never reads the
wall clock into results.
select_payloads/compact/stf_simulatecome from the sample sums of the existinglean_block_proposal_attestation_build_phase_secondshistogram, deltaed betweeniterations;
overheadis the clamped remainder of wall minus the phases.duration of a CPU-bound run.
Tests Added / Run
command.rsunit tests cover the benchmark token: it parses with no node argument, itrejects node flags, and its usage line names the sub-command.
make bench; the CI smoke assertion(
jq -e '.schema_version == 1 and (.samples | length == 3)'); identical block-rootsequences across two runs at the same seed; and
ethlambda --genesis config.yamlstillfailing with clap's own missing-argument list.
make fmt,make lint,make test(582 tests, 30 suites) — all clean.Related Issues / PRs
nodesub-command for running the node #591✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(cargo test --workspace --profile release-fast) — all passing