Skip to content

bug(test-benchmark): give every stateful benchmark the same startup blocks - #3596

Open
benaadams wants to merge 3 commits into
ethereum:forks/amsterdamfrom
benaadams:bench/stateful-startup-block
Open

benaadams wants to merge 3 commits into
ethereum:forks/amsterdamfrom
benaadams:bench/stateful-startup-block

Conversation

@benaadams

@benaadams benaadams commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

Warm-up is currently an uncontrolled variable across the stateful benchmark
suite, and for one family it lands directly in the scored payload.

Counting the setup payloads each test delivers before its measured block, over
the 692 tests in the jochemnet/v1 + glamsterdam-devnet-8 stateful set:

setup payloads tests
0 72
1 560
2 24
3-8 2
9-105 34

The blocks that do exist carry one to three transactions (directly measured at
1.1 per block on the long chains). The 72 with none are
test_ether_transfers_onchain_receivers, whose senders and receivers all exist
in the pre-run bundle, so it allocates nothing from pre and its setup step is
a lone forkchoiceUpdated. Under a runner that recreates the client container
per test — which is what the stateful context does — the measured payload for
that family is the first block the process has ever seen.

That matters only for the two clients with a managed runtime, but it matters a
lot. Per-block cost over a chain of near-empty blocks, median over the 16 tests
whose setup chain is at least 20 blocks long, all clients on identical hardware:

block .NET client JVM client client C client D client E
1 170.2 80.7 31.5 2.8 20.0
3-10 21.4 89.2 10.7 18.0 21.8
11-20 22.1 78.5 12.4 20.2 21.4
21-30 18.5 74.5 13.6 22.1 22.1
31-50 20.0 66.8 13.9 24.5 21.4
51-105 19.5 63.2 15.4 30.8 21.3

Against each client's own steady state, the .NET client is 8.7x on the first
block and back to 1.1x by the third; the JVM client is 1.28x on the first,
rises to 1.41x over blocks three to ten, and is still 1.06x at blocks 31-50.
The other three show no warm-up at all — their curves rise, which is state
growth, not compilation.

At the top end this is worth a lot. For the family with no startup block, the
fitted per-block fixed cost is 575 ms for the .NET client against 29-154 ms for
the others, while the marginal cost of 100 Mgas more transfers is within 20%
across the three fastest. The whole gap is the fixed term.

What this changes

Run the same preamble ahead of every stateful benchmark, through the existing
setup_blocks= field, so no scenario is measured under a different warm-up
regime from another.

The two constants are sized from the cost of what they buy:

  • Transactions are cheap — microseconds each, against tens of milliseconds
    for a block. So the first block carries 256 transfers, comfortably over the
    invocation counts at which JIT runtimes leave their first tier (.NET promotes
    at 30 and does not start counting until a startup delay expires; the JVM's
    first profiled tier is at 200). That is 5.4 Mgas.
  • Blocks are expensive, so there are only four: enough to leave the
    first-block regime with margin. Reaching a per-block promotion threshold
    would take thirty of them, and at tens of milliseconds each that is not worth
    the run time.

Cost, against a per-test cycle of about 32 s on the reference runner: roughly
0.1 s per test for the .NET client and 0.4 s for the JVM one, or 0.3% and 1.3%
of the run. Fixtures grow by 259 transactions per test.

Every account the preamble touches is created by it, so no state a benchmark
reads is warmed; the blocks are setup-phase, so none of it is scored and
expected_benchmark_gas_used is unaffected.

Two tests gained the pre fixture, which they did not previously request.

What this does not fix

The JVM client's curve is still falling at block 100, and no startup block a
benchmark can afford reaches that runtime's second-tier threshold of several
thousand invocations. An AOT image is the lever there. This change stops the
fixture handing it an interpreted first block on top of that.

It is also worth noting separately that one client in this suite opts out of
the container recreate entirely via rollback_strategy: rpc-debug-setHead, so
it keeps its process and caches across every test. That is a larger consistency
question than this PR, but it moves the same numbers.

Testing

just static passes: mypy over 4263 files, ruff check, ruff format --check,
codespell, ethereum-spec-lint, vulture, actionlint, uv lock --check.
All 540 stateful benchmark tests still collect, and the helper was checked
directly to produce four setup-phase blocks of [256, 1, 1, 1] transactions with
259 distinct fresh receivers and a single throwaway sender.

I could not fill the stateful fixtures locally — that needs the bloatnet
snapshot and a client — so the generated fixtures are unverified. Worth a fill
against the snapshot before merging.

Related Issues or PRs

N/A.

Checklist

  • Ran fast static checks to avoid CI fails, see Code Standards & Verifying Changes: just static
  • PR title has the form <type>(<area>): <title>, where <type> and <area> come from an appropriate C-<type>, respectively A-<area>, label. The title should match the target squash commit message.

Cute Animal Picture

image

…rtup block

test_ether_transfers_onchain_receivers is the only stateful benchmark
whose senders and receivers all exist in the pre-run bundle, so it
allocates nothing from `pre` and its setup step is a lone
forkchoiceUpdated. The measured payload is therefore the first block the
client process ever sees, and it is charged the one-off cost of getting
there: compiling hot paths, filling caches that start empty, opening
database handles.

Measured on the benchmark runners, that cost is large and differs by an
order of magnitude between clients, so it moves the ranking rather than
the thing under test. Across the 36 configs that exist at both 200M and
300M gas, the increment for the extra 100M of transfers is within 20% for
the three fastest clients while their fitted per-block fixed cost spans
29 ms to 575 ms. In the families that do send a setup payload, the same
fixed term drops to 11-65 ms.

Prepend a setup-phase block of transfers between accounts created by that
block. It leaves the benchmark's own senders and receivers untouched, so
the state under test stays cold, and it is tagged SETUP so it is
delivered in the setup step and never scored.

The helper is shared because any stateful benchmark that satisfies its
pre-state entirely from the bundle has the same problem.
…reshold

The startup block existed to leave the client's first-block state, and ten
transactions did that for the per-block work.  It does not reach the point
where a JIT runtime promotes the per-transaction paths: .NET's default is 30
invocations, and its call counting does not start until a startup delay has
expired, so a block of ten leaves every per-transaction method in the
first-call tier for the measured block.

Measured on the benchmark runners, the per-block cost of the slowest client
keeps falling until roughly the twentieth block of a process - 170 ms on the
first, 20-27 ms over blocks three to ten, 16-22 ms from block twenty-one,
while the two clients it is measured against stay flat or rise as state
grows.  A single block cannot close that, but sizing it over the invocation
threshold covers the paths that run once per transaction, which is where the
transfers under test spend their time.

Sixty-four transfers cost about 1.3 Mgas against a 200-300 Mgas benchmark
budget, and the block is setup-phase, so none of it is scored.
…locks

Warm-up was an uncontrolled variable across the suite.  Counting the setup
payloads each test sends before its measured block: 72 tests send none, 560
send one, 24 send two, and 34 send between nine and a hundred and five.  The
blocks that do exist carry one to three transactions.  A client whose cost
depends on how much it has already executed is therefore measured under a
different regime in each family, and the family that sends nothing has the
client's first-ever block as the scored one.

Measured on the benchmark runners, only the two managed-runtime clients care.
Per-block cost over a chain of near-empty blocks, median over sixteen tests:
the .NET client runs 170 ms on the first block and 19-22 ms from the third
onwards; the JVM client runs 81, then 89 ms over blocks three to ten, falling
to 63 ms past block fifty and still drifting.  The other three are flat from
the first block, or rise with state, and have no warm-up to speak of.

Run the same preamble ahead of every stateful benchmark.  Its two constants
are picked from the cost of what they buy.  Transactions are cheap, so the
first block carries enough of them to clear the invocation thresholds at
which JIT runtimes leave their first tier, with headroom for .NET's startup
delay before counting begins.  Blocks are expensive - tens of milliseconds
each - so there are only enough to leave the first-block regime with margin;
reaching a per-block threshold would take thirty and is not worth the run
time.  That is 5.4 Mgas and roughly 0.1-0.4 s per test, against a per-test
cycle of about 32 seconds.

Every account is created by these blocks, so no state a benchmark reads is
warmed, and they are setup-phase, so none of it is scored.
Copilot AI lite review requested due to automatic review settings September 16, 2026 01:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The common setup helper still needs stateful fixture-fill or equivalent integration-test validation.

Pull request overview

Standardizes warm-up behavior across stateful benchmarks with shared setup-phase blocks before measured execution.

Changes:

  • Adds a reusable four-block preamble containing 259 fresh transfers.
  • Applies it across Bloatnet, depth, and EIP-7928 benchmarks.
  • Adds required pre fixtures.
File summaries
File Change
tests/benchmark/stateful/eip7928_block_level_access_lists/helpers.py Adds startup setup to BAL benchmarks.
tests/benchmark/stateful/bloatnet/test_transient_storage.py Adds startup setup and pre.
tests/benchmark/stateful/bloatnet/test_transaction_types.py Adds startup setup.
tests/benchmark/stateful/bloatnet/test_sstore.py Adds startup setup.
tests/benchmark/stateful/bloatnet/test_sload.py Adds startup setup and pre.
tests/benchmark/stateful/bloatnet/test_create.py Adds startup setup.
tests/benchmark/stateful/bloatnet/test_call.py Adds startup setup.
tests/benchmark/stateful/bloatnet/test_account_query.py Adds startup setup.
tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py Adds startup setup.
tests/benchmark/helper/transactions.py Defines shared startup-block generation; integration validation remains required.
Review details

Suppressed comments (1)

tests/benchmark/helper/transactions.py:143

  • This helper is now on the common path for every stateful benchmark, but the PR only checks collection/static analysis and explicitly does not fill a fixture against the required snapshot/client. That leaves the critical integration contract—259 valid setup transactions, setup-phase partitioning, and unchanged measured gas/BAL/post-state—unverified; please run the stateful fill or add an equivalent automated fixture test before merging.
    with TestPhaseManager.setup():
        sender = pre.fund_eoa()
        return [
            Block(
                txs=[
                    Transaction(
                        to=pre.fund_eoa(amount=0),
                        value=1,
                        sender=sender,
                    )
                    # Only the first block carries the transactions; the rest
                    # are there for the per-block cost and stay cheap.
                    for _ in range(STARTUP_BLOCK_TX_COUNT if i == 0 else 1)
                ]
            )
            for i in range(STARTUP_BLOCK_COUNT)
  • Files reviewed: 10/10 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@LouisTsai-Csie
LouisTsai-Csie self-requested a review September 16, 2026 05:46
@LukaszRozmej

Copy link
Copy Markdown

To be completely correct each pre-test warm-up should warm-up the code paths it later benchmarks, but using different data paths. But that is a lot of work. (Applies to stateless benchmarks too, but there at least the run is shared, so warm-up somewhat accumulates in the node).

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