diff --git a/AGENTS.md b/AGENTS.md
index 4bc1538058..cafbe986e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,7 @@ progressively the deeper you go. Existing package guides include:
- `evmrpc/AGENTS.md` — EVM JSON-RPC (`eth_*`, `sei_*`, `sei2_*`, `debug_*`) semantics
- `x/evm/AGENTS.md` — EVM module: address association, StateDB bridge, precompiles, pointers
- `sei-tendermint/AGENTS.md` — sei-tendermint module conventions
+- `tools/gasbench/AGENTS.md` — per-opcode gas-vs-time differential microbenchmark harness
## Code style
diff --git a/Makefile b/Makefile
index 085bf60949..398176c405 100644
--- a/Makefile
+++ b/Makefile
@@ -566,3 +566,7 @@ test-group-%:split-test-packages
PARALLEL="-parallel=4"; \
fi; \
cat $(BUILDDIR)/packages.txt.$* | xargs go test $$PARALLEL -mod=readonly -timeout=10m -race -coverprofile=$*.profile.out -covermode=atomic -coverpkg=./...
+
+.PHONY: gasbench
+gasbench: ## Run the compute-opcode microbench (pin core via GASBENCH_CORE)
+ ./tools/gasbench/run.sh
diff --git a/go.mod b/go.mod
index 66bfbda919..89c52f5b74 100644
--- a/go.mod
+++ b/go.mod
@@ -98,6 +98,7 @@ require (
golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/net v0.49.0
+ golang.org/x/perf v0.0.0-20230113213139-801c7ef9e5c5
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
golang.org/x/time v0.13.0
@@ -112,6 +113,7 @@ require (
require (
dario.cat/mergo v1.0.0 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
+ github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
diff --git a/go.sum b/go.sum
index 86a857402a..950c3ff285 100644
--- a/go.sum
+++ b/go.sum
@@ -659,6 +659,7 @@ github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkT
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/Zilliqa/gozilliqa-sdk v1.2.1-0.20201201074141-dd0ecada1be6/go.mod h1:eSYp2T6f0apnuW8TzhV3f6Aff2SE8Dwio++U4ha4yEM=
github.com/aclements/go-gg v0.0.0-20170118225347-6dbb4e4fefb0/go.mod h1:55qNq4vcpkIuHowELi5C8e+1yUHtoLoOUR9QU5j7Tes=
+github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 h1:xlwdaKcTNVW4PtpQb8aKA4Pjy0CdJHEqvFbAnvR5m2g=
github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY=
github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f h1:JjxwchlOepwsUWcQwD2mLUAGE9aCp0/ehy6yCHFBOvo=
github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f/go.mod h1:tMDTce/yLLN/SK8gMOxQfnyeMeCg8KGzp0D1cbECEeo=
@@ -2287,6 +2288,7 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
+golang.org/x/perf v0.0.0-20230113213139-801c7ef9e5c5 h1:ObuXPmIgI4ZMyQLIz48cJYgSyWdjUXc2SZAdyJMwEAU=
golang.org/x/perf v0.0.0-20230113213139-801c7ef9e5c5/go.mod h1:UBKtEnL8aqnd+0JHqZ+2qoMDwtuy6cYhhKNoHLBiTQc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/tools/gasbench/AGENTS.md b/tools/gasbench/AGENTS.md
new file mode 100644
index 0000000000..fb6dc1889b
--- /dev/null
+++ b/tools/gasbench/AGENTS.md
@@ -0,0 +1,70 @@
+# gasbench
+
+Differential microbenchmark harness: per-opcode EVM execution time vs gas
+cost, tracer-free. See `README.md` for the full rationale (differential
+construction, acceptance-gate design, active-benchmarking diagnostics); this
+file is the short orientation.
+
+## Rules for changes here
+
+- **Never attach a tracer to a timed run.** `debug=true` in the interpreter
+ dilates per-step time. Whole-program gas needs no tracer, so it's read off
+ the same tracer-free call that's timed — there is no separate gas-only
+ pass. A future per-opcode gas breakdown *would* need a tracer, and that
+ breakdown must come from a separate, untimed call. See README.md.
+- **Correlate against EVM gas, never the Cosmos gas meter.** `Program.Run`
+ deliberately has no ante handler / `GasMeter` in its path.
+- **A new `Case` must terminate cleanly** — every `Case` `BuildCaseWith`
+ builds ends in STOP with a balanced, net-zero-gas stack by construction;
+ `NewProgram` rejects anything that doesn't run clean once, and
+ `bench_test.go`'s self-check will fail loudly if the algebra is wrong. (The
+ general `Program.Run` contract also accepts RETURN — see README.md "Error
+ contract" — but every `Case` built here uses STOP.)
+- **New opcode specs:** hand-verify `Arity` (against the fork's
+ `core/vm/jump_table.go` `minStack`/`maxStack`) and `ConstGas` (against
+ `core/vm/jump_table.go` + `core/vm/eips.go`); the self-check catches a
+ wrong `ConstGas` but not a self-consistent wrong `Arity` — this is the
+ authoritative verification checklist, README.md's mention of it points
+ back here. `ConstGas` must come from the geth constant, not a Sei
+ override — production never reprices a scalar opcode (stock `vm.Config{}`,
+ no custom jump table); the only Sei gas param is `SeiSstoreSetGasEIP2200`,
+ a storage opcode and out of scope here.
+- **Data-dependent ops: verify the operands hit the real kernel.** For a
+ `DataDependent` spec, confirm `seedOperands` exercise the intended path, not
+ a `holiman/uint256` short-circuit — equal or degenerate operands make
+ `DIV(x,x)`/`MOD(x,x)` return without dividing, so the row would time a
+ compare. The gas self-check will NOT catch this (gas is unchanged); it's a
+ timing trap. `seedOperands` is ascending (dividend above divisor, smallest at
+ the modulus slot) for this reason — see README.md "Differential construction".
+- Keep code comments lean (what, not why); put methodology/rationale in
+ README.md instead of growing doc comments.
+
+## Running
+
+```bash
+tools/gasbench/run.sh
+```
+
+End-to-end walkthrough incl. how to interpret the output: README.md
+"Quickstart". Env vars, output schema, and the `-count` semantics caveat:
+README.md "Running it" / "Output schema".
+
+## Files
+
+| File | Contents |
+|---|---|
+| `gasbench.go` | timing core: `Measure`, `Config`, `Series`, rusage snapshot |
+| `diff.go` | `Subtract`: per-pass baseline/target differencing, `Diff` (medians/delta/gas/CoV) |
+| `stats.go` | `Summarize`: median/stddev/CoV over a sample series |
+| `crossrun.go` | `analyzeCrossRun`: cross-run benchmath verdict (paired delta CI gate + advisory Mann-Whitney p) |
+| `program.go` | `Program`: warmed tracer-free EVM environment, one bytecode input |
+| `programs.go` | `OpSpec`/`Specs`/`Case`/`BuildCaseWith`: the differential bytecode construction |
+| `emit.go` | `Run`, the verdict (`distinguishable`/`classifyStatus`), CSV/NDJSON output |
+| `emit_test.go` | pins `Run.Status`/CI as pure functions of the `crossRun` verdict (`classifyStatus`, `distinguishable`), the effect floor, and D-1 null-CI writer safety |
+| `crossrun_test.go` | pins `analyzeCrossRun`: drift-survival (paired beats unpaired), straddles-zero, underpowered |
+| `diff_test.go` | pins `Subtract`'s per-pass contract (delta/gas/per-op) |
+| `programs_test.go` | pins the `BuildCaseWith` arity-0 panic guard |
+| `bench_test.go` | `BenchmarkOpcodes`: wires the above into `go test -bench` |
+| `run.sh` | pinned-core runner + operator checklist for turbo/governor/isolation |
+| `README.md` | operator quickstart + full rationale: construction, acceptance gate, diagnostics |
+| `AGENTS.md` | this file |
diff --git a/tools/gasbench/README.md b/tools/gasbench/README.md
new file mode 100644
index 0000000000..41269ee6f3
--- /dev/null
+++ b/tools/gasbench/README.md
@@ -0,0 +1,490 @@
+# gasbench
+
+Differential microbenchmark harness that measures per-opcode EVM execution
+time and correlates it with gas cost. Built to unblock the gas-repricing
+project's F2 hypothesis test: does gas cost track execution time — the same
+"gas is not a faithful meter of execution cost" question Broken Metre (Perez &
+Livshits, NDSS 2020, arXiv 1909.07220) posed for mainnet, answered here
+fork-natively. The method — isolate an opcode by repetition, then convert
+runtime to gas — is the accepted state of the art (EIP-7904, Compute Gas Cost
+Increase), whose anchor-rate / MGas·s⁻¹ vocabulary the repricing framing adopts.
+
+Design: `designs/gas-repricing-telemetry/gas-vs-time-instrumentation.md`
+(harness) and `gasbench-benchmath-integration.md` (the cross-run statistics and
+acceptance gate) in the `bdchatham-designs` repo; prior-art lineage in
+`research/gasbench-prior-art-scan.md`. Noise-floor calibration behind the
+acceptance gate below:
+`designs/gas-repricing-telemetry/research/microbenchmark-noise-isolation-tradeoffs.md`
+in the same repo.
+
+## Quickstart
+
+To iterate on this harness with an agent, point it at this directory's
+`AGENTS.md` first — it carries the rules a change here must not break.
+
+**1. Smoke run (any machine, indicative numbers only):**
+
+```bash
+cd sei-chain
+GASBENCH_OUT_CSV=gasbench.csv GASBENCH_OUT_NDJSON=gasbench.ndjson \
+ tools/gasbench/run.sh | tee gasbench.log
+```
+
+This benchmarks every scalar opcode in `Specs` and writes ONE aggregate row
+per opcode to the output files (`-count=10` by default: `-count` reruns each
+subtest inside one benchmark invocation, and benchmath folds those passes into
+the row's cross-run CI — the file is written once at the end).
+Relative output paths land in `tools/gasbench/` (the test binary's working
+directory), not where you invoked `run.sh` — pass absolute paths to put
+them elsewhere. The `tee` is optional: it captures the `go test` log (per-opcode
+metrics, host-health warnings), not measurement data — the CSV/NDJSON aggregate
+is the output of record. Roughly ten minutes at the defaults; set
+`GASBENCH_ITERS=2000 GASBENCH_COUNT=1` for a fast first look (note `-count=1` is
+`underpowered` by construction — no finite CI). On a laptop or shared box,
+`run.sh` will warn that results are indicative: expect cheap opcodes (`ADD`'s
+per-op cost is ~1 ns, a whole-program delta close to the noise) to come back
+`insignificant` or `sub_threshold` there, and don't read anything into them.
+
+**2. For real numbers, use a quiet, dedicated Linux host:** bare metal (no
+co-tenants), fixed or pinned CPU frequency, one core reserved for the run
+(`GASBENCH_CORE`). `run.sh`'s header comment is the operator checklist. A
+fixed-frequency ARM server instance (e.g. a Graviton `.metal`) needs the
+least setup because there is no turbo/governor to disable. Kernel-level
+isolation (`isolcpus` etc.) is deliberately NOT required — see "Acceptance
+gate" below for why plain pinning is enough.
+
+
+EC2 recipe (the exact workflow behind the shipped numbers)
+
+A `c6g.metal` (Graviton2: fixed frequency, no SMT, no co-tenants) is the
+least-setup host that satisfies the checklist. Ephemeral workflow via SSM —
+no SSH keys, no inbound ports:
+
+```bash
+# 1. Launch. Needs: an AL2023 arm64 AMI, any egress-capable subnet/SG, and an
+# instance profile carrying AmazonSSMManagedInstanceCore.
+AMI=$(aws ssm get-parameter \
+ --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64 \
+ --query 'Parameter.Value' --output text)
+IID=$(aws ec2 run-instances --image-id "$AMI" --instance-type c6g.metal \
+ --subnet-id --security-group-ids \
+ --iam-instance-profile Name= \
+ --block-device-mappings 'DeviceName=/dev/xvda,Ebs={VolumeSize=60,VolumeType=gp3}' \
+ --query 'Instances[0].InstanceId' --output text)
+
+# 2. Wait for SSM Online (bare metal boots in ~10 min), then bootstrap + run.
+# Building sei-chain's module graph needs the 60GB root and a few minutes.
+aws ssm send-command --instance-ids "$IID" --document-name AWS-RunShellScript \
+ --parameters '{"commands":[
+ "dnf install -y -q git tar gzip",
+ "curl -sL https://go.dev/dl/go1.25.6.linux-arm64.tar.gz | tar -C /usr/local -xz",
+ "export HOME=/root PATH=/usr/local/go/bin:$PATH",
+ "git clone --depth 1 https://github.com/sei-protocol/sei-chain.git /opt/sei-chain",
+ "cd /opt/sei-chain && go test -count=1 ./tools/gasbench/...",
+ "mkdir -p /opt/out && cd /opt/sei-chain && GASBENCH_OUT_CSV=/opt/out/gasbench.csv GASBENCH_OUT_NDJSON=/opt/out/gasbench.ndjson tools/gasbench/run.sh > /opt/out/run.log 2>&1"
+ ],"executionTimeout":["3600"]}'
+
+# 3. Fetch results (SSM output is text-only; base64 the CSV through it),
+# then TERMINATE — the box bills ~$2.2/hr and the run needs ~15 min total.
+# (send "base64 /opt/out/gasbench.csv", decode locally)
+aws ec2 terminate-instances --instance-ids "$IID"
+```
+
+The full suite at defaults is ~11 min on this host; expect every row
+`status=ok` with CI half-widths well under 1% of `exec_time_ns`. `run.sh`
+prints `cannot verify turbo/governor` on Graviton — expected: there is no
+turbo to check, which is the point of the host choice.
+
+
+
+**3. Read the output:** each row is one opcode's *differential* cost — the
+target-minus-baseline delta, so setup/loop overhead has already cancelled
+(see "Differential construction"):
+
+- **per-op time** = `exec_time_ns / reps`.
+- **two gas columns, and they are not the same number.** `const_gas` is the
+ nominal gas the chain actually charges for the opcode (the jump-table
+ constant: ADD=3, MULMOD=8). `gas_used` is the *differential* whole-program
+ delta the harness measured — net of the filler the baseline leaves behind —
+ so it is smaller (ADD→1, MULMOD→4) and arity-dependent. `gas_used` exists to
+ drive the construction self-check; **`const_gas` is the denominator for any
+ repricing statement.**
+- **ns-per-gas** = `exec_time_ns / reps / const_gas` — **divide by `const_gas`
+ (nominal), never `gas_used`**: the differential denominator is deflated by an
+ arity-correlated factor (up to 3x) and would report a spread even for a
+ perfectly-priced instruction set. This is the number the hypothesis test is
+ about: time per gas-the-chain-charges (EIP-7904 frames the same quantity as an
+ anchor rate — its 100 MGas/s ≈ 10 ns/gas). If pricing tracked execution time
+ it would be roughly constant across opcodes; the spread IS the mispricing
+ signal. Quote the spread
+ only across `status==ok` rows: the cheap ops (DUP1, ADD…) are dominated by the
+ op-minus-filler marginal and routinely come back `insignificant` or
+ `sub_threshold` on a normal host — do not anchor the low end of the spread on
+ one. Comparing rows within
+ one run cancels the common clock-speed factor, so the spread is meaningful
+ even though the absolute nanoseconds are host-specific — but microarchitectural
+ ratios (adder vs shifter vs multiplier cost) still differ between hosts, which
+ is why repricing-grade numbers come from the step-2 host, not a laptop.
+
+**4. Only trust a row that earns it — filter on `status`:**
+
+- `status=ok` is the only correlation-eligible verdict: the paired delta CI
+ excludes zero (distinguishable) AND the per-op median delta clears the
+ effect-size floor (`GASBENCH_MIN_PEROP_NS`, default 1.0 ns). Sign-check
+ `exec_time_ns`: a negative value is a measured *speedup* (target faster than
+ baseline), still distinguishable — the gate is "measurably different," never
+ "more expensive."
+- `status=sub_threshold`: distinguishable but below the effect floor — "not
+ measurably more expensive in absolute time at this precision." This is NOT
+ "correctly priced": a cheap op can still be mispriced. Do not feed
+ `sub_threshold` rows into the ns-per-gas correlation.
+- `status=insignificant`: the delta CI straddles zero — indistinguishable from
+ no difference at this precision. Raise `GASBENCH_ITERS` or `-count`, or move
+ to the quiet host; don't average or rank insignificant rows.
+- `status=underpowered`: too few counts for a finite CI (`-count<6` at the
+ default confidence — precisely: `<2` counts, or any count whose CI bound is
+ non-finite at the requested confidence; see "Output schema"); `ci_lo`/`ci_hi`
+ are null. Raise `GASBENCH_COUNT`.
+- `status=error`: the case never produced a trustworthy measurement — its
+ subtest failed before accumulating a count (invalid program, `Measure`
+ error), or a gas self-check fired. Numerics are zero, CI null; see the test
+ log for the underlying failure.
+- `high_variance=true`: advisory — the run saw unusual dispersion (noisy
+ neighbor, throttling). It does not invalidate an `ok` result, and
+ `nivcsw` tells you where to look: nonzero means the scheduler preempted
+ the process mid-window (blame the host); near-zero alongside the high
+ CoV points at a non-scheduler tail inside the harness itself (see
+ "Active-benchmarking diagnostics").
+- The cross-count agreement check is now IN the row, not something you
+ reconstruct: `run.sh` defaults to `-count=10`, and benchmath folds those 10
+ passes into the paired delta CI (`ci_lo`/`ci_hi`, at `confidence`). A tight CI
+ that excludes zero IS the stable-across-counts result; a wide one that
+ straddles zero is the noise verdict. There is no longer a per-count row to
+ eyeball or a `benchstat`-consumable stdout to pipe — the aggregate row is the
+ output of record. On a dedicated pinned host, expect the CI half-width to be a
+ low-single-digit percent of `exec_time_ns`. See "Running it" for what
+ `-count` does and doesn't give you.
+
+**Scope:** scalar constant-gas opcodes only (arithmetic/bitwise/comparison/
+stack). Parametric opcodes (`KECCAK256`/`EXP`/copies), the state-touching
+matrix (`SLOAD`/`SSTORE`/`CALL`), and real-block replay are deferred — see
+"Scope" at the bottom for the un-defer triggers. To add an opcode within
+scope, follow `AGENTS.md` "New opcode specs".
+
+## The load-bearing invariant: never attach a tracer to a timed call
+
+Attaching a `tracing.Hooks` tracer to sum per-opcode gas sets `debug=true` in
+the interpreter loop, which dilates per-step execution time. This harness
+only measures whole-program gas (not a per-opcode breakdown), and
+whole-program gas needs no tracer at all: `Program.Run`
+(`GasLimit - leftOverGas` via `runtime.Call`, not `runtime.Execute`, which
+discards `leftOverGas`) returns it directly. So the *same* tracer-free call
+that's timed also yields its gas — `Measure` (`gasbench.go`) reads `gasUsed`
+from the identical `run()` invocation that `time.Since` wraps. There is no
+separate gas-only pass in this harness.
+
+**If a future phase adds a per-opcode gas breakdown**, that requires
+attaching a tracer, and the timed run must stay tracer-free — that
+breakdown would have to come from a *separate* tracer-on call whose timing
+is never read, not from the timed call.
+
+## Differential construction
+
+Each `Case` (`programs.go`) is a baseline/target bytecode pair, identical
+except the target executes the opcode under test. Everything the two
+programs share — loop overhead, setup, dispatch cost — cancels when
+`Subtract` (`diff.go`) takes the difference; what remains is attributable to
+the opcode.
+
+The repeating unit is balanced so gas is net-zero and the stack never grows:
+for an opcode consuming `n` operands and producing 1 result, `n` *distinct*
+operands sit at the base of the stack and each unit lifts fresh copies of all
+`n` with `DUP` (`DUP` copies the deepest of the `n`; repeated `n` times
+it re-lifts the whole tuple in order):
+
+```
+setup = PUSH v0 .. PUSH v_{n-1} // n distinct operands, once
+target = DUP x n , OP , POP // lift the n-tuple, run op, drop result
+baseline = DUP x n , POP x n // lift the n-tuple, drop them
+```
+
+The `n` DUPs cancel, one POP cancels the target's trailing POP, so the
+per-unit gas delta is `ConstGas(op) - (n-1)*GasQuickStep` (every `DUP` is
+`GasFastestStep`, so the fill choice doesn't change the algebra).
+
+**The operands must be distinct, and this is load-bearing, not cosmetic.**
+`holiman/uint256` short-circuits degenerate operands before the real kernel:
+`DIV(x,x)→1` and `MOD(x,x)→0` return without a division, so feeding `n` copies
+of one value (an earlier version's `DUP1 x n`) would time an `Eq` compare, not
+a 256-bit divide — understating DIV/MOD by ~10x and inverting their place in
+the spread. `seedOperands` is ascending so the arity-2 dividend (top) stays
+above the divisor and DIV/MOD reach `udivrem`, and the smallest value lands at
+the modulus slot so ADDMOD/MULMOD operands aren't pre-reduced. Two opcode
+families are still special-cased in `BuildCaseWith`:
+
+- **Stack ops** (`DUP1`, `SWAP1`) aren't "n operands → 1 result" — each gets
+ its own construction.
+- **SHL/SHR/SAR** need a small *in-range* shift amount on top (not a full-width
+ value): an out-of-range shift takes go-ethereum's constant-time early-out
+ (the cheapest case; `value.Clear()`, or `SetAllOne()` for SAR on a negative
+ operand) — at shift ≥256 for SHL/SHR, shift >256 for SAR — so a full-width
+ top operand would measure that early-out instead of the real limb-shift path.
+ `seedShift` keeps the shift amount in-range and distinct from the value.
+
+`EXP` and anything with dynamic/memory/state gas is out of scope — see
+`OpSpec.DataDependent` and the design's Non-goals.
+
+## Gas isolation from the Cosmos layer
+
+`Program.Run` builds a bare go-ethereum EVM (`runtime.Call`) with no ante
+handler and no Sei `GasMeter`. `gasUsed` is pure EVM gas
+(`cfg.GasLimit - leftOverGas`); there is deliberately no Cosmos gas meter in
+this path. Correlate against **EVM** gas, not the Cosmos gas meter — mixing
+the two units was the CON-368 trap (`x/evm/keeper/msg_server.go`'s
+`serverRes.GasUsed` assignment, which is in EVM's gas unit, not Sei's).
+
+## Program reuse across calls (journal growth)
+
+A `Program` (`program.go`) is built once per bytecode input and its `Run`
+method is called once per timed iteration, reusing one `StateDB` — rebuilding
+state per call would swamp a nanosecond-scale signal with allocation noise.
+Each call takes a `Snapshot()` that's never reverted or `Finalise`-d, so the
+journal's revision/touch-change list grows roughly two entries per call over
+a `Program`'s life. This is harmless for the current MVP: growth is
+symmetric between baseline and target (both take one snapshot + one
+zero-value transfer per call), gas accounting is unaffected (it comes only
+from interpreter opcodes), and the cost lands as periodic tail latency from
+slice reallocation, not a shift in the median the difference-of-medians
+estimator reads. **Re-check this before reusing a `Program` across an
+order-of-magnitude more iterations, or for a `Case` that touches persistent
+state (SSTORE/LOG/CREATE).**
+
+This periodic reallocation is also the one noise source that inflates the
+coefficient of variation (CoV, stddev/mean — see "Acceptance gate" below)
+without the `getrusage`-based diagnostics ("Active-benchmarking diagnostics"
+further below) being able to see it (it's not a scheduler preemption).
+
+## Error contract
+
+`Program.Run` returns `err == nil` only on a clean STOP/RETURN. `REVERT`,
+out-of-gas, and invalid-opcode all return a non-nil `err` (see the doc
+comment on `Run` for the specific sentinel per case). Every `Case` built by
+`BuildCaseWith` terminates cleanly, so a non-nil `err` during measurement
+means the run is invalid — `Measure` (`gasbench.go`) propagates it as a hard
+error rather than recording a bogus sample.
+
+## Measurement methodology
+
+`Measure` (`gasbench.go`) runs `Warmup` discarded iterations (warm
+I-cache/D-cache/branch predictor, catch a broken program early), then
+`Iterations` timed iterations of `RunOnce`, config in `Config`/`DefaultConfig`:
+
+- GC disabled for the timed window (a GC pause inside a sample corrupts the
+ tail) — the heap grows unbounded during the window, so keep the program
+ allocation-light or lower `Iterations`.
+- The measuring goroutine is locked to its OS thread and `GOMAXPROCS` should
+ be 1, so nothing migrates the goroutine across cores mid-window.
+- Timing uses `time.Now`/`time.Since` (monotonic, immune to NTP steps)
+ around the tracer-free `RunOnce` call.
+
+## Acceptance gate: paired delta CI + effect floor, not CoV
+
+The verdict lives in `emit.go` (`distinguishable`/`effectSizePass`/
+`classifyStatus`), computed at the cross-run (`-count`) layer by
+`benchmath.AssumeNothing` (`crossrun.go`). It has two independent halves; a row
+is accepted (`status=ok`) only if BOTH clear.
+
+**1. Distinguishable — the statistical half.** Each `-count` pass measures
+baseline then target back-to-back in one pinned, GC-off window, so the design
+is *paired*: `delta_k = target_median_k − baseline_median_k`. The gate is the
+order-statistic CI on that per-count delta series excluding zero —
+`distinguishable := ci_lo > 0 || ci_hi < 0` (`benchmath.AssumeNothing.Summary`
+over the deltas). It is symmetric: a CI wholly below zero (a measured speedup)
+is just as distinguishable as one wholly above — the gate is "measurably
+different," never "more expensive."
+
+Paired, not unpaired, is load-bearing. Across-count common-mode drift
+(frequency wander, cache-state shift — exactly what `-count` exists to survive)
+moves both series together, so the paired delta cancels it. An unpaired
+two-sample test over the baseline-median series vs the target-median series
+loses the signal under that drift: a true +3 ns/op with ±30 ns shared drift
+makes the two median series overlap, while the paired deltas `[3,3,3,…]` are
+unmistakable and their CI cleanly excludes zero. `benchmath` exposes no
+paired/signed-rank test, so the CI on the delta series IS the paired primitive.
+The unpaired Mann-Whitney U p (`p_value`, over the baseline vs target median
+series) is emitted as an **advisory column only** for cross-checking — NOT the
+gate, so its known weaknesses (tie fragility at integer-ns medians, power loss
+under shared drift) never touch the acceptance decision.
+
+**2. Effect-size floor — the practical half.** Distinguishable at large n is
+not the same as meaningful. `status=ok` additionally requires the per-op median
+delta (`exec_time_ns / reps`) to clear `GASBENCH_MIN_PEROP_NS` (default 1.0 ns,
+compared in absolute value so a speedup still counts). A row that is
+distinguishable but below the floor is `sub_threshold`, NOT `ok`.
+
+`sub_threshold` means "not measurably more expensive in absolute time at this
+precision" — it does NOT mean "correctly priced." A cheap-but-mispriced op can
+be demoted on the absolute-ns floor even though its ns-per-gas is an outlier;
+the floor is a *measurability* guard, not a pricing verdict. **Only `status=ok`
+rows are eligible for the ns-per-gas repricing correlation.** The 1.0 ns default
+is a consumer-tunable calibration (it may demote ADD/DUP-class boundary ops on
+some hosts — cite the noise-isolation research when retuning); set
+`GASBENCH_MIN_PEROP_NS<=0` to disable the floor entirely. A gas-relative floor
+(ns-per-nominal-gas deviation) is deferred pending the reference-anchor choice
+(EIP-7904's 100 MGas/s is the citable candidate but is host-frequency-specific).
+
+(Distinguishability is decided at the cross-run n=10 layer, not the within-run
+n≈20000 layer — at within-run n the test is over-powered, any sub-nanosecond
+shift clears it; the effect floor is the second half of the gate.)
+
+**CoV is advisory, never the gate.** A raw per-series CoV of several percent
+under plain core-pinning (no kernel-level isolcpus/nohz_full/rcu_nocbs) is
+expected physics (the periodic scheduler tick, device IRQs, shared-cache
+contention that pinning alone can't remove), not a defect. No major
+benchmarking harness (JMH, criterion.rs, Go's own `benchstat`) gates on a
+fixed dispersion threshold either — all three gate on effect size against
+the estimate's own uncertainty.
+
+`Diff.HighVariance` (CoV above `CoVCeiling`, default 0.25) is advisory only:
+it flags a run worth a closer look (a noisy neighbor, a throttling event) but
+never overrides `status`. The 0.25 default sits above the 4-8% CoV
+measured as normal on a dedicated, pinned, bare-metal host with no
+kernel-level isolation, and well below the ~40%+ territory a genuinely
+pathological run would show. See the research doc linked above for the full
+sweep (JMH/criterion.rs/benchstat comparison, isolcpus vs. taskset, the
+practitioner consensus that isolcpus-level isolation is real-time/HFT-grade
+overkill for this class of measurement) and for why the harness deliberately
+does *not* add cpuset/IRQ-affinity isolation beyond `run.sh`'s taskset pin.
+
+## Active-benchmarking diagnostics: Nvcsw/Nivcsw
+
+`Series.NvcswDelta`/`NivcswDelta` (`gasbench.go`) are `getrusage(2)`
+voluntary/involuntary context-switch counts over the timed window
+(`RUSAGE_SELF` — process-wide, not thread-scoped; Go's `syscall` package
+exposes no portable `RUSAGE_THREAD`). A nonzero `NivcswDelta` is direct,
+measured evidence the kernel scheduler preempted a thread in this process
+mid-window — Brendan Gregg's "active benchmarking" check, automated instead
+of a one-off manual `perf stat`/`/proc/interrupts` session. It does not cover
+every noise source: a high CoV with `NivcswDelta` near zero points at a
+non-scheduler tail instead (the journal-growth reallocation above is the
+known example). Near-zero `NvcswDelta` confirms the loop stayed on-CPU
+without blocking, as expected for a pure-compute loop.
+
+`Diff` surfaces both as `BaselineNvcsw`/`TargetNvcsw` and
+`BaselineNivcsw`/`TargetNivcsw`; `Run` surfaces the worse (max) of each pair
+as `Nvcsw`/`Nivcsw`. A zero in either reflects an undisturbed window *or* a
+failed `getrusage` call — check `Series.Warnings` (surfaced per-opcode via
+`b.Logf` in `bench_test.go`) to tell the two apart.
+
+## Running it
+
+`run.sh` pins one measurement thread to one core (`GASBENCH_CORE`, default
+3) and checks (but cannot enforce) turbo/governor/isolation settings — see
+its header comment for the operator checklist. It runs:
+
+```
+go test ./tools/gasbench/ -run '^$' -bench '^BenchmarkOpcodes$' \
+ -benchtime=1x -count=10 -benchmem
+```
+
+`-benchtime=1x` matters: `BenchmarkOpcodes`'s inner timing loop is
+`Measure`'s, not `b.N`'s, so `b.N` must stay at 1. `-count=K` reruns each
+opcode subtest K times **within the same OS process** (inside a single
+`BenchmarkOpcodes` invocation); benchmath folds those K passes into the one
+aggregate row's cross-run CI. `-benchtime=1x` does **not** neutralize
+`-count`: the flags act at different layers — `benchtime` caps iterations
+*within* one pass (`launch` adds none beyond `run1`'s single body execution),
+while the `-count` loop sits *outside*, in `processBench`, re-running the
+whole leaf benchmark K times — so each subtest body executes exactly K times
+total. Observable either way: `-count=1` emits `count=1`/`underpowered` rows,
+`-count=10` emits `count=10` rows with finite CIs, and `-v` shows K result
+lines per opcode. This is cross-run variance, not process-level
+independence — true process-level independence would need K separate `go test`
+invocations. See the research doc for why this matters less than it sounds:
+JMH's fork/warmup/measurement-iteration model is the gold standard, but this
+MVP's within-process reruns already surfaced reproducible per-opcode deltas
+(see the research doc's empirical run). **`-count>=6` is needed for a finite CI**
+at the default confidence; below that a row reads `underpowered` with a
+benchmath warning, so the default `-count=10` has headroom.
+
+Env vars:
+
+- `GASBENCH_WARMUP`, `GASBENCH_ITERS` — override `DefaultConfig`'s within-run
+ warmup / timed iterations.
+- `GASBENCH_COUNT` (default 10) — cross-run `-count` passes; see the finite-CI
+ minimum above.
+- `GASBENCH_ALPHA` (default 0.05) — `CompareAlpha` behind the advisory
+ `p_value` (Mann-Whitney U); does not affect the gate.
+- `GASBENCH_CI_CONFIDENCE` (default 0.95) — confidence for the paired delta CI
+ (the gate). Raising it raises the minimum `-count` for a finite CI (~6 at
+ 0.95), so bump `GASBENCH_COUNT` in lockstep.
+- `GASBENCH_MIN_PEROP_NS` (default 1.0) — effect-size floor on `|per-op median
+ delta ns|`; distinguishable-but-below → `sub_threshold`. Set `<=0` to disable.
+- `GASBENCH_COV_CEILING` (default 0.25) — advisory `high_variance` ceiling on
+ per-series CoV; never gates the verdict.
+- `GASBENCH_OUT_CSV`, `GASBENCH_OUT_NDJSON` — output paths.
+
+## Output schema
+
+One `Run` (`emit.go`) per opcode, written as CSV and/or NDJSON. Columns are in
+`csvHeader` order:
+
+| Column | Meaning |
+|---|---|
+| `input_id` | opcode id, e.g. `ADD` |
+| `class` | opcode family, e.g. `arithmetic` |
+| `reps` | opcode executions the delta represents |
+| `gas_used` | *differential* whole-program gas delta (target − baseline), net of filler; per-op = `gas_used/reps`. Drives the construction self-check — NOT what the chain charges, NOT the repricing denominator |
+| `const_gas` | nominal per-op gas the chain charges (jump-table `ConstGas`); the denominator for ns-per-gas — see "Read the output" |
+| `exec_time_ns` | **median** whole-program time delta over the `-count` passes (`Summary.Center`), ns; always finite; per-op = `exec_time_ns/reps`. **Sign-check it: a negative value is a measured speedup.** |
+| `count` | cross-run n: number of `-count` passes aggregated |
+| `iterations` | within-run timed iterations behind each series |
+| `ci_lo` | order-statistic CI low on `exec_time_ns` (whole-program ns). **Nullable**: empty CSV field / JSON `null` when the bound is non-finite (underpowered) |
+| `ci_hi` | CI high; same nullable rule |
+| `confidence` | actual CI confidence (≥ requested) |
+| `distinguishable` | **the gate, statistical half:** paired delta CI excludes zero (`ci_lo>0 \|\| ci_hi<0`), computed from the raw benchmath bounds — on an underpowered row the emitted `ci_lo`/`ci_hi` are null and this reads `false` |
+| `effect_size_pass` | the gate, practical half: per-op median delta clears `GASBENCH_MIN_PEROP_NS` |
+| `p_value` | **advisory only:** unpaired Mann-Whitney U p over baseline vs target medians; NOT the gate |
+| `alpha` | `CompareAlpha` behind the advisory `p_value` |
+| `status` | `ok \| sub_threshold \| insignificant \| underpowered \| error` — see below; never gated on CoV |
+| `cov` | worse (max) of the baseline/target series CoV, max across counts — advisory only. CSV rounds to 6 sig figs for readability; NDJSON carries full precision — expect tail digits to differ across formats |
+| `high_variance` | advisory: CoV exceeded `CoVCeiling` on any count |
+| `nvcsw` / `nivcsw` | worse (max) of the baseline/target voluntary/involuntary context-switch counts, max across counts |
+
+`status` enum:
+
+- `ok` — distinguishable AND clears the effect floor. **The only
+ correlation-eligible verdict.**
+- `sub_threshold` — distinguishable but below the effect floor: "not measurably
+ more expensive in absolute time," NOT "correctly priced" (a cheap op can
+ still be mispriced).
+- `insignificant` — the delta CI straddles zero: not distinguishable at this
+ precision.
+- `underpowered` — too few counts for a finite CI (`count<2`, or a non-finite
+ bound at the requested confidence); `ci_lo`/`ci_hi` are null.
+- `error` — the case never produced a trustworthy measurement: its subtest
+ failed before accumulating a count (invalid program, `Measure` error), or a
+ gas self-check fired (the construction violated its own invariant). Numerics
+ are zero/finite, `ci_lo`/`ci_hi` null; the test log carries the cause.
+
+**Consumer note:** filter on `status` (only `ok` is correlation-eligible) and
+sign-check `exec_time_ns` (a negative value = a measured speedup). `ci_lo`/`ci_hi`
+are null on underpowered rows — an older CSV wrote `+Inf` here, the current CSV
+writes an empty field.
+
+A per-`Case` self-check (`bench_test.go`) also verifies the measured gas
+delta matches the definitional expected delta from `BuildCaseWith`'s algebra
+— a correctness check on the harness construction itself, not on opcode
+timing. It catches a wrong `ConstGas` but not a self-consistent wrong
+`Arity`; see `AGENTS.md` "New opcode specs" for the hand-verification
+checklist a new `Specs` entry needs.
+
+## Scope
+
+Cleanly-benchmarkable-as-a-scalar opcodes only (arithmetic/bitwise/
+comparison/stack — the four `Class` families in `Specs`). Deferred, not yet
+implemented: parametric-curve
+opcodes (`KECCAK256`/`EXP`/copy/memory/`LOG` by size), the state-touching
+context-dependent matrix (`SLOAD`/`SSTORE` by warm/cold, `CALL` family,
+`CREATE`), real-block replay (F1), and Sei's custom precompiles (0x1001+,
+off-model — cosmos-module time, not EVM).
diff --git a/tools/gasbench/bench_test.go b/tools/gasbench/bench_test.go
new file mode 100644
index 0000000000..eceb2e3521
--- /dev/null
+++ b/tools/gasbench/bench_test.go
@@ -0,0 +1,218 @@
+package gasbench
+
+import (
+ "os"
+ "strconv"
+ "testing"
+)
+
+// BenchmarkOpcodes drives one differential measurement per scalar opcode
+// Case. See README.md "Running it" for the required -benchtime=1x flag and
+// what -count=K actually gives you (not K independent process forks).
+func BenchmarkOpcodes(b *testing.B) {
+ cases := BuildCases()
+ def := DefaultConfig()
+ cfg := Config{
+ Warmup: envInt(b, "GASBENCH_WARMUP", def.Warmup),
+ Iterations: envInt(b, "GASBENCH_ITERS", def.Iterations),
+ DisableGC: def.DisableGC,
+ LockThread: def.LockThread,
+ }
+ // 0.25 default: see README.md "Acceptance gate" for the calibration.
+ covCeiling := envFloat(b, "GASBENCH_COV_CEILING", 0.25)
+ alpha := envFloat(b, "GASBENCH_ALPHA", 0.05)
+ confidence := envFloat(b, "GASBENCH_CI_CONFIDENCE", 0.95)
+ // Effect-size floor: a per-op median delta below this (ns) is distinguishable
+ // but not "meaningfully more expensive" -> sub_threshold. See README "Acceptance gate".
+ minPerOpNs := envFloat(b, "GASBENCH_MIN_PEROP_NS", 1.0)
+
+ // One accumulator per case, indexed by the case-loop index. Go 1.22+
+ // per-iteration loop-var capture makes &collectors[i] inside the closure
+ // bind to cases[i]'s slot regardless of how -count reruns interleave.
+ collectors := make([]opcodeAccum, len(cases))
+ for i, c := range cases {
+ b.Run(c.OpcodeID, func(b *testing.B) {
+ baseProg, err := NewProgram(c.Baseline)
+ if err != nil {
+ b.Fatalf("%s: baseline program invalid: %v", c.OpcodeID, err)
+ }
+ tgtProg, err := NewProgram(c.Target)
+ if err != nil {
+ b.Fatalf("%s: target program invalid: %v", c.OpcodeID, err)
+ }
+
+ base, err := Measure(c.OpcodeID+"/baseline", baseProg.Run, cfg)
+ if err != nil {
+ b.Fatal(err)
+ }
+ tgt, err := Measure(c.OpcodeID+"/target", tgtProg.Run, cfg)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ d := Subtract(c.OpcodeID, base, tgt, c.Reps, covCeiling)
+
+ // Self-check of the harness construction (see README.md "Output
+ // schema"), not of opcode timing. Insensitive to a
+ // mis-transcribed OpSpec.Arity that still yields a
+ // self-consistent ExpectedGasDelta -- see AGENTS.md.
+ // Lock-free like sink (gasbench.go): subtests and -count reruns are
+ // sequential (no b.Parallel). A future parallelize needs a mutex.
+ acc := &collectors[i]
+ acc.iterations = cfg.Iterations
+ if d.GasDelta != c.ExpectedGasDelta {
+ b.Errorf("%s: gas self-check failed: measured delta %d != expected %d (baseline/target pair does not isolate the opcode)",
+ c.OpcodeID, d.GasDelta, c.ExpectedGasDelta)
+ // b.Errorf does not unwind; taint the case so aggregate emits
+ // status=error instead of a normal-looking row built on a
+ // construction that failed its own invariant.
+ acc.selfCheckFailed = true
+ }
+ // The gas delta is definitional and must be identical every count;
+ // a drift means the differential construction is not deterministic.
+ if acc.gasDeltaSet && d.GasDelta != acc.gasDelta {
+ b.Errorf("%s: gas delta not identical across counts: %d != first-count %d",
+ c.OpcodeID, d.GasDelta, acc.gasDelta)
+ acc.selfCheckFailed = true
+ }
+ if !acc.gasDeltaSet {
+ acc.gasDelta, acc.gasDeltaSet = d.GasDelta, true
+ }
+ acc.baseMedians = append(acc.baseMedians, d.BaselineMedian)
+ acc.tgtMedians = append(acc.tgtMedians, d.TargetMedian)
+ acc.deltas = append(acc.deltas, d.DeltaNs)
+ acc.covMax = max(acc.covMax, d.BaselineCoV, d.TargetCoV)
+ acc.highVariance = acc.highVariance || d.HighVariance
+ acc.nvcsw = max(acc.nvcsw, d.BaselineNvcsw, d.TargetNvcsw)
+ acc.nivcsw = max(acc.nivcsw, d.BaselineNivcsw, d.TargetNivcsw)
+
+ b.ReportMetric(d.BaselineMedian, "baseline-median-ns")
+ b.ReportMetric(d.TargetMedian, "target-median-ns")
+ b.ReportMetric(d.PerOpNs, "per-op-ns")
+ // per-op-gas-delta is differential (net of filler); const-gas is the
+ // nominal denominator for ns-per-gas.
+ b.ReportMetric(d.PerOpGas, "per-op-gas-delta")
+ b.ReportMetric(float64(c.ConstGas), "const-gas")
+
+ for _, w := range append(base.Warnings, tgt.Warnings...) {
+ b.Logf("%s: %s", c.OpcodeID, w)
+ }
+ if d.HighVariance {
+ b.Logf("%s: series CoV above health-check ceiling %.4g (baseline=%.4g target=%.4g, nivcsw base=%d tgt=%d) -- advisory only, does not gate the verdict",
+ c.OpcodeID, covCeiling, d.BaselineCoV, d.TargetCoV, base.NivcswDelta, tgt.NivcswDelta)
+ }
+ })
+ }
+
+ // Aggregate once, in Specs order, after all counts have accumulated.
+ runs := make([]Run, 0, len(cases))
+ for i := range cases {
+ runs = append(runs, aggregate(b, &collectors[i], cases[i], alpha, confidence, minPerOpNs))
+ }
+ writeRuns(b, runs)
+}
+
+// opcodeAccum accumulates one opcode's per-count raw data across the -count
+// reruns of its subtest. Test-local: production analyzeCrossRun takes
+// crossRunInput instead. The Case is not stored here -- aggregation reads it
+// from cases[i].
+type opcodeAccum struct {
+ iterations int
+ baseMedians []float64 // within-run median per count, ns
+ tgtMedians []float64 // within-run median per count, ns
+ deltas []float64 // per-count delta, ns (tgtMed - baseMed)
+ gasDelta uint64 // exact; guarded identical every count
+ gasDeltaSet bool
+ selfCheckFailed bool // a gas self-check b.Errorf fired; the case is tainted
+ covMax float64 // running max of per-count worse-of-pair CoV (advisory)
+ highVariance bool // OR across counts (advisory)
+ nvcsw int64 // running max of worse-of-pair voluntary ctx switches
+ nivcsw int64 // running max of worse-of-pair involuntary ctx switches
+}
+
+// aggregate builds the cross-run input from the collector, runs the benchmath
+// analysis, and composes the per-opcode Run. Thin glue: the verdict lives in
+// NewRun/classifyStatus (emit.go).
+func aggregate(b *testing.B, acc *opcodeAccum, c Case, alpha, confidence, minPerOpNs float64) Run {
+ // A subtest that b.Fatalf'd (invalid program, Measure failure) unwinds only
+ // its own goroutine; the parent still aggregates. An empty accumulator
+ // through benchmath yields a NaN center that would corrupt the writers --
+ // emit the error row instead.
+ if len(acc.deltas) == 0 || acc.selfCheckFailed {
+ reason := "no measurements accumulated (subtest failed)"
+ if acc.selfCheckFailed {
+ reason = "gas self-check failed (construction invalid)"
+ }
+ b.Logf("%s: %s; emitting status=error row", c.OpcodeID, reason)
+ return NewErrorRun(c, acc.iterations)
+ }
+ cr := analyzeCrossRun(crossRunInput{
+ BaselineMedians: acc.baseMedians,
+ TargetMedians: acc.tgtMedians,
+ Deltas: acc.deltas,
+ }, alpha, confidence)
+ // Surface benchmath's cross-run warnings (e.g. too few counts for a finite
+ // CI, or n below the U-test's minimum) so an underpowered/degenerate run is
+ // visible, not silent.
+ for _, w := range cr.Warnings {
+ b.Logf("%s: %v", c.OpcodeID, w)
+ }
+ return NewRun(c, cr, acc.gasDelta, len(acc.deltas), acc.iterations, hostHealth{
+ CoV: acc.covMax,
+ HighVariance: acc.highVariance,
+ Nvcsw: acc.nvcsw,
+ Nivcsw: acc.nivcsw,
+ }, minPerOpNs)
+}
+
+func writeRuns(b *testing.B, runs []Run) {
+ if p := os.Getenv("GASBENCH_OUT_CSV"); p != "" {
+ writeFile(b, p, "csv", func(f *os.File) error { return WriteCSV(f, runs) })
+ }
+ if p := os.Getenv("GASBENCH_OUT_NDJSON"); p != "" {
+ writeFile(b, p, "ndjson", func(f *os.File) error { return WriteNDJSON(f, runs) })
+ }
+}
+
+// writeFile checks the Close error too: a failed final flush silently truncates
+// the results file the operator then trusts as measurement data.
+func writeFile(b *testing.B, path, kind string, write func(*os.File) error) {
+ f, err := os.Create(path)
+ if err != nil {
+ b.Fatalf("create %s: %v", kind, err)
+ }
+ if err := write(f); err != nil {
+ f.Close()
+ b.Fatalf("write %s: %v", kind, err)
+ }
+ if err := f.Close(); err != nil {
+ b.Fatalf("close %s: %v", kind, err)
+ }
+}
+
+// envInt/envFloat fail loud on a set-but-unparseable value rather than silently
+// falling back to the default: a typo like GASBENCH_ITERS=2O00 should stop the
+// run, not quietly measure something the operator didn't ask for.
+func envInt(b *testing.B, key string, def int) int {
+ v := os.Getenv(key)
+ if v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ b.Fatalf("%s=%q: not an integer: %v", key, v, err)
+ }
+ return n
+}
+
+func envFloat(b *testing.B, key string, def float64) float64 {
+ v := os.Getenv(key)
+ if v == "" {
+ return def
+ }
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ b.Fatalf("%s=%q: not a float: %v", key, v, err)
+ }
+ return f
+}
diff --git a/tools/gasbench/crossrun.go b/tools/gasbench/crossrun.go
new file mode 100644
index 0000000000..ca1954c16d
--- /dev/null
+++ b/tools/gasbench/crossrun.go
@@ -0,0 +1,58 @@
+package gasbench
+
+import (
+ "slices"
+
+ "golang.org/x/perf/benchmath"
+)
+
+// crossRunInput is one opcode's cross-run series: one entry per -count pass.
+// Named fields prevent transposing the three same-typed slices at the call
+// site. Deltas[k] = TargetMedians[k] - BaselineMedians[k].
+type crossRunInput struct {
+ BaselineMedians []float64 // within-run median per count, ns
+ TargetMedians []float64 // within-run median per count, ns
+ Deltas []float64 // per-count delta, ns
+}
+
+// crossRun is the benchmath verdict for one opcode's cross-run series.
+//
+// CILo/CIHi are the raw order-statistic CI bounds on the delta and may be
+// non-finite (too few counts). The acceptance gate reads them raw (a ±Inf
+// bound is simply not > 0 / < 0, so an underpowered row never reads
+// distinguishable); the emit layer maps non-finite to a null column.
+type crossRun struct {
+ MedianDeltaNs float64 // Summary.Center -- median whole-program delta
+ CILo, CIHi float64 // Summary.Lo/Hi -- order-statistic CI; ±Inf when underpowered
+ Confidence float64 // Summary.Confidence -- actual (>= requested)
+ P float64 // Compare.P -- advisory only, never the gate
+ Alpha float64 // Compare.Alpha
+ Underpowered bool // < 2 counts, or a non-finite CI bound
+ Warnings []error // Summary.Warnings ++ Compare.Warnings
+}
+
+// analyzeCrossRun runs the paired delta CI (the acceptance gate, via Summary
+// over the per-count deltas) and the advisory unpaired Mann-Whitney p (via
+// Compare over the baseline vs target median series). The paired CI is the
+// primitive that survives common-run drift; the p-value is surfaced for
+// cross-checking only. Inputs are cloned: NewSample sorts in place.
+func analyzeCrossRun(in crossRunInput, alpha, confidence float64) crossRun {
+ thr := &benchmath.Thresholds{CompareAlpha: alpha}
+
+ sum := benchmath.AssumeNothing.Summary(benchmath.NewSample(slices.Clone(in.Deltas), thr), confidence)
+ cmp := benchmath.AssumeNothing.Compare(
+ benchmath.NewSample(slices.Clone(in.BaselineMedians), thr),
+ benchmath.NewSample(slices.Clone(in.TargetMedians), thr),
+ )
+
+ return crossRun{
+ MedianDeltaNs: sum.Center,
+ CILo: sum.Lo,
+ CIHi: sum.Hi,
+ Confidence: sum.Confidence,
+ P: cmp.P,
+ Alpha: cmp.Alpha,
+ Underpowered: len(in.Deltas) < 2 || !finite(sum.Lo) || !finite(sum.Hi),
+ Warnings: slices.Concat(sum.Warnings, cmp.Warnings),
+ }
+}
diff --git a/tools/gasbench/crossrun_test.go b/tools/gasbench/crossrun_test.go
new file mode 100644
index 0000000000..0c8d9409c9
--- /dev/null
+++ b/tools/gasbench/crossrun_test.go
@@ -0,0 +1,88 @@
+package gasbench
+
+import (
+ "math"
+ "testing"
+)
+
+// TestAnalyzeCrossRunPairedSurvivesDrift is the F1 scenario: a true +3ns/op shift
+// under common-mode drift makes the baseline/target median series overlap, so the
+// advisory unpaired p is WEAK (fails to distinguish), yet the paired delta CI
+// cleanly excludes zero. This is the reason the gate reads the paired CI, not
+// Compare -- so the test asserts both sides of that split.
+func TestAnalyzeCrossRunPairedSurvivesDrift(t *testing.T) {
+ base := []float64{200, 215, 208, 230, 205, 220, 212, 218, 209, 225}
+ tgt := make([]float64, len(base))
+ deltas := make([]float64, len(base))
+ for i, b := range base {
+ tgt[i] = b + 3
+ deltas[i] = tgt[i] - b
+ }
+ cr := analyzeCrossRun(crossRunInput{BaselineMedians: base, TargetMedians: tgt, Deltas: deltas}, 0.05, 0.95)
+
+ if cr.Underpowered {
+ t.Fatalf("n=10 must not be underpowered")
+ }
+ if math.IsInf(cr.CILo, 0) || math.IsInf(cr.CIHi, 0) {
+ t.Fatalf("n=10 CI must be finite, got [%v, %v]", cr.CILo, cr.CIHi)
+ }
+ // The advisory unpaired test must FAIL to distinguish under drift -- the whole
+ // point of gating on the paired CI instead.
+ if cr.P <= cr.Alpha {
+ t.Errorf("unpaired p should be weak under drift (> alpha), got P=%v alpha=%v", cr.P, cr.Alpha)
+ }
+ // The paired CI must exclude zero.
+ if cr.CILo <= 0 {
+ t.Errorf("paired CI must exclude zero (Lo>0), got Lo=%v", cr.CILo)
+ }
+ if cr.MedianDeltaNs != 3 {
+ t.Errorf("median delta got %v, want 3", cr.MedianDeltaNs)
+ }
+}
+
+// TestAnalyzeCrossRunStraddlesZero pins the finite-CI-includes-zero path: a
+// zero-centered delta series is measurable (not underpowered) but the CI
+// straddles zero, so the gate must NOT read it as distinguishable.
+func TestAnalyzeCrossRunStraddlesZero(t *testing.T) {
+ deltas := []float64{-4, -3, -1, 0, 1, 1, 2, 3, 4, 5}
+ base := make([]float64, len(deltas))
+ tgt := make([]float64, len(deltas))
+ for i, d := range deltas {
+ base[i] = 100
+ tgt[i] = 100 + d
+ }
+ cr := analyzeCrossRun(crossRunInput{BaselineMedians: base, TargetMedians: tgt, Deltas: deltas}, 0.05, 0.95)
+
+ if cr.Underpowered {
+ t.Fatalf("n=10 must not be underpowered")
+ }
+ if math.IsInf(cr.CILo, 0) || math.IsInf(cr.CIHi, 0) {
+ t.Fatalf("n=10 CI must be finite, got [%v, %v]", cr.CILo, cr.CIHi)
+ }
+ if !(cr.CILo <= 0 && cr.CIHi >= 0) {
+ t.Errorf("zero-centered deltas: CI must straddle zero, got [%v, %v]", cr.CILo, cr.CIHi)
+ }
+}
+
+// TestAnalyzeCrossRunUnderpowered pins the degenerate single-count path: a finite
+// point median but a non-finite CI, flagged underpowered, no panic.
+func TestAnalyzeCrossRunUnderpowered(t *testing.T) {
+ cr := analyzeCrossRun(crossRunInput{
+ BaselineMedians: []float64{200},
+ TargetMedians: []float64{203},
+ Deltas: []float64{3},
+ }, 0.05, 0.95)
+
+ if !cr.Underpowered {
+ t.Fatalf("n=1 must be underpowered")
+ }
+ if cr.MedianDeltaNs != 3 {
+ t.Errorf("point median of a single delta got %v, want 3", cr.MedianDeltaNs)
+ }
+ if !math.IsInf(cr.CILo, -1) || !math.IsInf(cr.CIHi, 1) {
+ t.Errorf("n=1 CI bounds got [%v, %v], want ±Inf", cr.CILo, cr.CIHi)
+ }
+ if len(cr.Warnings) == 0 {
+ t.Error("n=1 must surface at least one benchmath warning")
+ }
+}
diff --git a/tools/gasbench/diff.go b/tools/gasbench/diff.go
new file mode 100644
index 0000000000..e5e9f269f6
--- /dev/null
+++ b/tools/gasbench/diff.go
@@ -0,0 +1,70 @@
+package gasbench
+
+// Diff is the difference-of-medians subtraction of a baseline series from a
+// target series for one -count pass. Distinguishability is decided at the
+// cross-run layer (crossrun.go), not here: Diff carries only the per-pass
+// point medians, delta, gas, and advisory health signals.
+type Diff struct {
+ OpcodeID string
+
+ BaselineMedian float64 // ns
+ TargetMedian float64 // ns
+ BaselineCoV float64
+ TargetCoV float64
+
+ DeltaNs float64 // per-program time attributable to the opcode reps
+
+ // HighVariance is advisory only (CoV exceeded CoVCeiling). See README.md
+ // "Acceptance gate".
+ HighVariance bool
+ CoVCeiling float64
+
+ // BaselineNvcsw/TargetNvcsw and BaselineNivcsw/TargetNivcsw are each
+ // series' voluntary/involuntary context-switch counts (see
+ // Series.NvcswDelta/NivcswDelta). See README.md "Active-benchmarking
+ // diagnostics".
+ BaselineNvcsw int64
+ TargetNvcsw int64
+ BaselineNivcsw int64
+ TargetNivcsw int64
+
+ Reps int // opcode executions per program (from the Case)
+ PerOpNs float64 // DeltaNs / Reps
+
+ GasDelta uint64 // measured whole-program gas delta; exact (ground truth)
+ PerOpGas float64 // GasDelta / Reps
+}
+
+// Subtract computes the per-pass opcode cost from baseline/target series.
+// covCeiling is an advisory health-check ceiling, not a gate: a raw per-series
+// CoV above it flags the run for a closer look (a possible neighbor or
+// throttling event). reps normalizes the per-program delta to one opcode.
+func Subtract(opcodeID string, baseline, target Series, reps int, covCeiling float64) Diff {
+ bs := Summarize(baseline.Samples)
+ ts := Summarize(target.Samples)
+
+ d := Diff{
+ OpcodeID: opcodeID,
+ BaselineMedian: bs.Median,
+ TargetMedian: ts.Median,
+ BaselineCoV: bs.CoV,
+ TargetCoV: ts.CoV,
+ CoVCeiling: covCeiling,
+ BaselineNvcsw: baseline.NvcswDelta,
+ TargetNvcsw: target.NvcswDelta,
+ BaselineNivcsw: baseline.NivcswDelta,
+ TargetNivcsw: target.NivcswDelta,
+ Reps: reps,
+ }
+ d.DeltaNs = ts.Median - bs.Median
+ d.HighVariance = bs.CoV > covCeiling || ts.CoV > covCeiling
+
+ if target.GasUsed >= baseline.GasUsed {
+ d.GasDelta = target.GasUsed - baseline.GasUsed
+ }
+ if reps > 0 {
+ d.PerOpNs = d.DeltaNs / float64(reps)
+ d.PerOpGas = float64(d.GasDelta) / float64(reps)
+ }
+ return d
+}
diff --git a/tools/gasbench/diff_test.go b/tools/gasbench/diff_test.go
new file mode 100644
index 0000000000..bc74cd2873
--- /dev/null
+++ b/tools/gasbench/diff_test.go
@@ -0,0 +1,32 @@
+package gasbench
+
+import (
+ "testing"
+ "time"
+)
+
+// TestSubtractPerPass pins the slimmed per-pass contract: Subtract carries the
+// point median delta and the exact gas delta, with no significance verdict
+// (distinguishability now lives at the cross-run layer, crossrun.go).
+func TestSubtractPerPass(t *testing.T) {
+ baseline := Series{
+ GasUsed: 100,
+ Samples: []time.Duration{100 * time.Nanosecond, 102 * time.Nanosecond, 104 * time.Nanosecond},
+ }
+ target := Series{
+ GasUsed: 130,
+ Samples: []time.Duration{110 * time.Nanosecond, 112 * time.Nanosecond, 114 * time.Nanosecond},
+ }
+
+ d := Subtract("ADD", baseline, target, 10, 0.25)
+
+ if d.DeltaNs != 10 {
+ t.Errorf("DeltaNs = %v, want 10 (target median - baseline median)", d.DeltaNs)
+ }
+ if d.GasDelta != 30 {
+ t.Errorf("GasDelta = %d, want 30", d.GasDelta)
+ }
+ if d.PerOpNs != 1 {
+ t.Errorf("PerOpNs = %v, want 1 (DeltaNs/reps)", d.PerOpNs)
+ }
+}
diff --git a/tools/gasbench/emit.go b/tools/gasbench/emit.go
new file mode 100644
index 0000000000..6dcf9af37a
--- /dev/null
+++ b/tools/gasbench/emit.go
@@ -0,0 +1,230 @@
+package gasbench
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "strconv"
+)
+
+// Run is one emitted measurement row: the per-opcode aggregate over all
+// -count passes, ready for a gas-vs-time correlation. See README.md "Output
+// schema".
+type Run struct {
+ InputID string `json:"input_id"` // opcode id, e.g. "ADD"
+ Class string `json:"class"` // opcode family, e.g. "arithmetic"
+ Reps int `json:"reps"` // opcode executions the delta represents
+ GasUsed uint64 `json:"gas_used"` // whole-program gas delta (target - baseline); per-op = GasUsed/Reps -- this is the DIFFERENTIAL (net of filler), not the gas the chain charges
+ ConstGas uint64 `json:"const_gas"` // nominal per-op gas the chain charges (jump-table ConstGas); the repricing-relevant denominator for ns-per-gas. See README.md "Read the output"
+ ExecTimeNs float64 `json:"exec_time_ns"` // median whole-program delta over the counts (Summary.Center); always finite; per-op = ExecTimeNs/Reps
+ Count int `json:"count"` // cross-run n: number of -count passes aggregated
+ Iterations int `json:"iterations"` // timed iterations behind each series
+
+ // CILo/CIHi are the order-statistic CI bounds on ExecTimeNs (whole-program
+ // ns). Nullable: nil (JSON null / CSV empty) when the raw benchmath bound
+ // is non-finite (underpowered). THE GATE (Distinguishable) reads the raw
+ // bounds, never these pointers -- see crossrun.go and distinguishable.
+ CILo *float64 `json:"ci_lo"`
+ CIHi *float64 `json:"ci_hi"`
+ Confidence float64 `json:"confidence"` // actual CI confidence (>= requested)
+ Distinguishable bool `json:"distinguishable"` // paired delta CI excludes zero (ci_lo>0 || ci_hi<0) -- the statistical half of the gate (F1)
+ EffectSizePass bool `json:"effect_size_pass"` // per-op median delta clears the effect floor -- the practical half of the gate
+ PValue float64 `json:"p_value"` // advisory only: unpaired Mann-Whitney U p over baseline vs target medians; NOT the gate
+ Alpha float64 `json:"alpha"` // CompareAlpha behind the advisory p_value
+ Status string `json:"status"` // ok | sub_threshold | insignificant | underpowered | error -- never gated on CoV
+
+ CoV float64 `json:"cov"` // worse (max) of the baseline/target series CoV, max across counts -- advisory only
+ HighVariance bool `json:"high_variance"` // advisory: CoV exceeded the health-check ceiling on any count; does not invalidate Status=ok
+ Nvcsw int64 `json:"nvcsw"` // worse (max) of the baseline/target voluntary-context-switch count, max across counts; see README.md "Active-benchmarking diagnostics"
+ Nivcsw int64 `json:"nivcsw"` // worse (max) of the baseline/target involuntary-context-switch count, max across counts; see README.md "Active-benchmarking diagnostics"
+}
+
+// Status values. See README.md "Acceptance gate" for why Status reads the
+// paired delta CI (Distinguishable), not CoV.
+const (
+ StatusOK = "ok" // distinguishable AND clears the effect-size floor
+ StatusSubThreshold = "sub_threshold" // distinguishable but below the effect floor -- resolvable, but not meaningfully more expensive in ABSOLUTE time. NOT "correctly priced": a cheap op can still be mispriced. Only `ok` is correlation-eligible.
+ StatusInsignificant = "insignificant" // measurable but the CI straddles zero -- not distinguishable at this precision
+ StatusUnderpowered = "underpowered" // too few counts for a finite CI (count<2 or non-finite bound); the verdict short-circuits before distinguishability
+ // StatusError marks a case whose measurement never happened: its subtest
+ // b.Fatalf'd (invalid program, Measure failure), which unwinds only that
+ // subtest -- the parent still aggregates, and an empty accumulator must
+ // become an error row (NewErrorRun), never NaN in the output.
+ StatusError = "error"
+)
+
+// distinguishable is the statistical half of the acceptance gate (F1): the
+// paired delta CI excludes zero. It reads the RAW float64 bounds from crossRun
+// (never the null-mapped *float64), so a non-finite bound is ±Inf-safe -- -Inf
+// is not > 0 and +Inf is not < 0, so an underpowered row is never
+// distinguishable and no nil deref is possible. It is deliberately NOT the
+// advisory p_value.
+func distinguishable(ciLo, ciHi float64) bool { return ciLo > 0 || ciHi < 0 }
+
+// effectSizePass is the practical half of the gate: the per-op median delta
+// clears a minimum absolute-ns floor, so "significant" means "meaningfully more
+// expensive," not merely "distinguishable at large n". A minPerOpNs <= 0
+// disables the floor (every distinguishable row passes). The gas-relative floor
+// is deferred (see README/design).
+func effectSizePass(perOpDeltaNs, minPerOpNs float64) bool {
+ return minPerOpNs <= 0 || math.Abs(perOpDeltaNs) >= minPerOpNs
+}
+
+// classifyStatus derives the verdict. Underpowered short-circuits first (its CI
+// is non-finite, so distinguishability is not meaningful). Acceptance
+// (StatusOK) requires BOTH distinguishable and effectPass; distinguishable but
+// below the floor is StatusSubThreshold. CoV/HighVariance never enter here.
+func classifyStatus(underpowered, distinguishable, effectPass bool) string {
+ switch {
+ case underpowered:
+ return StatusUnderpowered
+ case distinguishable && effectPass:
+ return StatusOK
+ case distinguishable:
+ return StatusSubThreshold
+ default:
+ return StatusInsignificant
+ }
+}
+
+// nullableBound maps a raw CI bound to the wire's nullable column: nil when
+// non-finite (D-1: neither ±Inf nor NaN may reach encoding/json — it rejects
+// both). This is the only place raw->nullable happens; the gate reads the raw
+// bound upstream.
+func nullableBound(x float64) *float64 {
+ if !finite(x) {
+ return nil
+ }
+ return &x
+}
+
+// finite reports whether x is a real number (not ±Inf, not NaN).
+func finite(x float64) bool { return !math.IsInf(x, 0) && !math.IsNaN(x) }
+
+// NewErrorRun is the row for a case whose measurement never happened (its
+// subtest failed before accumulating a single count). Every numeric field is
+// finite/zero and the CI is null, so the writers stay NaN-free (D-1) and a
+// consumer filtering on status sees the failure instead of a corrupt number.
+func NewErrorRun(c Case, iterations int) Run {
+ return Run{
+ InputID: c.OpcodeID,
+ Class: string(c.Class),
+ Reps: c.Reps,
+ ConstGas: c.ConstGas,
+ Iterations: iterations,
+ PValue: 1,
+ Status: StatusError,
+ }
+}
+
+// hostHealth carries the advisory host-health signals for one opcode: the
+// max-across-counts values from the collector. Named fields (like crossRunInput)
+// keep the same-typed Nvcsw/Nivcsw from transposing at the NewRun call site.
+type hostHealth struct {
+ CoV float64 // worse-of-pair within-run CoV, max across counts
+ HighVariance bool // CoV exceeded the ceiling on any count
+ Nvcsw int64 // worse-of-pair voluntary ctx switches, max across counts
+ Nivcsw int64 // worse-of-pair involuntary ctx switches, max across counts
+}
+
+// NewRun composes the per-opcode aggregate row. cr carries the cross-run
+// benchmath verdict (raw CI bounds); h carries the advisory host-health signals;
+// minPerOpNs is the effect-size floor (<=0 disables it). The gate reads cr's raw
+// bounds; the wire gets null-mapped ones.
+func NewRun(c Case, cr crossRun, gasUsed uint64, count, iterations int, h hostHealth, minPerOpNs float64) Run {
+ // dist/effect (not the package funcs) to avoid shadowing.
+ dist := distinguishable(cr.CILo, cr.CIHi)
+ effect := effectSizePass(cr.MedianDeltaNs/float64(c.Reps), minPerOpNs)
+ return Run{
+ InputID: c.OpcodeID,
+ Class: string(c.Class),
+ Reps: c.Reps,
+ GasUsed: gasUsed,
+ ConstGas: c.ConstGas,
+ ExecTimeNs: cr.MedianDeltaNs,
+ Count: count,
+ Iterations: iterations,
+ CILo: nullableBound(cr.CILo),
+ CIHi: nullableBound(cr.CIHi),
+ Confidence: cr.Confidence,
+ Distinguishable: dist,
+ EffectSizePass: effect,
+ PValue: cr.P,
+ Alpha: cr.Alpha,
+ Status: classifyStatus(cr.Underpowered, dist, effect),
+ CoV: h.CoV,
+ HighVariance: h.HighVariance,
+ Nvcsw: h.Nvcsw,
+ Nivcsw: h.Nivcsw,
+ }
+}
+
+var csvHeader = []string{"input_id", "class", "reps", "gas_used", "const_gas", "exec_time_ns", "count", "iterations", "ci_lo", "ci_hi", "confidence", "distinguishable", "effect_size_pass", "p_value", "alpha", "status", "cov", "high_variance", "nvcsw", "nivcsw"}
+
+// formatBound renders a nullable CI bound: empty for a null (underpowered)
+// bound, full precision otherwise.
+func formatBound(p *float64) string {
+ if p == nil {
+ return ""
+ }
+ return strconv.FormatFloat(*p, 'g', -1, 64)
+}
+
+// WriteCSV writes a header plus one row per run.
+func WriteCSV(w io.Writer, runs []Run) error {
+ cw := csv.NewWriter(w)
+ if err := cw.Write(csvHeader); err != nil {
+ return fmt.Errorf("gasbench: write csv header: %w", err)
+ }
+ for i, r := range runs {
+ rec := []string{
+ r.InputID,
+ r.Class,
+ strconv.Itoa(r.Reps),
+ strconv.FormatUint(r.GasUsed, 10),
+ strconv.FormatUint(r.ConstGas, 10),
+ strconv.FormatFloat(r.ExecTimeNs, 'g', -1, 64),
+ strconv.Itoa(r.Count),
+ strconv.Itoa(r.Iterations),
+ // A null bound (underpowered) renders as an empty field; older CSV
+ // artifacts wrote +Inf here. See README.md schema.
+ formatBound(r.CILo),
+ formatBound(r.CIHi),
+ strconv.FormatFloat(r.Confidence, 'g', -1, 64),
+ strconv.FormatBool(r.Distinguishable),
+ strconv.FormatBool(r.EffectSizePass),
+ strconv.FormatFloat(r.PValue, 'g', -1, 64),
+ strconv.FormatFloat(r.Alpha, 'g', -1, 64),
+ r.Status,
+ // CoV is advisory-only (README.md "Acceptance gate"), so this
+ // deliberately rounds to 6 sig figs for readability; NDJSON's
+ // json.Encoder gives CoV full precision instead -- a consumer
+ // comparing CoV across the two formats will see this difference.
+ strconv.FormatFloat(r.CoV, 'g', 6, 64),
+ strconv.FormatBool(r.HighVariance),
+ strconv.FormatInt(r.Nvcsw, 10),
+ strconv.FormatInt(r.Nivcsw, 10),
+ }
+ if err := cw.Write(rec); err != nil {
+ return fmt.Errorf("gasbench: write csv row %d: %w", i, err)
+ }
+ }
+ cw.Flush()
+ if err := cw.Error(); err != nil {
+ return fmt.Errorf("gasbench: flush csv: %w", err)
+ }
+ return nil
+}
+
+// WriteNDJSON writes one JSON object per line.
+func WriteNDJSON(w io.Writer, runs []Run) error {
+ enc := json.NewEncoder(w) // Encode appends a newline: NDJSON by construction
+ for i := range runs {
+ if err := enc.Encode(&runs[i]); err != nil {
+ return fmt.Errorf("gasbench: encode run %d: %w", i, err)
+ }
+ }
+ return nil
+}
diff --git a/tools/gasbench/emit_test.go b/tools/gasbench/emit_test.go
new file mode 100644
index 0000000000..5c23a2359c
--- /dev/null
+++ b/tools/gasbench/emit_test.go
@@ -0,0 +1,258 @@
+package gasbench
+
+import (
+ "bytes"
+ "encoding/csv"
+ "encoding/json"
+ "math"
+ "testing"
+)
+
+// TestDistinguishableReadsRawBounds pins the gate (F1): it reads the raw CI
+// bounds and is ±Inf-safe -- -Inf is not > 0 and +Inf is not < 0, so an
+// underpowered (non-finite) CI is never distinguishable.
+func TestDistinguishableReadsRawBounds(t *testing.T) {
+ cases := []struct {
+ name string
+ lo, hi float64
+ want bool
+ }{
+ {"wholly above zero", 1, 3, true},
+ {"wholly below zero", -3, -1, true},
+ {"straddles zero", -1, 2, false},
+ {"lower bound touches zero", 0, 5, false},
+ {"underpowered ±Inf", math.Inf(-1), math.Inf(1), false},
+ {"NaN bounds", math.NaN(), math.NaN(), false},
+ }
+ for _, tc := range cases {
+ if got := distinguishable(tc.lo, tc.hi); got != tc.want {
+ t.Errorf("%s: distinguishable(%v,%v) = %v, want %v", tc.name, tc.lo, tc.hi, got, tc.want)
+ }
+ }
+}
+
+// TestNewErrorRunWriterSafe pins the failed-subtest path: an empty accumulator
+// becomes an error row whose numerics are all finite/zero and whose CI is null,
+// so a partially-failed run still emits parseable CSV/NDJSON (no NaN).
+func TestNewErrorRunWriterSafe(t *testing.T) {
+ r := NewErrorRun(Case{OpcodeID: "ADD", Class: ClassArithmetic, Reps: 1000, ConstGas: 3}, 20000)
+ if r.Status != StatusError {
+ t.Errorf("status got %q, want %q", r.Status, StatusError)
+ }
+ if r.CILo != nil || r.CIHi != nil {
+ t.Error("error row CI must be null")
+ }
+ if !finite(r.ExecTimeNs) || r.Distinguishable || r.EffectSizePass {
+ t.Errorf("error row must be finite and non-passing: exec=%v dist=%v eff=%v",
+ r.ExecTimeNs, r.Distinguishable, r.EffectSizePass)
+ }
+ var nd, cb bytes.Buffer
+ if err := WriteNDJSON(&nd, []Run{r}); err != nil {
+ t.Fatalf("WriteNDJSON of an error row must not error: %v", err)
+ }
+ if err := WriteCSV(&cb, []Run{r}); err != nil {
+ t.Fatalf("WriteCSV of an error row must not error: %v", err)
+ }
+}
+
+// TestNullableBoundNonFinite pins D-1's full contract: every non-finite bound
+// (±Inf and NaN — encoding/json rejects both) maps to nil; finite passes through.
+func TestNullableBoundNonFinite(t *testing.T) {
+ for _, x := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} {
+ if nullableBound(x) != nil {
+ t.Errorf("nullableBound(%v) must be nil", x)
+ }
+ }
+ if p := nullableBound(42.5); p == nil || *p != 42.5 {
+ t.Errorf("nullableBound(42.5) got %v, want 42.5", p)
+ }
+}
+
+// TestClassifyStatus pins the verdict matrix: underpowered short-circuits
+// first; ok requires BOTH distinguishable and effectPass; distinguishable but
+// below the floor is sub_threshold; otherwise insignificant.
+func TestClassifyStatus(t *testing.T) {
+ cases := []struct {
+ name string
+ underpowered, distinguishable, effectPass bool
+ want string
+ }{
+ {"underpowered wins over all", true, true, true, StatusUnderpowered},
+ {"underpowered wins even if not distinguishable", true, false, false, StatusUnderpowered},
+ {"distinguishable + effect => ok", false, true, true, StatusOK},
+ {"distinguishable but below floor => sub_threshold", false, true, false, StatusSubThreshold},
+ {"not distinguishable => insignificant", false, false, true, StatusInsignificant},
+ {"not distinguishable, no effect => insignificant", false, false, false, StatusInsignificant},
+ }
+ for _, tc := range cases {
+ if got := classifyStatus(tc.underpowered, tc.distinguishable, tc.effectPass); got != tc.want {
+ t.Errorf("%s: classifyStatus(up=%v,dist=%v,eff=%v) = %q, want %q",
+ tc.name, tc.underpowered, tc.distinguishable, tc.effectPass, got, tc.want)
+ }
+ }
+}
+
+// TestEffectSizePass pins the practical-half gate: absolute per-op ns clears the
+// floor; a non-positive floor disables it.
+func TestEffectSizePass(t *testing.T) {
+ cases := []struct {
+ name string
+ perOpDeltaNs, minPerOpNs float64
+ want bool
+ }{
+ {"above floor", 5, 1, true},
+ {"below floor", 0.5, 1, false},
+ {"at floor", 1, 1, true},
+ {"negative delta above floor by magnitude", -5, 1, true},
+ {"floor disabled (zero)", 0.001, 0, true},
+ {"floor disabled (negative)", 0.001, -1, true},
+ }
+ for _, tc := range cases {
+ if got := effectSizePass(tc.perOpDeltaNs, tc.minPerOpNs); got != tc.want {
+ t.Errorf("%s: effectSizePass(%v,%v) = %v, want %v", tc.name, tc.perOpDeltaNs, tc.minPerOpNs, got, tc.want)
+ }
+ }
+}
+
+// TestNewRunUnderpoweredNullCIAndNDJSONSafe covers D-1: a non-finite CI maps to
+// nil (JSON null / CSV empty) and does not break WriteNDJSON with an
+// UnsupportedValueError. The status short-circuits to underpowered.
+func TestNewRunUnderpoweredNullCIAndNDJSONSafe(t *testing.T) {
+ c := Case{OpcodeID: "ADD", Class: ClassArithmetic, Reps: 1000, ConstGas: 3}
+ up := crossRun{MedianDeltaNs: 3, CILo: math.Inf(-1), CIHi: math.Inf(1), P: 1, Underpowered: true}
+
+ r := NewRun(c, up, 100, 1, 20000, hostHealth{}, 1.0)
+ if r.CILo != nil || r.CIHi != nil {
+ t.Errorf("underpowered CI must map to nil, got Lo=%v Hi=%v", r.CILo, r.CIHi)
+ }
+ if r.Distinguishable {
+ t.Error("underpowered row must not be distinguishable")
+ }
+ if r.Status != StatusUnderpowered {
+ t.Errorf("status = %q, want %q", r.Status, StatusUnderpowered)
+ }
+
+ var nd bytes.Buffer
+ if err := WriteNDJSON(&nd, []Run{r}); err != nil {
+ t.Fatalf("WriteNDJSON of an underpowered row must not error (D-1): %v", err)
+ }
+ var csvBuf bytes.Buffer
+ if err := WriteCSV(&csvBuf, []Run{r}); err != nil {
+ t.Fatalf("WriteCSV of an underpowered row must not error: %v", err)
+ }
+ if !bytes.Contains(csvBuf.Bytes(), []byte(",,")) {
+ t.Errorf("underpowered CSV must render empty CI fields, got:\n%s", csvBuf.String())
+ }
+}
+
+// TestNewRunDistinguishableFiniteCI pins the ok path: a finite CI excluding
+// zero AND a per-op delta above the floor yields distinguishable + effect_size_pass
+// + non-nil bounds + status ok. Per-op = 30000/1000 = 30ns >> the 1.0ns floor.
+func TestNewRunDistinguishableFiniteCI(t *testing.T) {
+ c := Case{OpcodeID: "DIV", Class: ClassArithmetic, Reps: 1000, ConstGas: 5}
+ fin := crossRun{MedianDeltaNs: 30000, CILo: 28000, CIHi: 32000, Confidence: 0.95, P: 4e-5, Alpha: 0.05}
+
+ r := NewRun(c, fin, 5000, 10, 20000, hostHealth{CoV: 0.01}, 1.0)
+ if r.CILo == nil || r.CIHi == nil {
+ t.Fatal("finite CI must be non-nil")
+ }
+ if !r.Distinguishable {
+ t.Error("CI excluding zero must be distinguishable")
+ }
+ if !r.EffectSizePass {
+ t.Error("30ns/op must clear the 1.0ns floor")
+ }
+ if r.Status != StatusOK {
+ t.Errorf("status = %q, want %q", r.Status, StatusOK)
+ }
+}
+
+// TestNewRunEffectFloorDemotes (AC9): a distinguishable row whose per-op delta
+// is below the floor is demoted to sub_threshold -- distinguishable, but not
+// meaningfully more expensive. Per-op = 500/1000 = 0.5ns < the 1.0ns floor.
+func TestNewRunEffectFloorDemotes(t *testing.T) {
+ c := Case{OpcodeID: "ADD", Class: ClassArithmetic, Reps: 1000, ConstGas: 3}
+ cr := crossRun{MedianDeltaNs: 500, CILo: 400, CIHi: 600, Confidence: 0.95, P: 1e-4, Alpha: 0.05}
+
+ r := NewRun(c, cr, 1000, 10, 20000, hostHealth{}, 1.0)
+ if !r.Distinguishable {
+ t.Error("CI excluding zero must be distinguishable")
+ }
+ if r.EffectSizePass {
+ t.Error("0.5ns/op must NOT clear the 1.0ns floor")
+ }
+ if r.Status != StatusSubThreshold {
+ t.Errorf("status = %q, want %q", r.Status, StatusSubThreshold)
+ }
+}
+
+// TestNewRunEffectFloorPasses (AC10): the same shape scaled above the floor is ok.
+// Per-op = 5000/1000 = 5ns > the 1.0ns floor.
+func TestNewRunEffectFloorPasses(t *testing.T) {
+ c := Case{OpcodeID: "ADD", Class: ClassArithmetic, Reps: 1000, ConstGas: 3}
+ cr := crossRun{MedianDeltaNs: 5000, CILo: 4000, CIHi: 6000, Confidence: 0.95, P: 1e-4, Alpha: 0.05}
+
+ r := NewRun(c, cr, 1000, 10, 20000, hostHealth{}, 1.0)
+ if !r.EffectSizePass {
+ t.Error("5ns/op must clear the 1.0ns floor")
+ }
+ if r.Status != StatusOK {
+ t.Errorf("status = %q, want %q", r.Status, StatusOK)
+ }
+}
+
+// TestWriteRoundTripMixed pins the writers on a mixed batch: a finite-CI ok row
+// and an underpowered null-CI row. NDJSON must round-trip through a standard
+// decoder (finite bound -> the float, null -> nil), and CSV must render the
+// finite bound as a number and the null bound as an empty field.
+func TestWriteRoundTripMixed(t *testing.T) {
+ okRun := NewRun(
+ Case{OpcodeID: "DIV", Class: ClassArithmetic, Reps: 1000, ConstGas: 5},
+ crossRun{MedianDeltaNs: 72534, CILo: 72501, CIHi: 72558.5, Confidence: 0.978, P: 4e-5, Alpha: 0.05},
+ 3000, 10, 20000, hostHealth{CoV: 0.01}, 1.0)
+ upRun := NewRun(
+ Case{OpcodeID: "ADD", Class: ClassArithmetic, Reps: 1000, ConstGas: 3},
+ crossRun{MedianDeltaNs: 3, CILo: math.Inf(-1), CIHi: math.Inf(1), P: 1, Underpowered: true},
+ 1000, 1, 20000, hostHealth{}, 1.0)
+ runs := []Run{okRun, upRun}
+
+ var nd bytes.Buffer
+ if err := WriteNDJSON(&nd, runs); err != nil {
+ t.Fatalf("WriteNDJSON: %v", err)
+ }
+ dec := json.NewDecoder(&nd)
+ var got [2]Run
+ for i := range got {
+ if err := dec.Decode(&got[i]); err != nil {
+ t.Fatalf("decode NDJSON row %d: %v", i, err)
+ }
+ }
+ if got[0].CILo == nil || *got[0].CILo != 72501 {
+ t.Errorf("ok row ci_lo round-trip got %v, want 72501", got[0].CILo)
+ }
+ if got[1].CILo != nil || got[1].CIHi != nil {
+ t.Errorf("underpowered row CI must decode to nil, got %v/%v", got[1].CILo, got[1].CIHi)
+ }
+ if got[0].Status != StatusOK || got[1].Status != StatusUnderpowered {
+ t.Errorf("statuses got %q/%q, want %q/%q", got[0].Status, got[1].Status, StatusOK, StatusUnderpowered)
+ }
+
+ var cb bytes.Buffer
+ if err := WriteCSV(&cb, runs); err != nil {
+ t.Fatalf("WriteCSV: %v", err)
+ }
+ recs, err := csv.NewReader(&cb).ReadAll()
+ if err != nil {
+ t.Fatalf("parse CSV: %v", err)
+ }
+ // header + 2 rows; ci_lo/ci_hi are columns 8/9 (0-indexed).
+ if len(recs) != 3 {
+ t.Fatalf("CSV records got %d, want 3", len(recs))
+ }
+ if recs[1][8] != "72501" {
+ t.Errorf("ok row ci_lo CSV got %q, want %q", recs[1][8], "72501")
+ }
+ if recs[2][8] != "" || recs[2][9] != "" {
+ t.Errorf("underpowered row CI CSV must be empty, got %q/%q", recs[2][8], recs[2][9])
+ }
+}
diff --git a/tools/gasbench/gasbench.go b/tools/gasbench/gasbench.go
new file mode 100644
index 0000000000..4de7a4119c
--- /dev/null
+++ b/tools/gasbench/gasbench.go
@@ -0,0 +1,147 @@
+// Package gasbench measures per-opcode EVM execution time and correlates it
+// with gas cost via differential microbenchmarking. See README.md for the
+// full methodology and rationale.
+package gasbench
+
+import (
+ "fmt"
+ "runtime"
+ "runtime/debug"
+ "syscall"
+ "time"
+)
+
+// RunOnce executes a pre-loaded EVM program to completion through the
+// tracer-free interpreter and reports the gas it consumed. It closes over
+// its program (see Program.Run) so the timing loop never rebuilds
+// interpreter/StateDB state per call.
+//
+// Contract:
+// - Deterministic: identical program yields identical gasUsed and
+// equivalent work every call.
+// - Self-contained and tracer-free: no I/O, logging, or tracer on the hot
+// path.
+// - Sufficient work: total runtime well above timer resolution
+// (target >= ~10us).
+// - Allocation-light: GC is disabled during the window (see Measure), so
+// allocations across warmup+Iterations must fit in RAM.
+type RunOnce func() (gasUsed uint64, err error)
+
+// sink defeats dead-code elimination of the gas result. Safe without
+// synchronization only because BenchmarkOpcodes's subtests run sequentially
+// (no b.Parallel) -- GOMAXPROCS=1 and thread-locking do not by themselves
+// make the ^= below race-free, and do not protect this var if a future
+// change parallelizes the subtests.
+var sink uint64
+
+// Config controls one measurement series.
+type Config struct {
+ Warmup int // discarded iterations; warm I-cache/D-cache/branch predictor and lazy init
+ Iterations int // timed iterations retained as samples
+ DisableGC bool // stop the collector for the measurement window (recommended)
+ LockThread bool // pin the goroutine to its OS thread for the window (recommended)
+}
+
+// DefaultConfig is a sane starting point; tune Iterations to the noise floor.
+func DefaultConfig() Config {
+ return Config{Warmup: 2000, Iterations: 20000, DisableGC: true, LockThread: true}
+}
+
+// Series is the raw output of one measurement: per-iteration wall-clock samples
+// plus the deterministic gas cost.
+type Series struct {
+ InputID string // the measured input's identity, e.g. "ADD/baseline" (opcode + variant)
+ GasUsed uint64
+ Samples []time.Duration
+ Warnings []string
+
+ // NvcswDelta/NivcswDelta are process-wide (RUSAGE_SELF) voluntary/
+ // involuntary context-switch counts over the timed window (Warmup
+ // excluded). See README.md "Active-benchmarking diagnostics".
+ NvcswDelta int64
+ NivcswDelta int64
+}
+
+// rusageSnapshot reads the process's current voluntary/involuntary
+// context-switch counters (process-wide, not thread-scoped: Go's syscall
+// package exposes no portable RUSAGE_THREAD).
+func rusageSnapshot() (nvcsw, nivcsw int64, err error) {
+ var ru syscall.Rusage
+ if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
+ return 0, 0, fmt.Errorf("gasbench: getrusage: %w", err)
+ }
+ return ru.Nvcsw, ru.Nivcsw, nil
+}
+
+// Measure runs Warmup discarded iterations, then Iterations timed iterations
+// of run(), returning per-iteration monotonic samples.
+func Measure(id string, run RunOnce, cfg Config) (Series, error) {
+ if run == nil {
+ return Series{}, fmt.Errorf("gasbench: nil RunOnce for %q", id)
+ }
+ if cfg.Iterations < 1 {
+ return Series{}, fmt.Errorf("gasbench: Iterations must be >= 1 for %q", id)
+ }
+
+ s := Series{InputID: id, Samples: make([]time.Duration, cfg.Iterations)}
+ if n := runtime.GOMAXPROCS(0); n != 1 {
+ s.Warnings = append(s.Warnings,
+ fmt.Sprintf("GOMAXPROCS=%d (want 1): scheduler noise will inflate the tail", n))
+ }
+
+ if cfg.LockThread {
+ // Stop the OS from migrating this goroutine across cores mid-window,
+ // which would cost cold-cache and cross-NUMA restarts inside a sample.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ }
+
+ // Warmup, discarded. Also the first place a broken program is caught.
+ var g uint64
+ var err error
+ for i := 0; i < cfg.Warmup; i++ {
+ if g, err = run(); err != nil {
+ return Series{}, fmt.Errorf("gasbench: warmup failed for %q: %w", id, err)
+ }
+ }
+ sink ^= g
+
+ if cfg.DisableGC {
+ // A GC pause landing inside a sample is pure tail noise and is exactly
+ // what corrupts p99. Collect once to start from a clean heap, stop the
+ // collector for the window, and restore the prior target on exit. Cost:
+ // the heap grows unbounded during the window (see RunOnce contract).
+ runtime.GC()
+ prev := debug.SetGCPercent(-1)
+ defer debug.SetGCPercent(prev)
+ }
+
+ nvcswBefore, nivcswBefore, rerr := rusageSnapshot()
+ rusageOK := rerr == nil
+ if rerr != nil {
+ s.Warnings = append(s.Warnings, rerr.Error())
+ }
+
+ for i := 0; i < cfg.Iterations; i++ {
+ start := time.Now()
+ g, err = run()
+ d := time.Since(start) // monotonic; immune to wall-clock/NTP steps
+ if err != nil {
+ return Series{}, fmt.Errorf("gasbench: iteration %d failed for %q: %w", i, id, err)
+ }
+ s.Samples[i] = d
+ sink ^= g
+ }
+ s.GasUsed = g
+
+ if rusageOK {
+ nvcswAfter, nivcswAfter, rerr := rusageSnapshot()
+ if rerr != nil {
+ s.Warnings = append(s.Warnings, rerr.Error())
+ } else {
+ s.NvcswDelta = nvcswAfter - nvcswBefore
+ s.NivcswDelta = nivcswAfter - nivcswBefore
+ }
+ }
+ return s, nil
+}
diff --git a/tools/gasbench/program.go b/tools/gasbench/program.go
new file mode 100644
index 0000000000..31d9ec18c5
--- /dev/null
+++ b/tools/gasbench/program.go
@@ -0,0 +1,71 @@
+package gasbench
+
+import (
+ "fmt"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/core/vm/runtime"
+)
+
+// contractAddr matches the address runtime.Execute uses internally, for
+// parity with the fork's own test harness.
+var contractAddr = common.BytesToAddress([]byte("contract"))
+
+// newRuntimeConfig builds a fresh, tracer-free interpreter config against a
+// fresh in-memory StateDB. Uses runtime.Call, not runtime.Execute: Execute
+// discards leftOverGas (see Program.Run). Replicates Execute's fresh-state
+// setup (state.New + CreateAccount + SetCode).
+func newRuntimeConfig(code []byte) (*runtime.Config, error) {
+ cfg := new(runtime.Config)
+ runtime.SetDefaults(cfg) // Shanghai+Cancun active, GasLimit=MaxUint64, no tracer
+ cfg.EVMConfig = vm.Config{}
+
+ sdb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
+ if err != nil {
+ return nil, fmt.Errorf("gasbench: new statedb: %w", err)
+ }
+ cfg.State = sdb
+ cfg.State.CreateAccount(contractAddr)
+ cfg.State.SetCode(contractAddr, code)
+ return cfg, nil
+}
+
+// Program holds a warmed EVM environment so a timing loop measures only the
+// hot Call, not StateDB construction. Build one Program per bytecode input;
+// call Run in the timing loop. See README.md "Program reuse across calls"
+// before reusing a Program across a much longer or state-touching series.
+type Program struct {
+ cfg *runtime.Config
+}
+
+// NewProgram loads code into a warmed environment and validates that it runs
+// clean once before any timing. A program that reverts/OOGs is rejected here
+// so the timing loop never records a bogus sample.
+func NewProgram(code []byte) (*Program, error) {
+ cfg, err := newRuntimeConfig(code)
+ if err != nil {
+ return nil, err
+ }
+ p := &Program{cfg: cfg}
+ if _, err := p.Run(); err != nil {
+ return nil, fmt.Errorf("gasbench: program does not run clean: %w", err)
+ }
+ return p, nil
+}
+
+// Run executes the loaded code once through a bare go-ethereum EVM (no
+// Cosmos ante handler or GasMeter -- see README.md "Gas isolation from the
+// Cosmos layer") and returns EVM gas consumed (cfg.GasLimit - leftOverGas).
+//
+// err is nil on a clean STOP/RETURN, vm.ErrExecutionReverted on REVERT
+// (gasUsed partial), vm.ErrOutOfGas on out-of-gas (gasUsed == GasLimit), or
+// vm.ErrInvalidOpCode on a bad opcode. Every Case built by programs.go
+// terminates cleanly, so a non-nil err means the run is invalid -- see the
+// RunOnce contract in gasbench.go.
+func (p *Program) Run() (gasUsed uint64, err error) {
+ _, leftOverGas, callErr := runtime.Call(contractAddr, nil, p.cfg)
+ return p.cfg.GasLimit - leftOverGas, callErr
+}
diff --git a/tools/gasbench/programs.go b/tools/gasbench/programs.go
new file mode 100644
index 0000000000..8562f722c5
--- /dev/null
+++ b/tools/gasbench/programs.go
@@ -0,0 +1,231 @@
+package gasbench
+
+import (
+ "fmt"
+
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/core/vm/program"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/holiman/uint256"
+)
+
+// DefaultReps is the number of unrolled (straight-line, no loop counter)
+// body units per program, fixed and equal for baseline and target.
+const DefaultReps = 1000
+
+// Class groups opcodes by arithmetic character.
+type Class string
+
+// Opcode classes.
+const (
+ ClassArithmetic Class = "arithmetic"
+ ClassBitwise Class = "bitwise"
+ ClassComparison Class = "comparison"
+ ClassStack Class = "stack"
+)
+
+// OpSpec is one benchmarkable opcode.
+type OpSpec struct {
+ Name string
+ Op vm.OpCode
+ Class Class
+ Arity int // stack items consumed by the op (operands)
+ ConstGas uint64 // definitional constant gas from the fork jump table
+ // DataDependent is true when execution TIME varies with operand
+ // magnitude even though gas is constant. See README.md "Scope".
+ DataDependent bool
+}
+
+// Specs is the benchmarkable scalar-opcode set: every entry is
+// constant-gas in this fork (verified against core/vm/jump_table.go +
+// core/vm/eips.go), none touches memory/state. EXP is omitted: its gas is
+// parametric (gasExpFrontier), not constant. See AGENTS.md for the
+// hand-verification requirement when adding an entry.
+var Specs = []OpSpec{
+ // arithmetic
+ {"ADD", vm.ADD, ClassArithmetic, 2, vm.GasFastestStep, false},
+ {"MUL", vm.MUL, ClassArithmetic, 2, vm.GasFastStep, true},
+ {"SUB", vm.SUB, ClassArithmetic, 2, vm.GasFastestStep, false},
+ {"DIV", vm.DIV, ClassArithmetic, 2, vm.GasFastStep, true},
+ {"MOD", vm.MOD, ClassArithmetic, 2, vm.GasFastStep, true},
+ {"ADDMOD", vm.ADDMOD, ClassArithmetic, 3, vm.GasMidStep, true},
+ {"MULMOD", vm.MULMOD, ClassArithmetic, 3, vm.GasMidStep, true},
+ // bitwise
+ {"AND", vm.AND, ClassBitwise, 2, vm.GasFastestStep, false},
+ {"OR", vm.OR, ClassBitwise, 2, vm.GasFastestStep, false},
+ {"XOR", vm.XOR, ClassBitwise, 2, vm.GasFastestStep, false},
+ {"NOT", vm.NOT, ClassBitwise, 1, vm.GasFastestStep, false},
+ {"SHL", vm.SHL, ClassBitwise, 2, vm.GasFastestStep, true},
+ {"SHR", vm.SHR, ClassBitwise, 2, vm.GasFastestStep, true},
+ {"SAR", vm.SAR, ClassBitwise, 2, vm.GasFastestStep, true},
+ // comparison
+ {"LT", vm.LT, ClassComparison, 2, vm.GasFastestStep, false},
+ {"GT", vm.GT, ClassComparison, 2, vm.GasFastestStep, false},
+ {"SLT", vm.SLT, ClassComparison, 2, vm.GasFastestStep, false},
+ {"SGT", vm.SGT, ClassComparison, 2, vm.GasFastestStep, false},
+ {"EQ", vm.EQ, ClassComparison, 2, vm.GasFastestStep, false},
+ {"ISZERO", vm.ISZERO, ClassComparison, 1, vm.GasFastestStep, false},
+ // stack
+ {"DUP1", vm.DUP1, ClassStack, 0, vm.GasFastestStep, false},
+ {"SWAP1", vm.SWAP1, ClassStack, 0, vm.GasFastestStep, false},
+}
+
+// Case is a differential bytecode pair for one opcode, ready for
+// measurement. See README.md "Differential construction".
+//
+// ExpectedGasDelta is the definitional whole-program gas delta from
+// BuildCaseWith's algebra (not necessarily Reps*ConstGas -- stack
+// rebalancing adds its own cost); bench_test.go cross-checks it against the
+// measured Diff.GasDelta as a self-check of the construction itself.
+type Case struct {
+ OpcodeID string
+ Class Class
+ DataDependent bool
+ Reps int
+ ConstGas uint64 // nominal per-op gas the chain charges (OpSpec.ConstGas); the repricing-relevant denominator, distinct from the differential GasDelta
+ Baseline []byte
+ Target []byte
+ ExpectedGasDelta uint64
+}
+
+// seedOperands are the distinct, non-zero, ascending working-value operands
+// fed to the general (default) branch -- one per stack slot the opcode
+// consumes, index 0 landing at the modulus/divisor position and the last at
+// the top of the stack.
+//
+// Distinctness and order are load-bearing, not cosmetic. Equal operands make
+// holiman/uint256 short-circuit the arithmetic before the real kernel:
+// DIV(x,x) returns 1 and MOD(x,x) returns 0 without ever calling udivrem
+// (uint256.go Div/Mod), so an all-equal input would time a compare, not a
+// 256-bit division, and understate DIV/MOD by ~10x. Ascending order keeps the
+// arity-2 dividend (top) above the divisor so DIV/MOD reach udivrem, and puts
+// the smallest value at the modulus slot for ADDMOD/MULMOD so their operands
+// are not already reduced. All three are multi-limb; the largest is full-width.
+// See README.md "Differential construction".
+var seedOperands = []*uint256.Int{
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffff"), // 2^192-1 (divisor/modulus slot)
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), // 2^224-1
+ uint256.MustFromHex("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), // 2^256-1 (full width)
+}
+
+// seedShift is a fixed, in-range (<256) shift amount for SHL/SHR/SAR -- it must
+// be a small distinct operand, not a full-width value, or the shift would hit
+// go-ethereum's "shift >= 256 -> 0" early-out. See README.md "Differential
+// construction".
+var seedShift = uint256.NewInt(4)
+
+// BuildCases builds every spec's case at DefaultReps.
+func BuildCases() []Case {
+ out := make([]Case, 0, len(Specs))
+ for _, s := range Specs {
+ out = append(out, BuildCaseWith(s, DefaultReps, seedOperands))
+ }
+ return out
+}
+
+// BuildCaseWith constructs the differential case for one opcode. See
+// README.md "Differential construction" for the balanced-DUP/POP algebra;
+// per-branch delta formulas are inlined below next to the code they justify.
+// Every unit is net-0 so stack depth never grows or underflows.
+func BuildCaseWith(s OpSpec, reps int, operands []*uint256.Int) Case {
+ base := program.New()
+ tgt := program.New()
+
+ // DUP1/SWAP1 need two values on the stack; the general branch needs one
+ // distinct operand per slot the opcode consumes. Guard here so a caller
+ // that under-supplies operands fails at construction, not with a silent
+ // stack underflow at measurement.
+ if len(operands) < 2 || len(operands) < s.Arity {
+ panic(fmt.Sprintf("gasbench: %s needs >= max(2, arity=%d) operands, got %d", s.Name, s.Arity, len(operands)))
+ }
+ // Guard the conversions below: reps feeds uint64(reps) in ExpectedGasDelta
+ // and Arity feeds the DUP opcode byte (DUP16 is the EVM ceiling).
+ if reps <= 0 {
+ panic(fmt.Sprintf("gasbench: %s needs reps > 0, got %d", s.Name, reps))
+ }
+ if s.Arity > 16 {
+ panic(fmt.Sprintf("gasbench: %s has Arity %d > 16 (DUP16 is the deepest lift)", s.Name, s.Arity))
+ }
+
+ var perUnitDelta uint64
+ switch s.Op {
+ case vm.DUP1:
+ base.Push(operands[0]).Push(operands[1])
+ tgt.Push(operands[0]).Push(operands[1])
+ for i := 0; i < reps; i++ {
+ tgt.Op(vm.DUP1, vm.POP)
+ base.Op(vm.PUSH0, vm.POP)
+ }
+ perUnitDelta = s.ConstGas - vm.GasQuickStep // 3 - 2
+
+ case vm.SWAP1:
+ base.Push(operands[0]).Push(operands[1])
+ tgt.Push(operands[0]).Push(operands[1])
+ for i := 0; i < reps; i++ {
+ tgt.Op(vm.SWAP1)
+ base.Op(vm.JUMPDEST)
+ }
+ perUnitDelta = s.ConstGas - params.JumpdestGas // 3 - 1
+
+ case vm.SHL, vm.SHR, vm.SAR:
+ // Shifts need a small in-range shift amount on top and a full-width
+ // value beneath -- distinct operands, or the shift hits go-ethereum's
+ // ">= 256 -> 0" early-out. This is the arity-2 shape of the default
+ // branch with a bespoke top operand; see README.md "Differential
+ // construction".
+ value := operands[len(operands)-1] // full-width
+ base.Push(value).Push(seedShift)
+ tgt.Push(value).Push(seedShift)
+ for i := 0; i < reps; i++ {
+ tgt.Op(vm.DUP2, vm.DUP2, s.Op, vm.POP)
+ base.Op(vm.DUP2, vm.DUP2, vm.POP, vm.POP)
+ }
+ perUnitDelta = s.ConstGas - vm.GasQuickStep // 3 - 2, same shape as the n=2 default case
+
+ default:
+ // General n-operand case: see README.md "Differential construction"
+ // for the (n-1)*GasQuickStep derivation this formula implements.
+ // Arity 0 must be special-cased above (like DUP1/SWAP1): the
+ // (n-1)*GasQuickStep term below assumes n >= 1.
+ if s.Arity < 1 {
+ panic(fmt.Sprintf("gasbench: %s has Arity %d < 1 and is not special-cased in BuildCaseWith", s.Name, s.Arity))
+ }
+ // Push one DISTINCT operand per slot, then DUP reps times.
+ // DUP copies the deepest of the arity operands; repeating it
+ // arity times lifts fresh copies of all arity operands back to the top
+ // in order, so every unit re-runs the op on the SAME distinct tuple
+ // (equal operands would let uint256 short-circuit DIV/MOD -- see
+ // seedOperands). GasFastestStep is identical for every DUP, so this
+ // leaves the (n-1)*GasQuickStep gas algebra unchanged from a DUP1 fill.
+ dupN := vm.DUP1 + vm.OpCode(s.Arity-1) //nolint:gosec // 1 <= Arity <= 16 guarded above
+ for d := 0; d < s.Arity; d++ {
+ base.Push(operands[d])
+ tgt.Push(operands[d])
+ }
+ for i := 0; i < reps; i++ {
+ for d := 0; d < s.Arity; d++ {
+ tgt.Op(dupN)
+ base.Op(dupN)
+ }
+ tgt.Op(s.Op, vm.POP)
+ for d := 0; d < s.Arity; d++ {
+ base.Op(vm.POP)
+ }
+ }
+ perUnitDelta = s.ConstGas - uint64(s.Arity-1)*vm.GasQuickStep //nolint:gosec // 1 <= Arity <= 16 guarded above
+ }
+
+ base.Op(vm.STOP)
+ tgt.Op(vm.STOP)
+
+ return Case{
+ OpcodeID: s.Name,
+ Class: s.Class,
+ DataDependent: s.DataDependent,
+ Reps: reps,
+ ConstGas: s.ConstGas,
+ Baseline: base.Bytes(),
+ Target: tgt.Bytes(),
+ ExpectedGasDelta: perUnitDelta * uint64(reps), //nolint:gosec // reps > 0 guarded above
+ }
+}
diff --git a/tools/gasbench/programs_test.go b/tools/gasbench/programs_test.go
new file mode 100644
index 0000000000..e47da5c003
--- /dev/null
+++ b/tools/gasbench/programs_test.go
@@ -0,0 +1,20 @@
+package gasbench
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/core/vm"
+)
+
+// TestBuildCaseWithPanicsOnUnhandledArityZero pins the default branch's
+// Arity guard: an Arity-0 spec that is not special-cased (unlike DUP1/SWAP1)
+// must panic rather than silently feed n=0 into the (n-1)*GasQuickStep
+// delta formula.
+func TestBuildCaseWithPanicsOnUnhandledArityZero(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Error("BuildCaseWith accepted an Arity-0 spec in the default branch; want panic")
+ }
+ }()
+ BuildCaseWith(OpSpec{Name: "PC", Op: vm.PC, Class: ClassStack, Arity: 0, ConstGas: vm.GasQuickStep}, 10, seedOperands)
+}
diff --git a/tools/gasbench/run.sh b/tools/gasbench/run.sh
new file mode 100755
index 0000000000..3b5a64dfe3
--- /dev/null
+++ b/tools/gasbench/run.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# One measurement thread pinned to one core. Set CORE to a core that is NOT
+# shared with an active hyperthread sibling and, ideally, isolated on the
+# kernel cmdline (isolcpus/nohz_full/rcu_nocbs).
+CORE="${GASBENCH_CORE:-3}"
+
+# --- Operator responsibilities the PROCESS CANNOT set -----------------------
+# These MUST be set at OS/BIOS before trusting numbers. This runner checks and
+# warns; it cannot enforce them. On a dedicated EC2 host, ensure no co-tenants.
+# * Turbo/boost OFF: echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
+# (or disable in BIOS) -- else frequency drifts per-sample.
+# * Governor=performance:
+# sudo cpupower frequency-set -g performance
+# * Deep C-states OFF: boot intel_idle.max_cstate=1 processor.max_cstate=1
+# (C-state exit latency lands in the tail).
+# * SMT OFF for the measured core, or pin away from its sibling.
+# * Core isolation: isolcpus=$CORE nohz_full=$CORE rcu_nocbs=$CORE (cmdline).
+# * IRQ affinity moved off $CORE.
+
+warn() { printf 'WARN: %s\n' "$*" >&2; }
+
+checked_freq=0
+if [ -r /sys/devices/system/cpu/intel_pstate/no_turbo ]; then
+ checked_freq=1
+ [ "$(cat /sys/devices/system/cpu/intel_pstate/no_turbo)" = "1" ] || warn "turbo appears ENABLED"
+fi
+gov="/sys/devices/system/cpu/cpu${CORE}/cpufreq/scaling_governor"
+if [ -r "$gov" ]; then
+ checked_freq=1
+ [ "$(cat "$gov")" = "performance" ] || warn "cpu${CORE} governor != performance"
+fi
+# These sysfs paths are x86/Linux-specific and absent on ARM (Graviton) and
+# non-Linux. Absence means "could not verify", NOT "clean" -- say so, or the
+# operator reads silence as a passing check.
+[ "$checked_freq" = "1" ] || warn "cannot verify turbo/governor here (no intel_pstate or cpufreq) -- trust numbers only on a known fixed-frequency host"
+
+export GOMAXPROCS=1
+# See README.md "Running it" for the full env-var list and defaults.
+export GASBENCH_OUT_CSV="${GASBENCH_OUT_CSV:-gasbench.csv}"
+export GASBENCH_OUT_NDJSON="${GASBENCH_OUT_NDJSON:-gasbench.ndjson}"
+
+PIN=(env)
+if command -v taskset >/dev/null 2>&1; then
+ # taskset -c N aborts outright if CPU index N does not exist (e.g. the
+ # default CORE=3 on a 2-core CI runner), so validate against the host and
+ # fall back to unpinned like the no-taskset branch instead of failing the run.
+ ncpu=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
+ if [ "$CORE" -ge 0 ] 2>/dev/null && [ "$CORE" -lt "$ncpu" ]; then
+ PIN=(taskset -c "$CORE")
+ else
+ warn "GASBENCH_CORE=$CORE out of range (host has $ncpu CPUs); running unpinned -- results are indicative only"
+ fi
+else
+ warn "taskset not found (non-Linux?); running unpinned -- results are indicative only"
+fi
+
+exec "${PIN[@]}" go test ./tools/gasbench/ \
+ -run '^$' -bench '^BenchmarkOpcodes$' \
+ -benchtime=1x -count="${GASBENCH_COUNT:-10}" -benchmem
diff --git a/tools/gasbench/stats.go b/tools/gasbench/stats.go
new file mode 100644
index 0000000000..3e0c1636d3
--- /dev/null
+++ b/tools/gasbench/stats.go
@@ -0,0 +1,70 @@
+package gasbench
+
+import (
+ "math"
+ "sort"
+ "time"
+)
+
+// Stats summarizes a sample series. All time fields are nanoseconds.
+type Stats struct {
+ N int
+ Min float64 // least-perturbed estimator for CPU-bound work; noise only adds time
+ Max float64
+ Mean float64
+ Median float64
+ P99 float64
+ Stddev float64 // sample stddev (n-1)
+ CoV float64 // Stddev/Mean; advisory only -- see README.md "Acceptance gate"
+}
+
+// Summarize computes the full stat set over the samples.
+func Summarize(samples []time.Duration) Stats {
+ n := len(samples)
+ st := Stats{N: n}
+ if n == 0 {
+ return st
+ }
+ xs := make([]float64, n)
+ var sum float64
+ for i, d := range samples {
+ xs[i] = float64(d.Nanoseconds())
+ sum += xs[i]
+ }
+ sort.Float64s(xs)
+
+ st.Min = xs[0]
+ st.Max = xs[n-1]
+ st.Mean = sum / float64(n)
+ st.Median = percentile(xs, 50)
+ st.P99 = percentile(xs, 99)
+
+ if n > 1 {
+ var ss float64
+ for _, x := range xs {
+ dx := x - st.Mean
+ ss += dx * dx
+ }
+ st.Stddev = math.Sqrt(ss / float64(n-1))
+ }
+ if st.Mean > 0 {
+ st.CoV = st.Stddev / st.Mean
+ }
+ return st
+}
+
+// percentile interpolates linearly between closest ranks (numpy default).
+// xs must be sorted ascending.
+func percentile(xs []float64, p float64) float64 {
+ n := len(xs)
+ if n == 1 {
+ return xs[0]
+ }
+ rank := p / 100 * float64(n-1)
+ lo := int(math.Floor(rank))
+ hi := int(math.Ceil(rank))
+ if lo == hi {
+ return xs[lo]
+ }
+ return xs[lo] + (rank-float64(lo))*(xs[hi]-xs[lo])
+}