|
| 1 | +# Plan: `ethlambda benchmark` — offline block-building benchmark sub-command |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +The README roadmap lists **"Optimize block building" (issue #465)** as the top near-term |
| 6 | +priority, but block building is only observable today through Prometheus histograms on a |
| 7 | +live devnet — there is no reproducible, offline way to measure it or to compare an |
| 8 | +optimization against a baseline. This adds an `ethlambda benchmark` sub-command that |
| 9 | +drives the exact production proposer code path against controlled workloads. |
| 10 | + |
| 11 | +Fixed scope decisions: offline harness; synthetic **and** replay-from-datadir workloads; |
| 12 | +real XMSS/leanVM crypto by default with a mock fast mode. |
| 13 | + |
| 14 | +## What gets measured |
| 15 | + |
| 16 | +The proposer pipeline as executed at interval 4, entered through the same functions the |
| 17 | +actor calls: |
| 18 | + |
| 19 | +``` |
| 20 | +produce_block_with_signatures (crates/blockchain/src/store.rs:788) ← already public |
| 21 | + ├─ preamble: on_tick → interval 0, promote attestations, |
| 22 | + │ fork-choice head, pool deep-clone → reported as derived "build_overhead" |
| 23 | + └─ build_block: select_payloads → compact → stf_simulate |
| 24 | +seal_block (extracted from crates/blockchain/src/lib.rs:504-631, see refactor) |
| 25 | + └─ sign → wrap_proposer_type1 (leanVM) → merge_type_2 (leanVM) |
| 26 | +``` |
| 27 | + |
| 28 | +**Excluded** (same boundary as the node's own `time_block_building` metric): gossip |
| 29 | +publish, slot-alignment sleep, block import. |
| 30 | + |
| 31 | +**Phase capture with zero hot-path changes**: the existing |
| 32 | +`lean_block_proposal_attestation_build_phase_seconds` HistogramVec accumulates exact f64 |
| 33 | +sums, observed exactly once per phase per build — the harness deltas per-label sums |
| 34 | +between iterations (prometheus 0.14 exposes `get_sample_sum()`, readable in-process). |
| 35 | +Guards: assert per-phase count advanced by exactly 1, and warn if `wall − Σphases` |
| 36 | +exceeds 2%. |
| 37 | + |
| 38 | +**Statistics**: warmup 3 + 10 iterations (defaults, configurable); min/mean/p50/p90/max + |
| 39 | +CV>10% warning per phase; raw samples always exported; outliers never auto-discarded |
| 40 | +(XMSS rejection-sampling and OTS window advancement produce legitimate tails). Each |
| 41 | +iteration records `block.hash_tree_root()` — diffing root sequences between baseline and |
| 42 | +optimized runs proves an optimization changed only speed, not attestation selection. |
| 43 | + |
| 44 | +## CLI (verified on clap 4.6.1) |
| 45 | + |
| 46 | +Every existing flat invocation (devnet skills, Dockerfile, lean-quickstart) parses |
| 47 | +byte-for-byte unchanged. |
| 48 | + |
| 49 | +- `command.rs` (new) owns dispatch, and `cli.rs` keeps the exact shape it had: a leading |
| 50 | + `node` or `benchmark` token is removed before parsing, and the untouched `CliOptions` |
| 51 | + parser then sees the very same arguments as before for every other form. Its seven |
| 52 | + required arguments stay plain `PathBuf`/`String`, so clap's own missing-argument errors |
| 53 | + are preserved without an `Option<T>` to unwrap anywhere. |
| 54 | + |
| 55 | + The rejected alternative was clap's `subcommand_negates_reqs` + |
| 56 | + `args_conflicts_with_subcommands` with `command: Option<Command>` on `CliOptions`. It |
| 57 | + works, but forces all seven required arguments to `Option<T>` — `negates_reqs` lifts |
| 58 | + only the *requirement check*, while the derive still fails extracting a non-`Option` |
| 59 | + field that the command line never supplied — which means an unwrap helper on the node |
| 60 | + path for an invariant clap already enforces. Reviewers pushed back on that churn in |
| 61 | + #497, and it buys nothing the token dispatch does not. |
| 62 | +- `benchmark` parses through its own `clap::Parser` (`BenchmarkCommand`), so harness |
| 63 | + arguments never enter `CliOptions` at all. Its argv[0] is rewritten to |
| 64 | + `ethlambda benchmark` so usage lines name the sub-command that owns them. |
| 65 | +- Because the tokens never reach clap, they would be absent from `--help`; `cli.rs` |
| 66 | + carries one `after_help` line listing both, sourced from a const `command.rs` owns. |
| 67 | +- `main.rs`: `main` is synchronous and matches on the invocation. The node path keeps the |
| 68 | + `#[tokio::main]` attributes on `run_node`; the benchmark runs on the main thread and |
| 69 | + never starts the runtime. |
| 70 | + |
| 71 | +``` |
| 72 | +ethlambda benchmark synthetic --num-validators 8 --warmup-slots 8 |
| 73 | + --proofs-per-data 1 --seed 42 [--key-cache-dir <dir>] # cache: M2 |
| 74 | +ethlambda benchmark replay --data-dir <path> --genesis config.yaml [--no-copy] |
| 75 | + [--validators … --hash-sig-keys-dir … --node-id …] # enables seal |
| 76 | +common: --iterations 10 --mock-crypto --enable-proposer-aggregation |
| 77 | + --max-attestations-per-block 3 --format human|json --output <path> |
| 78 | +``` |
| 79 | + |
| 80 | +Implementation refinements (M1): there is no `--pool-datas` knob — the pool |
| 81 | +accumulates one distinct `AttestationData` per elapsed slot naturally, exactly |
| 82 | +as on a live node, and per-sample `pool_entries` makes the growth visible. |
| 83 | +`--proofs-per-data` defaults to 1 (a single full-coverage aggregate per data, |
| 84 | +what a committee aggregator emits) so justification/finalization advance every |
| 85 | +slot; higher values exercise multi-proof selection but stall justification |
| 86 | +without proposer aggregation — the real coverage cost of that node flag. |
| 87 | +Warmup slots double as chain advancement, so there is no separate warmup- |
| 88 | +iterations knob. |
| 89 | + |
| 90 | +Known pre-existing issue (unrelated): `lean-quickstart/client-cmds/ethlambda-cmd.sh` |
| 91 | +still uses `--custom-network-config-dir`, removed in #321 — needs an upstream fix. |
| 92 | + |
| 93 | +## Harness design (`bin/ethlambda/src/benchmark/{mod,keys,corpus,report}.rs`) |
| 94 | + |
| 95 | +- **Iteration model**: slots advance monotonically, proposer rotates `slot % N` (matches |
| 96 | + round-robin `is_proposer`); each built block is imported via |
| 97 | + `on_block_without_verification` so the empty-slot gap stays constant; the pool is |
| 98 | + re-seeded per iteration in fixed seeded order (insertion order pins proof choice). |
| 99 | +- **Keys**: seeded in-process keygen, cached on disk keyed by (leansig rev, seed, index, |
| 100 | + role). Minimal-window keygen costs ~1s/key in release (verified empirically; the window |
| 101 | + floors at 131,072 epochs — ample for thousands of bench slots; the 2^32 lifetime is |
| 102 | + fixed in the type and unaffected). Arbitrary N, no Docker, no fixture download. |
| 103 | +- **Synthetic corpus**: `State::from_genesis` + `InMemoryBackend`; K warmup blocks; pool |
| 104 | + = attestations from the last `--pool-datas` slots × `--proofs-per-data` real type-1 |
| 105 | + proofs via `aggregate_signatures` (built outside the timed span, progress on stderr). |
| 106 | +- **`--mock-crypto`**: empty proofs, forces the `keep_best` path (clap `conflicts_with |
| 107 | + --enable-proposer-aggregation`, since `compact` invokes the real prover), seal skipped |
| 108 | + and reported as null-not-zero. Runs in seconds → CI smoke test. |
| 109 | +- **Replay (v1 scope)**: copies the datadir before opening (mandatory — `on_tick`/head |
| 110 | + updates write Metadata per interval and RocksDB has no read-only mode; `--no-copy` |
| 111 | + opt-out with a warning). Loads via `Store::from_db_state`, builds at head+1. Pools are |
| 112 | + in-memory-only and unrecoverable from disk, so v1 replay measures selection + STF + |
| 113 | + state-root realism on real deep states; supplying the node's key trio additionally |
| 114 | + enables the seal phases. Type-2 splitting / pool recording = deferred future work. |
| 115 | +- **Report**: human table + `--format json` (stdout pipe-clean, logs to stderr) with |
| 116 | + `schema_version`, environment (CPU model, cores, OS, ethlambda rev via vergen, leansig |
| 117 | + lock rev via a small `build.rs` Cargo.lock parse — leansig tracks the moving `devnet4` |
| 118 | + branch), full params + seed, per-iteration raw samples. One configuration per process |
| 119 | + invocation (global cumulative histograms, rayon/prover state). |
| 120 | + |
| 121 | +## The one library refactor |
| 122 | + |
| 123 | +Extract `crates/blockchain/src/lib.rs:504-631` (proposer sign → type-1 wrap → pubkey |
| 124 | +resolution → type-2 merge) into `pub fn seal_block(...) -> Result<SignedBlock, |
| 125 | +SealBlockError>` in the blockchain crate; `propose_block` calls it. Justified: the |
| 126 | +benchmark cannot reach these phases otherwise (a bin-side copy would drift), it collapses |
| 127 | +six repeated error-return-with-metric blocks into one `match` (net-negative LOC), and |
| 128 | +adding `sign`/`wrap_proposer_type1`/`merge_type_2` labels to the existing phase histogram |
| 129 | +gives production dashboards the currently-untimed expensive steps issue #465 targets. |
| 130 | +Verbatim move, own commit, devnet smoke before merge. `build_block` stays `pub(crate)`. |
| 131 | + |
| 132 | +## Milestones |
| 133 | + |
| 134 | +| | Deliverable | Files | |
| 135 | +|---|---|---| |
| 136 | +| **M1** — CLI + mock end-to-end | `ethlambda benchmark synthetic --mock-crypto` runs in seconds; table + JSON; flat-invocation compat tests; `make bench`; CI smoke step in the existing Test job. Includes one small library fix found by the determinism gate: `extend_proofs_greedily` kept its candidate set in a `HashSet`, so equal-coverage proof ties were broken by randomized hash order and block contents differed run to run — ties now break to the lowest pool index | `cli.rs`, `main.rs`, `benchmark/{mod,corpus,report}.rs`, `build.rs` (leansig rev), `Makefile`, `ci.yml`, `block_builder.rs` (tie-break) | |
| 137 | +| **M2** — real crypto | `seal_block` extraction (first commit) + 3 new phase labels; seeded keygen + cache; real type-1 pools; all 7 phases measured; first baseline JSON recorded | `crates/blockchain/src/{seal.rs,lib.rs,metrics.rs}`, `benchmark/keys.rs`, `types/src/signature.rs` (keygen wrapper) | |
| 138 | +| **M3** — replay + docs | replay mode against a devnet-runner datadir; `docs/benchmarking.md` + `SUMMARY.md` + README roadmap line | `benchmark/corpus.rs`, docs | |
| 139 | + |
| 140 | +One PR per milestone; `make fmt/lint/test` before each; M2 additionally gated by a devnet |
| 141 | +smoke via `test-branch.sh`. |
| 142 | + |
| 143 | +## Verification |
| 144 | + |
| 145 | +- clap `try_parse_from` tests: flat invocation parses, missing-arg errors preserved, |
| 146 | + `benchmark` parses without node args, mixed invocation rejected. |
| 147 | +- Determinism: two same-seed runs produce identical per-iteration block-root sequences. |
| 148 | +- Accounting: Σphases ≥ 98% of wall per iteration, per-phase count deltas == 1. |
| 149 | +- CI mock smoke: `benchmark synthetic --mock-crypto --num-validators 4 --iterations 3 |
| 150 | + --format json | jq -e '.schema_version == 1'`. |
| 151 | + |
| 152 | +## Main risks |
| 153 | + |
| 154 | +- Real-mode setup cost: iterations × pool proofs of leanVM proving → default real run |
| 155 | + takes minutes (mitigated: mock mode, small defaults, ETA logging, key cache). |
| 156 | +- `seal_block` extraction touches consensus-critical `propose_block` — verbatim |
| 157 | + extraction, careful review of the six error branches, devnet smoke. |
| 158 | +- Cross-run comparability: rayon-parallel proving is machine/load-sensitive and leansig |
| 159 | + is a moving branch — the env block in every report is the guard, not a fix. |
0 commit comments