Skip to content

Commit c443f6b

Browse files
committed
docs: add the block-building benchmark design plan
"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. Records the design of an offline `ethlambda benchmark` sub-command ahead of the implementation, so the parts worth arguing about are on the table before there is code to argue around: what is inside the measured span and what is deliberately outside it, how per-phase attribution reuses the existing block-proposal histogram rather than adding hot-path instrumentation, why outliers are never auto-discarded, how the CLI gains a second entry point without disturbing the flat node invocation every deployment uses, and which workloads land in which milestone.
1 parent 53db2b3 commit c443f6b

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)