Skip to content

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

Merged
MauroToscano merged 30 commits into
mainfrom
review/ethrex-real-block-fixups
Jul 31, 2026
Merged

fix(tooling): review fixups for the real ethrex block fixture#884
MauroToscano merged 30 commits into
mainfrom
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 branch includes a merge of current main (@ 9ccdaf2), so all numbers below are main-vintage.

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 (main vintage) keccak ECSM measured prove
ethrex_bench_20 (synthetic, growth sweep only) 8,734,622 411 80 ~25 s (CPU bench runner)
mainnet 25368371 (the /bench workload) 50,781,557 10,478 116 158.8 s CPU runner · 59.87 s RTX 5090 (--epoch-size-log2 22)

Real/synthetic ≈ 5.8× by cycles. Both prove times are measured (see "First measured runs" below), not estimated.

Measurement rules learned the hard way, recorded in tooling/ethrex-block-converter/README.md:

  • Cycles/gas is not constant (26–38 across measured blocks) — candidates must be measured, never extrapolated from gas.
  • Cycle counts are guest-build-sensitive: thin-LTO (perf(programs): build the ethrex guest with thin LTO #861) is worth −5.5% on this real block vs −2.3% on toy fixtures, and the guest's C objects (secp256k1) make counts clang-major-sensitive (the Makefile pins target flags, not the compiler). Numbers must be labelled with their ELF vintage and compiler; the pre-LTO figures (74.8M) are superseded and marked as such.
  • ECSM = exactly 4 × signature recoveries (116 = 4×29), confirming the per-ecrecover cost model.

Benchmark tiers

  • /benchproves the real block, and nothing else: 3 sampled runs vs the cached 3-run baseline published on push to main (/bench N adjusts, 1–5; missing baseline renders the PR side alone with a notice). Verdict bands are honest about resolution: 3–10% deltas report as unresolved. Measured job time ~12 min. The synthetic sampled screen was removed deliberately: it was the only monolithic-path coverage (a path considered vestigial) and its EC-dense mix (8.83 ECSM/Mcycle vs 1.3–1.6 on real blocks) is not a workload that will ever be real. benchmark-pr.yml records which block it measured and refuses to diff across a repoint.
  • /bench-gpu — the real block (golden target), epoch 2^22 per calibration (28.9% VRAM headroom on a 32 GiB card; 2^23 needs ~44 GiB). A/B/B/A paired runs on a rented box — absolute times are per-host (the prover is partly host-CPU-bound); the deltas are what travel. Takes an optional pair count (/bench-gpu [N]) and nothing else.
  • /bench-growth, /bench-abba, /bench-verify — unchanged specialist commands (the growth sweep still uses the synthetic fixture family — a scaling curve needs a family of comparable blocks, which real blocks can't provide).
  • bench_verify.sh / perf_diff.sh / bench_abba.sh: WORKLOAD=synthetic|real, defaulting to synthetic. bench_verify.sh composes with Fix /bench-verify and measure continuations #878's monolithic/continuation split: WORKLOAD picks the block, Fix /bench-verify and measure continuations #878's knobs pick how it's proven; under WORKLOAD=real the monolithic arm is skipped (a real block monolithically is ~240 GB of heap — it can only OOM).

Continuations are mandatory for real blocks, not tuning: monolithic peak heap grows ~3–5 GB/Mcycle (measured, R²≈0.998). 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. Regenerating the fixture from the hosted cache reproduces it byte-for-byte. No binaries in git. The converter (tooling/ethrex-block-converter, renamed from ethrex-real-block — it converts any block, the name now says so) moves off the per-PR path into a path-filtered workflow (ethrex-block-converter.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.
  • The two alternate candidate blocks keep their pre-LTO measurements, in a separate table explicitly labelled with vintage, pending re-measurement.
  • Guest builds inherit whatever clang is on PATH (moves cycle counts ~0.1–2% between clang majors); a ready pin lives on the unpushed-to-PR branch fix/pin-guest-clang — optional hygiene, the bench runner builds its own ELF so bench numbers never depended on it.
  • Epoch-size defaults are encoded from measured calibration: GPU flow 2^22, bench runner 2^22, 128 GiB-class servers 2^23 (tables in the converter README); the CLI's laptop default stays 2^20.

First measured runs (branch dispatches, pre-merge)

CPU bench runner (run): real block, 3 sampled runs at epoch 2^22 — median 158.8 s, spread 2.8% (156.2/158.8/160.7), peak heap ~52 GB, total job ~12 min including the growth sweep. Two findings: the runner proves the real block at 3.13 s/M cycles vs 5.3–5.6 on synthetic — real-world keccak/trie work is cheaper per cycle than the synthetic EC-heavy mix, so all prior time projections were pessimistic; and peak heap is machine-dependent (52 GB here vs 32 GiB on a 32-core calibration box at the same epoch — table parallelism scales with cores), leaving ~19% headroom on the runner. Stable across all three runs.

GPU (4-pair A/B/B/A branch-vs-main dispatch, rented RTX 5090, epoch 2^22): PR 117.9 s vs main 118.3 s — −0.35%, 95% CI [−1.38%, +0.67%], verdict inconclusive-at-zero, which is the pass condition for a tooling-only branch. Full chain validated (rental → provision → dual build → real-block ABBA → paired-t/Wilcoxon) in ~20 min. Note the per-run absolute (~118 s) vs the calibration box's 59.87 s on the same card class: the prover is partly host-CPU-bound, and rented hosts vary — absolute GPU times are per-box; only same-box A/B deltas are comparable, which is what this workflow measures.

Note: PR-comment triggers run the workflow definition from main (GitHub semantics), so these numbers came from workflow_dispatch on the branch; after merge, plain /bench comments exercise this flow directly, and the merge push publishes the first baseline.

How to test

make ethrex-real-block-fixture   # fetch + digest-verify (~1 MB)
make test-ethrex-offline         # host-side suite, no network

Comment-triggered /bench exercises this workflow only after merge (comment triggers run main's workflow definition); pre-merge validation was done via workflow_dispatch on the branch — both runs linked above.

nicole-graus and others added 11 commits July 27, 2026 17:40
… sha256 not length, skip heavy VM test in local target, and add offline variant
… 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 thread tooling/ethrex-real-block/Cargo.toml Outdated
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.
…ock-fixups

# Conflicts:
#	scripts/bench_verify.sh
The branch predated #861 (thin LTO on the ethrex guest) and #863, so every cycle
count and derived cost in these docs described a guest that no longer exists.
Re-measured on this branch at the merge, guest rebuilt from source:

  real block   74,819,518 -> 50,781,557 cycles
  synthetic    9,063,727  ->  8,734,622 cycles
  ratio             ~7x   ->      ~5.8x

so the CPU prove drops ~6 min -> ~4.5-4.8 min and the monolithic-heap figure
~330 GB -> ~240 GB (re-derived from the same measured growth fit via a same-ELF
cycle ratio, not rescaled by hand). Accelerator call counts are unchanged at
10,478 keccak / 116 ECSM, as expected — they follow the workload, not codegen.

Every count now names the ELF it came from. Two things move them and both have bitten
this doc already: guest optimisation (#861), and the clang major on PATH, worth ~2% —
the Makefile pins the guest's target flags but not its compiler, so `cc` takes whatever
clang is installed. The 50,713,534 the RTX 5090 box measured for this block on the same
main commit is that effect, not a regression: clang 18 there, clang 21 here, 0.13%
apart.

GPU is no longer unmeasured: 59.87 s wall at --epoch-size-log2 22 on an RTX 5090,
which is the recommended GPU epoch because VRAM binds — 2^22 leaves 28.9% headroom and
2^23 does not fit a 32 GiB card at all. CPU is ~4.5-4.8x that wall time. The
CPU-server epoch recommendation is left as "pending calibration" rather than guessed
from the GPU figure.

The two alternate candidate blocks keep their PRE-LTO numbers, explicitly labelled and
marked for re-measurement. Removing them would lose the selection evidence; presenting
them next to a main-vintage number without a label would be exactly the vintage-mixing
error the rest of this change is about.
From the RTX 5090 calibration on 2026-07-31 against main @9ccdaf2 (raw traces in
~/workspace/lambda_vm_bench_cache/gpu_epoch_calib_2026-07-31/), same fixture and
CLI, one prove per setting:

  2^21   70.52 s   19,193 MiB VRAM (58.9%)   25 epochs   1.65 GB proof
  2^22   59.87 s   23,193 MiB VRAM (71.1%)   13 epochs   1.12 GB proof
  2^23   OOM at 32,079 MiB (98.4%) after 9.7 s — needs ~44 GiB

VRAM is the binding constraint, so 2^22 is the largest setting that fits a 32 GiB
card, and it is ~15% faster than 2^21 with 28.9% headroom left. 2^23 is unreachable
for any card below 48 GiB.

Scoped to the GPU flow. bench_abba.sh keeps its 2^20 default for the CPU
/bench-abba, and the CLI's DEFAULT_CONTINUATION_EPOCH_SIZE_LOG2 stays 20: 2^22 needs
~28 GiB of HOST memory on a CPU build and would break laptops. VRAM binds on one path
and host RAM on the other — not the same question, so not the same default. The
CPU-server recommendation is left explicitly pending calibration.

Applied to the real-block path only, not to the synthetic cont[TX] tokens. Those
exist so a GPU number recorded before the real-block migration still reproduces with
the syntax that produced it, and silently re-pointing their epoch would break exactly
that. mono[TX] has no epoch.
The CPU sweep landed (2026-07-31, 124 GiB / 32-core box, real block, branch vintage
at 53,757,588 cycles):

  2^20  52 ep  616.90 s  14.56 GiB RSS  2.83 GB proof
  2^21  26 ep  464.26 s  18.43 GiB      1.72 GB
  2^22  13 ep  397.88 s  32.21 GiB      1.15 GB
  2^23   7 ep  356.47 s  60.01 GiB      0.90 GB
  2^24   4 ep  ~334 s    ~97-105 GiB (provisional)

There is a real knee: gain per doubling shrinks 24.7 -> 14.3 -> 10.4 -> ~6% while
memory roughly doubles at each step near the top. So the choice is per-machine, and
this commit encodes three tiers rather than one number:

  GPU 32 GiB card      2^22  (VRAM-bound; already set in benchmark-gpu.yml)
  CPU bench runner     2^22  (host-RAM-bound: 32.2 GiB fits a >=64 GiB box with ~50%
                              headroom, 2^23's 60 GiB does not)
  CPU 128 GiB class    2^23  (the knee; 48% of a 124 GiB box)
  laptops / CLI        2^20  (unchanged)

/bench-real moves 2^21 -> 2^22, worth ~14% wall. NOT ~35% — that figure is the
2^20 -> 2^22 gap, and this path was never at the CLI default; it has been explicitly
2^21 since it was wired. scripts/perf_diff.sh moves the same way, since it targets the
same bench server and its real-block epoch should not disagree with /bench-real's.

Absolute seconds from that table are deliberately not copied into any estimate: the
calibration box runs ~8.6 s/Mcycle against this runner's 5.31-5.62, so the ratios
between epochs transfer and the wall times do not. The docs say the first /bench-real
run at 2^22 establishes the runner's real number.

Left alone on purpose: the CLI's DEFAULT_CONTINUATION_EPOCH_SIZE_LOG2 (20, so a
laptop can still prove), bench_abba.sh's 2^20 (CPU /bench-abba), and
bench_verify.sh's CONT_EPOCH_LOG2 (20, chosen by #878 for its SYNTHETIC arm to match
bench_abba's bundle shape) — that one gains a pointer to the tier table for anyone
running its real-block arm by hand.

2^24 fits a 124 GiB box but buys ~6% for ~15% headroom, so it is documented as "fits,
marginal, not recommended on shared boxes" rather than adopted. Its RSS cell is marked
provisional pending the calibration agent's formal report.
`/bench-real` is gone. The real block now runs on every invocation of plain `/bench`,
alongside the synthetic screen rather than instead of it, and push-to-main publishes a
real-block baseline the same way it publishes the synthetic one. One command instead of
two, for the number that actually means something.

Sampled 3x and summarised exactly like the synthetic screen — median headline, spread,
and every raw value — so a single slow run shows up as spread instead of silently
moving the verdict. The verdict bands are wider than the synthetic 5%: 3 runs of a
minutes-long prove resolve less than 5 runs of a 25s one, so anything from 3-10% is
reported as unresolved rather than as "fine".

The baseline fallback path samples to the same count. A 3-vs-1 comparison would put the
two sides' noise on different footings, which is the error sampling exists to avoid.

Cost, stated plainly because it lands on a shared resource: one runner carries every
/bench, /bench-abba and /bench-verify in the repo, and this takes a /bench from about a
minute of proving to ~15-20 min, on every comment and every push to main. That trade is
deliberate. BENCH_RUNS_REAL is the dial, and the comment next to it says so — the
runner's per-run time is still unmeasured (the epoch calibration ran on a different
box), so if runs land above ~6 min each, turning 3 down is the first move rather than
the last resort.

Checked rather than assumed: the proof bundle (~1.15 GB at epoch 2^22) is deleted
between every run on both the PR and baseline loops, so 3 runs do not accumulate ~3.5 GB
of disk; and peak heap is ~32 GiB against a runner floor of >=64 GiB with nothing else
scheduled, since the runner serialises.

The block-identity guard is untouched: a baseline that measured a different block still
refuses to diff.

Renderer: run count moved into the heading ("median of 3") to match the synthetic
section instead of being repeated in the subtitle, and the footer no longer offers a
command to request the real block — its absence now means the fixture could not be
fetched, so it says that and points at the URL. Exercised over 8 scenarios including
tight-spread, bad-spread, and fixture-unavailable.
Removes the synthetic sampled screen from /bench. The job now proves one workload —
3 sampled runs of the real block on the PR side against the cached 3-run baseline
push-to-main publishes — and the comment carries one table.

Why the screen went, recorded in the workflow so the next person doesn't re-add it:

  1. Its only unique coverage was the MONOLITHIC prove path, which is vestigial
     (reportedly slower than a single-epoch continuation). Runner time spent covering
     a path we intend to delete is not a trade worth making.
  2. Its crypto mix is one no real block has — 8.83 ECSM/Mcycle against a real block's
     ~1.3-1.6 — so screening against it tunes the prover for a worst case that cannot
     occur.

Net -280 lines: the synthetic prove loop, its baseline arm, its half of the metrics
artifact, its comparison arithmetic, and its section of the renderer all go together.
Leaving the baseline arm behind would have kept proving numbers nothing reads.

`/bench N` now sets the real-block sample count, replacing the old PR-vs-baseline
split (3 and 5). Both sides use one count deliberately: an asymmetric count puts the
two sides' noise on different footings, which is the error sampling exists to prevent.
Still clamped to 5 — at ~4-5 min a run that is also the difference between a 15 and a
25 minute occupancy of the one bench runner.

Untouched, as scoped: GROWTH_PROGRAMS and the synthetic fixture generation it needs
(/bench-growth still sweeps them for a heap-vs-block-size slope, which needs a family
of blocks and cannot come from one real one), /bench-abba, /bench-verify. The
block-identity guard still refuses to diff across a repoint.

Two things the removal exposed and this fixes:
- metrics.txt was created by the synthetic step with `>` and appended to by everything
  else. It is now truncated in the config step, which always runs — otherwise a
  persistent self-hosted runner would have carried a previous run's values forward.
- A missing baseline is no longer an error. The old code hard-failed on a zero/empty
  synthetic baseline; for the real block an absent baseline is the normal state before
  main has published one, so it now emits a notice and renders the PR side alone.

Renderer wording, caught by the scenario harness: "prove time down -17.2%" and "up
+17.3%" double-signed themselves, and the no-fixture footer still said "synthetic
blocks only" — which is now simply false, since nothing else is proven. Exercised over
9 scenarios (normal, improvement, regression, unresolved band, bad spread, no
baseline, block mismatch, fixture unavailable, growth sweep).
@MauroToscano

Copy link
Copy Markdown
Contributor Author

/bench

benchmark-pr.yml's comment step is ~200 lines of JS embedded in YAML, and the only
other way to see its output was to push and run a real benchmark — now ~15-20 min of
occupancy on the single shared bench runner. This extracts the script block and
renders it against synthetic env values, so a wording or formatting change is checked
in a second.

It renders rather than asserts, deliberately: the failures it has actually caught were
readability ones a human spots instantly and an assertion would have to anticipate —
double-signed percentages ("prove time down -17.2%"), and a footer that still claimed
"synthetic blocks only" after the synthetic screen was removed.

The nine scenarios are the renderer's branches: normal, improvement, regression, the
unresolved 3-10% band, bad spread, no baseline yet, block mismatch, fixture
unavailable, and the growth sweep. It lived in a scratchpad while the workflow was in
flux; committing it so the next person editing that comment has it.
@github-actions

Copy link
Copy Markdown

Benchmark — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 50217 MB 49935 MB -282 MB (-0.6%) ⚪
Prove time 24.695s 24.508s -0.187s (-0.8%) ⚪

✅ No significant change.

🔬 Looks like a small speedup (-0.8%) — below what 3 runs can confirm. Comment /bench-abba to run the drift-free ABBA tiebreaker (paired-t CI + exact Wilcoxon). Note: it occupies the bench server for ~30–40 min.
Optional pair count: /bench-abba 32 (20 resolves ~1%, 32 for ~0.6%).

✅ Low variance (time: 2.1%, heap: 0.3%)

Commit: 7a29bb1 · Baseline: cached · Runner: self-hosted bench

@MauroToscano
MauroToscano changed the base branch from feat/ethrex-real-block to main July 31, 2026 22:22
…rypto dev-dep

Review suggestion (Oppen): declaring k256 with `expose-field` in [dependencies]
unifies the feature set between `cargo run` and `cargo test` just as well as
promoting lambda-vm-ethrex-crypto out of [dev-dependencies] — resolver 3 unions
the direct declaration into both resolutions — while keeping the test-only
crypto crate where it belongs and dropping it from the converter binary's
dependency graph entirely.

Verified: `cargo tree -e features{,no-dev} -i k256` identical (both list
expose-field); `cargo tree --no-dev` no longer contains
lambda-vm-ethrex-crypto; converter tests 3/3.

The trade is a duplicated feature requirement that must track
crypto/ethrex-crypto's k256 line; the verification commands in the comment are
the guard.
"ethrex real block" read as a benchmark; it is validation for the fixture and
its converter (tests, not measurements). The bench lives in benchmark-pr.yml.
The crate was named after the motivation that produced it ("get a real
block to benchmark") rather than after what it does. What it does is
convert an ethrex-replay cache JSON — of any Ethereum block — into the
rkyv ProgramInput fixture the guest deserializes. Nothing in it is
specific to the benchmark block: its own tests are pinned to Hoodi
1265656 precisely because the conversion is what is under test, not the
workload. Name it for its function.

  tooling/ethrex-real-block         -> tooling/ethrex-block-converter
  package ethrex-real-block         -> ethrex-block-converter
  .github/workflows/
    ethrex-real-block.yml           -> ethrex-block-converter.yml
    (display name "ethrex real-block fixture tests"
                                    -> "ethrex block-converter tests")

Deliberately unchanged, because they name the BLOCK and that is still
correct: the Makefile's ETHREX_REAL_BLOCK* variables and every
`*-real-block-*` target, the `test_ethrex_real_block_{native,vm}` tests
in tooling/ethrex-tests, the fixture filenames, release URLs and
sha256s. Only path values pointing into the moved directory moved.

Also unchanged: the "lambda-vm-ethrex-real-block-usable" rust-cache key
in the renamed workflow. It keys the block-usability job, whose
workspace (tooling/ethrex-tests) did not move — renaming it would
discard a valid cache of the heavy ethrex tree for nothing. The
converter job's key and its `workspaces:` entry did both move; that
entry is load-bearing, since the crate is a detached workspace the
default `. -> target` would miss.

No behavior change. The converter's 3 tests pass and the reproducibility
digest is untouched, so the fixture bytes are identical.
… key

One-time cold cache on the next run; the key now names the screen rather than
the renamed crate.
@MauroToscano
MauroToscano enabled auto-merge July 31, 2026 22:55
@MauroToscano
MauroToscano disabled auto-merge July 31, 2026 23:00
The synthetic cont[TX]/mono[TX] modes are gone: the workflow takes an optional
pair count and nothing else, pins bench_abba.sh's knobs explicitly (the script
is shared with the CPU ABBA flow), and drops the mode input, the token parsing,
the per-mode epoch conditional and the rkyv pre-flight that existed only for
large synthetic continuation proofs. The running-comment ETA now scales from
the measured 4-pair run instead of quoting a synthetic figure, and the Vast
RAM-floor comment cites the measured real-block peak.
@MauroToscano
MauroToscano enabled auto-merge July 31, 2026 23:08
@MauroToscano
MauroToscano added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit ce17377 Jul 31, 2026
17 checks passed
@MauroToscano
MauroToscano deleted the review/ethrex-real-block-fixups branch July 31, 2026 23:37
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.

4 participants