Skip to content

Commit 98848df

Browse files
authored
feat(clis,filler): Add trace types to allow trace analysis and gas optimizations (#1979)
* feat(clis): Trace types * feat(specs): Add gas optimization to state tests * feat(filler): Add gas optimization flags * feat(command): src/cli/modify_static_test_gas_limits.py * docs: Document new feature * docs: Changelog * fix(cli): Improve `modify_static_test_gas_limits` comments * fix(clis,specs): Improve logging * feat(filler): Add `--optimize-gas-max-gas-limit` flag * docs: review suggestions * fix: tox
1 parent 5dc2e5d commit 98848df

File tree

14 files changed

+764
-45
lines changed

14 files changed

+764
-45
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Test fixtures for use by clients are available for each release on the [Github r
1313
#### `fill`
1414

1515
- 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)).
16+
- ✨ Added `--optimize-gas`, `--optimize-gas-output` and `--optimize-gas-post-processing` flags that allow to binary search the minimum gas limit value for a transaction in a test that still yields the same test result ([#1979](https://github.com/ethereum/execution-spec-tests/pull/1979)).
1617

1718
#### `consume`
1819

docs/navigation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* [Code Standards](writing_tests/code_standards.md)
2222
* [Exception Tests](writing_tests/exception_tests.md)
2323
* [Using and Extending Fork Methods](writing_tests/fork_methods.md)
24+
* [Gas Optimization](writing_tests/gas_optimization.md)
2425
* [Referencing an EIP Spec Version](writing_tests/reference_specification.md)
2526
* [EIP Checklist Generation](writing_tests/eip_checklist.md)
2627
* [Testing Checklist Templates](writing_tests/checklist_templates/index.md)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Gas Optimization
2+
3+
The `--optimize-gas` feature helps find the minimum gas limit required for transactions to execute correctly while maintaining the same execution trace and post-state. This is useful for creating more efficient test cases and understanding the exact gas requirements of specific operations.
4+
5+
## Basic Usage
6+
7+
Enable gas optimization for all tests:
8+
9+
```bash
10+
uv run fill --optimize-gas
11+
```
12+
13+
## Output Configuration
14+
15+
Specify a custom output file for gas optimization results:
16+
17+
```bash
18+
uv run fill --optimize-gas --optimize-gas-output=my_gas_results.json path/to/some/test/to/optimize
19+
```
20+
21+
## Post-Processing Mode
22+
23+
Enable post-processing to handle opcodes that put the current gas in the stack (like `GAS` opcode):
24+
25+
```bash
26+
uv run fill --optimize-gas --optimize-gas-post-processing
27+
```
28+
29+
### What Post-Processing Does
30+
31+
Post-processing mode is essential when your test transactions use the `GAS` opcode or other operations that push the current gas value onto the execution stack. Without post-processing, gas optimization would fail because:
32+
33+
1. **Gas Value in Stack**: The `GAS` opcode pushes the current gas value onto the stack
34+
2. **Different Gas Limits**: When optimizing gas, different gas limits result in different values being pushed by `GAS`
35+
3. **Trace Comparison Failure**: The execution traces would differ due to these different gas values in the stack, causing optimization to fail
36+
37+
### How Post-Processing Works
38+
39+
When `enable_post_processing=True` is passed to the `verify_modified_gas_limit` function:
40+
41+
1. **Gas Removal**: The system identifies traces where the previous operation was `GAS` and removes the gas value from the stack (`trace.stack[-1] = None`)
42+
2. **Trace Normalization**: This allows trace comparison to succeed even when different gas limits produce different gas values in the stack
43+
3. **Equivalent Execution**: The optimization can proceed because the traces are considered equivalent after removing gas-dependent stack values
44+
45+
### When to Use Post-Processing
46+
47+
Use `--optimize-gas-post-processing` when your tests:
48+
49+
- Use the `GAS` opcode to read current gas
50+
- Have contracts that push gas values onto the stack
51+
- Would otherwise fail gas optimization due to gas-dependent stack operations
52+
53+
Without post-processing, such tests would be considered "impossible to compare" and gas optimization would fail with an error.
54+
55+
### Safety Considerations
56+
57+
**⚠️ Important**: Post-processing mode is **not the default** for good reasons:
58+
59+
- **Guaranteed Equivalence**: Without post-processing, the test execution is guaranteed to be exactly the same as the original, ensuring complete behavioral equivalence
60+
- **Extra Care Required**: When using `--optimize-gas-post-processing`, extra care must be taken to verify that the optimized test still behaves correctly, as the post-processing modifies trace comparison logic
61+
- **Potential Risks**: The gas value removal from traces could potentially mask subtle differences in execution that might be important for test correctness
62+
- **Verification Needed**: Always thoroughly test the optimized results to ensure they maintain the intended behavior, especially for contracts that rely on gas values for logic
63+
64+
**Recommendation**: Only use post-processing mode when absolutely necessary (i.e., when tests fail without it due to `GAS` opcode usage), and always verify the optimized test results carefully.
65+
66+
## How It Works
67+
68+
The gas optimization algorithm uses a binary search approach:
69+
70+
1. **Initial Validation**: First tries reducing the gas limit by 1 to verify when even minimal changes affect the execution trace
71+
2. **Binary Search**: Uses binary search between 0 and the original gas limit to find the minimum viable gas limit
72+
3. **Verification**: For each candidate gas limit, it verifies:
73+
- Execution traces are equivalent (with optional post-processing)
74+
- Post-state allocation matches the expected result
75+
- Transaction validation passes
76+
- Account states remain consistent
77+
4. **Result**: Outputs the minimum gas limit that still produces correct execution
78+
79+
## Output Format
80+
81+
The optimization results are saved to a JSON file (default: `optimize-gas-output.json`) containing:
82+
83+
- Test identifiers as keys of the JSON object
84+
- Optimized gas limits in each value or `null` if the optimization failed.
85+
86+
## Use Cases
87+
88+
- **Test Efficiency**: Create tests with minimal gas requirements
89+
- **Gas Analysis**: Understand exact gas costs for specific operations
90+
- **Regression Testing**: Ensure gas optimizations don't break test correctness
91+
- **Performance Testing**: Benchmark gas usage across different scenarios
92+
93+
## Limitations
94+
95+
- Only works with state tests (not blockchain tests)
96+
- Requires trace collection to be enabled
97+
- May significantly increase test execution time due to multiple trial runs
98+
- Some tests may not be optimizable if they require the exact original gas limit
99+
100+
## Integration with Test Writing
101+
102+
When writing tests, you can use gas optimization to:
103+
104+
1. **Optimize Existing Tests**: Run `--optimize-gas` on your test suite to find more efficient gas limits
105+
2. **Validate Gas Requirements**: Ensure your tests use the minimum necessary gas
106+
3. **Create Efficient Test Cases**: Use the optimized gas limits in your test specifications
107+
4. **Benchmark Changes**: Compare gas usage before and after modifications
108+
109+
## Example Workflow
110+
111+
```bash
112+
# 1. Write your test
113+
# 2. Run with gas optimization
114+
uv run fill --optimize-gas --optimize-gas-output=optimization_results.json
115+
116+
# 3. Review the results
117+
cat optimization_results.json
118+
119+
# 4. Update your test with optimized gas limits if desired
120+
# 5. Re-run to verify correctness
121+
uv run fill
122+
```
123+
124+
## Best Practices
125+
126+
### Leave a Buffer for Future Forks
127+
128+
When using the optimized gas limits in your tests, it's recommended to add a small buffer (typically 5-10%) above the exact value outputted by the gas optimization. This accounts for potential gas cost changes in future Ethereum forks that might increase the gas requirements for the same operations.
129+
130+
For example, if the optimization outputs a gas limit of 100,000, consider using 105,000 or 110,000 in your test specification to ensure compatibility with future protocol changes.

docs/writing_tests/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ For help deciding which test format to select, see [Types of Tests](./types_of_t
2525
- [Adding a New Test](./adding_a_new_test.md) - Step-by-step guide to adding new tests
2626
- [Writing a New Test](./writing_a_new_test.md) - Detailed guide on writing different test types
2727
- [Using and Extending Fork Methods](./fork_methods.md) - How to use fork methods to write fork-adaptive tests
28+
- [Gas Optimization](./gas_optimization.md) - Optimize gas limits in your tests for efficiency and compatibility with future forks.
2829
- [Porting tests](./porting_legacy_tests.md): A guide to porting @ethereum/tests to EEST.
2930

3031
Please check that your code adheres to the repo's coding standards and read the other pages in this section for more background and an explanation of how to implement state transition and blockchain tests.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ fillerconvert = "cli.fillerconvert.fillerconvert:main"
106106
groupstats = "cli.show_pre_alloc_group_stats:main"
107107
extract_config = "cli.extract_config:extract_config"
108108
compare_fixtures = "cli.compare_fixtures:main"
109+
modify_static_test_gas_limits = "cli.modify_static_test_gas_limits:main"
109110

110111
[tool.setuptools.packages.find]
111112
where = ["src"]
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Command to scan and overwrite the static tests' gas limits to new optimized value given in the
3+
input file.
4+
"""
5+
6+
import json
7+
import re
8+
from pathlib import Path
9+
from typing import Dict, List, Set
10+
11+
import click
12+
import yaml
13+
14+
from ethereum_test_base_types import EthereumTestRootModel, HexNumber, ZeroPaddedHexNumber
15+
from ethereum_test_specs import StateStaticTest
16+
from pytest_plugins.filler.static_filler import NoIntResolver
17+
18+
19+
class GasLimitDict(EthereumTestRootModel):
20+
"""Formatted JSON file with new gas limits in each test."""
21+
22+
root: Dict[str, int | None]
23+
24+
def unique_files(self) -> Set[Path]:
25+
"""Return a list of unique test files."""
26+
files = set()
27+
for test in self.root:
28+
filename, _ = test.split("::")
29+
files.add(Path(filename))
30+
return files
31+
32+
def get_tests_by_file_path(self, file: Path | str) -> Set[str]:
33+
"""Return a list of all tests that belong to a given file path."""
34+
tests = set()
35+
for test in self.root:
36+
current_file, _ = test.split("::")
37+
if current_file == str(file):
38+
tests.add(test)
39+
return tests
40+
41+
42+
class StaticTestFile(EthereumTestRootModel):
43+
"""A static test file."""
44+
45+
root: Dict[str, StateStaticTest]
46+
47+
48+
def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: bool, verbose: bool):
49+
"""Perform some checks on the fixtures contained in the specified directory."""
50+
# Load the test dictionary from the input JSON file
51+
test_dict = GasLimitDict.model_validate_json(input_path.read_text())
52+
53+
# Iterate through each unique test file that needs modification
54+
for test_file in test_dict.unique_files():
55+
tests = test_dict.get_tests_by_file_path(test_file)
56+
test_file_contents = test_file.read_text()
57+
58+
# Parse the test file based on its format (YAML or JSON)
59+
if test_file.suffix == ".yml" or test_file.suffix == ".yaml":
60+
loaded_yaml = yaml.load(test_file.read_text(), Loader=NoIntResolver)
61+
try:
62+
parsed_test_file = StaticTestFile.model_validate(loaded_yaml)
63+
except Exception as e:
64+
raise Exception(
65+
f"Unable to parse file {test_file}: {json.dumps(loaded_yaml, indent=2)}"
66+
) from e
67+
else:
68+
parsed_test_file = StaticTestFile.model_validate_json(test_file_contents)
69+
70+
# Validate that the file contains exactly one test
71+
assert len(parsed_test_file.root) == 1, f"File {test_file} contains more than one test."
72+
_, parsed_test = parsed_test_file.root.popitem()
73+
74+
# Skip files with multiple gas limit values
75+
if len(parsed_test.transaction.gas_limit) != 1:
76+
if dry_run or verbose:
77+
print(
78+
f"Test file {test_file} contains more than one test (after parsing), skipping."
79+
)
80+
continue
81+
82+
# Get the current gas limit and check if modification is needed
83+
current_gas_limit = int(parsed_test.transaction.gas_limit[0])
84+
if max_gas_limit is not None and current_gas_limit <= max_gas_limit:
85+
# Nothing to do, finished
86+
for test in tests:
87+
test_dict.root.pop(test)
88+
continue
89+
90+
# Collect valid gas values for this test file
91+
gas_values: List[int] = []
92+
for gas_value in [test_dict.root[test] for test in tests]:
93+
if gas_value is None:
94+
if dry_run or verbose:
95+
print(
96+
f"Test file {test_file} contains at least one test that cannot "
97+
"be updated, skipping."
98+
)
99+
continue
100+
else:
101+
gas_values.append(gas_value)
102+
103+
# Calculate the new gas limit (rounded up to nearest 100,000)
104+
new_gas_limit = max(gas_values)
105+
modified_new_gas_limit = ((new_gas_limit // 100000) + 1) * 100000
106+
if verbose:
107+
print(
108+
f"Changing exact new gas limit ({new_gas_limit}) to "
109+
f"rounded ({modified_new_gas_limit})"
110+
)
111+
new_gas_limit = modified_new_gas_limit
112+
113+
# Check if the new gas limit exceeds the maximum allowed
114+
if max_gas_limit is not None and new_gas_limit > max_gas_limit:
115+
if dry_run or verbose:
116+
print(f"New gas limit ({new_gas_limit}) exceeds max ({max_gas_limit})")
117+
continue
118+
119+
if dry_run or verbose:
120+
print(f"Test file {test_file} requires modification ({new_gas_limit})")
121+
122+
# Find the appropriate pattern to replace the current gas limit
123+
potential_types = [int, HexNumber, ZeroPaddedHexNumber]
124+
substitute_pattern = None
125+
substitute_string = None
126+
127+
attempted_patterns = []
128+
129+
for current_type in potential_types:
130+
potential_substitute_pattern = rf"\b{current_type(current_gas_limit)}\b"
131+
potential_substitute_string = f"{current_type(new_gas_limit)}"
132+
if (
133+
re.search(
134+
potential_substitute_pattern, test_file_contents, flags=re.RegexFlag.MULTILINE
135+
)
136+
is not None
137+
):
138+
substitute_pattern = potential_substitute_pattern
139+
substitute_string = potential_substitute_string
140+
break
141+
142+
attempted_patterns.append(potential_substitute_pattern)
143+
144+
# Validate that a replacement pattern was found
145+
assert substitute_pattern is not None, (
146+
f"Current gas limit ({attempted_patterns}) not found in {test_file}"
147+
)
148+
assert substitute_string is not None
149+
150+
# Perform the replacement in the test file content
151+
new_test_file_contents = re.sub(substitute_pattern, substitute_string, test_file_contents)
152+
153+
assert test_file_contents != new_test_file_contents, "Could not modify test file"
154+
155+
# Skip writing changes if this is a dry run
156+
if dry_run:
157+
continue
158+
159+
# Write the modified content back to the test file
160+
test_file.write_text(new_test_file_contents)
161+
for test in tests:
162+
test_dict.root.pop(test)
163+
164+
if dry_run:
165+
return
166+
167+
# Write changes to the input file
168+
input_path.write_text(test_dict.model_dump_json(indent=2))
169+
170+
171+
MAX_GAS_LIMIT = 16_777_216
172+
173+
174+
@click.command()
175+
@click.option(
176+
"--input",
177+
"-i",
178+
"input_str",
179+
type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True),
180+
required=True,
181+
help="The input json file or directory containing json listing the new gas limits for the "
182+
"static test files files.",
183+
)
184+
@click.option(
185+
"--max-gas-limit",
186+
default=MAX_GAS_LIMIT,
187+
expose_value=True,
188+
help="Gas limit that triggers a test modification, and also the maximum value that a test "
189+
"should have after modification.",
190+
)
191+
@click.option(
192+
"--dry-run",
193+
"-d",
194+
"dry_run",
195+
is_flag=True,
196+
default=False,
197+
expose_value=True,
198+
help="Don't modify any files, simply print operations to be performed.",
199+
)
200+
@click.option(
201+
"--verbose",
202+
"-v",
203+
"verbose",
204+
is_flag=True,
205+
default=False,
206+
expose_value=True,
207+
help="Print extra information.",
208+
)
209+
def main(input_str: str, max_gas_limit, dry_run: bool, verbose: bool):
210+
"""Perform some checks on the fixtures contained in the specified directory."""
211+
input_path = Path(input_str)
212+
if not dry_run:
213+
# Always dry-run first before actually modifying
214+
_check_fixtures(
215+
input_path=input_path,
216+
max_gas_limit=max_gas_limit,
217+
dry_run=True,
218+
verbose=False,
219+
)
220+
_check_fixtures(
221+
input_path=input_path,
222+
max_gas_limit=max_gas_limit,
223+
dry_run=dry_run,
224+
verbose=verbose,
225+
)

0 commit comments

Comments
 (0)