Conversation
…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.
Contributor
There was a problem hiding this comment.
🔵 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
prefixtures.
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
self-requested a review
September 16, 2026 05:46
|
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). |
kamilchodola
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-8stateful set: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 existin the pre-run bundle, so it allocates nothing from
preand its setup step isa lone
forkchoiceUpdated. Under a runner that recreates the client containerper 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:
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-upregime from another.
The two constants are sized from the cost of what they buy:
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.
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_usedis unaffected.Two tests gained the
prefixture, 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, soit 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 staticpasses: 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
just static<type>(<area>): <title>, where<type>and<area>come from an appropriateC-<type>, respectivelyA-<area>, label. The title should match the target squash commit message.Cute Animal Picture