Skip to content

fix(tooling): review fixups for the real ethrex block fixture - #884

Open
MauroToscano wants to merge 10 commits into
feat/ethrex-real-blockfrom
review/ethrex-real-block-fixups
Open

fix(tooling): review fixups for the real ethrex block fixture#884
MauroToscano wants to merge 10 commits into
feat/ethrex-real-blockfrom
review/ethrex-real-block-fixups

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this became

Started as review fixups for #880; grew into the wiring that PR was missing. #880 built a validated real-block fixture but nothing benchmarked with it — every perf number still came from the synthetic 20-transfer block its own motivation section calls unrepresentative. This branch closes that gap and swaps the block for a measured, screened mainnet one.

The block: mainnet 25368371

2,428,684 gas, 29 txs — selected via Dune sweeps against measured mainnet composition (baseline: 3.5M txs + a 6-month weekly profile). It is the only block in 90 days in the 1.6–2.6M gas band with 2 heavy txs, no whale tx (top tx 22.5% of gas), and a mainnet-like transfer mix (heavy-gas 44.3% vs mainnet ~39%, p50 tx gas 41,297 vs ~38k, tx/Mgas 11.9). Verified: executes statelessly to its post-state root on the guest's precompile surface (no KZG 0x0a — the no-KZG screen was live during the check), free of transient-contract dependence (predates the FWA lottery deployment), 1 EIP-7702 authorization.

workload cycles* keccak ECSM prove (bench runner) peak heap
ethrex_bench_20 (synthetic screen) 9.06M 411 80 ~25 s
mainnet 25368371 (real, default) 74.82M 10,478 116 ~6 min ~18 GB @2^21
mainnet 25453112 (screened alternate) 125.93M 18,463 164 ~10 min ~19 GB

* Jul-15 guest ELF; counts move ~14% with ELF vintage. Cycles/gas is not constant (26–38 across measured blocks) — candidates must be measured, not extrapolated from gas. ECSM = exactly 4 × signature recoveries (116 = 4×29), confirming the per-ecrecover cost model.

Benchmark tiers

  • /bench — unchanged: synthetic screen, 3+5 sampled runs, ~25 s/prove. Sensitive to ~1.5% regressions.
  • /bench-real — new: the real block, 1 run, ~6 min. Push-to-main publishes the baseline, so a PR pays for its side only. benchmark-pr.yml records which block it measured and refuses to diff across a repoint.
  • /bench-gpu — the real block is now the default workload (golden target). Synthetic reachable via the existing cont[TX]/mono[TX] tokens; GPU env/tuning untouched. Real-block GPU runtime is deliberately undocumented until the first run establishes it (prior: the GPU prover was CPU-bound at the serial producer above epoch 2^21, so no speedup is assumed).
  • bench_verify.sh / perf_diff.sh / bench_abba.sh: WORKLOAD=synthetic|real, defaulting to synthetic.

Continuations are mandatory for real blocks, not tuning: monolithic peak heap grows ~3–5 GB/Mcycle (measured, R²≈0.998) — ~330 GB for this block. prove --continuations also now prints Peak heap (it printed nothing; the benchmark parser would have hard-failed).

Fixture mechanism

Fetched by URL + sha256 (the prepare-sysroot pattern: .tmp + verify + move, digest re-checked every invocation), from the bench-fixtures-v1 pre-release — both fixtures plus their ethrex-replay provenance caches, all digest-pinned. No binaries in git. The converter (tooling/ethrex-real-block) moves off the per-PR path into a path-filtered workflow (ethrex-real-block.yml); its ~335-package build, cache download and rev pin leave the required gate. no_kzg_backend_linked stays in the gate (microseconds, and it's the property the usability screen rests on). Converter pins stay on Hoodi — the one upstream-hosted cache — decoupled from the benchmarked block; repointing the benchmark is five Makefile lines + one test constant.

Review fixups (original scope)

  • Stale-cache bug: an ETHREX_REPLAY_REV bump silently kept converting the old cache (now guarded).
  • CI double-build: k256 expose-field came only from a dev-dependency, so cargo run and cargo test resolved different feature sets and rebuilt the ethrex tree twice per job.
  • Chain-config assert independent of the digest — it's what forced the deliberate HOODI_CHAIN_ID → MAINNET_CHAIN_ID flip during the repoint instead of letting a substituted config hide behind a regenerated hash.
  • Measured doc corrections (ethrex_bench_20 is 32,766 B in distinct mode, not 17 KB; rkyv note; "cheapest first" ordering).

Known items

  • /bench-abba's escalation text points from a 20-tx monolithic result to a 100-tx continuations tiebreaker — pre-existing mismatch, flagged not fixed.
  • tooling/ethrex-tests/Cargo.lock gains one line (ecsm/num-integer): pre-existing staleness; cargo build --locked in that workspace currently fails on main. Separable on request.
  • make test-ethrex's sysroot step wasn't runnable locally (sudo); the real-block screen ran via the exact command CI uses.

How to test

make ethrex-real-block-fixture   # fetch + digest-verify (~1 MB)
make test-ethrex-offline         # host-side suite, no network
/bench-real                      # on this PR, once a main baseline exists

… changes

The cache filename is keyed on the block number alone and its download rule
had no prerequisites, so make treated an already-present
`caches/cache_hoodi_<n>.json` as up to date across an `ETHREX_REPLAY_REV`
bump and kept converting the old input. Depending on a rev-stamped marker
makes a re-pin discard the stale cache and refetch.

Without this the mismatch only surfaced downstream as a
`conversion_is_reproducible` digest failure whose message reads "regenerate
the fixture", pointing at the wrong cause. CI never hit it (fresh checkout,
`caches/` is gitignored); local re-pins did.
…uild

Two independent costs in the `test-executor` job, both paid on every PR run:

- `Swatinem/rust-cache` defaulted to `. -> target`, so neither detached
  ethrex workspace was cached. `tooling/ethrex-real-block` alone locks ~335
  packages, including the blst, c-kzg and secp256k1-sys C builds, malachite
  and ark-ff/asm. `cache-all-crates` covers the registry, not artifacts.
  Adds both tool workspaces to `workspaces:`.

- k256's `expose-field` was requested only by `lambda-vm-ethrex-crypto`, a
  dev-dependency. Under resolver 3 dev-dependency features are not unified
  into builds that exclude them, so `cargo run` (the fixture step) and the
  `cargo test` right after it resolved different k256 feature sets and
  rebuilt k256 plus every ethrex crate above it. Moving that one dependency
  to `[dependencies]` makes both resolutions identical:

    cargo tree -e features,no-dev -i k256   # lists expose-field
    cargo tree -e features        -i k256   # matches

  sha2 stays a dev-dependency; it has no feature delta either way.

Also corrects the rkyv sync note: it named `executor/Cargo.toml`, which
declares no rkyv. The readers are the guest and the two other detached
ethrex tool workspaces.
… digest

`conversion_is_reproducible`'s sha256 is the assertion an ethrex rev bump
forces someone to rewrite: the layout moves, the test goes red, a fresh
digest gets pasted in. At that moment it stops covering the substituted-
chain-config case the README picked it for. Assert the Hoodi chain id
directly so that check survives the churn.

Docs, all measured rather than estimated:

- `ethrex_bench_20.bin` is 32,766 B, not 17 KB. The benchmark scripts
  generate it in `distinct` mode (20 senders to 20 recipients); 17 KB is the
  committed `ethrex_bench_4.bin`. `same` mode would be 16,811 B. Names the
  mode so the row is reproducible, and replaces "a handful" of trie nodes
  with the 40 accounts that mode actually touches.

- "How to test" and the adopt-a-new-block steps omitted `make test-ethrex`,
  i.e. the only check that screens precompile 0x0a. This crate links a
  working c-kzg, so a block needing point evaluation passes here and fails
  in the guest — following the README alone would have missed it.

- The validation list was labelled "cheapest first" but is ordered by the
  argument it builds, and the ~60s cold-build figure understated a ~335
  package release tree. The per-test "milliseconds" claim is accurate
  (0.01-0.03s each) and is kept.

Cache-absent failures now say to run `make ethrex-real-block-fixture`
instead of surfacing a bare ENOENT from `unwrap()`.
Comment on lines +35 to +47
# The `Crypto` impl the guest injects. Off riscv64 its keccak and ECSM paths both
# fall back to software, so this exercises the guest's *fallback* crypto semantics
# — not the accelerators themselves.
#
# Used only by the tests, but declared here rather than in `[dev-dependencies]` on
# purpose: it is the only thing that asks for k256's `expose-field`, and under
# resolver 3 a dev-dependency's features are not unified into builds that exclude
# dev-dependencies. As a dev-dep, `cargo run` (the `make ethrex-real-block-fixture`
# step) and the `cargo test` that follows it in CI resolve two different k256
# feature sets, so k256 and every ethrex crate above it compile a second time in
# the same job. Confirm with:
# cargo tree -e features,no-dev -i k256 # must list expose-field
# cargo tree -e features -i k256 # must match the above

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make more sense to just set the k256 expose-field feature here?

The real-block fixture from tooling/ethrex-real-block existed but nothing
benchmarked with it: every perf measurement still ran on ethrex_bench_20,
a synthetic block of 20 plain transfers. Measured on the same guest ELF,
the two workloads are not the same benchmark:

  ethrex_bench_20   14.2M cycles     411 keccak     80 ecsm
  real block       168.3M cycles   9,046 keccak     44 ecsm

so the synthetic block is ecrecover-bound where a real one is keccak- and
trie-bound, and a prover change can move the two in opposite directions.

Wire the real block in as the headline, gated exactly like the growth
sweep — `/bench-real`, push to main, workflow_dispatch — and NOT on plain
`/bench`. One real-block prove is ~15 min on the CPU bench runner against
~25s for the synthetic block, and that runner is single and serialized
across all PRs. Because push-to-main always publishes a real-block number
into the baseline artifact, `/bench-real` on a PR pays for its own side
only. The synthetic block stays as the fast per-comment screen, relabelled
as such; GROWTH_PROGRAMS stays synthetic because it plots heap against
block size, which needs a family of blocks and cannot come from one real
one.

Continuations are forced for the real block rather than chosen: monolithic
peak heap grows ~3.1 GB per million cycles on this workload family (2007
MB/transfer at R^2 = 0.998, from the current main baseline artifact), so
168M cycles would need ~500 GB. At epoch 2^21 it fits in ~16 GB.

Supporting changes:

- cli: report `Peak heap` from `prove --continuations`. The tracker only
  existed on the monolithic path, so the continuation path had no heap
  metric at all and the benchmark's parser would have failed on it.
- Makefile: derive every real-block path from ETHREX_REAL_BLOCK_NETWORK +
  ETHREX_REAL_BLOCK, and expose `print-real-block-fixture`. Repointing the
  block is now those two lines; scripts and CI resolve the path from make
  instead of naming the fixture themselves.
- benchmark-pr.yml: anchor the baseline-artifact greps. `real_time_s`
  contains `time_s`, so the existing unanchored greps would have matched
  two lines and written a multi-line step output.
- benchmark-pr.yml: record which block was measured and refuse to diff
  against a baseline that measured a different one, so a fixture swap
  cannot masquerade as a regression.
- bench_verify.sh / perf_diff.sh: WORKLOAD=synthetic|real, defaulting to
  synthetic. Both auto-generate the gitignored fixture when absent, and
  bench_verify.sh carries the prove flags in its proof-cache marker so a
  WORKLOAD switch cannot reuse the other workload's proof.
- executor/.gitignore: ignore every accepted network's fixture name, not
  just hoodi's, so repointing cannot make a ~1 MB fixture committable.
…R gate

Two changes with one root: the real-block fixture is a benchmark INPUT, and it
was being treated as a build artifact and a correctness gate.

Fetch it instead of building it.
`make ethrex-real-block-fixture` now downloads the finished .bin from a pinned
URL and verifies its sha256 before moving it into place, mirroring
prepare-sysroot (same dual sha256sum/shasum handling, same hard error when
neither exists). Off the fixture's path, and therefore deleted from it: the 3.8
MB ethrex-replay cache download, the converter build, the ~335-package ethrex
host dependency tree, and the ETHREX_REPLAY_REV pin.

That also unblocks the block we actually want. Upstream hosts an ethrex-replay
cache for Hoodi and nothing else, so a mainnet block cannot be produced by the
convert-locally route at all — generating its cache takes ~4 min and ~700 archive
RPC calls. Fetching a verified binary makes the benchmark block independent of
what lambdaclass happens to host, and independent of this crate's own pins, which
stay on Hoodi because that is what conversion_is_reproducible reads.

ETHREX_REAL_BLOCK_FIXTURE_URL is deliberately left EMPTY — the artifact is not
hosted yet, and a guessed URL fails with a 404 nobody can act on. Unset, it fails
with an actionable message, and every consumer degrades instead of breaking:
benchmark-pr.yml warns and skips its real-block section rather than reddening
every push to main, and the usability screen skips too. Adopting mainnet 25453112
once hosted is four Makefile lines plus REAL_BLOCK_FIXTURE in ethrex-tests; its
digest and measured cost are recorded in the crate README.

Take it out of the required gate.
pr_main.yaml's test-executor job no longer generates the fixture, no longer
builds tooling/ethrex-real-block, and skips both real-block tests. No product
code reads this fixture: it has to be right when it changes, not on every PR, and
it was costing every PR a network download plus a cold ~335-package build
(blst, c-kzg, secp256k1-sys, malachite, ark-ff/asm) inside the branch-protection
gate.

Those checks are not dropped, they move to a new path-filtered workflow
(.github/workflows/ethrex-real-block.yml) triggered by tooling/ethrex-real-block,
tooling/ethrex-tests and the Makefile — i.e. by the things that can actually
invalidate them. It runs the converter's tests (host-side parity, network
rejection, reproducibility digest) and the block-usability screen
(test_ethrex_real_block_native, which is what catches a block needing precompile
0x0a). no_kzg_backend_linked stays in the PR gate: it is a pure unit test and it
is the property the usability screen depends on, so it should fail on the PR that
breaks it.

This supersedes part of 50dfca4: `tooling/ethrex-real-block -> target` is gone
from test-executor's rust-cache workspaces, since that job no longer builds the
workspace; the new job carries its own cache entry under its own key.
`tooling/ethrex-tests -> target` stays and is still doing its job.

Corrections while in here, all against a CURRENT guest ELF (counts move ~14% with
ELF vintage, so every quoted figure now names its ELF):
- synthetic 9.06M cycles, real 147.5M — a 16x ratio, not the 11.9x that came from
  comparing measurements taken on two different ELF vintages
- monolithic peak heap ~4.9 GB/Mcycle from the measured growth fit, so ~700 GB
  for the real block rather than ~500 GB
- continuation peak ~21 GB at epoch 2^21 (15.8 GB working set plus ~69 MB/epoch of
  accumulated proofs), not ~16 GB; the bundle is ~5 GB on disk and needs rkyv
  pointer_width_64 to serialize past 2 GiB
- ~13 min per prove, not ~15
- bench_vs/run_ethrex.sh claimed ethrex_10_transfers is ~42M cycles and OOMs at
  36 GB. It is 6.8M; the figure predates ecrecover becoming an ECSM accelerator.
…sured costs

Follow-up to the fetch change. Two things.

No block number appears outside the Makefile's four variables. Comments in
benchmark-pr.yml and this README previously named a specific candidate block as
the destination of a repoint, which would have gone stale the moment a different
candidate won and read as if the wiring depended on it. Every figure that is a
property of the block is now labelled "current default", and the repoint procedure
shows placeholders rather than one candidate's values. Verified: no workflow,
script or env var names a block.

Recorded the measured cost of each candidate, since prove time is a property of
the block and moves with the repoint. mainnet 25453112 is 125,932,956 cycles on
the mid-July ELF against hoodi 1265656's 168,319,360 on the same ELF — ~110M vs
147.5M on a current one, so ~10 min and ~19 GB rather than ~13 min and ~21 GB.
Cheaper despite the larger fixture: cycles per gas is not constant across blocks,
so the earlier gas-ratio estimate for it was wrong. Costs are stated as ranges
where they span the candidates.
Executes the documented repoint now that block selection is final. The chosen
block was the only one in a 90-day Dune sweep matching the shape constraints
(exactly 2 heavy txs, no whale tx, sane plain-transfer share) in the 1.6-2.6M gas
band, so it is structurally typical rather than merely large.

It is also the cheapest candidate measured, which matters because this workload
sits on a single shared bench runner: ~65.6M cycles on a current guest ELF
(74,819,518 on the mid-July one) against hoodi 1265656's 147.5M, i.e. ~6 min per
prove rather than ~13, ~18 GB peak and a ~1.9 GB bundle. Note cycles/gas is not
constant across blocks (~26-38 here), so ranking candidates by gas would have
picked wrong: 25453112 carries MORE gas than the hoodi block yet costs ~25% fewer
cycles.

Wiring: unchanged. Nothing outside the Makefile's variables names a block, so the
repoint is those variables plus the test constants that prove it self-consistent.
Verified by grep that no workflow, script or env var mentions a block number.

The cache is now fetched and verified like the fixture rather than pulled from
raw.githubusercontent.com at a pinned ethrex-replay rev. That route only ever
worked for the one block upstream publishes, which is what previously forced the
converter's tests onto a different block from the benchmarks. Hosting both
artifacts collapses that split: conversion_is_reproducible now pins the SAME
block the benchmarks prove, so its digest assert covers the artifact in use.
ETHREX_REPLAY_REV and its stamp are gone with it — this supersedes 05e0285,
whose staleness guard is carried forward in a stronger form below.

Fetch targets are now phony with an internal guard (the prepare-sysroot shape)
instead of file rules. A file rule does not run when its target exists, so it
could never catch the case that matters: an artifact already on disk that is
stale after a re-upload under the same block number. The digest is now re-checked
on every invocation, which also catches corruption and hand-placed copies.

Validation, all run:
- make test-ethrex-real-block-converter: 3/3 pass against the new constants.
  conversion_is_reproducible passing is the self-consistency proof — the cache,
  the block stats, MAINNET_CHAIN_ID and the digest all describe one block.
- regenerating from the cache reproduces the fixture byte for byte (61eba49b...),
  so the handed-over .bin and the hosted cache agree.
- test_ethrex_real_block_native and no_kzg_backend_linked pass: the block clears
  the guest's precompile surface with the KZG screen live.
- fetch guard exercised: matching digest is a silent no-op, a tampered file is
  detected and reported, and an unset URL fails with an actionable message.

Incidental: tooling/ethrex-tests/Cargo.lock gains ecsm's num-integer entry. That
manifest has declared it all along and the detached lockfile was stale, so any
build regenerates this and a --locked build in that workspace would have failed.
Repoints the benchmark fixture only. The converter's test constants stay on Hoodi
1265656 and do NOT move with it: what they exercise is the CONVERSION, which any
real block demonstrates equally well, and Hoodi's is the one cache ethrex-replay
publishes upstream — so pinning there costs us no hosting and cannot drift. The
fetch is what makes that decoupling possible, and it means a future repoint touches
the Makefile plus one constant in tooling/ethrex-tests, never this crate.

That restores ETHREX_REPLAY_REV and its rev stamp for the converter's cache, so
05e0285 is no longer superseded. The benchmark block's own cache is fetched from
our release alongside the fixture and read only by regen-real-block-fixture; the two
caches are now separate variables (ETHREX_CONVERTER_CACHE vs ETHREX_REAL_BLOCK_CACHE)
because they serve different purposes and move independently.

The fixture URLs are live (bench-fixtures-v1 release), so the TBDs are filled in.
The unset-URL guards stay: they now cover the window after a repoint but before the
new artifact is uploaded, which is exactly when a push to main would otherwise go red
over an upload nobody in CI can perform.

Block: 2,428,684 gas, 29 txs, ~65.6M cycles on a current guest ELF (74,819,518 on
the mid-July one), 10,478 keccak, 116 ECSM, ~6 min per prove, ~18 GB peak, ~1.9 GB
bundle. Only block in a 90-day Dune sweep matching the shape constraints in the
1.6-2.6M gas band (2 heavy txs, no whale, sane transfer mix; heavy-gas 44.3%,
top-tx 22.5%, p50 tx gas 41,297), and pre-FWA so transient-clean. Also the cheapest
of the three measured candidates, which matters on a shared runner.

Validation, all run end to end against the live release:
- make ethrex-real-block-fixture from a clean slate: fetch -> checksum -> place,
  digest 61eba49b... matches. Same for both caches (release + upstream).
- unset-URL path still reads correctly for the default block.
- converter tests 3/3 against the Hoodi pins.
- test_ethrex_real_block_native and no_kzg_backend_linked pass on the mainnet block.
The GPU benchmark ran the synthetic 100-transfer fixture; it now runs the real
block, and unlike the CPU path the real block is the DEFAULT here rather than
opt-in. The two paths differ because their scarce resource differs: /bench-gpu
rents its own Vast box per run, so the cost is money and nothing queues behind it,
while the CPU bench shares one runner with every /bench, /bench-abba and
/bench-verify in the repo. Same reasoning, opposite conclusion.

Synthetic workloads stay reachable through the existing `cont[TX]` / `mono[TX]`
tokens with unchanged semantics, so every number recorded before this reproduces
with the exact syntax that produced it. A new `real` token names the default
explicitly.

Implementation follows the pattern already used by bench_verify.sh and
perf_diff.sh: scripts/bench_abba.sh gains WORKLOAD=synthetic|real (defaulting to
synthetic, so CPU /bench-abba is untouched), resolves the fixture through
`make -s print-real-block-fixture`, and fetches it when absent — which a rented box
always needs, since it is a fresh checkout. That fetch is a ~1 MB URL + sha256
download, not a build, so it costs seconds of rental. WORKLOAD=real forces
--continuations rather than offering it: monolithic would only OOM at this trace
length.

Deliberately NOT touched, per the GPU sweep's tuning: LAMBDA_VM_VRAM_BUDGET_MB,
mempool settings, EPOCH_SIZE_LOG2, K, and the offer query's values. Changing any of
them silently would break comparability with the sweep that chose them.

No GPU duration is asserted anywhere, because none has been measured — the CPU rate
does not transfer, and the sweep's finding that the prover is CPU-bound at the
serial producer above epoch 2^21 cuts against assuming a large speedup. The
"running…" comment now says the real-block runtime is unmeasured and that the run
establishes the baseline, instead of quoting the synthetic workload's ~2.5 hr.

Two things checked rather than assumed:
- The box RAM floor stays 48 GB and needs no change. Continuation peak heap is set
  by the epoch size, not the block, so the real block costs the same ~10 GB working
  set at the default epoch 2^20 plus ~4.6 GB of accumulated epoch proofs — ~14 GB,
  or ~18 GB at 2^21. The comment now records that instead of leaving the next reader
  to re-derive it.
- The rkyv pointer_width_64 fail-fast is now scoped to synthetic >=40tx. It exists
  because a large synthetic continuation bundle exceeds the old 2 GiB cap; the real
  block's bundle is ~1.9 GB, so applying the guard to it would reject PR branches
  over a limit they do not reach.

This workflow never checks the repo out (it orchestrates over the Vast API and SSH),
so the workload label is a fixed string rather than a make query; bench_abba.sh
prints the resolved fixture path on the box and that lands in the run log.
…anual

The knob exists (WORKLOAD=real, which benchmark-gpu.yml drives) but the CPU ABBA
workflow keeps its synthetic default: 20 pairs x 2 proves x ~6 min is ~4 hours on
the one shared bench runner that every other bench queues behind. Documented as an
option to reach for deliberately rather than left as an undocumented capability.
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