Skip to content

feat(fill-stateful): add --extract-opcode-count opcode tracing#3124

Open
skylenet wants to merge 4 commits into
ethereum:forks/amsterdamfrom
skylenet:opcode-filing
Open

feat(fill-stateful): add --extract-opcode-count opcode tracing#3124
skylenet wants to merge 4 commits into
ethereum:forks/amsterdamfrom
skylenet:opcode-filing

Conversation

@skylenet

@skylenet skylenet commented Jul 7, 2026

Copy link
Copy Markdown
Member

🗒️ Description

Adds an opt-in --extract-opcode-count flag to fill-stateful.

When set, each execution-phase block built against the live client is traced via debug_traceBlockByHash using a small JS opcode-counting tracer, and the aggregated per-opcode execution counts are recorded in the fixture's _info.metadata.opcode_count — matching what the standard fill benchmark path already emits (and what diff_opcode_counts.py reads).

Details:

  • DebugRPC.trace_block_by_hash wraps debug_traceBlockByHash.
  • ClientBackend gains optional debug_rpc + extract_opcode_count ctor params, a JS tracer constant, extract_block_opcode_count() (aggregates per-tx opcode counts across a block), and a real reset_opcode_count() that zeroes the per-test tally (previously a no-op).
  • make_stateful_fixture traces execution-phase blocks only (setup blocks are skipped — no wasted trace) and accumulates onto the backend, which the existing filler plumbing serializes into _info.metadata.opcode_count. No filler/fixture-model changes needed.
  • fill_stateful plugin: the --extract-opcode-count flag, debug_rpc wired into the backend, and a per-test reset_opcode_count() in the t8n override.
  • Docs updated in docs/filling_tests/fill_stateful.md.

The flag is fully opt-in: with it off, no trace RPCs are issued and opcode_count stays None, so the filler omits the metadata key exactly as before. It requires the client to expose the debug namespace with JS tracer support; the tracer re-executes each block, so it is slow and intended for analysis runs.

Opcode-count verification: when a benchmark test declares a target opcode count (fixed_opcode_count/expected_opcode_count) and --extract-opcode-count is on, the live-client trace is verified against that target and the fill fails on >5% divergence — surfacing benchmarks that don't execute what the author expected. With the flag off, benchmark_opcode_count stays None and verification is skipped, exactly as before.

Robustness:

  • Non-fatal extraction: a debug_traceBlockByHash RPC error is logged and skipped rather than aborting the fill.
  • Opcode-name normalization: geth emits opcode 0xNN not defined for undefined opcodes, which OpcodeCount could not parse; such names are reduced to the bare 0xNN (UndefinedOpcode), and anything still unrecognized is dropped with a warning.

just static (mypy, ruff, vulture, ethereum-spec-lint, actionlint, codespell) passes.

🔗 Related Issues or PRs

N/A.

✅ Checklist

  • All: Ran fast static checks to avoid unnecessary CI fails, see also Code Standards and Verifying Changes:
    just static
  • All: PR title have the form <type>(<area>):, where <type> and <area> come from an approrpriate C-<type>, respectively A-<area>, label. The title should match the a target squash commit message.
  • All: Considered updating the online docs in the ./docs/ directory.
  • All: Set appropriate labels for the changes (only maintainers can apply labels).

Cute Animal Picture

cute cat

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.
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.30%. Comparing base (40172e5) to head (439bccd).
⚠️ Report is 6 commits behind head on forks/amsterdam.

Additional details and impacted files
@@                 Coverage Diff                 @@
##           forks/amsterdam    #3124      +/-   ##
===================================================
- Coverage            93.30%   93.30%   -0.01%     
===================================================
  Files                  624      624              
  Lines                36986    36986              
  Branches              3383     3383              
===================================================
- Hits                 34511    34508       -3     
- Misses                1693     1695       +2     
- Partials               782      783       +1     
Flag Coverage Δ
unittests 93.30% <ø> (-0.01%) ⬇️

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.

skylenet added 2 commits July 7, 2026 12:38
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.
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.
skylenet added a commit to ethpandaops/benchmarkoor that referenced this pull request Jul 7, 2026
## What

Adds an opt-in `extract_opcode_count` option to eest-payloads targets.
When enabled, benchmarkoor passes `--extract-opcode-count` to
`fill-stateful`, which traces each execution-phase block
(`debug_traceBlockByHash` + a JS opcode-counting tracer) and records
per-opcode execution counts in each fixture's
`_info.metadata.opcode_count`.

## Depends on

**ethereum/execution-specs#3124 — the
`fill-stateful --extract-opcode-count` flag lives there. This
benchmarkoor change is just the passthrough; it's **inert unless
enabled**, and only functions when `eest_repo`/`eest_ref` point at a
`fill-stateful` that carries the flag (i.e. once #3124 lands, or a
fork/branch with it).

## Changes

- `pkg/config`: `extract_opcode_count` (`*bool`) on `EESTPayloadTarget`
+ `EESTPayloadDefaults`, hoistable to `config:` and inherited via
`ResolveTarget`.
- `pkg/builder`: `buildFillArgs` appends `--extract-opcode-count` when
enabled.
- Example config: a commented note documenting the option + its
requirements.
- Tests: `buildFillArgs` cases (enabled/disabled/absent-by-default) and
`ResolveTarget` inheritance/override coverage.

## Requirements / caveats

- Filler must support the `debug` namespace + JS tracers — **geth**
works (besu/nethermind/reth/ethrex don't expose geth-style JS tracers).
- **Slow**: a full re-execution trace per block. Opt-in for that reason.

## Validation

Ran a real geth build against a branch carrying #3124
(`skylenet/execution-specs @ opcode-filing`), filling the bn128 compute
subset. The fill argv included `--extract-opcode-count`, the build
succeeded, and **all 36 filled tests** got a non-empty `opcode_count`.
Confirmed against the written fixture on disk, e.g.:

```
tests/benchmark/compute/precompile/test_alt_bn128.py::test_bn128_pairings_amortized[fork_Osaka-...-value_10M]
  _info.metadata.opcode_count = {
    "CALLDATASIZE":11,"PUSH1":42,"CALLDATACOPY":1,"JUMPDEST":1,"GAS":10,"STATICCALL":10,"POP":9
  }
```

(Values are sensible: the BN128 pairing work runs inside the precompile,
invoked via `STATICCALL` to `0x08` — counted 10× — so the traced EVM
opcodes are the calling harness.)

`go build`/`go test` green (except the pre-existing macOS-only
`/proc/mounts` `TestValidateDataDirMethods_SchelkBinary`); golangci-lint
`--new-from-rev=origin/master` 0 issues.
@jochem-brouwer

jochem-brouwer commented Jul 7, 2026

Copy link
Copy Markdown
Member

EDIT/NOTE: written with Claude, reflects my thoughts.

Preliminary comment — this feature is definitely something we want.

One concern: debug_traceBlockByHash is in execution-apis, but custom JS tracers are not — they're a client-specific extension with unspecified output. That's fine today since the trace only runs at fill time against geth, but it locks opcode extraction to JS-tracer-capable clients, and _normalize_opcode_name() already shows the next-fork fragility (geth emitting "opcode 0xNN not defined" for unknown opcodes).

Longer term: since we already have testing_buildBlock / testing_commitBlock, a testing_ method returning opcode counts directly would remove the JS-tracer dependency entirely — and it's much cheaper than standardizing full trace output.

Question: is the motivation parity with the standard fill benchmark path (diff_opcode_counts.py), or also feeding Nethermind's gas-benchmarks flow?

Not blocking — for Glamsterdam we can't wait for a standardized endpoint, so this is the right pragmatic step. Will re-visit this week 👍 😄

@skylenet

skylenet commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

@jochem-brouwer the motivation from my side is mainly getting the opcode counts to be able to show them on benchmarkoor. But the good side effect is that we can also detect opcode divergence early.

The feature is behind a flag and not enabled by default so it shouldn't interfere with existing tooling.

I agree that maybe some custom testing API endpoint would be the best/fastest, but for now I think it's fine if we do the following:

1 ) Extract via JS tracer. Small response body. Works with nethermind, geth.

2 ) Fallback to a counting them from the structLogs. Big response body, but should work with other clients that don't support JS tracers, e.g. Besu.

I'm working now on testing the fallback approach.

…+ 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.
@skylenet

skylenet commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

So I've got this working with geth, nethermind (using JS tracer) and besu (using the fallback)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-feat Category: an improvement or new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants