Skip to content

perf(test-forks): memoize per-fork gas costs - #3303

Draft
jochem-brouwer wants to merge 1 commit into
ethereum:forks/amsterdamfrom
jochem-brouwer:perf/memoize-fork-gas-costs
Draft

perf(test-forks): memoize per-fork gas costs#3303
jochem-brouwer wants to merge 1 commit into
ethereum:forks/amsterdamfrom
jochem-brouwer:perf/memoize-fork-gas-costs

Conversation

@jochem-brouwer

Copy link
Copy Markdown
Member

Description

Fork.gas_costs() was recomputed on every call. Each EIP layer in the fork hierarchy implements it as replace(super().gas_costs(), ...), so a single call on a late fork walks the whole chain and allocates one GasCosts per layer: 16 dataclasses.replace calls for Amsterdam, at 787 us per call.

The filler calls it in the inner loop of the benchmark gas searches (transaction_intrinsic_cost_calculator inside _binary_search_iterations),where it accounted for ~17% of dataclasses.replace time in a py-spy profile of a stateful fill.

BaseForkMeta.__new__ now wraps every gas_costs override in a per-fork cache, so the super() chain runs once per fork rather than once per call. Wrapping in the metaclass rather than at each of the 18 definition sites is what makes this work: Fork.gas_costs() resolves through the MROto the last EIP that overrode it, and only a cache on that override can return before the chain runs. It also means a new EIP cannot silently opt out.

Each override keeps its own cache keyed on cls, so a half-assembled value from the middle of a super() chain can never be handed out as the final one, and no fork can serve another fork's value. The abstract declaration on BaseFork is left alone so abc still reports it unimplemented.

Sharing one object is safe because GasCosts is a frozen dataclass whose every field is an int -- there is nothing a caller could mutate. The MEMOIZED_FORK_METHODS docstring states that contract for anything added later, and a unit test enforces it.

Measured, per call:

fork before after
Amsterdam 787.41 us 0.19 us
Prague 502.58 us 0.19 us
Cancun 335.11 us 0.18 us
Frontier 27.58 us 0.19 us

End to end on fill --fork=Prague tests/prague/eip7623_increase_calldata_cost/ (1467 fixtures, single process, 3 runs each): 53.16 s -> 38.99 s,a 1.36x speedup.

Output is unchanged:

  • GasCosts is byte-identical for all 35 forks, dumped and diffed against the unmodified base.
  • The 1467 fixtures above are byte-identical between base and this branch (same sha256 roll-up).

Related Issues or PRs

N/A.

Checklist

  • Ran fast static checks to avoid CI fails: ruff check, ruff format --check and mypy are clean repo-wide (just is unavailable in thisenvironment; the 3 remaining mypy errors are the pre-existing rust_pyspec_glue/ethash import stubs that --group optimized supplies). pytest packages/testing/src is 1941 passed, 31 skipped, 19 xfailed, 2 xpassed.
  • PR title has the form <type>(<area>): <title>

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.49%. Comparing base (7a0430d) to head (a29c2f7).

Additional details and impacted files
@@               Coverage Diff                @@
##           forks/amsterdam    #3303   +/-   ##
================================================
  Coverage            93.49%   93.49%           
================================================
  Files                  624      624           
  Lines                37056    37056           
  Branches              3394     3394           
================================================
  Hits                 34647    34647           
  Misses                1653     1653           
  Partials               756      756           
Flag Coverage Δ
unittests 93.49% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@LouisTsai-Csie LouisTsai-Csie 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.

Some comments on the new test cases: I'm unsure if they properly test cache behavior.

AUTHORIZATION_EXISTING_AUTHORITY = auto()


MEMOIZED_FORK_METHODS = ("gas_costs",)

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.

Should we move it under BaseForkMeta?

Comment on lines +833 to +841
def _all_fork_classes() -> List[Type[BaseFork]]:
"""
Return every concrete fork class.

Transition forks are excluded: they are assembled from
`TransitionBaseClass` rather than `BaseFork`, so they do not declare the
memoized methods at all and delegate to a concrete fork per block.
"""
return sorted(get_forks(), key=str)

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.

This should be an fixture instead of a function, so we dont need to sort the array for each invocation.

Something like this:

@pytest.fixture
def tx_gas_limit(fork: Fork, gas_benchmark_value: int) -> int:
    """Return the transaction gas limit cap."""
    return fork.transaction_gas_limit_cap() or gas_benchmark_value

Comment on lines +878 to +879
first = getattr(fork, method_name)()
assert getattr(fork, method_name)() is first

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.

I am not very sure about the intention of this comparison, is it necessary?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Let me check, this is Claude generated, will report back 😄 👍

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