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]) +}