Skip to content

Commit a2f2841

Browse files
feat(benchmark): add benchmark_test test type (#1945)
* feat: wrap blockchain test for benchmark * feat: wrap state test for benchmark * feat(benchmark): add code generator to generate transaction * fix: resolve typing issue * refactor: update benchmark code generator and test wrapper * fix: udpate example changes * refactor: resolve typing and update func interface * refactor: remove benchmark state test wrapper * fix: pydantic model validation for benchmark manager * refactor synatx and parameter * refactor: remove benchmark manager feature * refactor: update logic and add benchmark tests * refactor: enforce single property requirement in blockchain test generation * refactor: update Bytecode serialization schema to use format_ser_schema * refactor: update import paths * refactor: update serialization schema * refactor: remove unused parameters * doc: add changelog entry * fix typo
1 parent 99af8e3 commit a2f2841

File tree

12 files changed

+506
-41
lines changed

12 files changed

+506
-41
lines changed

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Test fixtures for use by clients are available for each release on the [Github r
1010

1111
### 🛠️ Framework
1212

13+
- ✨ Add benchmark-specific test wrapper (`benchmark_test`) that supports **EIP-7825** and create a benchmark code generator for common test pattern ([#1945](https://github.com/ethereum/execution-spec-tests/pull/1945)).
14+
1315
#### `fill`
1416

1517
- Move pytest marker registration for `fill` and `execute-*` from their respective ini files to the shared `pytest_plugins.shared.execute_fill` pytest plugin ([#2110](https://github.com/ethereum/execution-spec-tests/pull/2110)).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Benchmark code generator classes for creating optimized bytecode patterns."""
2+
3+
from .benchmark_code_generator import (
4+
BenchmarkCodeGenerator,
5+
ExtCallGenerator,
6+
JumpLoopGenerator,
7+
)
8+
9+
__all__ = (
10+
"BenchmarkCodeGenerator",
11+
"ExtCallGenerator",
12+
"JumpLoopGenerator",
13+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Benchmark code generator classes for creating optimized bytecode patterns."""
2+
3+
from ethereum_test_forks import Fork
4+
from ethereum_test_specs.benchmark import BenchmarkCodeGenerator
5+
from ethereum_test_types import Alloc, Transaction
6+
from ethereum_test_vm import Bytecode
7+
from ethereum_test_vm.opcodes import Opcodes as Op
8+
9+
10+
class JumpLoopGenerator(BenchmarkCodeGenerator):
11+
"""Generates bytecode that loops execution using JUMP operations."""
12+
13+
def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
14+
"""Deploy the looping contract."""
15+
# Benchmark Test Structure:
16+
# setup + JUMPDEST + attack + attack + ... + attack + JUMP(setup_length)
17+
code = self.generate_repeated_code(self.attack_block, self.setup, fork)
18+
self._contract_address = pre.deploy_contract(code=code)
19+
20+
def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
21+
"""Generate transaction that executes the looping contract."""
22+
if not hasattr(self, "_contract_address"):
23+
raise ValueError("deploy_contracts must be called before generate_transaction")
24+
25+
return Transaction(
26+
to=self._contract_address,
27+
gas_limit=gas_limit,
28+
sender=pre.fund_eoa(),
29+
)
30+
31+
32+
class ExtCallGenerator(BenchmarkCodeGenerator):
33+
"""Generates bytecode that fills the contract to maximum allowed code size."""
34+
35+
def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
36+
"""Deploy both target and caller contracts."""
37+
# Benchmark Test Structure:
38+
# There are two contracts:
39+
# 1. The target contract that executes certain operation but not loop (e.g. PUSH)
40+
# 2. The loop contract that calls the target contract in a loop
41+
42+
max_iterations = min(
43+
fork.max_stack_height(), fork.max_code_size() // len(self.attack_block)
44+
)
45+
46+
# Deploy target contract that contains the actual attack block
47+
self._target_contract_address = pre.deploy_contract(
48+
code=self.attack_block * max_iterations
49+
)
50+
51+
# Create caller contract that repeatedly calls the target contract
52+
# attack = POP(STATICCALL(GAS, target_contract_address, 0, 0, 0, 0))
53+
# setup + JUMPDEST + attack + attack + ... + attack + JUMP(setup_length)
54+
code_sequence = Op.POP(Op.STATICCALL(Op.GAS, self._target_contract_address, 0, 0, 0, 0))
55+
56+
caller_code = self.generate_repeated_code(code_sequence, Bytecode(), fork)
57+
self._contract_address = pre.deploy_contract(code=caller_code)
58+
59+
def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
60+
"""Generate transaction that executes the caller contract."""
61+
if not hasattr(self, "_contract_address"):
62+
raise ValueError("deploy_contracts must be called before generate_transaction")
63+
64+
return Transaction(
65+
to=self._contract_address,
66+
gas_limit=gas_limit,
67+
sender=pre.fund_eoa(),
68+
)

src/ethereum_test_specs/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .base import BaseTest, TestSpec
44
from .base_static import BaseStaticTest
5+
from .benchmark import BenchmarkTest, BenchmarkTestFiller, BenchmarkTestSpec
56
from .blobs import BlobsTest, BlobsTestFiller, BlobsTestSpec
67
from .blockchain import (
78
BlockchainTest,
@@ -23,6 +24,9 @@
2324
__all__ = (
2425
"BaseStaticTest",
2526
"BaseTest",
27+
"BenchmarkTest",
28+
"BenchmarkTestFiller",
29+
"BenchmarkTestSpec",
2630
"BlobsTest",
2731
"BlobsTestFiller",
2832
"BlobsTestSpec",
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""Ethereum benchmark test spec definition and filler."""
2+
3+
import math
4+
from abc import ABC, abstractmethod
5+
from dataclasses import dataclass, field
6+
from typing import Callable, ClassVar, Dict, Generator, List, Sequence, Type
7+
8+
import pytest
9+
from pydantic import ConfigDict, Field
10+
11+
from ethereum_clis import TransitionTool
12+
from ethereum_test_base_types import HexNumber
13+
from ethereum_test_exceptions import BlockException, TransactionException
14+
from ethereum_test_execution import (
15+
BaseExecute,
16+
ExecuteFormat,
17+
LabeledExecuteFormat,
18+
TransactionPost,
19+
)
20+
from ethereum_test_fixtures import (
21+
BaseFixture,
22+
BlockchainEngineFixture,
23+
BlockchainEngineXFixture,
24+
BlockchainFixture,
25+
FixtureFormat,
26+
LabeledFixtureFormat,
27+
)
28+
from ethereum_test_forks import Fork
29+
from ethereum_test_types import Alloc, Environment, Transaction
30+
from ethereum_test_vm import Bytecode
31+
from ethereum_test_vm.opcodes import Opcodes as Op
32+
33+
from .base import BaseTest
34+
from .blockchain import Block, BlockchainTest
35+
36+
37+
@dataclass(kw_only=True)
38+
class BenchmarkCodeGenerator(ABC):
39+
"""Abstract base class for generating benchmark bytecode."""
40+
41+
attack_block: Bytecode
42+
setup: Bytecode = field(default_factory=Bytecode)
43+
44+
@abstractmethod
45+
def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
46+
"""Deploy any contracts needed for the benchmark."""
47+
...
48+
49+
@abstractmethod
50+
def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
51+
"""Generate a transaction with the specified gas limit."""
52+
...
53+
54+
def generate_repeated_code(
55+
self, repeated_code: Bytecode, setup: Bytecode, fork: Fork
56+
) -> Bytecode:
57+
"""Calculate the maximum number of iterations that can fit in the code size limit."""
58+
assert len(repeated_code) > 0, "repeated_code cannot be empty"
59+
max_code_size = fork.max_code_size()
60+
61+
overhead = len(setup) + len(Op.JUMPDEST) + len(Op.JUMP(len(setup)))
62+
available_space = max_code_size - overhead
63+
max_iterations = available_space // len(repeated_code)
64+
65+
code = setup + Op.JUMPDEST + repeated_code * max_iterations + Op.JUMP(len(setup))
66+
self._validate_code_size(code, fork)
67+
68+
return code
69+
70+
def _validate_code_size(self, code: Bytecode, fork: Fork) -> None:
71+
"""Validate that the generated code fits within size limits."""
72+
if len(code) > fork.max_code_size():
73+
raise ValueError(
74+
f"Generated code size {len(code)} exceeds maximum allowed size "
75+
f"{fork.max_code_size()}"
76+
)
77+
78+
79+
class BenchmarkTest(BaseTest):
80+
"""Test type designed specifically for benchmark test cases."""
81+
82+
model_config = ConfigDict(extra="forbid")
83+
84+
pre: Alloc
85+
post: Alloc = Field(default_factory=Alloc)
86+
tx: Transaction | None = None
87+
blocks: List[Block] | None = None
88+
block_exception: (
89+
List[TransactionException | BlockException] | TransactionException | BlockException | None
90+
) = None
91+
env: Environment = Field(default_factory=Environment)
92+
expected_benchmark_gas_used: int | None = None
93+
gas_benchmark_value: int = Field(default_factory=lambda: int(Environment().gas_limit))
94+
code_generator: BenchmarkCodeGenerator | None = None
95+
96+
supported_fixture_formats: ClassVar[Sequence[FixtureFormat | LabeledFixtureFormat]] = [
97+
BlockchainFixture,
98+
BlockchainEngineFixture,
99+
BlockchainEngineXFixture,
100+
]
101+
102+
supported_execute_formats: ClassVar[Sequence[LabeledExecuteFormat]] = [
103+
LabeledExecuteFormat(
104+
TransactionPost,
105+
"benchmark_test",
106+
"An execute test derived from a benchmark test",
107+
),
108+
]
109+
110+
supported_markers: ClassVar[Dict[str, str]] = {
111+
"blockchain_test_engine_only": "Only generate a blockchain test engine fixture",
112+
"blockchain_test_only": "Only generate a blockchain test fixture",
113+
}
114+
115+
@classmethod
116+
def pytest_parameter_name(cls) -> str:
117+
"""Return the parameter name used in pytest to select this spec type."""
118+
return "benchmark_test"
119+
120+
@classmethod
121+
def discard_fixture_format_by_marks(
122+
cls,
123+
fixture_format: FixtureFormat,
124+
fork: Fork,
125+
markers: List[pytest.Mark],
126+
) -> bool:
127+
"""Discard a fixture format from filling if the appropriate marker is used."""
128+
if "blockchain_test_only" in [m.name for m in markers]:
129+
return fixture_format != BlockchainFixture
130+
if "blockchain_test_engine_only" in [m.name for m in markers]:
131+
return fixture_format != BlockchainEngineFixture
132+
return False
133+
134+
def get_genesis_environment(self, fork: Fork) -> Environment:
135+
"""Get the genesis environment for this benchmark test."""
136+
return self.env
137+
138+
def split_transaction(self, tx: Transaction, gas_limit_cap: int | None) -> List[Transaction]:
139+
"""Split a transaction that exceeds the gas limit cap into multiple transactions."""
140+
if gas_limit_cap is None:
141+
tx.gas_limit = HexNumber(self.gas_benchmark_value)
142+
return [tx]
143+
144+
if gas_limit_cap >= self.gas_benchmark_value:
145+
tx.gas_limit = HexNumber(self.gas_benchmark_value)
146+
return [tx]
147+
148+
num_splits = math.ceil(self.gas_benchmark_value / gas_limit_cap)
149+
remaining_gas = self.gas_benchmark_value
150+
151+
split_transactions = []
152+
for i in range(num_splits):
153+
split_tx = tx.model_copy()
154+
split_tx.gas_limit = HexNumber(remaining_gas if i == num_splits - 1 else gas_limit_cap)
155+
remaining_gas -= gas_limit_cap
156+
split_tx.nonce = HexNumber(tx.nonce + i)
157+
split_transactions.append(split_tx)
158+
159+
return split_transactions
160+
161+
def generate_blocks_from_code_generator(self, fork: Fork) -> List[Block]:
162+
"""Generate blocks using the code generator."""
163+
if self.code_generator is None:
164+
raise Exception("Code generator is not set")
165+
166+
self.code_generator.deploy_contracts(self.pre, fork)
167+
gas_limit = fork.transaction_gas_limit_cap() or self.gas_benchmark_value
168+
benchmark_tx = self.code_generator.generate_transaction(self.pre, gas_limit, fork)
169+
170+
execution_txs = self.split_transaction(benchmark_tx, gas_limit)
171+
execution_block = Block(txs=execution_txs)
172+
173+
return [execution_block]
174+
175+
def generate_blockchain_test(self, fork: Fork) -> BlockchainTest:
176+
"""Create a BlockchainTest from this BenchmarkTest."""
177+
set_props = [
178+
name
179+
for name, val in [
180+
("code_generator", self.code_generator),
181+
("blocks", self.blocks),
182+
("tx", self.tx),
183+
]
184+
if val is not None
185+
]
186+
187+
if len(set_props) != 1:
188+
raise ValueError(
189+
f"Exactly one must be set, but got {len(set_props)}: {', '.join(set_props)}"
190+
)
191+
192+
if self.code_generator is not None:
193+
generated_blocks = self.generate_blocks_from_code_generator(fork)
194+
return BlockchainTest.from_test(
195+
base_test=self,
196+
genesis_environment=self.env,
197+
pre=self.pre,
198+
post=self.post,
199+
blocks=generated_blocks,
200+
)
201+
elif self.blocks is not None:
202+
return BlockchainTest.from_test(
203+
base_test=self,
204+
genesis_environment=self.env,
205+
pre=self.pre,
206+
post=self.post,
207+
blocks=self.blocks,
208+
)
209+
elif self.tx is not None:
210+
gas_limit = fork.transaction_gas_limit_cap() or self.gas_benchmark_value
211+
212+
transactions = self.split_transaction(self.tx, gas_limit)
213+
214+
blocks = [Block(txs=transactions)]
215+
216+
return BlockchainTest.from_test(
217+
base_test=self,
218+
pre=self.pre,
219+
post=self.post,
220+
blocks=blocks,
221+
genesis_environment=self.env,
222+
)
223+
else:
224+
raise ValueError("Cannot create BlockchainTest without transactions or blocks")
225+
226+
def generate(
227+
self,
228+
t8n: TransitionTool,
229+
fork: Fork,
230+
fixture_format: FixtureFormat,
231+
) -> BaseFixture:
232+
"""Generate the blockchain test fixture."""
233+
self.check_exception_test(exception=self.tx.error is not None if self.tx else False)
234+
if fixture_format in BlockchainTest.supported_fixture_formats:
235+
return self.generate_blockchain_test(fork=fork).generate(
236+
t8n=t8n, fork=fork, fixture_format=fixture_format
237+
)
238+
else:
239+
raise Exception(f"Unsupported fixture format: {fixture_format}")
240+
241+
def execute(
242+
self,
243+
*,
244+
fork: Fork,
245+
execute_format: ExecuteFormat,
246+
) -> BaseExecute:
247+
"""Execute the benchmark test by sending it to the live network."""
248+
if execute_format == TransactionPost:
249+
return TransactionPost(
250+
blocks=[[self.tx]],
251+
post=self.post,
252+
)
253+
raise Exception(f"Unsupported execute format: {execute_format}")
254+
255+
256+
BenchmarkTestSpec = Callable[[str], Generator[BenchmarkTest, None, None]]
257+
BenchmarkTestFiller = Type[BenchmarkTest]

0 commit comments

Comments
 (0)