Skip to content

Commit e6382bc

Browse files
feat(test-cli): add --extract-opcode-count opcode tracing (#3124)
* feat(fill-stateful): add --extract-opcode-count opcode tracing Trace each execution-phase block via debug_traceBlockByHash with a JS opcode-counting tracer and record per-opcode execution counts in the fixture's _info.metadata.opcode_count, matching the standard fill benchmark path. - DebugRPC.trace_block_by_hash wraps debug_traceBlockByHash. - ClientBackend gains debug_rpc + extract_opcode_count; a JS tracer constant; extract_block_opcode_count() aggregating per-tx opcode counts; reset_opcode_count() now zeroes the per-test tally. - make_stateful_fixture traces execution-phase blocks only (setup blocks skipped) and accumulates onto the backend, which the filler already serializes into _info.metadata.opcode_count. - fill_stateful plugin: --extract-opcode-count flag, debug_rpc wired into the backend, per-test reset_opcode_count in the t8n override. * fix(fill-stateful): harden opcode extraction; decouple from verification Address review of the --extract-opcode-count feature: - Decouple metadata from benchmark verification: only accumulate onto the metadata opcode_count, never set benchmark_opcode_count. The live-client trace is recorded for analysis; it no longer activates target-opcode verification (which stays a t8n-path concern and could otherwise fail benchmark tests on >5% trace divergence). - Make extract_block_opcode_count non-fatal: a debug_traceBlockByHash RPC error is logged and skipped instead of aborting the fill. - Normalize opcode names: geth emits 'opcode 0xNN not defined' for undefined opcodes, which OpcodeCount could not parse and would raise on. Reduce such names to the bare 0xNN (UndefinedOpcode); drop any still-unrecognized name with a warning. * feat(fill-stateful): verify opcode counts against declared targets With --extract-opcode-count on, feed the live-client traced count to benchmark opcode verification: a test declaring fixed/expected_opcode_ count now fails the fill when its live-client count diverges >5% from the target. Stays None (verification skipped) when the flag is off. * feat(fill-stateful): opcode counting across clients (normalize names + struct-log fallback) - Normalize tracer opcode names case-insensitively so clients emitting non-canonical names (nethermind returns calldatasize / pusH1) are counted instead of dropped as unrecognized. - Fall back to the universally-supported struct-log tracer (disableStack/Memory/Storage, counting structLogs[].op) when a client's JS tracer is unavailable — erroring or empty — so besu (no JS tracer) is covered. Cached per client to avoid re-probing the JS tracer every block. - Update the --extract-opcode-count help text accordingly. Verified against geth (JS), nethermind (JS + normalize) and besu (struct-log fallback): all three produce identical opcode counts. * feat(fill-stateful): record per-payload opcode counts as opcode_counts array Replace the single aggregated _info.metadata.opcode_count with _info.metadata.opcode_counts — an array with one entry per engineNewPayloads block (opcode_counts[i] is the count for engineNewPayloads[i], or null if its trace was unavailable), so multi-block benchmarks keep per-payload granularity. The list is emitted via FillResult.metadata (only when --extract-opcode-count is on); reset_opcode_count is now a no-op so the filler no longer writes the singular opcode_count for stateful fixtures. * refactor: add fixture and reduce comment * refactor: opcode count trace logic * refactor: error handling for rpc call --------- Co-authored-by: LouisTsai <q1030176@gmail.com>
1 parent 56e615e commit e6382bc

5 files changed

Lines changed: 200 additions & 14 deletions

File tree

docs/filling_tests/fill_stateful.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The target client must expose:
1313

1414
- `testing` (`testing_buildBlockV1`) — block construction with explicit transaction ordering.
1515
- `engine``engine_newPayloadVX`, `engine_forkchoiceUpdatedVX`.
16-
- `eth`, `debug` — chain queries and `debug_setHead` (or `debug_resetHead` on clients like Nethermind that lack `debug_setHead`) for between-test rewind.
16+
- `eth`, `debug` — chain queries and `debug_setHead` (or `debug_resetHead` on clients like Nethermind that lack `debug_setHead`) for between-test rewind. `--extract-opcode-count` additionally needs `debug_traceBlockByHash` with JS tracer support.
1717
- `web3` (optional) — `web3_clientVersion` is recorded into the fixture's `_info.filling-transition-tool` for traceability.
1818

1919
The production-ready filler is `ethpandaops/geth:master`.
@@ -122,6 +122,7 @@ Optional:
122122
- `--default-{gas-price,max-fee-per-gas,max-priority-fee-per-gas,max-fee-per-blob-gas}` — pin per-session fees; defaults bump live-query values by `1.5×`.
123123
- `--output PATH` — default `./fixtures`.
124124
- `--clean` — wipe the output dir before filling.
125+
- `--extract-opcode-count` — after building each block, trace it via `debug_traceBlockByHash` (a JS opcode-counting tracer) and record per-opcode execution counts (execution-phase blocks only) in the fixture's `_info.metadata.opcode_counts` — an array with one entry per `engineNewPayloads` block (`opcode_counts[i]` is the count for `engineNewPayloads[i]`, or `null` if its trace was unavailable), so multi-block benchmarks keep per-payload granularity. When a benchmark test declares a target opcode count (`fixed_opcode_count`/`expected_opcode_count`), the live-client count is verified against it and the fill fails on >5% divergence. Requires the `debug` namespace with JS tracer support. Adds a full re-execution trace per block, so it is slow and opt-in.
125126

126127
## Output layout
127128

@@ -135,7 +136,7 @@ Optional:
135136
└── <test>.json # per-test setup + execution payloads
136137
```
137138

138-
Each `pre_run/<start_block_hash>.json` (a `StatefulPreRunFixture`) is replayed once per `benchmarkoor` run. Per-test fixtures (`BlockchainEngineStatefulFixture`) reference their setup file by hash: a fixture with `startBlockHash = 0xabc...` is preceded by `pre_run/0xabc....json`. Each per-test fixture carries `snapshotBlockNumber`/`Hash`, `startBlockNumber`/`Hash`, `setupEngineNewPayloads`, `engineNewPayloads`, plus a `benchmarkGasUsed` field and the EL build in `_info.filling-transition-tool`. The hash-based filename leaves room for multiple pre-run files (e.g. different setup variants off one snapshot) without coordinating names.
139+
Each `pre_run/<start_block_hash>.json` (a `StatefulPreRunFixture`) is replayed once per `benchmarkoor` run. Per-test fixtures (`BlockchainEngineStatefulFixture`) reference their setup file by hash: a fixture with `startBlockHash = 0xabc...` is preceded by `pre_run/0xabc....json`. Each per-test fixture carries `snapshotBlockNumber`/`Hash`, `startBlockNumber`/`Hash`, `setupEngineNewPayloads`, `engineNewPayloads`, plus a `benchmarkGasUsed` field and the EL build in `_info.filling-transition-tool`. With `--extract-opcode-count`, it also carries `_info.metadata.opcode_counts` (an array of per-opcode execution counts, one entry per `engineNewPayloads` block). The hash-based filename leaves room for multiple pre-run files (e.g. different setup variants off one snapshot) without coordinating names.
139140

140141
!!! warning "Snapshot anchoring"
141142
`--snapshot-block` accepts a hash on purpose. Anchoring to `latest` works against a quiescent client, but a live reorg between session start and fixture write would silently re-anchor the fixture to a different block. The hash form rejects that.

packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ def pytest_addoption(parser: pytest.Parser) -> None:
132132
"by hash. The produced fixtures are anchored by hash regardless."
133133
),
134134
)
135+
group.addoption(
136+
"--extract-opcode-count",
137+
action="store_true",
138+
dest="extract_opcode_count",
139+
default=False,
140+
help=(
141+
"Trace each built block via debug_traceBlockByHash and record "
142+
"per-opcode execution counts in the fixture's "
143+
"_info.metadata.opcode_counts. Uses a client-side JS tracer where "
144+
"supported (geth/nethermind/erigon/reth) and falls back to the "
145+
"struct-log tracer otherwise (besu). Requires the `debug` "
146+
"namespace. Adds a full re-execution trace per block — slow; "
147+
"opt-in."
148+
),
149+
)
135150

136151

137152
def _resolve_session_fork(
@@ -475,9 +490,17 @@ def debug_rpc(eth_rpc: EthRPC) -> DebugRPC:
475490
return DebugRPC(eth_rpc.url)
476491

477492

493+
@pytest.fixture(scope="session")
494+
def extract_opcode_count(request: pytest.FixtureRequest) -> bool:
495+
"""Whether --extract-opcode-count block tracing is enabled."""
496+
return request.config.getoption("extract_opcode_count")
497+
498+
478499
@pytest.fixture(scope="session")
479500
def client_backend(
480501
eth_rpc: ChainBuilderEthRPC,
502+
debug_rpc: DebugRPC,
503+
extract_opcode_count: bool,
481504
session_fork: Fork | TransitionFork,
482505
default_gas_price: int | None,
483506
default_max_fee_per_gas: int | None,
@@ -501,6 +524,8 @@ def client_backend(
501524
engine_rpc=eth_rpc.engine_rpc,
502525
eth_rpc=eth_rpc,
503526
fork=session_fork,
527+
debug_rpc=debug_rpc,
528+
extract_opcode_count=extract_opcode_count,
504529
)
505530

506531
priority_fee = default_max_priority_fee_per_gas
@@ -719,7 +744,8 @@ def session_t8n(
719744
def t8n(
720745
session_t8n: ClientBackend,
721746
) -> Generator[ClientBackend, None, None]:
722-
"""Override: no per-test reset needed for ClientBackend."""
747+
"""Override: zero per-test opcode counts (no-op unless enabled)."""
748+
session_t8n.reset_opcode_count()
723749
yield session_t8n
724750

725751

packages/testing/src/execution_testing/client_clis/client_backend.py

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,25 @@
1111
info, block boundaries, and pre-alloc declarations flow unchanged.
1212
"""
1313

14+
import re
1415
from pathlib import Path
1516
from typing import Any, ClassVar, Dict, List, Optional
1617

1718
from execution_testing.base_types import Bytes, Hash
1819
from execution_testing.exceptions import ExceptionBase, ExceptionMapper
1920
from execution_testing.forks import Fork, TransitionFork
20-
from execution_testing.rpc import EngineRPC, EthRPC, TestingRPC, Web3RPC
21+
from execution_testing.logging import get_logger
22+
from execution_testing.rpc import (
23+
DebugRPC,
24+
EngineRPC,
25+
EthRPC,
26+
TestingRPC,
27+
Web3RPC,
28+
)
2129
from execution_testing.rpc.rpc_types import (
2230
ForkchoiceState,
2331
GetPayloadResponse,
32+
JSONRPCError,
2433
PayloadAttributes,
2534
PayloadStatusEnum,
2635
)
@@ -35,9 +44,91 @@
3544
Result,
3645
Traces,
3746
TransitionToolOutput,
47+
validate_opcode,
3848
)
3949
from .transition_tool import TransitionTool
4050

51+
logger = get_logger(__name__)
52+
53+
# Per-tx opcode tally; clients ship no built-in opcode-count tracer.
54+
OPCODE_COUNT_TRACER_JS = """{
55+
counts: {},
56+
step: function(log) {
57+
var op = log.op.toString();
58+
this.counts[op] = (this.counts[op] || 0) + 1;
59+
},
60+
fault: function() {},
61+
result: function() { return this.counts; }
62+
}"""
63+
64+
# Keep each struct-log step small.
65+
STRUCT_LOG_TRACER_CONFIG = {
66+
"disableStack": True,
67+
"disableMemory": True,
68+
"disableStorage": True,
69+
}
70+
71+
72+
def _normalize_opcode_name(name: str) -> str | None:
73+
"""
74+
Map a tracer opcode name to one ``OpcodeCount`` accepts.
75+
76+
Handles non-canonical casing (nethermind) and geth's
77+
``"opcode 0xNN not defined"``; unrecognized names are dropped.
78+
"""
79+
for candidate in (name, name.upper()):
80+
try:
81+
validate_opcode(candidate)
82+
return candidate
83+
except Exception:
84+
continue
85+
match = re.search(r"0x[0-9a-fA-F]+", name)
86+
if match is not None:
87+
return match.group(0)
88+
logger.warning(f"opcode trace: dropping unrecognized {name!r}")
89+
return None
90+
91+
92+
def _opcode_count_from_js_tracer(traces: Any) -> OpcodeCount:
93+
"""Aggregate the per-tx ``{opcode: count}`` maps the JS tracer emits."""
94+
counts: Dict[str, int] = {}
95+
for entry in traces or []:
96+
if not isinstance(entry, dict):
97+
continue
98+
tx_counts = entry.get("result")
99+
# Clients that ignore the JS tracer echo struct logs.
100+
if not isinstance(tx_counts, dict) or "structLogs" in tx_counts:
101+
continue
102+
for opcode, count in tx_counts.items():
103+
key = _normalize_opcode_name(opcode)
104+
if key is None or not isinstance(count, int):
105+
continue
106+
counts[key] = counts.get(key, 0) + count
107+
return OpcodeCount.model_validate(counts)
108+
109+
110+
def _opcode_count_from_struct_logs(traces: Any) -> OpcodeCount:
111+
"""Count ``structLogs[].op`` entries, one per executed opcode."""
112+
counts: Dict[str, int] = {}
113+
for entry in traces or []:
114+
if not isinstance(entry, dict):
115+
continue
116+
# Some clients return the struct-log result unwrapped.
117+
result = entry.get("result")
118+
if not isinstance(result, dict):
119+
result = entry
120+
for step in result.get("structLogs") or []:
121+
if not isinstance(step, dict):
122+
continue
123+
op = step.get("op")
124+
if not op:
125+
continue
126+
key = _normalize_opcode_name(op)
127+
if key is None:
128+
continue
129+
counts[key] = counts.get(key, 0) + 1
130+
return OpcodeCount.model_validate(counts)
131+
41132

42133
class ClientBackendExceptionMapper(ExceptionMapper):
43134
"""
@@ -89,12 +180,18 @@ def __init__(
89180
engine_rpc: EngineRPC,
90181
eth_rpc: EthRPC,
91182
fork: Fork | TransitionFork,
183+
debug_rpc: DebugRPC | None = None,
184+
extract_opcode_count: bool = False,
92185
) -> None:
93186
"""Initialize with the RPC clients and the session fork."""
94187
self.testing_rpc = testing_rpc
95188
self.engine_rpc = engine_rpc
96189
self.eth_rpc = eth_rpc
97190
self.fork = fork
191+
self.debug_rpc = debug_rpc
192+
self.extract_opcode_count = extract_opcode_count
193+
# Sticky fallback to struct logs (besu has no JS tracer).
194+
self._js_tracer_unsupported = False
98195
self.exception_mapper = ClientBackendExceptionMapper()
99196
self.snapshot_block = None
100197
self.start_block = None
@@ -126,7 +223,7 @@ def reset_traces(self) -> None:
126223
return
127224

128225
def reset_opcode_count(self) -> None:
129-
"""No-op — opcode counting not supported."""
226+
"""No-op — ``opcode_count`` stays ``None`` so the filler skips it."""
130227
return
131228

132229
def set_cache(self, *, key: str) -> bool:
@@ -209,6 +306,45 @@ def evaluate(
209306
),
210307
)
211308

309+
def extract_block_opcode_count(
310+
self, block_hash: Hash
311+
) -> OpcodeCount | None:
312+
"""
313+
Tally executed opcodes for a block via ``debug_traceBlockByHash``.
314+
315+
``None`` when ``--extract-opcode-count`` is off or the trace
316+
fails (logged, never fatal). Prefers the JS tracer; a client
317+
that rejects it falls back to struct logs for the session,
318+
while transient errors only skip the block.
319+
"""
320+
if not self.extract_opcode_count or self.debug_rpc is None:
321+
return None
322+
323+
try:
324+
if not self._js_tracer_unsupported:
325+
try:
326+
traces = self._trace_block(
327+
block_hash, {"tracer": OPCODE_COUNT_TRACER_JS}
328+
)
329+
return _opcode_count_from_js_tracer(traces)
330+
except JSONRPCError as e:
331+
logger.info(f"JS tracer rejected ({e}); using struct logs")
332+
self._js_tracer_unsupported = True
333+
traces = self._trace_block(block_hash, STRUCT_LOG_TRACER_CONFIG)
334+
return _opcode_count_from_struct_logs(traces)
335+
except Exception as e:
336+
logger.warning(f"opcode trace failed for block {block_hash}: {e}")
337+
return None
338+
339+
def _trace_block(
340+
self, block_hash: Hash, tracer_config: Dict[str, Any]
341+
) -> Any:
342+
"""Raw ``debug_traceBlockByHash`` call; exceptions propagate."""
343+
assert self.debug_rpc is not None
344+
return self.debug_rpc.trace_block_by_hash(
345+
str(block_hash), tracer_config
346+
)
347+
212348
def _payload_attributes(
213349
self,
214350
env: Any,

packages/testing/src/execution_testing/rpc/rpc.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,15 @@ def trace_call(self, tr: dict[str, str], block_number: str) -> Any | None:
13011301
request=RPCCall(method="traceCall", params=params)
13021302
).result_or_raise()
13031303

1304+
def trace_block_by_hash(
1305+
self, block_hash: str, tracer_config: dict[str, Any]
1306+
) -> Any:
1307+
"""`debug_traceBlockByHash`: Trace every transaction in a block."""
1308+
params = [block_hash, tracer_config]
1309+
return self.post_request(
1310+
request=RPCCall(method="traceBlockByHash", params=params)
1311+
).result_or_raise()
1312+
13041313
def set_head(self, block_number: str) -> None:
13051314
"""`debug_setHead`: Reset chain head to the given block number."""
13061315
self.post_request(

packages/testing/src/execution_testing/specs/blockchain.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,8 @@ def make_stateful_fixture(
14651465

14661466
setup_payloads: List[FixtureEngineNewPayload] = []
14671467
execution_payloads: List[FixtureEngineNewPayload] = []
1468+
# Aligned 1:1 with execution_payloads; None when no trace.
1469+
execution_opcode_counts: List[Dict[str, int] | None] = []
14681470
head_hash = start_block_hash
14691471
benchmark_gas_used: int | None = None
14701472
benchmark_opcode_count: OpcodeCount | None = None
@@ -1485,21 +1487,29 @@ def make_stateful_fixture(
14851487
payload = payload_metadata_to_fixture(
14861488
built_block.engine_payload, phase=block.phase
14871489
)
1490+
# The client's authoritative block hash (the FixtureHeader RLP
1491+
# hash diverges — the client picks fields like gas_limit).
1492+
client_hash = Hash(
1493+
built_block.engine_payload.payload_response.execution_payload.block_hash
1494+
)
14881495
if payload.phase == TestPhase.SETUP:
14891496
setup_payloads.append(payload)
14901497
else:
14911498
execution_payloads.append(payload)
1499+
block_opcode_count = t8n.extract_block_opcode_count(
1500+
client_hash
1501+
)
1502+
execution_opcode_counts.append(
1503+
block_opcode_count.model_dump()
1504+
if block_opcode_count is not None
1505+
else None
1506+
)
14921507
if self.operation_mode == OpMode.BENCHMARKING:
14931508
benchmark_gas_used = int(built_block.result.gas_used)
1494-
benchmark_opcode_count = built_block.result.opcode_count
1495-
# Overwrite the block_hash apply_new_parent just recorded —
1496-
# it's the FixtureHeader-recomputed RLP hash, which diverges
1497-
# from the client's authoritative hash (client picks fields
1498-
# like gas_limit). The next block's parent_hash must point at
1499-
# what the client actually built.
1500-
client_hash = Hash(
1501-
built_block.engine_payload.payload_response.execution_payload.block_hash
1502-
)
1509+
# Consumed by BenchmarkTest's opcode-count verification.
1510+
benchmark_opcode_count = block_opcode_count
1511+
# apply_new_parent records the RLP hash; the next block's
1512+
# parent_hash must point at what the client actually built.
15031513
env = apply_new_parent(built_block.env, built_block.header)
15041514
env = env.copy(
15051515
block_hashes={
@@ -1525,11 +1535,15 @@ def make_stateful_fixture(
15251535
else None
15261536
),
15271537
)
1538+
metadata: Dict[str, Any] = {}
1539+
if t8n.extract_opcode_count:
1540+
metadata["opcode_counts"] = execution_opcode_counts
15281541
return FillResult(
15291542
fixture=fixture,
15301543
gas_optimization=None,
15311544
benchmark_gas_used=benchmark_gas_used,
15321545
benchmark_opcode_count=benchmark_opcode_count,
1546+
metadata=metadata,
15331547
post_verifications=PostVerifications.from_alloc(self.post),
15341548
)
15351549

0 commit comments

Comments
 (0)