Skip to content

tools/gasbench: per-opcode EVM gas-vs-execution-time differential microbenchmark (F2 MVP)#3761

Open
bdchatham wants to merge 20 commits into
mainfrom
brandon2/gasbench-mvp
Open

tools/gasbench: per-opcode EVM gas-vs-execution-time differential microbenchmark (F2 MVP)#3761
bdchatham wants to merge 20 commits into
mainfrom
brandon2/gasbench-mvp

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

What

An offline, tracer-free differential microbenchmark harness (tools/gasbench/) that measures per-opcode EVM execution time and correlates it with the gas the chain charges. This is the F2 MVP from the gas-repricing telemetry design: it makes the hypothesis "does gas cost track execution time" falsifiable for the scalar constant-gas opcode set (22 opcodes: arithmetic/bitwise/comparison/stack).

Start at tools/gasbench/README.md — the Quickstart is the runbook (run it, read the output, know when to trust a row). tools/gasbench/AGENTS.md carries the iteration rules for agents working in the package.

Two commits, deliberately split:

  1. fix(tools/gasbench): distinct per-case operands (equal operands let uint256 short-circuit DIV(x,x)/MOD(x,x) — those rows timed a compare, not a division) + emit nominal const_gas alongside the differential gas_used.
  2. feat(tools/gasbench): statistics folded into golang.org/x/perf/benchmath (direct dep) with an effect-size floor.

How it works

  • Baseline/target bytecode pairs isolate one opcode per Case; everything shared cancels in the difference; a per-Case gas self-check cross-verifies the measured delta against the jump-table algebra on every count.
  • Never attaches a tracer to a timed call; pure EVM gas via runtime.Call (no Cosmos GasMeter).
  • Acceptance gate (two halves): distinguishable — the paired per-count delta CI (order-statistic, benchmath.AssumeNothing) excludes zero; measured at the cross-run -count layer where the statistics are designed to live, and paired so common-run drift cancels. Plus an effect-size floor (GASBENCH_MIN_PEROP_NS, default 1.0ns): status=ok means meaningfully different, not merely distinguishable at n=20000. The unpaired Mann-Whitney p_value is emitted advisory-only.
  • One aggregate row per opcode (CSV/NDJSON): exec_time_ns (median delta over counts), ci_lo/ci_hi, distinguishable, effect_size_pass, status (ok | sub_threshold | insignificant | underpowered), plus const_gas (the repricing denominator) and the differential gas_used (the self-check driver). Only ok is correlation-eligible; sub_threshold does NOT mean "correctly priced."

Validation

Full E2E at defaults (20k iterations, -count=10, pinned core) on a dedicated c6g.metal (fixed-frequency Graviton2, no co-tenants), at this branch's HEAD:

  • Build, vet, and all unit tests green on the target platform (linux/arm64); full suite PASS in ~11 min.
  • 22/22 opcodes status=ok — every row's paired delta CI excludes zero and clears the effect floor; 0 gas-self-check failures across all counts.
  • CIs are razor-thin on the pinned host (e.g. MULMOD [208831, 209044] around a 208942ns median — ±0.05%), so cross-count reproducibility is now in the row, not eyeballed.
  • The answer is loud: ns-per-nominal-gas spans 0.98 (DUP1) → 26.1 (MULMOD) — a ~27× spread. If gas tracked execution time that column would be flat. The division/modmul family is the expensive end (MULMOD 26.1, MOD 16.5, DIV 14.5, ADDMOD 13.7 ns/gas); the cheap ALU/stack ops sit at 1–4.
op per-op ns ns per nominal gas status
DUP1 2.95 0.98 ok
ADD 8.79 2.93 ok
MUL 19.6 3.93 ok
ADDMOD 109.5 13.7 ok
DIV 72.5 14.5 ok
MOD 82.7 16.5 ok
MULMOD 208.9 26.1 ok

Prior art & provenance

  • The methodology (isolate-by-repetition, runtime→gas anchor rate) is the accepted approach — EIP-7904; the "gas ≠ execution time" hypothesis lineage is Broken Metre (NDSS 2020). The statistics deliberately stand on x/perf/benchmath (Go-team-maintained, nonparametric) instead of hand-rolled math — see the prior-art scan.
  • Design: gas-vs-time-instrumentation (harness) + gasbench-benchmath-integration (statistics/gate).
  • Review: benchmath ledger (8 rounds incl. the final peer sweep, unanimous at each gate) + MVP ledger (8 rounds incl. the operand-fix lineage).
  • Dependency note: golang.org/x/perf is pinned at an exact pseudo-version (pre-1.0, experimental upstream) and confined to one file (crossrun.go) — don't go get -u it casually; the pin is deliberate.

Scope & caveats

  • Scalar constant-gas opcodes only. Parametric opcodes (KECCAK256/EXP/copies), the state-touching matrix (SLOAD/SSTORE/CALL), and real-block replay (F1) are deferred — un-defer triggers in the README.
  • Output schema is a wire contract (one row per opcode; distinguishable replaced significant). No downstream consumer exists yet; the schema should be treated as frozen once a repricing consumer depends on it.
  • Deferred fast-follows: gas-relative effect floor (anchor choice open), baseline/target interleaving, per-thread pinning.

🤖 Generated with Claude Code

bdchatham and others added 13 commits July 14, 2026 15:00
… MVP

Tracer-free differential microbenchmark: measures 22 scalar EVM opcodes
(arithmetic/bitwise/comparison/stack) via core/vm/runtime.Call against a
fresh in-memory StateDB, no tracer attached (per the design's load-bearing
constraint: gas and time are never measured in the same run). Per-opcode
gas comes from the interpreter's own accounting; per-opcode time from
difference-of-medians between a balanced baseline/target bytecode pair.

CoV is the first-class output and the acceptance gate (design's
noise-floor criterion). Gas self-check asserts measured whole-program
gas delta == definitional expected delta for every case (all 22 pass).

Local integration of two coral-built components (timing/stats/emission;
EVM wiring/bytecode construction) reconciled during assembly:
Program (renamed from a Harness/Harness name collision) is a pre-warmed
per-program executor so state setup is amortized out of the timed loop,
and RunOnce is zero-arg (closes over its Program) rather than taking
code per call.

Local branch only, not pushed. Design: bdchatham-designs
designs/gas-repricing-telemetry/gas-vs-time-instrumentation.md
…persisted diff, shift-op operands

Three correctness-grade findings from systems-engineer (assigned dissenter):

- MISMATCH: Run.Status was gated only on NoiseOK (per-series stability),
  never on Diff.Significant (whether the delta clears measurement
  uncertainty). A statistically-insignificant opcode delta could be
  emitted Status="ok". Status is now OK only when both NoiseOK and
  Significant hold, with a new StatusInsignificant distinguishing 'noisy
  measurement' from 'stable series, zero-indistinguishable delta'.

- MISSING: persisted CSV/NDJSON carried only the raw per-series median
  (one row per baseline/target variant), never the differential itself.
  Run is redesigned to one row per opcode carrying Reps, GasUsed (the
  measured whole-program gas delta), ExecTimeNs (the measured whole-
  program time delta), and Significant -- the actual (gas, time) pair
  the design's acceptance criteria describe.

- MISMATCH: SHL/SHR/SAR reused the same full-width seed as both stack
  operands, making the shift AMOUNT itself 2^256-1 -- go-ethereum's
  value.Clear() early-out for shifts >=256, the cheapest possible path,
  not the limb-shift work the old comment claimed to measure.
  BuildCaseWith now special-cases the shift family with a distinct
  in-range shift-amount operand (seedShift) via a DUP2/DUP2 construction
  that keeps the differential net-0. Verified: post-fix per-op-ns for
  SHL/SHR/SAR rose ~50-70% (1.3-1.4ns -> 2.0-2.4ns), consistent with
  exercising real limb-shift work instead of the degenerate clear path.

Also closes two non-blocking items from the other two lenses:
- solidity-developer: Program's reuse-safety doc comment cited 'no state
  change' as the reason it's safe to reuse across calls; corrected to
  the real basis (deterministic gas + tail-only, baseline/target-
  symmetric journal growth from an un-Finalise-d Snapshot per call),
  with a warning against extending to high-iteration or state-touching
  use without re-checking.
- idiomatic-reviewer (style/advisory): DefaultConfig() is now the actual
  source of truth bench_test.go reads through (was dead code, values
  triplicated in run.sh); WriteCSV errors now wrap with the gasbench:
  prefix like WriteNDJSON; Case.Class now flows all the way to the
  persisted Run/CSV row instead of being unused; Series.InputID and
  StatusError documented.

All 22 opcodes re-verified: gas self-check passes, build/vet/gofmt clean.

Local branch only, not pushed.
…omatic-reviewer)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lation research

Three refinements derived from designs/gas-repricing-telemetry/research/
microbenchmark-noise-isolation-tradeoffs.md:

- Status is now purely Significant-driven (ok/insignificant). CoV no
  longer overrides a statistically solid result -- three independent
  benchmarking harnesses (JMH, criterion.rs, Go's own benchstat) all
  gate on effect-size-vs-uncertainty, not a fixed dispersion threshold,
  and the research's practitioner sweep gave the same reasoning
  (CoV describes sample dispersion, not confidence in the derived
  median; the differential's own propagated SE is the right metric).
  CoV survives as an independent HighVariance advisory flag.

- Recalibrated the default health-check ceiling from 2% to 25%: above
  the 4-8% CoV measured as normal on a dedicated, pinned, bare-metal
  host with no kernel-level isolation, well below the "something is
  actually wrong" territory (~40%+) the research's practitioner sweep
  named.

- Added getrusage-based active-benchmarking diagnostics (Nvcsw/Nivcsw
  deltas around the timed window). This automates, on every future run,
  the same active-benchmarking check (Gregg's methodology) that was
  previously a one-off manual perf-stat/interrupts session on the
  EC2 host -- involuntary context-switches are now the direct,
  measured evidence behind HighVariance, not merely inferred from CoV.

Deliberately NOT adding opportunistic cpuset/IRQ-affinity isolation to
run.sh -- the research's explicit recommendation was not to invest
further isolation effort for this MVP; doing so now would contradict
that finding.

Local branch only, not pushed.
…o docs

Closes the findings from this refinement's first xreview round
(systems-engineer, idiomatic-reviewer, solidity-developer):

- Fixed stats.go's stale "CoV is the acceptance gate" comment
  (contradicted the refinement's own thesis).
- Corrected the Nvcsw/Nivcsw doc comments: RUSAGE_SELF is process-wide,
  not thread-scoped, and a nonzero Nivcsw explains only the
  scheduler-preemption slice of CoV, not the Program-reuse
  journal-growth tail (a separate, already-documented noise source).
- Surfaced the previously write-only-and-dead Nvcsw counter through to
  Diff and Run, and into the CSV/NDJSON schema (new nvcsw column).
- Fixed bench_test.go's false claim that -count=K gives K independent
  process runs (Go reruns within the same OS process).
- Added a table test (emit_test.go) pinning that Status is a pure
  function of Significant, independent of HighVariance/CoV.

Also restructures where rationale lives, per direct feedback: this
package's doc comments had grown into multi-paragraph methodology
essays (the differential-construction algebra, the acceptance-gate
research, the active-benchmarking diagnostics). Moved that narrative
into tools/gasbench/README.md and tools/gasbench/AGENTS.md; code
comments are back to lean, present-state statements that point at the
docs for why. Wired tools/gasbench/AGENTS.md into the top-level
AGENTS.md's nested-guides list.

Local branch only, not pushed.
xreview R2's assigned dissenter (systems-engineer) found that
README.md's "load-bearing invariant" section, written this round,
claimed gas and time come from two separate tracer-free runs. They
don't: gasbench.go's Measure times the exact same run() call whose
return value becomes GasUsed. This harness only measures whole-program
gas, which needs no tracer at all, so there's no separate gas pass to
speak of -- the real invariant is "never attach a tracer to the timed
call." Fixed in both README.md and AGENTS.md (which restated the same
error), and added a note that a future per-opcode gas breakdown (which
would need a tracer) must come from a separate, untimed call.

Also, from the same round's secondary findings:

- emit_test.go's table test asserted Status is independent of
  HighVariance but never varied BaselineCoV/TargetCoV, so a regression
  gating Status on raw CoV directly would have passed unnoticed. Added
  two cases with CoV=0.9 to actually exercise that path.
- Added a same-value doc pointer to Nvcsw (Nivcsw already had one) for
  the getrusage-failure-zero ambiguity.
- Added inline pointers at the SHL/SHR/SAR and default BuildCaseWith
  branches back to README.md, since the correctness reasoning for
  DUP2-vs-DUP1 no longer lives next to the switch.
- Added the new files to AGENTS.md's Files table.

And from prose-steward's independent pass over the same two new docs:

- README's invariant header now matches AGENTS's framing exactly (both
  fixes above happened to close prose-steward's top finding too: the
  two docs stated the load-bearing invariant at different scopes).
- Reconciled AGENTS's "terminate cleanly (STOP...)" with README's
  general STOP/RETURN error contract -- every Case built here uses
  STOP by construction, but the general Program.Run contract also
  accepts RETURN.
- Reconciled the new-opcode-spec verification recipe: AGENTS is now
  the one authoritative checklist (bridges Arity to jump_table.go's
  minStack/maxStack); README points to it instead of restating a
  partial version.
- Expanded CoV on first use, fixed a forward reference to the
  getrusage section, dropped a hardcoded "22 Specs entries" count that
  would rot on the next addition.

Local branch only, not pushed.
…ertainty

xreview R3's assigned dissenter (systems-engineer) ran a from-scratch
correctness audit (not just re-verifying prior fixes) and found a real
bug in the acceptance gate itself: Summarize only computes
Stddev/SEMean/SEMedian when a series has more than 1 sample, so at
Iterations=1 (or any degenerate zero-variance sample) Uncertainty is
exactly 0. Significant was `|DeltaNs| > SigmaK*0`, trivially true for
any nonzero delta -- every opcode reported significant=true with a
statistically meaningless uncertainty of zero. GASBENCH_ITERS is
operator-settable with no documented floor, so this was reachable, not
theoretical. Verified empirically: at Iterations=1 every one of the 22
opcodes (including MULMOD's 44us delta) used to report
significant=true; Subtract now requires Uncertainty > 0, so all 22
correctly report insignificant instead. Added
TestSubtractNeverSignificantWithZeroUncertainty pinning this.

Also closes the remaining Round 3 findings, all RATIFY-with-advisory
from idiomatic-reviewer, solidity-developer, and prose-steward:

- gasbench.go: fixed the sink comment's wrong justification (it isn't
  GOMAXPROCS=1/thread-locking that makes the shared var race-free,
  it's that BenchmarkOpcodes's subtests run sequentially).
- bench_test.go: removed a redundant c := c loop-variable copy (dead
  under Go 1.22+ per-iteration loop-var semantics, and b.Run here
  never runs in parallel).
- programs.go: added a Class const-block group comment for symmetry
  with Status's; trimmed the SHL/SHR/SAR inline comment back to a
  README pointer instead of restating go-ethereum internals; added an
  explicit Arity<1 guard in BuildCaseWith's default branch (the
  (n-1)*GasQuickStep formula assumes n>=1; DUP1/SWAP1 are the only
  Arity-0 specs and are already special-cased above it).
- emit.go: documented the CSV-vs-NDJSON CoV precision asymmetry
  (6-sig-fig display rounding vs full precision) as a deliberate,
  advisory-only choice rather than leaving it silent.
- README.md: fixed SAR's early-out boundary (strictly >256, vs
  SHL/SHR's >=256); replaced a line-number anchor into
  x/evm/keeper/msg_server.go with a symbol reference so it can't rot;
  moved the CoV term's expansion to its actual first use.

Local branch only, not pushed.
All four R4 reviewers ratified; these are their advisory-tier items:

- diff_test.go: t.Errorf+continue instead of t.Fatalf inside the table
  loop, so a broken fixture doesn't mask whether sibling cases also
  regressed (idiomatic-reviewer, per the keep-going table convention
  emit_test.go established).
- programs_test.go: new recover-based test pinning BuildCaseWith's
  Arity<1 panic guard, previously the only invariant in the package
  with no unit test (it is dead code for the in-tree Specs, so only a
  test can exercise it).
- README.md: the SHL/SHR/SAR early-out is value.Clear() -- or
  SetAllOne() for SAR on a negative operand (solidity-developer,
  verified against the fork's opSAR).

Local branch only, not pushed.
A handoff-ready walkthrough at the top of README.md: smoke-run command,
what a trustworthy host looks like, how to interpret the output
(per-op time, per-op gas, and ns-per-gas as the hypothesis metric --
constant across opcodes if pricing tracked time, spread = the
mispricing signal), and when to trust a row (significant /
high_variance / nivcsw / cross-count agreement). AGENTS.md points
agents at it; README points operators at AGENTS.md for iteration
rules.

Reviewed by systems-engineer (every claim vs the code) and
prose-steward (standalone dual-audience legibility); their findings
folded in:

- The confidence-check instruction originally pointed at the CSV, but
  each -count rerun rewrites the output files, so the CSV holds only
  the final count's rows (os.Create truncation per BenchmarkOpcodes
  invocation -- verified). The runbook now pipes run.sh through tee
  and directs the cross-count agreement check at the captured
  benchmark stdout (benchstat-consumable), which is where the
  per-count history actually lives.
- emit.go's StatusInsignificant doc claimed a quieter host doesn't
  help; it does (lower Stddev -> lower SEMedian). Reconciled.
- "cancels host-speed effects" softened to "cancels the common
  clock-speed factor" -- microarchitectural ratios still differ across
  hosts, which is why repricing-grade numbers come from the dedicated
  host.
- nivcsw trust rule now defines both sides of the discriminator
  (nonzero -> host preemption; near-zero + high CoV -> the harness's
  own non-scheduler tail).
- Tied status=insignificant and significant=false as one condition;
  step-lead grammar made uniform; gas_used positivity noted for the
  ns-per-gas formula.

Local branch only, not pushed.
…ated E2E

The full E2E run on the dedicated c6g.metal host falsified the
runbook's claim that each -count rerun rewrites the output files. Go
applies -count to the leaf subtests inside a single BenchmarkOpcodes
invocation, so runs accumulates across counts and the file is written
once holding one row per opcode per count (verified on the host CSV:
220 rows for 22 opcodes at -count=10, and reproduced locally with
-count=3). The cross-count agreement check therefore reads straight
from the CSV; the teed stdout is the benchstat-consumable form of the
same data, now optional.
Two correctness fixes to the differential construction, validated E2E on a
dedicated c6g.metal (220/220 significant, all gas self-checks green):

- Feed each case distinct ascending operands (2^192-1, 2^224-1, 2^256-1)
  lifted via DUP<arity>, instead of n copies of one seed via DUP1. Equal
  operands let holiman/uint256 short-circuit DIV(x,x)->1 and MOD(x,x)->0
  before udivrem, so those rows timed a compare instead of a 256-bit
  division (~10x understated, inverting their place in the spread). The
  gas algebra is unchanged (every DUP<k> is GasFastestStep); the per-count
  gas self-check still passes on all 22 opcodes.
- Emit the nominal jump-table gas as const_gas alongside the differential
  gas_used. ns-per-gas divides by const_gas (what the chain charges); the
  differential denominator is deflated by an arity-correlated factor and
  would report a spread even for perfectly-priced opcodes.

Corrected headline on the pinned host: ns-per-nominal-gas spans 1.05 (DUP1)
to 27.2 (MULMOD), ~26x, with DIV/MOD/MULMOD correctly at the expensive end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-size floor

Replace the hand-rolled significance machinery with golang.org/x/perf/benchmath
(now a direct dep) and gate acceptance on statistics applied at the layer they
are designed for:

- The acceptance gate is the PAIRED per-count delta CI: distinguishable =
  the order-statistic CI on the -count delta series excludes zero
  (benchmath.AssumeNothing.Summary). Baseline/target are measured
  back-to-back per count, so the paired delta cancels common-run drift the
  prior 3-sigma gate (and any unpaired test) could not. The unpaired
  Mann-Whitney p (AssumeNothing.Compare) is emitted as p_value, advisory
  only. Deletes SEMedian/SEMean (a normal-theory estimator on non-normal
  latency data), the Hypot quadrature, SigmaK, Significant, and
  GASBENCH_SIGMA_K.
- One aggregate row per opcode: -count passes accumulate in-process
  (opcodeAccum) and benchmath folds them into the row's CI; the per-count
  rows and the benchstat-over-stdout step are retired. New columns: count,
  ci_lo/ci_hi (null when underpowered; +/-Inf never reaches encoding/json),
  confidence, distinguishable, effect_size_pass, p_value, alpha.
- Effect-size floor (GASBENCH_MIN_PEROP_NS, default 1.0ns, <=0 disables):
  status=ok requires distinguishable AND the per-op median delta clearing
  the floor, so "significant" means "meaningfully different," not merely
  distinguishable at n=20000. Distinguishable-but-below-floor rows read
  sub_threshold (NOT "correctly priced"); only ok is correlation-eligible.
  New statuses: sub_threshold, underpowered (count<2 or non-finite CI).
- The per-count gas self-check is kept and strengthened with a
  gas-delta-identical-across-counts guard. The differential construction
  (programs.go) and the Measure loop (gasbench.go) are untouched.
- Harness hygiene: run.sh warns when it cannot verify turbo/governor
  (ARM/non-Linux) instead of staying silent; output-file Close errors and
  unparseable GASBENCH_* env values fail loud.
- README rewritten as the schema of record: paired-CI + effect-floor
  acceptance gate, full column table, GASBENCH_ALPHA/CI_CONFIDENCE/
  MIN_PEROP_NS; cites EIP-7904 (anchor-rate framing) and Broken Metre
  (NDSS 2020) for the method and hypothesis lineage.

Design and review ledger: bdchatham-designs designs/gas-repricing-telemetry/
gasbench-benchmath-integration.md (2 design rounds + 4 gated implementation
increments, unanimous slate at each gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TestWriteRoundTripMixed: a finite-CI ok row and an underpowered null-CI row
  through both writers, with the NDJSON decoded back through a standard
  json.Decoder (finite bound -> float, null -> nil) and the CSV parsed to
  assert the number/empty rendering -- closes the round-trip clause of the
  underpowered acceptance criterion and the finite-CI writer path.
- TestAnalyzeCrossRunUnderpowered now also pins the ±Inf raw bounds and that
  benchmath warnings are surfaced at n=1.
- AGENTS.md file table: add the crossrun_test/diff_test/programs_test rows.
- README: drop a stale "control" class from Scope (Specs has four families);
  document the CSV-vs-NDJSON CoV precision divergence in the schema table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
New offline benchmark tooling and a pinned golang.org/x/perf dev dependency only; no changes to consensus, live transaction handling, or production EVM gas metering.

Overview
Adds tools/gasbench, an offline harness that measures per-opcode EVM execution time vs nominal gas for 22 scalar constant-gas opcodes, aimed at gas-repricing hypothesis testing (F2).

Each opcode uses a baseline/target bytecode pair so shared overhead cancels in the delta; BuildCaseWith uses distinct operands (and special cases for shifts/stack ops) so DIV/MOD and similar ops hit real kernels instead of uint256 short-circuits. Timing is tracer-free via warmed Program + Measure (GC off, thread lock, getrusage diagnostics); gas is pure EVM from runtime.Call, not Cosmos metering.

Cross-run stats use golang.org/x/perf/benchmath: acceptance is a paired delta CI plus GASBENCH_MIN_PEROP_NS effect floor; rows emit const_gas (repricing denominator) alongside differential gas_used, with status (ok / sub_threshold / etc.) and CSV/NDJSON from BenchmarkOpcodes. make gasbench and run.sh (core pin, env tuning) wire the default run; root AGENTS.md documents the package.

Unit tests cover subtraction, cross-run drift behavior, emit/writer safety, and bytecode construction guards.

Reviewed by Cursor Bugbot for commit eb5795f. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 15, 2026, 7:43 PM

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc7205f. Configure here.

Comment thread tools/gasbench/emit.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-tested, self-contained offline benchmarking tool under tools/gasbench/ that touches no production code path; the differential construction, gas algebra, and null-safe CI handling are sound. Codex's single finding (count inflation) does not hold under the harness's mandated -benchtime=1x, and Cursor produced no output.

Findings: 0 blocking | 4 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Disagree with the Codex finding (bench_test.go:34, the -count/count-column concern). Under the harness's mandated -benchtime=1x, the leaf closure runs exactly count times, so the emitted count is correct: BenchmarkOpcodes is a container whose run1() returns false (runs the body once); each leaf reuses the B.Run run1 iteration as the j==0 measurement (processBench's i>0||j>0 guard), and launch calls runN only when benchTime.n>1. The double-count Codex describes only occurs if -benchtime=1x is dropped — which run.sh hard-codes and the README flags as load-bearing. Not a bug as shipped.
  • Cursor's second-opinion pass (cursor-review.md) produced no output — noted per instructions; only Codex's single (refuted) finding was available to merge.
  • Robustness nit (documented, not blocking): BenchmarkOpcodes has no guard against being invoked without -benchtime=1x. If run with the default benchtime, launch's b.N escalation would re-invoke the (already O(20000-iter) Measure) closure many times and silently inflate count. A cheap if b.N != 1 { b.Fatalf("run via run.sh / -benchtime=1x") } inside the leaf would make the footgun fail loud rather than relying solely on the README.
  • Theoretical edge (skip-worthy): nullableBound/distinguishable/Underpowered special-case ±Inf but not NaN; a NaN CI bound would bypass the null mapping and make WriteNDJSON error via json's UnsupportedValue. benchmath's order-statistic bounds on finite deltas won't produce NaN in practice, so this is informational only.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is well-structured (offline benchmark tooling isolated to tools/gasbench/, no consensus/gas-metering code paths touched), but golangci-lint currently fails on the two issues flagged inline — those are CI-blocking, not just nits, so this needs a human/author fix-and-recheck before merge.

Extended reasoning...

Overview

Adds tools/gasbench/, an offline, tracer-free differential microbenchmark harness for correlating per-opcode EVM execution time with jump-table gas cost. Touches only new files under tools/gasbench/, a Makefile target, an AGENTS.md pointer, and a pinned golang.org/x/perf dependency in go.mod/go.sum. No changes to consensus, ante handlers, live gas metering, or any production code path.

Security risks

None identified. This is standalone offline tooling with no network exposure, no user input parsing beyond env vars, and no interaction with production state or keys. The new dependency (golang.org/x/perf) is confined to one file (crossrun.go) and used only for statistics.

Level of scrutiny

Low-to-moderate is appropriate given the blast radius (sandboxed dev tool, not production code), but I verified the two reported lint failures directly rather than taking them on faith, since a green PR that can't pass make lint is a real merge blocker. I ran golangci-lint run ./tools/gasbench/... against the checked-out code and reproduced both findings verbatim: the G115 int->uint64 conversions in programs.go and the unconvert redundant-conversion in gasbench.go (confirmed syscall.Rusage.Nvcsw/Nivcsw are already int64 on this linux/amd64 build). Neither is excluded by .golangci.yml (whose only gosec exclusion is the unrelated weak-RNG rule).

Other factors

The PR's own validation section only mentions build/vet/test passing on linux/arm64, not golangci-lint, which is consistent with why these slipped through. The underlying logic in both flagged spots is correct at runtime (the values are non-negative by construction); these are static-analysis compliance fixes, not correctness fixes, but they still block the required CI lint gate. Given they are confirmed and CI-blocking, I'm deferring rather than approving.

Comment thread tools/gasbench/gasbench.go
Comment thread tools/gasbench/programs.go Outdated
bdchatham and others added 2 commits July 15, 2026 10:05
- nullableBound treats NaN as non-finite alongside ±Inf (encoding/json rejects
  both), and Underpowered detection uses the same finite() predicate — closes
  the D-1 gap Cursor flagged. benchmath's median CI emits ±Inf sentinels, never
  NaN, so this was latent, but the wire invariant is "non-finite never reaches
  the encoder," now enforced and unit-pinned (distinguishable(NaN,NaN)=false,
  nullableBound(NaN)=nil).
- Drop the redundant int64() conversions in rusageSnapshot: Rusage.Nvcsw/Nivcsw
  are int64 on every target platform (darwin/arm64, linux/amd64, linux/arm64) —
  clears unconvert.
- Guard BuildCaseWith's conversions for gosec G115: reps > 0 and Arity <= 16
  (DUP16 is the deepest lift) now panic at construction, with nolint on the
  guarded sites. golangci-lint run ./tools/gasbench/...: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A self-contained, offline, well-documented and well-tested per-opcode EVM gas-vs-time differential microbenchmark harness under tools/gasbench/ (no consensus/production surface, labeled non-app-hash-breaking). No blocking issues found; the one blocker raised by Codex appears to be a false positive.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex (P1) claimed -count=10 re-runs BenchmarkOpcodes from scratch so collectors never accumulates, leaving every row count=1/underpowered. This is a false positive: Go sub-benchmarks (b.Run) honor -count via their own count loop and are re-run within the single parent invocation, so all passes append into the one collectors slice allocated at the top of the body; the aggregate carries count=N. Corroborated by the author's E2E validation (22/22 status=ok, finite razor-thin CIs), which would be impossible under count=1. No change needed.
  • Cursor's review (cursor-review.md) is empty — that pass produced no output. REVIEW_GUIDELINES.md is also empty, so no repo-specific review standards were applied.
  • Efficiency (worth confirming, not blocking): because Go's -count also re-runs the top-level BenchmarkOpcodes body once per count in addition to each sub-benchmark honoring -count, total measurement passes may scale ~O(count²) rather than O(count), and writeRuns is invoked once per top-level re-run (each overwrite is idempotent, so correctness is unaffected). If confirmed, consider whether the default -count=10 does ~10× redundant measurement work; low priority for an offline tool.
  • Minor: sink ^= g, GC-disabled unbounded heap growth, and the CSV-vs-NDJSON CoV precision difference are all intentional and clearly documented — no action needed, noting only that they rely on the subtests staying sequential (already called out in comments).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread tools/gasbench/bench_test.go
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 53.79538% with 140 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.02%. Comparing base (ad863c7) to head (eb5795f).

Files with missing lines Patch % Lines
tools/gasbench/programs.go 12.50% 53 Missing and 3 partials ⚠️
tools/gasbench/gasbench.go 0.00% 50 Missing ⚠️
tools/gasbench/program.go 0.00% 22 Missing ⚠️
tools/gasbench/emit.go 91.39% 4 Missing and 4 partials ⚠️
tools/gasbench/stats.go 88.57% 2 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3761      +/-   ##
==========================================
- Coverage   59.88%   59.02%   -0.86%     
==========================================
  Files        2288     2208      -80     
  Lines      190023   180111    -9912     
==========================================
- Hits       113786   106309    -7477     
+ Misses      66091    64448    -1643     
+ Partials    10146     9354     -792     
Flag Coverage Δ
sei-chain-pr 53.79% <53.79%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
tools/gasbench/crossrun.go 100.00% <100.00%> (ø)
tools/gasbench/diff.go 100.00% <100.00%> (ø)
tools/gasbench/stats.go 88.57% <88.57%> (ø)
tools/gasbench/emit.go 91.39% <91.39%> (ø)
tools/gasbench/program.go 0.00% <0.00%> (ø)
tools/gasbench/gasbench.go 0.00% <0.00%> (ø)
tools/gasbench/programs.go 12.50% <12.50%> (ø)

... and 106 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A tooling-only, non-app-hash-breaking PR adding an offline per-opcode EVM gas-vs-time microbenchmark under tools/gasbench/. It touches no chain/consensus/state code, is exceptionally well documented and unit-tested, and carries no security risk. No blockers; a couple of methodology notes, and I disagree with Codex's headline P1 finding.

Findings: 0 blocking | 5 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Disagreement with Codex P1 #1 (bench_test.go:32, -count resets collectors → count=1, files overwritten): this is incorrect. With go test -bench=BenchmarkOpcodes -count=K, the outer BenchmarkOpcodes is a sub-benchmark container — its own count-loop (processBench) never runs because run1 returns early once it detects sub-benchmarks. The -count=K loop instead re-executes each leaf subtest (b.Run(opcode, …)) K times within the single outer invocation, each pass appending to collectors[i]; the aggregate then runs once with count=K and writeRuns fires once at the end. This matches the code's own design comment, the README, and the author's reported count=10 / 22-of-22 status=ok E2E output. Recommend this NOT block. (One genuine but harmless subtlety: running with -v adds a chatty pass, so count would read K+1 — run.sh omits -v, so this doesn't affect the documented workflow.)
  • Codex P1 #2 (exec_time_ns is target-minus-baseline, i.e. time(OP) − (n−1)·time(POP), yet README divides by full nominal const_gas): a legitimate methodological nuance — the time numerator is deflated by an arity-dependent filler term while the denominator is the un-deflated nominal gas, so the ns-per-gas ratio carries a small arity-correlated bias. However, POP is ~sub-ns, so for the expensive division/modmul family that drives the ~27× spread (MULMOD ~209ns) the correction is ≈1%, and the cheap ops where it matters most are exactly the ones filtered out as sub_threshold/insignificant. The README already frames exec_time_ns as a 'net-of-filler' differential and lists a gas-relative effect floor as a deferred fast-follow. Worth acknowledging in the README's ns-per-gas section as a known bias, but not a correctness bug for an explicitly-MVP research tool.
  • Cursor's second-opinion review (cursor-review.md) was empty — that pass produced no output. REVIEW_GUIDELINES.md was also empty, so no repo-specific standards were applied.
  • No prompt-injection detected: the agent-directed language in the PR description and README/AGENTS.md ('point an agent at AGENTS.md', 'Rules for changes here') is legitimate contributor documentation, not an attempt to manipulate the reviewer.
  • Dependency footprint: adds golang.org/x/perf (direct) and github.com/aclements/go-moremath (indirect). Both are Go-team/experimental pre-1.0 modules pinned to exact pseudo-versions and confined to crossrun.go; the pin rationale is documented. Reasonable, but note these are experimental upstreams should the harness ever move onto a build-critical path.

The README said what the host must be but not how to get one. Add the exact
SSM-based c6g.metal workflow behind the shipped numbers: launch (AL2023 arm64,
SSM instance profile, 60GB root), bootstrap (Go + clone + unit tests), the
full run, result fetch, and the terminate reminder with cost/runtime expectations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread tools/gasbench/bench_test.go
bdchatham and others added 2 commits July 15, 2026 10:35
CI's gosec version also flags uint64(s.Arity-1) in perUnitDelta; same guard
applies (Arity >= 1 panics above in the same branch), same annotation as the
two sibling sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A b.Fatalf inside a b.Run subtest unwinds only that goroutine; the parent
still aggregates the case's empty accumulator, and benchmath's median of an
empty sample is NaN -- which corrupted the CSV ('NaN' string) and crashed
WriteNDJSON at the end of the run, masking the original failure (verified
empirically: empty accum -> Center=NaN, CI ±Inf). aggregate() now short-
circuits an empty accumulator into NewErrorRun: all numerics finite/zero,
CI null, status=error -- the row StatusError was reserved for. Unit-pinned
incl. writer safety; README documents the status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A self-contained, well-tested offline microbenchmark tool under tools/gasbench that measures per-opcode EVM execution time vs. gas; it touches no production/consensus path and is labeled non-app-hash-breaking. No blocking issues found. The one prior finding (Codex P1) is a false positive based on a misreading of Go's -count semantics for sub-benchmarks.

Findings: 0 blocking | 6 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex P1 (cross-run aggregation cannot work across -count) is a FALSE POSITIVE. It assumes -count reruns the outer BenchmarkOpcodes and recreates collectors each time. For a benchmark that registers sub-benchmarks via b.Run, Go applies -count to the leaf sub-benchmarks, not the parent: the parent's run1() sets hasSub and returns false, so processBench never repeats the parent body. collectors is allocated once and accumulates K deltas per opcode from the K subtest reruns, and writeRuns runs once. This matches the author's reported count=10/tight-CI validation. Not a bug.
  • REVIEW_GUIDELINES.md and cursor-review.md were empty, so no repo-specific guidelines and no Cursor second-opinion pass were available; only the Codex pass produced output (one finding, refuted above).
  • go.mod promotes golang.org/x/perf to a direct dependency (and adds github.com/aclements/go-moremath indirect). Because crossrun.go is a non-test source file, benchmath becomes part of the main module's build-time dependency closure for everyone, not just tests. This is intentional and the version is deliberately pinned per the PR description; noting it only so future go mod tidy/upgrade sweeps don't drop or bump it casually.
  • OpSpec.DataDependent / Case.DataDependent is threaded through construction but not consumed by the emit/aggregate path; it is currently informational only. Fine for the MVP, just flagging in case a consumer is expected to read it.
  • Stats computes Min/Max/Mean/P99/Stddev but only Median and CoV are consumed downstream. Harmless and cheap, but the extra fields are effectively unused by the current output.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread tools/gasbench/bench_test.go

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the three findings this round are all minor nits (self-check bypass, run.sh core-pin edge case on low-core hosts, a docs-only inconsistency), each capped low and unreachable with the shipped 22-opcode spec table. The more substantive issues from earlier rounds (NaN CI writer crash, unconvert/gosec lint failures) have already been fixed and confirmed in the thread.

Extended reasoning...

Overview

This PR adds tools/gasbench/, a new offline, tracer-free differential microbenchmark harness for correlating per-opcode EVM execution time with gas cost. It's pure new tooling: no changes to consensus, RPC, gas metering, or any production code path. It also adds a pinned golang.org/x/perf dependency (confined to crossrun.go) and a make gasbench target.

Security risks

None relevant to production. The harness runs a bare go-ethereum EVM with no ante handler / Cosmos GasMeter, by design, and is only ever invoked manually via run.sh or make gasbench — it is not wired into any consensus-critical or externally reachable path.

Level of scrutiny

Low-to-moderate is appropriate: this is developer tooling for gas-repricing research, not a production code path, and it ships with solid unit test coverage (crossrun/emit/diff/programs tests) plus a documented real-hardware validation run (22/22 opcodes status=ok). The three new findings this round are all tagged [Nit]: an unreachable-with-current-specs self-check bypass, a run.sh core-pinning edge case that only bites hosts with ≤3 CPUs, and a docs-only contradiction between two README sections. None affect the shipped 22-opcode benchmark output or any downstream consumer today.

Other factors

The PR has already been through several rounds of review (Cursor bugbot, a prior claude[bot] pass, seidroid[bot]) and the author fixed every substantive issue raised — the NaN-CI/NDJSON-crash bug, the unconvert and gosec G115 lint failures — each confirmed via a follow-up commit and verified in the thread. The remaining nits are low-severity, well-explained in the inline comments, and don't warrant blocking on their own.

Comment thread tools/gasbench/bench_test.go Outdated
Comment thread tools/gasbench/run.sh
Comment thread tools/gasbench/README.md Outdated
…lback; README enum

- A gas self-check b.Errorf (per-count algebra check or cross-count drift
  guard) does not unwind the subtest, so the invalid measurement was appended
  and aggregated into a normal-looking row. Both sites now taint the case
  (opcodeAccum.selfCheckFailed) and aggregate() routes a tainted case to the
  status=error row -- a construction that fails its own invariant can no
  longer masquerade as a trustworthy measurement. The b.Errorf stays, so the
  run still fails loudly.
- run.sh validates GASBENCH_CORE against the host CPU count before pinning:
  taskset -c aborts outright on a nonexistent index (default CORE=3 on a
  2-core runner killed the run before go test started), so out-of-range or
  non-numeric values now fall back to unpinned-with-warning, mirroring the
  no-taskset branch. The pinned-host path is unchanged.
- README: the Output-schema `error` bullet said "not currently emitted",
  contradicting the code and the Quickstart since NewErrorRun landed; both
  bullets now describe the emitted behavior including the self-check-taint
  cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
seidroid[bot]
seidroid Bot previously requested changes Jul 15, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-documented, well-tested offline microbenchmark harness under tools/gasbench/ with no production-code impact. However, there is a credible correctness concern (independently raised by Codex) that the shipped run.sh combination of -benchtime=1x and -count=K does not actually accumulate K cross-run samples, which — if real — makes every emitted row underpowered and defeats the harness's purpose; this must be verified before merge.

Findings: 3 blocking | 4 non-blocking | 1 posted inline

Blockers

  • Cross-run sample accumulation may be broken (corroborates Codex's P1, with a corrected mechanism). run.sh runs go test -benchtime=1x -count=K. In Go's testing runtime, (*B).launch() skips runN when benchTime.n==1 (the -benchtime=1x "reuse the single run1 iteration" optimization), and a benchmark whose body only spawns sub-benchmarks has run1() short-circuit on hasSub. Net effect of that reading: each leaf opcode subtest body executes exactly once regardless of -count, so collectors[i] gets a single delta, analyzeCrossRun sets Underpowered (len(Deltas)<2), and every row emits status=underpowered with null CI — the opposite of the PR's reported "22/22 status=ok". Note the design tension the README itself documents: -benchtime=1x is required to keep b.N==1 (the timing loop is Measure's, not b.N's), but that same flag is what neutralizes -count; getting K deterministic passes would need K separate go test invocations (or a Measure-driven inner count loop), not -count. I could NOT confirm this by execution (the sandbox blocked go test and access to the Go toolchain). Because it contradicts the author's E2E validation, the fastest resolution is to attach the gasbench.csv produced by a default run.sh and confirm the count column is >1 and rows are ok; if so, this concern is void.
  • No end-to-end test exercises the load-bearing -count→sample-accumulation plumbing. crossrun_test.go / emit_test.go feed analyzeCrossRun/NewRun hand-built multi-element slices, so they pass whether or not the real benchmark ever collects more than one pass. A test that runs BenchmarkOpcodes with -count=2 (tiny iters) and asserts the resulting Run.Count == 2 / status != underpowered would have caught the concern above.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion review produced no output (empty file); Codex's did (a single P1 on cross-run sample accumulation, which this review corroborates and refines).
  • go.mod adds golang.org/x/perf (direct) and github.com/aclements/go-moremath (indirect) to the main module's dependency graph for a tools-only benchmark. The PR notes the exact pseudo-version pin is deliberate; acceptable, but worth flagging that a tools-only harness expands the production module graph.
  • No prompt-injection or malicious content found in the diff, PR body, or second-opinion files.
  • Minor: README/AGENTS.md are unusually long for a tools dir; content is high quality, but the -count semantics section ("benchmath folds those K passes") is exactly the claim the blocker questions — reconcile the docs with the verified behavior once the count mechanism is confirmed.

Comment thread tools/gasbench/run.sh

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found this run. Beyond the inline history already on this PR, I also checked whether the ADDMOD/MULMOD seedOperands selection could pick a modulus that skips uint256's fast reduction path — the seed values are ascending 192/224/256-bit distinct constants, none reducible by the others, so the real kernel path is exercised, not a short-circuit.

Extended reasoning...

Checked the ADDMOD/MULMOD operand-selection concern raised by a finder this run: seedOperands in tools/gasbench/programs.go are three distinct, ascending multi-limb values (2^192-1, 2^224-1, 2^256-1), with the smallest landing at the modulus slot per the README's documented construction. None of these values divide evenly into each other in a way that would trigger uint256's degenerate-operand fast paths (equal operands only), so ADDMOD/MULMOD exercise the real reduction kernel, consistent with the PR's reported measurements (ADDMOD/MULMOD sit at the expensive end of the ns/gas spread, as expected for real modular arithmetic rather than a short-circuited compare). This is a one-line informational note, not a new finding.

Third reviewer to conflate benchtime's within-pass iteration cap with the
-count loop that sits outside it in processBench; document the two layers and
the observable proof (count=1 -> underpowered rows; count=10 -> count=10 rows
with finite CIs; -v shows K result lines per opcode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-documented, self-contained offline microbenchmark harness under tools/gasbench with solid unit coverage and no changes to production/consensus code. No blocking issues found; a couple of non-blocking notes, and I disagree with Codex's P1 finding after verifying the Go benchmark semantics.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex's P1 (bench_test.go: "-count reruns the entire benchmark function, so every aggregation is count=1 and each run overwrites the output") is incorrect. Verified against Go 1.24 testing/benchmark.go: because BenchmarkOpcodes calls b.Run per case, its parent B has hasSub=true, so run1() returns false and processBench (the -count loop) is never invoked for the parent — the body, collectors, and writeRuns execute exactly once. The -count loop applies only to each leaf subtest (with -benchtime=1x, launch adds nothing beyond run1), so each collector accumulates exactly count deltas. This matches the PR's documented design and its validation (count=10, finite CIs, single write). If P1 were true, every row would be underpowered, contradicting the reported results.
  • Cursor produced no review output (cursor-review.md is empty); REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied.
  • Test-coverage gap: the per-Case gas self-check that validates ConstGas/Arity for all 22 Specs only runs inside BenchmarkOpcodes (i.e., only when the benchmark is executed on the dedicated host). A cheap CI-runnable unit test that iterates Specs, builds each Case via BuildCaseWith, NewProgram()s both baseline/target, and asserts the measured GasDelta == ExpectedGasDelta would catch a mistranscribed ConstGas in normal go test ./tools/gasbench/... rather than only on the pinned host. programs_test.go currently only pins the arity-0 panic guard.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread tools/gasbench/emit.go
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Codex P2 (valid, though deliberate): because distinguishable accepts a CI wholly below zero (ci_hi < 0) and effectSizePass compares math.Abs(perOpDeltaNs), a row with a negative per-op delta (target measured faster than its filler) can be classified status=ok. Since ok is documented as "the only correlation-eligible verdict," a naive downstream consumer computing exec_time_ns / const_gas would get a nonsensical negative ns-per-gas. The README does mitigate this by instructing consumers to sign-check exec_time_ns, and the symmetry is intentional ("measurably different, never more expensive"), so this is not a blocker. Consider whether the sign-check should be enforced (e.g. exclude negative deltas from ok, or emit a distinct status) rather than left advisory — a genuine negative delta on a scalar constant-gas opcode is itself a signal of a measurement/construction problem, not a correlation-eligible result.

@seidroid
seidroid Bot dismissed their stale review July 15, 2026 19:49

Superseded: latest AI review found no blocking issues.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the two inline nits, I also checked the claim that Program.Run rebuilding the vm.EVM object on every call (via runtime.Call) contradicts the package's docs — it doesn't: the doc comment and AGENTS.md only promise StateDB reuse ("not StateDB construction"), never EVM-struct reuse, so this isn't a real bug.

Extended reasoning...

Ruled out a candidate finding about tools/gasbench/program.go: Program.Run calls runtime.Call(contractAddr, nil, p.cfg) once per measured iteration, and that internally allocates a fresh vm.EVM each call. The finder flagged this as contradicting the harness's documented reuse contract. Reading program.go's doc comment ("Program holds a warmed EVM environment so a timing loop measures only the hot Call, not StateDB construction") and the identical framing in tools/gasbench/AGENTS.md, the promised optimization is specifically StateDB construction avoidance (state.New + CreateAccount + SetCode happen once in NewProgram), not avoidance of the EVM object allocation itself. Since the vm.EVM struct is cheap to construct relative to StateDB setup, and the documentation never claims EVM-level reuse, this is not a contradiction — the candidate was correctly ruled out.

Comment thread tools/gasbench/emit.go
Comment on lines +136 to +156
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 At exactly count=5, benchmath's order-statistic CI on the paired delta comes back asymmetric (one bound -Inf/+Inf, the other finite), but NewRun nulls CILo/CIHi independently per raw bound instead of gating on the row-level cr.Underpowered flag — so a status=underpowered row can leak one real-looking finite CI bound next to a null one, contradicting the README's documented row-level contract ("ci_lo/ci_hi are null on underpowered rows"). Fix by forcing both CILo/CIHi to nil whenever cr.Underpowered is true, rather than deciding null bound-by-bound from raw finiteness.

Extended reasoning...

What the bug is: NewRun (emit.go:149-150) builds CILo/CIHi by calling nullableBound(cr.CILo) and nullableBound(cr.CIHi) independently — each pointer is nulled purely based on whether that specific raw bound is finite. This implicitly assumes the CI is either symmetric (both bounds ±Inf) or fully resolved (both finite). But benchmath.AssumeNothing's order-statistic CI on the median is not symmetric in finiteness at small n: at exactly count=5 the paired-delta CI comes back with one bound non-finite and the other finite.

The mechanism, traced through the vendored golang.org/x/perf/benchmath + go-moremath: SummarymedianCIstats.QuantileCI(n, 0.5, 0.95). For n=5, QuantileCI returns LoOrder=0, HiOrder=5. SampleCI then maps LoOrder<1lo=-Inf, but HiOrder-1=4 < len=5hi=Xs[4] (a real, finite order statistic — the max sample). So at n=5 the CI is [-Inf, finite]. All three independent verifiers confirmed this exact asymmetry empirically (n=1..4 both ±Inf; n=5 one bound flips finite; n>=6 both finite).

Why the existing code doesn't prevent it: crossRun.Underpowered (crossrun.go) already collapses this scenario into a single row-level boolean: len(Deltas) < 2 || !finite(sum.Lo) || !finite(sum.Hi), which correctly evaluates to true at n=5. classifyStatus correctly reads this and emits status=underpowered. But NewRun never consults cr.Underpowered when building CILo/CIHi — it independently nulls each raw bound via nullableBound, so the finite bound survives onto the wire even though the row as a whole is flagged underpowered. Every existing test (TestNewRunUnderpoweredNullCIAndNDJSONSafe, TestWriteRoundTripMixed) only constructs the symmetric ±Inf case, so this asymmetric-at-n=5 path has zero coverage.

Impact: a consumer reading only the documented row-level contract (README's Output-schema and Quickstart sections both state "ci_lo/ci_hi are null on underpowered rows" as a per-row invariant, not a per-bound one) sees a status=underpowered row with an empty ci_lo next to a genuine-looking finite ci_hi (e.g. ci_lo=,ci_hi=5101.5,status=underpowered). That surviving number looks like a meaningful one-sided bound but is actually a statistical artifact of the binomial order-statistic's lopsided rejection region at this particular n — not a valid one-sided CI at the stated confidence.

Step-by-step proof:

  1. Run at exactly GASBENCH_COUNT=5 (a natural value to try since the README documents count>=6 as the minimum for a finite CI — an operator reducing GASBENCH_COUNT to save time would plausibly land on 5).
  2. analyzeCrossRun computes sum := benchmath.AssumeNothing.Summary(...) over the 5 per-count deltas; QuantileCI(5, 0.5, 0.95) returns LoOrder=0, HiOrder=5, so sum.Lo=-Inf and sum.Hi=<finite max sample>.
  3. cr.Underpowered = !finite(sum.Lo) || !finite(sum.Hi) evaluates to true — correctly.
  4. classifyStatus short-circuits on underpowered and returns StatusUnderpowered — correctly.
  5. But NewRun still runs CILo: nullableBound(cr.CILo)nil (since -Inf is non-finite) and CIHi: nullableBound(cr.CIHi) → a real *float64 pointer (since the value is finite) — independently of step 3's verdict.
  6. The emitted row is ci_lo=<empty>, ci_hi=<finite>, status=underpowered, contradicting the documented per-row null-CI contract.

Fix: gate nullableBound on cr.Underpowered — force both CILo/CIHi to nil whenever the row's status is underpowered, rather than deciding null-ness bound-by-bound from raw finiteness. One-line change in NewRun.

Severity note: all three independent verifiers capped this at nit rather than normal, and I agree — status itself is correctly underpowered, there's no crash or NaN, and the README's own documented consumer workflow ("filter on status; only ok is correlation-eligible") means a compliant consumer discards the row before ever reading the leaked bound. The harm is limited to a misleading finite number reaching a consumer that trusts the documented "both null" invariant literally, at exactly count=5 (a narrow, if plausible, trigger window).

Comment on lines +136 to +148
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 aggregate() treats any -bench-filtered (unrun) subtest the same as a genuinely failed one, since testing.(*B).Run never invokes the closure for a name that doesn't match -bench, leaving collectors[i] at its zero value. BenchmarkOpcodes then unconditionally aggregates and writes all 22 cases, so a filtered debug run with GASBENCH_OUT_CSV/NDJSON set (e.g. -bench 'BenchmarkOpcodes/ADD$') writes 1 real row plus 21 spurious status=error rows with a misleading "subtest failed" log line, even though go test exits 0.

Extended reasoning...

What happens: aggregate() (bench_test.go:136-148) short-circuits to NewErrorRun (status=error) whenever len(acc.deltas)==0, logging "%s: no measurements accumulated (subtest failed); emitting status=error row". This guard was added in commit 9706749 specifically for the case where a subtest's b.Fatalf unwinds its own goroutine without populating the accumulator. But an empty accumulator has a second, much more common cause: testing.(*B).Run (stdlib testing/benchmark.go) computes the subtest's full name and checks it against the -bench filter; if !ok { return true } returns immediately without ever invoking the closure f. So any Case whose OpcodeID doesn't match an active -bench regex leaves collectors[i] at its Go zero value — indistinguishable, from aggregate()'s point of view, from a subtest that actually ran and crashed.\n\nThe trigger path: BenchmarkOpcodes (bench_test.go:107-112) loops over all cases unconditionally after the b.Run loop, calling aggregate() for every case regardless of which ones the -bench filter actually let execute, and writeRuns (bench_test.go:112, called with no b.Failed() or subtest-ran gate) writes every resulting Run to the CSV/NDJSON files if GASBENCH_OUT_CSV/GASBENCH_OUT_NDJSON are set. So running:\n\ngo test ./tools/gasbench/ -run '^$' -bench 'BenchmarkOpcodes/ADD$' -benchtime=1x -count=10 \\n GASBENCH_OUT_CSV=out.csv GASBENCH_OUT_NDJSON=out.ndjson\n\nwrites 22 rows: 1 legitimate ADD row, and 21 status=error rows for every other opcode — each with a b.Logf claiming "subtest failed" — even though those 21 subtests were simply never selected to run, and go test itself exits 0 (no failure occurred anywhere).\n\nWhy nothing catches this today: aggregate()'s only guard against a bad empty-accumulator state is the len(acc.deltas)==0 check, which cannot distinguish "never selected by -bench" from "ran and crashed via b.Fatalf" — both produce the identical zero-valued opcodeAccum. This is a direct, if unintended, side effect of the fix in 9706749: before that commit, an empty accumulator flowed into analyzeCrossRun and produced a NaN-centered Run (crashing WriteNDJSON or writing a literal NaN to the CSV); after it, the same empty-accumulator condition now degrades cleanly to a writer-safe status=error row — but that row is emitted for filtered-out opcodes just as readily as for truly failed ones, and the README/AGENTS documentation of status=error ("the case never produced a trustworthy measurement... its subtest failed... or a gas self-check fired") does not mention filtering as a cause.\n\nRealism of the trigger: this isn't a contrived scenario. The PR author's own timeline comment ran exactly this filtered form — -count=10 -v -bench BenchmarkOpcodes/ADD$ — to settle the -benchtime=1x/-count semantics question in this same PR discussion. Had the author (or a future contributor debugging a single new opcode, exactly the workflow AGENTS.md's "New opcode specs" section encourages) also set GASBENCH_OUT_CSV/GASBENCH_OUT_NDJSON during that run — a natural thing to do while iterating on output — they would see 21 opcodes reported as status=error/"failed" in the CSV, when in fact none of those 21 subtests ever executed this run.\n\nStep-by-step proof:\n1. go test parses -bench 'BenchmarkOpcodes/ADD$'; only the top-level BenchmarkOpcodes (a prefix/partial match) and the ADD leaf subtest match.\n2. Inside BenchmarkOpcodes, the loop over cases still executes fully (it's plain Go code, not gated by any benchmark filter) and calls b.Run(c.OpcodeID, ...) for every one of the 22 cases.\n3. For c.OpcodeID == "ADD", b.Run's internal filter match succeeds, so the closure runs, measures, and appends to collectors[i] (the ADD slot) exactly as normal.\n4. For every other c.OpcodeID (e.g. "MUL", "SUB", ...), b.Run's filter match fails; per the stdlib's (*B).Run, it returns true immediately without calling the closure — collectors[i] for those 21 stays at its zero value (len(acc.deltas)==0, acc.iterations==0).\n5. After the b.Run loop, BenchmarkOpcodes calls aggregate(b, \&collectors[i], cases[i], ...) for all 22 indices unconditionally (bench_test.go:107-112) — including the 21 that were never run.\n6. aggregate() sees len(acc.deltas)==0 for those 21, logs "no measurements accumulated (subtest failed); emitting status=error row", and returns NewErrorRun(c, acc.iterations)status="error" for each.\n7. writeRuns(b, runs) (bench_test.go:112, unconditional, no gate on which subtests ran) writes all 22 Runs to GASBENCH_OUT_CSV/GASBENCH_OUT_NDJSON if those env vars are set.\n8. go test exits 0 — no failure occurred — yet the CSV/NDJSON of record contains 21 rows falsely implying 21 opcodes' subtests failed.\n\nFix direction: aggregate() (or writeRuns) should only emit a row for a case whose subtest actually ran — e.g. track which indices were visited inside the b.Run closure (a simple acc.ran = true set at the top of the closure, checked instead of/alongside len(acc.deltas)==0), or restrict writeRuns's row set to the -bench-selected cases instead of unconditionally iterating all of Specs/cases.\n\nWhy this is capped at nit, not blocking: the one documented, supported entry point (run.sh) always invokes -bench '^BenchmarkOpcodes$' with no subtest-level filter, so in the shipped, intended workflow all 22 collectors populate normally and this path never triggers. It requires the specific combination of a manual subtest-level -bench filter and the output-file env vars being set, which is a debug-only scenario. The documented consumer contract (README: "Only trust a row that earns it — filter on status") already tells consumers to discard non-ok rows, so no correlation numbers are corrupted, and go test's exit code is never affected. The concrete harm is limited to operator confusion from a misleading log line and spurious rows in a debug-only CSV/NDJSON — not a broken deliverable.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant