From 522a2c8c6f6fbecc2948b37255fec98754543ea7 Mon Sep 17 00:00:00 2001 From: Amlandeep Bhadra Date: Sat, 10 Oct 2026 03:21:37 -0400 Subject: [PATCH 1/2] perf: recover Zig 0.17 regressions and rebuild the benchmark suite Zig 0.17 ships LLVM 22 with loop vectorization disabled, so byte-at-a-time loops that 0.16 vectorized got slower. Read ABI offset/length words and zero-padding checks 8 bytes at a time, and write RLP integers with one big-endian store: - abi_decode_dynamic: back to within ~8% of v0.9.2 (was +50% on 0.17) - dex_decode_universal_router: 2.3x faster than v0.10.0 - tx_hash_eip1559: back to v0.9.2 parity ABI decode now rejects offset and length words that do not fit in a usize instead of clamping them, so a hostile word can no longer overflow the bounds checks, and bounds dynamic-array lengths by the remaining data before allocating. Benchmarks: ten hot-path operations measured against eth.zig's own past releases. Median of 31 batches in fractional ns, inputs behind an optimizer barrier, performance-core QoS on macOS, --filter/--json/--save/--baseline, bench-install step and bench/ab.py for interleaved version comparisons. Remove the alloy.rs / Voltaire comparison benches and docs. --- .github/workflows/bench.yml | 12 +- Makefile | 12 +- README.md | 75 +- bench/BTC_SOL.md | 20 - bench/README.md | 104 +++ bench/RESULTS.md | 84 -- bench/RESULTS_VOLTAIRE.md | 78 -- bench/ab.py | 81 ++ bench/alloy-bench/.gitignore | 2 - bench/alloy-bench/Cargo.toml | 25 - bench/alloy-bench/benches/eth_comparison.rs | 418 ---------- bench/alloy-bench/benches/u256_comparison.rs | 148 ---- bench/alloy-bench/src/lib.rs | 49 -- bench/bench.zig | 820 ++++++++----------- bench/compare.sh | 175 ---- bench/compare_u256.sh | 155 ---- bench/keccak_bench_cli.zig | 35 - bench/keccak_compare.zig | 160 ---- bench/u256_bench.zig | 296 ------- build.zig | 59 +- docs/content/docs/benchmarks.mdx | 114 +-- docs/content/docs/comparison.mdx | 58 -- docs/content/docs/dex-math.mdx | 2 +- docs/content/docs/faq.mdx | 6 +- docs/content/docs/introduction.mdx | 24 +- docs/content/docs/meta.json | 1 - docs/src/app/layout.tsx | 4 +- src/abi_decode.zig | 62 +- src/dex/abi_reader.zig | 30 +- src/dex/calldata.zig | 4 +- src/dex/routers.zig | 2 +- src/dex/v4.zig | 2 +- src/rlp.zig | 49 +- 33 files changed, 680 insertions(+), 2486 deletions(-) delete mode 100644 bench/BTC_SOL.md create mode 100644 bench/README.md delete mode 100644 bench/RESULTS.md delete mode 100644 bench/RESULTS_VOLTAIRE.md create mode 100644 bench/ab.py delete mode 100644 bench/alloy-bench/.gitignore delete mode 100644 bench/alloy-bench/Cargo.toml delete mode 100644 bench/alloy-bench/benches/eth_comparison.rs delete mode 100644 bench/alloy-bench/benches/u256_comparison.rs delete mode 100644 bench/alloy-bench/src/lib.rs delete mode 100755 bench/compare.sh delete mode 100755 bench/compare_u256.sh delete mode 100644 bench/keccak_bench_cli.zig delete mode 100644 bench/keccak_compare.zig delete mode 100644 bench/u256_bench.zig delete mode 100644 docs/content/docs/comparison.mdx diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 8921a40..eabcf58 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -2,8 +2,8 @@ name: Bench # Benchmarks run nightly (and on demand), off the PR-blocking path: the harness # is noisy on shared runners, so we capture numbers for trend/regression review -# rather than gate merges on them. Each run uploads the BENCH_JSON lines so a -# committed baseline + diff can be layered on later. +# rather than gate merges on them. Each run uploads the suite's JSON results; +# compare releases on one machine with bench/ab.py (see bench/README.md). on: schedule: - cron: "0 7 * * *" # daily at 07:00 UTC @@ -26,13 +26,9 @@ jobs: with: version: "0.17.0" - name: Run benchmarks - run: zig build bench | tee bench-results.txt - - name: Extract machine-readable results - run: grep '^BENCH_JSON' bench-results.txt > bench-results.jsonl || true + run: zig build bench -- --save bench-results.json - name: Upload benchmark results uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: bench-results - path: | - bench-results.txt - bench-results.jsonl + path: bench-results.json diff --git a/Makefile b/Makefile index 9893845..aefba7d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ZIG ?= zig KCOV ?= kcov -.PHONY: build test fmt fmt-fix lint ci integration-test bench bench-u256 bench-keccak coverage docs clean +.PHONY: build test fmt fmt-fix lint ci integration-test bench coverage docs clean ## Build the library (default) build: @@ -42,15 +42,9 @@ ci: build fmt test integration-test: $(ZIG) build integration-test -## Run all benchmarks (ReleaseFast) +## Run the benchmark suite (always optimize=fast; see bench/README.md) bench: - $(ZIG) build bench -Doptimize=ReleaseFast - -bench-u256: - $(ZIG) build bench-u256 -Doptimize=ReleaseFast - -bench-keccak: - $(ZIG) build bench-keccak -Doptimize=ReleaseFast + $(ZIG) build bench ## Remove build artifacts clean: diff --git a/README.md b/README.md index 8595087..ba576e8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Zig](https://img.shields.io/badge/Zig-%E2%89%A5%200.17.0-orange)](https://ziglang.org/) -**The fastest Ethereum library.** Beats Rust's alloy.rs on 18 out of 26 benchmarks. +**The fastest Ethereum library.** A complete Ethereum client library written in Zig -- ABI encoding, RLP serialization, secp256k1 signing, Keccak-256 hashing, HD wallets, ERC-20/721 tokens, JSON-RPC, ENS, and more. @@ -13,35 +13,20 @@ A complete Ethereum client library written in Zig -- ABI encoding, RLP serializa ## Why eth.zig? -**Fastest Ethereum library** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 26 benchmarks**, measured 2026-06-10 against current alloy 1.6/2.0. RLP transaction encoding 24.33x faster, ABI decoding up to 8.41x, secp256k1 recovery 4.23x, ECDSA signing 2.34x, u256 division 3.43x, mulDiv 1.82x. See the [full results](bench/RESULTS.md). +**Fast, and kept fast** -- vendored bitcoin-core secp256k1, XKCP Keccak, limb-native u256 math and allocation-light ABI/RLP codecs. Ten hot-path benchmarks are tracked release over release against eth.zig's own past versions; see [Performance](#performance). **Comptime-first** -- Function selectors and event topics are computed at compile time with zero runtime cost. The compiler does the hashing so your program doesn't have to. **Complete** -- ABI, RLP, secp256k1, Keccak-256, BIP-32/39/44 HD wallets, EIP-712, JSON-RPC, WebSocket, ENS, ERC-20/721 -- everything you need for Ethereum in one package. -## Performance vs alloy.rs - -eth.zig wins **18/26 benchmarks** against [alloy.rs](https://alloy.rs) (alloy-primitives 1.6.0, alloy-consensus 2.0.5; run 2026-06-10). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). Criterion-style harness with 0.5s warmup and 2s measurement. - -| Operation | eth.zig | alloy.rs | Winner | -|-----------|---------|----------|--------| -| secp256k1 sign | 22,033 ns | 51,490 ns | **zig 2.34x** | -| secp256k1 sign+recover | 52,095 ns | 220,150 ns | **zig 4.23x** | -| Keccak-256 (32B) | 263 ns | 319 ns | **zig 1.21x** | -| Keccak-256 (4KB) | 7,732 ns | 7,838 ns | **zig 1.01x** | -| ABI encode (static) | 25 ns | 97 ns | **zig 3.88x** | -| ABI encode (dynamic) | 171 ns | 337 ns | **zig 1.97x** | -| ABI decode (uint256) | 14 ns | 51 ns | **zig 3.64x** | -| ABI decode (dynamic) | 32 ns | 269 ns | **zig 8.41x** | -| RLP encode (EIP-1559 tx) | 3 ns | 73 ns | **zig 24.33x** | -| u256 mulDiv (512-bit) | 17 ns | 31 ns | **zig 1.82x** | -| u256 division | 7 ns | 24 ns | **zig 3.43x** | -| u256 multiply | 5 ns | 10 ns | **zig 2.00x** | -| UniswapV2 getAmountOut | 24 ns | 24 ns | tie | -| UniswapV4 swap | 45 ns | 42 ns | rs 1.07x | -| TX hash (EIP-1559) | 271 ns | 361 ns | **zig 1.33x** | - -alloy.rs wins on address hex parsing (1.55x -- SIMD), hex encoding (1.08x), UniswapV4 swap (1.07x), and Keccak at 256B/1KB inputs (within ~2%, measurement noise). See [full results](bench/RESULTS.md). +## Performance + +The benchmark suite covers the ten operations that dominate a trading bot, liquidator or searcher hot loop: Keccak-256 (32 B and 1 KB), secp256k1 sign and recover, EIP-1559 transaction hashing, ABI encode and decode, u256 `mulDiv`, a Uniswap V4 swap step, and Universal Router calldata decoding. Each run reports the median ns/op over 31 batches, and versions are compared by interleaving their runs so machine noise cancels out. See [bench/README.md](bench/README.md) for the method and the latest results against previous releases. + +```bash +zig build bench # run the suite +zig build bench -- --filter keccak # run a subset +``` ## Quick Start @@ -329,34 +314,6 @@ cd examples && zig build && ./zig-out/bin/01_derive_address | Provider middleware (retry, caching) | Planned | | Hardware wallet signers | Planned | -## Comparison with Other Libraries - -### Performance vs alloy.rs (Rust) - -| Category | eth.zig | alloy.rs | -|----------|---------|----------| -| Benchmarks won | **18/26** | 5/26 | -| secp256k1 signing | Faster (2.34-4.23x) | -- | -| ABI encoding/decoding | Faster (1.96-8.41x) | -- | -| Hashing (Keccak) | Faster on small inputs (1.19-1.21x) | Within ~2% on 256B-1KB | -| u256 arithmetic | Faster on mul/div/mulDiv (1.82-3.43x) | UniswapV4 swap (1.07x) | -| Hex operations | Faster decoding (1.17x) | Faster encoding (1.08x) and parsing (1.55x, SIMD) | - -### Features vs Zabi (Zig) - -| Feature | eth.zig | Zabi | -|---------|---------|------| -| Comptime selectors | Yes | No | -| secp256k1 ECDSA | Yes (libsecp256k1 + Zig fallback) | Yes (C binding) | -| ABI encode/decode | Yes | Yes | -| HD wallets (BIP-32/39/44) | Yes | Yes | -| ERC-20/721 wrappers | Yes | No | -| JSON ABI parsing | Yes | Yes | -| WebSocket transport | Yes | Yes | -| ENS resolution | Yes | Yes | -| EIP-712 typed data | Yes | Yes | -| Multicall3 | Yes | No | - ## Requirements - Zig >= 0.17.0 @@ -370,17 +327,13 @@ zig build integration-test # Integration tests (requires Anvil) ## Benchmarks -One command to run the full comparison (requires Zig, Rust, Python 3): - ```bash -bash bench/compare.sh +zig build bench # the ten tracked benchmarks +zig build bench -- --list # what each one measures +python3 bench/ab.py OLD_BENCH_BIN NEW_BENCH_BIN # interleaved version comparison ``` -Or run individually: - -```bash -zig build bench # eth.zig only -``` +See [bench/README.md](bench/README.md). ## Contributing diff --git a/bench/BTC_SOL.md b/bench/BTC_SOL.md deleted file mode 100644 index f6d1be1..0000000 --- a/bench/BTC_SOL.md +++ /dev/null @@ -1,20 +0,0 @@ -# BTC P2WPKH and SOL SLIP-0010 - -Address derivation from a BIP-39 seed. Not part of the alloy.rs comparison (`bench/RESULTS.md`); alloy has no BIP-84 P2WPKH or SLIP-0010 Solana path. - -## Sibling crate (measured) - -[hd.zig](https://github.com/Macho0x/hd.zig) — `zig build bench`, ReleaseFast, Linux x86_64, Zig 0.16.0, 5000 rounds, seed computed once: - -| Path | ns/op | µs/op | -|---|---:|---:| -| BTC BIP-84 P2WPKH `m/84'/0'/0'/0/0` | 363,330 | 363 | -| SOL SLIP-0010 `m/44'/501'/0'/0'` | 169,473 | 169 | - -BTC there uses an 8-bit generator comb and 5×52 field mul. SOL is HMAC-SHA512 SLIP-0010 plus std Ed25519 (same algorithm as `src/sol.zig` here). - -## This repo - -`zig build bench` now prints `btc_p2wpkh` and `sol_slip10`. - -In-tree BTC reuses `hd_wallet.deriveBtcAccount` and std `Secp256k1.basePoint.mul` (same as existing BIP-32 `deriveChild`), not the 5×52 comb. Expect BTC ns/op closer to a few hundred microseconds–milliseconds until pubkey-create goes through the vendored libsecp256k1 backend. SOL should be in the same ballpark as the table above. diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..2648d7d --- /dev/null +++ b/bench/README.md @@ -0,0 +1,104 @@ +# Benchmarks + +eth.zig benchmarks itself, release over release. The suite is ten operations +that dominate the hot loop of a trading bot, liquidator or searcher; anything +not on that list is either covered by one of them or too small to measure +reliably. + +| Benchmark | What it measures | +|---|---| +| `keccak256_32b` | selector, storage-slot and mapping-key hashing | +| `keccak256_1kb` | calldata- and transaction-sized hashing | +| `secp256k1_sign` | signing a transaction hash | +| `secp256k1_recover` | recovering a sender from a signature (recover only) | +| `tx_hash_eip1559` | RLP-encoding an EIP-1559 transaction and hashing it | +| `abi_encode_transfer` | encoding `transfer(address,uint256)` calldata | +| `abi_decode_dynamic` | decoding `(string,bytes)` return data | +| `u256_mulDiv` | full-precision `a*b/c` with a 512-bit intermediate | +| `u256_uniswapv4_swap` | one exact-in swap-step price update | +| `dex_decode_universal_router` | decoding a real 4-command Universal Router `execute` | + +## Running + +```bash +zig build bench # all ten +zig build bench -- --filter abi # a subset (substring match) +zig build bench -- --list # names and descriptions +zig build bench -- --json # machine-readable results +zig build bench -- --save results.json # write results to a file +zig build bench -- --baseline results.json # compare with a saved run +zig build bench -- --baseline results.json --fail-on-regression +``` + +The benchmark build is always `optimize=fast`, whatever `-Doptimize` says. + +## How it measures + +- Each benchmark is warmed up for 200 ms, then timed in batches sized to about + 10 ms so clock overhead is negligible. +- The figure is the **median ns/op over 31 batches** (`--samples`), reported to + hundredths of a nanosecond, with the minimum and the median absolute + deviation (as a percentage of the median) alongside. +- Inputs live in module-level variables and are read through an optimizer + barrier on every call, so the compiler cannot constant-fold the work away. + (The previous harness let several sub-10 ns benchmarks fold to a near no-op, + and reported whole nanoseconds only.) +- On macOS the process asks for the user-interactive QoS class so it stays on + performance cores. +- `--baseline` flags a regression only when the median is slower than the + baseline by more than `--threshold` percent (default 5) **and** by more than + twice the two runs' combined deviation. + +## Comparing against a past version + +A saved baseline is only meaningful on the same machine in the same state. The +reliable way to compare two versions is to build both and interleave their +runs with `bench/ab.py`: + +```bash +# in a checkout of each ref (the harness exists from v0.10.1 on) +zig build bench-install --prefix /tmp/old # -> /tmp/old/bin/bench +zig build bench-install --prefix /tmp/new + +python3 bench/ab.py --rounds 7 /tmp/old/bin/bench /tmp/new/bin/bench +``` + +`ab.py` runs every binary once per round in a shuffled order and keeps each +benchmark's fastest per-run median. Interference (another process, a run moved +to efficiency cores) only ever makes a run slower, so the best round is the +closest estimate of what the code can do. + +On a laptop doing other work, results are often bimodal: a run lands either +at full speed or roughly 1.7x slower. Use enough rounds that every binary gets +at least one fast round, and treat single-run absolute numbers with suspicion. +The nightly `Bench` workflow on a Linux runner uploads `bench-results.json` +for a longer-term trend. + +## Latest results + +Apple M4, macOS, 2026-10-10. `ab.py`, 7 interleaved rounds, fastest per-run +median. The baseline is v0.9.2 built with Zig 0.16.0 using this harness; the +candidate is this release built with Zig 0.17.0. + +| Benchmark | v0.9.2 (Zig 0.16) | this release (Zig 0.17) | change | +|---|---:|---:|---:| +| `keccak256_32b` | 136.66 ns | 136.55 ns | -0.1% | +| `keccak256_1kb` | 1043.85 ns | 1047.39 ns | +0.3% | +| `secp256k1_sign` | 11421.14 ns | 11390.99 ns | -0.3% | +| `secp256k1_recover` | 15159.87 ns | 15158.16 ns | 0.0% | +| `tx_hash_eip1559` | 142.99 ns | 143.49 ns | +0.3% | +| `abi_encode_transfer` | 18.77 ns | 18.74 ns | -0.2% | +| `abi_decode_dynamic` | 15.77 ns | 16.95 ns | +7.5% | +| `u256_mulDiv` | 12.79 ns | 13.00 ns | +1.6% | +| `u256_uniswapv4_swap` | 24.09 ns | 21.94 ns | -8.9% | +| `dex_decode_universal_router` | (new in v0.10.0) | 154.04 ns | | + +### Zig 0.17 notes + +Zig 0.17 ships LLVM 22 with loop vectorization disabled to work around an LLVM +regression. Byte-at-a-time loops that 0.16 used to vectorize silently got +slower, which is what pushed `abi_decode_dynamic` 50% and Universal Router +decoding 2.3x slower right after the upgrade. The fixes read 8 bytes at a time +instead (offset/length words, zero-padding checks) and write RLP integers with +one big-endian store. The remaining `abi_decode_dynamic` gap is in code +generation for the decode loop itself, not in eth.zig's logic. diff --git a/bench/RESULTS.md b/bench/RESULTS.md deleted file mode 100644 index 8857263..0000000 --- a/bench/RESULTS.md +++ /dev/null @@ -1,84 +0,0 @@ -# eth.zig vs alloy.rs: Ethereum Library Benchmark Comparison - -Zig vs Rust -- a head-to-head performance comparison of [eth.zig](https://github.com/StrobeLabs/eth.zig) and [alloy.rs](https://alloy.rs) across 26 core Ethereum operations: Keccak-256 hashing, ABI encoding/decoding, RLP serialization, secp256k1 ECDSA signing, u256 arithmetic (including UniswapV4 mulDiv with true 512-bit intermediate), hex operations, address derivation, and EIP-1559 transaction hashing. - -**Score: eth.zig wins 18/26 | alloy.rs wins 5/26 | tied 3/26** - -Run date: 2026-06-10 on Apple Silicon (aarch64-macos), Zig 0.16.0 `ReleaseFast` vs Rust 1.94.0 `--release`. alloy crate versions: alloy-primitives 1.6.0, alloy-sol-types 1.6.0, alloy-dyn-abi 1.6.0, alloy-consensus 2.0.5, alloy-signer 2.0.5, alloy-rlp 0.3.15, k256 0.13. Custom criterion-style harness with 0.5s warmup, calibrated batch sizes, and 2s measurement window. Both mulDiv benchmarks use true 512-bit intermediate arithmetic (eth.zig's `mulDiv`, alloy's `U512` from ruint). - -> Historical note: an earlier comparison (March 2026, against alloy-primitives 0.8 / alloy 0.6) scored eth.zig 23/26. alloy has since improved several hot paths (Keccak on larger inputs, RLP u256 decoding, hex encoding, UniswapV4-style swap math), and the table below is the current head-to-head against alloy 1.6/2.0. - -## Full Comparison - -| Benchmark | eth-zig | alloy.rs | Winner | -|---|---|---|---| -| keccak256_empty | 261 ns | 310 ns | **zig 1.19x** | -| keccak256_32b | 263 ns | 319 ns | **zig 1.21x** | -| keccak256_256b | 534 ns | 532 ns | rs 1.00x | -| keccak256_1kb | 2,073 ns | 2,039 ns | rs 1.02x | -| keccak256_4kb | 7,732 ns | 7,838 ns | **zig 1.01x** | -| secp256k1_sign | 22,033 ns | 51,490 ns | **zig 2.34x** | -| secp256k1_sign_recover | 52,095 ns | 220,150 ns | **zig 4.23x** | -| address_derivation | 265 ns | 328 ns | **zig 1.24x** | -| address_from_hex | 17 ns | 11 ns | rs 1.55x | -| checksum_address | 304 ns | 341 ns | **zig 1.12x** | -| abi_encode_transfer | 24 ns | 47 ns | **zig 1.96x** | -| abi_encode_static | 25 ns | 97 ns | **zig 3.88x** | -| abi_encode_dynamic | 171 ns | 337 ns | **zig 1.97x** | -| abi_decode_uint256 | 14 ns | 51 ns | **zig 3.64x** | -| abi_decode_dynamic | 32 ns | 269 ns | **zig 8.41x** | -| rlp_encode_eip1559_tx | 3 ns | 73 ns | **zig 24.33x** | -| rlp_decode_u256 | 7 ns | 7 ns | tie | -| u256_add | 4 ns | 4 ns | tie | -| u256_mul | 5 ns | 10 ns | **zig 2.00x** | -| u256_div | 7 ns | 24 ns | **zig 3.43x** | -| u256_uniswapv2_amount_out | 24 ns | 24 ns | tie | -| u256_mulDiv | 17 ns | 31 ns | **zig 1.82x** | -| u256_uniswapv4_swap | 45 ns | 42 ns | rs 1.07x | -| hex_encode_32b | 26 ns | 24 ns | rs 1.08x | -| hex_decode_32b | 24 ns | 28 ns | **zig 1.17x** | -| tx_hash_eip1559 | 271 ns | 361 ns | **zig 1.33x** | - -## Score Summary - -| | Count | -|---|---| -| eth-zig wins | 18 | -| alloy.rs wins | 5 | -| Tied | 3 | - -## Key Optimizations - -| Optimization | Impact | -|---|---| -| bitcoin-core/secp256k1 C backend (vendored) | secp256k1_sign: 2.34x faster than alloy; sign_recover: 4.23x faster | -| Lane-complementing Keccak-f[1600] (XKCP opt64) | keccak256_32b: 1.21x faster than alloy (larger inputs are now within ~2% of alloy 1.6) | -| Streamlined mulDiv with native u128 division | mulDiv: 1.82x faster than alloy (17ns vs 31ns) | -| Half-word division (`div128by64`) | u256_div: 7ns, 3.43x faster than alloy | -| mulWide + divWide (Knuth D for 512-bit) | Replaces 256-iteration binary long division with ~4-iteration Knuth D | -| FixedBufferAllocator in benchmarks | Eliminates allocator overhead for ABI/RLP/TX benchmarks | -| Vendored bitcoin-core/secp256k1 + XKCP keccak | Best-in-class C backends compiled by `zig build` | -| Custom criterion-style harness | Accurate timing in the sub-25ns regime; zbench had ~25ns floor on macOS | - -## Where alloy.rs Wins - -| Benchmark | Gap | Root Cause | -|---|---|---| -| address_from_hex | 1.55x | alloy uses SIMD hex parsing; eth.zig uses scalar loop | -| hex_encode_32b | 1.08x | alloy's const-hex crate uses vectorized encoding | -| u256_uniswapv4_swap | 1.07x | ruint's U512 widening mul/div edges out eth.zig on this path | -| keccak256_256b | 1.00x | within measurement noise (~0.4%) | -| keccak256_1kb | 1.02x | within measurement noise (~2%) | - -## Reproducing - -```bash -# Full comparison (requires Zig, Rust, Python 3) -bash bench/compare.sh - -# eth-zig benchmarks only -zig build bench - -# alloy benchmarks only -(cd bench/alloy-bench && cargo bench --bench eth_comparison) -``` diff --git a/bench/RESULTS_VOLTAIRE.md b/bench/RESULTS_VOLTAIRE.md deleted file mode 100644 index 7edd8d4..0000000 --- a/bench/RESULTS_VOLTAIRE.md +++ /dev/null @@ -1,78 +0,0 @@ -# eth.zig vs Voltaire: Head-to-Head Benchmarks - -Comparison of eth.zig and [Voltaire](https://github.com/evmts/voltaire) on the three core -Ethereum crypto operations. Both libraries use `std.crypto.ecc.Secp256k1` from the Zig -standard library. Voltaire's `keccak-asm` FFI is unavailable in pure-Zig mode (requires Cargo), -so both use Zig-native Keccak -- except eth.zig ships an optimized permutation. - -**Machine**: Apple M-series, Zig 0.15.2, `ReleaseFast` - -## Results - -### Keccak-256 - -| Input size | eth.zig (ns) | Voltaire (ns) | Winner | -|------------|-------------|---------------|--------| -| empty | 301 | ~384* | eth.zig | -| 32 bytes | 300 | ~340* | eth.zig | -| 256 bytes | 626 | ~727* | eth.zig | -| 1 KB | 2,456 | ~2,845* | eth.zig | -| 4 KB | 9,496 | ~10,329* | eth.zig | - -*\*Voltaire pure-Zig uses `std.crypto.hash.sha3.Keccak256` (unmodified stdlib). -eth.zig uses a lane-complementing Keccak-f\[1600\] permutation that saves ~120 NOT -operations per hash via the XKCP opt64 strategy.* - -### secp256k1 - -| Operation | eth.zig (ns) | Voltaire (ns) | Winner | -|----------------|-------------|---------------|--------| -| sign (ECDSA) | 160,130 | ~224,700* | eth.zig (1.40x) | -| sign + recover | 442,448 | ~506,970* | eth.zig (1.15x) | - -*\*Voltaire uses unmodified `std.crypto.ecc.Secp256k1.basePoint.mul()`. -eth.zig applies the [GLV endomorphism](https://codeberg.org/ziglang/zig/pulls/31356) -to split the 256-bit scalar multiplication into two 128-bit halves, -cutting EC doublings from 252 to 124.* - -### EIP-712 (Typed Data Hashing) - -| Operation | eth.zig (ns) | Voltaire approach | -|-----------------|-------------|-------------------| -| hashTypedData | 3,326 | HashMap + allocator per call | - -*eth.zig uses comptime struct types and stack-allocated field arrays. -Voltaire's EIP-712 (`unaudited_hashTypedData`) builds HashMaps at runtime -for type resolution, requiring heap allocation on every call.* - -## How to Replicate - -### eth.zig - -```bash -git clone https://github.com/ethzig/eth.zig -cd eth.zig -zig build bench -``` - -### Voltaire - -```bash -git clone https://github.com/evmts/voltaire -cd voltaire -# Voltaire uses zbench; check their build.zig for bench targets -zig build bench -``` - -Note: Voltaire's Keccak FFI (`keccak-asm`) requires Rust/Cargo. Without it, their -`keccak_asm.zig` falls back to `std.crypto.hash.sha3.Keccak256` (same as stdlib baseline). - -## Summary - -| Category | eth.zig | Voltaire | Advantage | -|------------|---------|----------|-----------| -| Keccak-256 | Lane-complementing opt64 | stdlib (or Rust FFI) | eth.zig: 13-28% faster (pure Zig) | -| secp256k1 | GLV endomorphism | stdlib | eth.zig: 40% faster signing | -| EIP-712 | Comptime structs | Runtime HashMaps | eth.zig: zero heap allocation | - -All eth.zig optimizations are pure Zig with zero external dependencies. diff --git a/bench/ab.py b/bench/ab.py new file mode 100644 index 0000000..d52a6e1 --- /dev/null +++ b/bench/ab.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Interleaved A/B comparison of eth.zig benchmark binaries. + +Runs every binary once per round, in a shuffled order, so drift in machine +load or clock speed lands on all of them equally. For each binary and +benchmark it keeps the fastest per-run median across rounds: interference +(another process, a run migrated to efficiency cores) only ever slows a run +down, so the best round is the closest estimate of what the code can do. +Changes are relative to the first binary (the baseline). + + python3 bench/ab.py --rounds 5 BASELINE_BIN CANDIDATE_BIN [MORE_BINS...] + +Build a binary for any ref with `zig build bench-install --prefix DIR` in a +checkout of that ref; it lands at DIR/bin/bench (see bench/README.md). +""" +import argparse +import json +import random +import statistics +import subprocess +import sys + + +def run(binary, samples, flt): + cmd = [binary, "--json", "--samples", str(samples)] + if flt: + cmd += ["--filter", flt] + out = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + return {r["name"]: r["median_ns"] for r in json.loads(out)["results"]} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("binaries", nargs="+", help="first one is the baseline") + ap.add_argument("--rounds", type=int, default=5) + ap.add_argument("--samples", type=int, default=15) + ap.add_argument("--filter", default=None) + ap.add_argument("--threshold", type=float, default=5.0, help="percent change to flag") + args = ap.parse_args() + + runs = {b: [] for b in args.binaries} + for r in range(args.rounds): + order = list(args.binaries) + random.shuffle(order) + for b in order: + runs[b].append(run(b, args.samples, args.filter)) + print(f"round {r + 1}/{args.rounds} done", file=sys.stderr) + + base = args.binaries[0] + names = list(runs[base][0].keys()) + for b in args.binaries[1:]: + for n in runs[b][0]: + if n not in names: + names.append(n) + + def med(b, n): + vals = [r[n] for r in runs[b] if n in r] + return (min(vals), (statistics.median(vals) - min(vals)) / min(vals) * 100) if vals else (None, None) + + labels = [b.rsplit("/", 1)[-1] for b in args.binaries] + print(f"\n{'benchmark':<30}" + "".join(f"{l[:20]:>22}" for l in labels)) + for n in names: + row = f"{n:<30}" + b0, _ = med(base, n) + for i, b in enumerate(args.binaries): + m, spread = med(b, n) + if m is None: + row += f"{'-':>22}" + continue + cell = f"{m:.2f}ns" + if i > 0 and b0: + ch = (m - b0) / b0 * 100 + mark = " !" if ch > args.threshold else (" +" if ch < -args.threshold else "") + cell += f" {ch:+.1f}%{mark}" + row += f"{cell:>22}" + print(row) + print(f"\n{args.rounds} interleaved rounds; '!' = slower than baseline by >{args.threshold:g}%, '+' = faster.") + + +if __name__ == "__main__": + main() diff --git a/bench/alloy-bench/.gitignore b/bench/alloy-bench/.gitignore deleted file mode 100644 index ca98cd9..0000000 --- a/bench/alloy-bench/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -Cargo.lock diff --git a/bench/alloy-bench/Cargo.toml b/bench/alloy-bench/Cargo.toml deleted file mode 100644 index cc3828b..0000000 --- a/bench/alloy-bench/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "alloy-bench" -version = "0.1.0" -edition = "2021" - -[dependencies] -alloy-primitives = { version = "1.6", features = ["rlp"] } -alloy-rlp = "0.3" -alloy-rlp-derive = "0.3" -alloy-sol-types = "1.6" -alloy-dyn-abi = "1.6" -alloy-consensus = "2.0" -alloy-signer = "2.0" -alloy-signer-local = "2.0" -alloy-network = "2.0" -k256 = { version = "0.13", features = ["ecdsa"] } -criterion = { version = "0.5", features = ["html_reports"] } - -[[bench]] -name = "eth_comparison" -harness = false - -[[bench]] -name = "u256_comparison" -harness = false diff --git a/bench/alloy-bench/benches/eth_comparison.rs b/bench/alloy-bench/benches/eth_comparison.rs deleted file mode 100644 index 6f1017f..0000000 --- a/bench/alloy-bench/benches/eth_comparison.rs +++ /dev/null @@ -1,418 +0,0 @@ -use alloy_bench::*; -use alloy_primitives::{keccak256, Address, U256, Uint}; -use criterion::{black_box, criterion_group, criterion_main, Criterion}; - -type U512 = Uint<512, 8>; - -// ================================================================ -// 1. Keccak256 benchmarks -// ================================================================ - -fn bench_keccak(c: &mut Criterion) { - let mut group = c.benchmark_group("keccak256"); - - group.bench_function("empty", |b| { - b.iter(|| black_box(keccak256(black_box(b"")))) - }); - - group.bench_function("32b", |b| { - let data = TEST_MSG_HASH; - b.iter(|| black_box(keccak256(black_box(&data)))) - }); - - group.bench_function("256b", |b| { - b.iter(|| black_box(keccak256(black_box(&KECCAK_256B)))) - }); - - group.bench_function("1kb", |b| { - b.iter(|| black_box(keccak256(black_box(&KECCAK_1KB)))) - }); - - group.bench_function("4kb", |b| { - b.iter(|| black_box(keccak256(black_box(&KECCAK_4KB)))) - }); - - group.finish(); -} - -// ================================================================ -// 2. secp256k1 ECDSA benchmarks -// ================================================================ - -fn bench_secp256k1(c: &mut Criterion) { - use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; - - let mut group = c.benchmark_group("secp256k1"); - - group.bench_function("sign", |b| { - let signing_key = SigningKey::from_bytes((&TEST_PRIVKEY).into()).unwrap(); - b.iter(|| { - let (sig, _recid): (k256::ecdsa::Signature, k256::ecdsa::RecoveryId) = - signing_key.sign_prehash(black_box(&TEST_MSG_HASH)).unwrap(); - black_box(sig); - }) - }); - - group.bench_function("sign_recover", |b| { - use k256::ecdsa::VerifyingKey; - let signing_key = SigningKey::from_bytes((&TEST_PRIVKEY).into()).unwrap(); - b.iter(|| { - let (sig, recid): (k256::ecdsa::Signature, k256::ecdsa::RecoveryId) = - signing_key.sign_prehash(black_box(&TEST_MSG_HASH)).unwrap(); - let recovered = - VerifyingKey::recover_from_prehash(&TEST_MSG_HASH, &sig, recid).unwrap(); - black_box(recovered); - }) - }); - - group.finish(); -} - -// ================================================================ -// 3. Address benchmarks -// ================================================================ - -fn bench_address(c: &mut Criterion) { - use k256::ecdsa::SigningKey; - - let mut group = c.benchmark_group("address"); - - // Derive address from private key (pubkey -> keccak -> last 20 bytes) - group.bench_function("derivation", |b| { - let sk = SigningKey::from_bytes((&TEST_PRIVKEY).into()).unwrap(); - b.iter(|| { - let vk = sk.verifying_key(); - let pubkey_bytes = vk.to_encoded_point(false); - let hash = keccak256(&pubkey_bytes.as_bytes()[1..]); - let addr = Address::from_slice(&hash[12..]); - black_box(addr); - }) - }); - - // Parse address from hex string - group.bench_function("from_hex", |b| { - b.iter(|| { - let addr: Address = black_box(ADDRESS_HEX).parse().unwrap(); - black_box(addr); - }) - }); - - // EIP-55 checksum formatting - group.bench_function("checksum", |b| { - b.iter(|| { - let checksum = black_box(TEST_ADDR).to_checksum(None); - black_box(checksum); - }) - }); - - group.finish(); -} - -// ================================================================ -// 4. ABI encoding benchmarks -// ================================================================ - -fn bench_abi_encode(c: &mut Criterion) { - use alloy_dyn_abi::DynSolValue; - use alloy_sol_types::SolCall; - - let mut group = c.benchmark_group("abi_encode"); - - // Static encoding: transfer(address, uint256) with selector - group.bench_function("transfer_call", |b| { - alloy_sol_types::sol! { - function transfer(address to, uint256 amount) returns (bool); - } - b.iter(|| { - let call = transferCall { - to: black_box(TEST_ADDR), - amount: black_box(ONE_ETH), - }; - let encoded = call.abi_encode(); - black_box(encoded); - }) - }); - - // Static encoding without selector: (address, uint256) - group.bench_function("static_addr_uint256", |b| { - use alloy_dyn_abi::DynSolValue; - b.iter(|| { - let values = DynSolValue::Tuple(vec![ - DynSolValue::Address(black_box(TEST_ADDR)), - DynSolValue::Uint(black_box(ONE_ETH), 256), - ]); - let encoded = values.abi_encode_params(); - black_box(encoded); - }) - }); - - // Dynamic encoding: (string, bytes, uint256[]) - group.bench_function("dynamic", |b| { - b.iter(|| { - let values = DynSolValue::Tuple(vec![ - DynSolValue::String(ABI_DYNAMIC_STRING.to_string()), - DynSolValue::Bytes(ABI_DYNAMIC_BYTES.to_vec()), - DynSolValue::Array(vec![ - DynSolValue::Uint(U256::from(1), 256), - DynSolValue::Uint(U256::from(2), 256), - DynSolValue::Uint(U256::from(3), 256), - DynSolValue::Uint(U256::from(4), 256), - DynSolValue::Uint(U256::from(5), 256), - ]), - ]); - let encoded = values.abi_encode_params(); - black_box(encoded); - }) - }); - - group.finish(); -} - -// ================================================================ -// 5. ABI decoding benchmarks -// ================================================================ - -fn bench_abi_decode(c: &mut Criterion) { - use alloy_dyn_abi::{DynSolType, DynSolValue}; - - let mut group = c.benchmark_group("abi_decode"); - - // Decode uint256 - group.bench_function("uint256", |b| { - // Pre-encode: ABI encoding of uint256(1 ETH) - let encoded = DynSolValue::Uint(ONE_ETH, 256).abi_encode_params(); - let ty = DynSolType::Uint(256); - b.iter(|| { - let decoded = ty.abi_decode(black_box(&encoded)).unwrap(); - black_box(decoded); - }) - }); - - // Decode dynamic: (string, bytes) - group.bench_function("dynamic", |b| { - let encoded = DynSolValue::Tuple(vec![ - DynSolValue::String(ABI_DYNAMIC_STRING.to_string()), - DynSolValue::Bytes(ABI_DYNAMIC_BYTES.to_vec()), - ]) - .abi_encode_params(); - - let ty = DynSolType::Tuple(vec![DynSolType::String, DynSolType::Bytes]); - b.iter(|| { - let decoded = ty.abi_decode_params(black_box(&encoded)).unwrap(); - black_box(decoded); - }) - }); - - group.finish(); -} - -// ================================================================ -// 6. RLP benchmarks -// ================================================================ - -fn bench_rlp(c: &mut Criterion) { - use alloy_consensus::TxEip1559; - use alloy_primitives::TxKind; - use alloy_rlp::{Decodable, Encodable}; - - let mut group = c.benchmark_group("rlp"); - - // RLP encode EIP-1559 transaction - group.bench_function("encode_eip1559_tx", |b| { - let tx = TxEip1559 { - chain_id: 1, - nonce: 42, - max_priority_fee_per_gas: 2_000_000_000, - max_fee_per_gas: 100_000_000_000, - gas_limit: 21000, - to: TxKind::Call(TEST_ADDR), - value: ONE_ETH, - input: Default::default(), - access_list: Default::default(), - }; - b.iter(|| { - let mut buf = Vec::with_capacity(256); - black_box(&tx).encode(&mut buf); - black_box(buf); - }) - }); - - // RLP decode u256 - group.bench_function("decode_u256", |b| { - let mut encoded = Vec::new(); - ONE_ETH.encode(&mut encoded); - b.iter(|| { - let decoded = U256::decode(&mut black_box(encoded.as_slice())).unwrap(); - black_box(decoded); - }) - }); - - group.finish(); -} - -// ================================================================ -// 7. U256 arithmetic benchmarks -// ================================================================ - -fn bench_u256(c: &mut Criterion) { - let mut group = c.benchmark_group("u256"); - - let a = ONE_ETH; - let b_val = U256::from(997_000_000_000_000_000u64); - - group.bench_function("add", |b| { - b.iter(|| { - let result = black_box(a).checked_add(black_box(b_val)); - black_box(result); - }) - }); - - group.bench_function("mul", |b| { - b.iter(|| { - let result = black_box(a).checked_mul(U256::from(997u64)); - black_box(result); - }) - }); - - group.bench_function("div", |b| { - let large = U256::from(997_000_000_000_000_000_000u128); - b.iter(|| { - let result = black_box(large).checked_div(black_box(a)); - black_box(result); - }) - }); - - // UniswapV2 getAmountOut full formula - group.bench_function("uniswap_v2_amountOut", |b| { - let amount_in = ONE_ETH; - let reserve_in = U256::from(100_000_000_000_000_000_000u128); - let reserve_out = U256::from(200_000_000_000u64); - - b.iter(|| { - let amount_in_with_fee = black_box(amount_in) * U256::from(997); - let numerator = amount_in_with_fee * black_box(reserve_out); - let denominator = black_box(reserve_in) * U256::from(1000) + amount_in_with_fee; - let amount_out = numerator / denominator; - black_box(amount_out); - }) - }); - - // mulDiv: (a * b) / c with full 512-bit intermediate (FullMath.mulDiv) - group.bench_function("mulDiv", |b| { - let liquidity = ONE_ETH; - // Q96 = 2^96 = 79228162514264337593543950336 - let sqrt_price = U256::from(79228162514264337593543950336u128); - let denom = ONE_ETH + U256::from(1_000_000u64); - b.iter(|| { - // True 512-bit intermediate: widen to U512, multiply, divide, narrow back - let a = U512::from(black_box(liquidity)); - let b_val = U512::from(black_box(sqrt_price)); - let d = U512::from(black_box(denom)); - let result = U256::from((a * b_val) / d); - black_box(result); - }) - }); - - // UniswapV4 getNextSqrtPriceFromAmount0RoundingUp (simplified non-overflow path). - // Values are chosen so that product = amount_in * sqrt_price (~7.9e43) and - // denominator = liquidity + product (~7.9e43) both fit in u256 without overflow, - // so checked arithmetic is unnecessary here. The benchmark measures the hot path - // that real swaps hit for typical pool parameters. - group.bench_function("uniswap_v4_swap", |b| { - let liquidity = ONE_ETH; - // Q96 = 2^96 = 79228162514264337593543950336 - let sqrt_price = U256::from(79228162514264337593543950336u128); - let amount_in = U256::from(1_000_000_000_000_000u64); - - b.iter(|| { - let product = black_box(amount_in) * black_box(sqrt_price); - let denominator = black_box(liquidity) + product; - // True 512-bit intermediate for numerator (liquidity * sqrt_price) - let num = U512::from(black_box(liquidity)) * U512::from(black_box(sqrt_price)); - let next_sqrt_price = U256::from(num / U512::from(denominator)); - black_box(next_sqrt_price); - }) - }); - - group.finish(); -} - -// ================================================================ -// 8. Hex encoding/decoding benchmarks -// ================================================================ - -fn bench_hex(c: &mut Criterion) { - use alloy_primitives::hex; - - let mut group = c.benchmark_group("hex"); - - group.bench_function("encode_32b", |b| { - let data = TEST_MSG_HASH; - b.iter(|| { - let result = hex::encode(black_box(&data)); - black_box(result); - }) - }); - - group.bench_function("decode_32b", |b| { - b.iter(|| { - let result = hex::decode(black_box(HEX_STRING_32B)).unwrap(); - black_box(result); - }) - }); - - group.finish(); -} - -// ================================================================ -// 9. Transaction hash benchmarks -// ================================================================ - -fn bench_tx_hash(c: &mut Criterion) { - use alloy_consensus::TxEip1559; - use alloy_primitives::TxKind; - use alloy_rlp::Encodable; - - let mut group = c.benchmark_group("tx_hash"); - - group.bench_function("eip1559", |b| { - let tx = TxEip1559 { - chain_id: 1, - nonce: 42, - max_priority_fee_per_gas: 2_000_000_000, - max_fee_per_gas: 100_000_000_000, - gas_limit: 21000, - to: TxKind::Call(TEST_ADDR), - value: ONE_ETH, - input: Default::default(), - access_list: Default::default(), - }; - b.iter(|| { - let mut buf = Vec::with_capacity(256); - buf.push(0x02); // EIP-1559 type byte - black_box(&tx).encode(&mut buf); - let hash = keccak256(&buf); - black_box(hash); - }) - }); - - group.finish(); -} - -// ================================================================ -// Criterion groups and main -// ================================================================ - -criterion_group!( - benches, - bench_keccak, - bench_secp256k1, - bench_address, - bench_abi_encode, - bench_abi_decode, - bench_rlp, - bench_u256, - bench_hex, - bench_tx_hash, -); -criterion_main!(benches); diff --git a/bench/alloy-bench/benches/u256_comparison.rs b/bench/alloy-bench/benches/u256_comparison.rs deleted file mode 100644 index 8bcaeb6..0000000 --- a/bench/alloy-bench/benches/u256_comparison.rs +++ /dev/null @@ -1,148 +0,0 @@ -/// u256-only benchmark: eth.zig vs alloy.rs (ruint) -/// -/// All test values match bench/u256_bench.zig exactly. -/// alloy's U256 is ruint::Uint<256, 4> -- native [u64; 4] limb arithmetic. - -use alloy_primitives::{U256, Uint}; -use criterion::{black_box, criterion_group, criterion_main, Criterion}; - -type U512 = Uint<512, 8>; - -// ================================================================ -// Test values -- identical to u256_bench.zig -// ================================================================ - -const ONE_ETH: U256 = U256::from_limbs([1_000_000_000_000_000_000u64, 0, 0, 0]); - -// 100 ETH = 100_000_000_000_000_000_000 = 0x56BC75E2D63100000 -const RESERVE_IN: U256 = U256::from_limbs([0x6BC75E2D63100000, 5, 0, 0]); -const RESERVE_OUT: U256 = U256::from_limbs([200_000_000_000u64, 0, 0, 0]); - -// 2^96 = 79228162514264337593543950336 -const SQRT_PRICE: U256 = U256::from_limbs([0, 0x100000000, 0, 0]); -const AMOUNT_IN_SMALL: U256 = U256::from_limbs([1_000_000_000_000_000u64, 0, 0, 0]); - -// Full-width 256-bit values -const FULL_A: U256 = U256::from_limbs([ - 0x12345678_9ABCDEF0, - 0xDEADBEEF_CAFEBABE, - 0x12345678_9ABCDEF0, - 0xDEADBEEF_CAFEBABE, -]); -const FULL_B: U256 = U256::from_limbs([ - 0xDEADBEEF_CAFEBABE, - 0x12345678_9ABCDEF0, - 0xDEADBEEF_CAFEBABE, - 0x12345678_9ABCDEF0, -]); -const FULL_C: U256 = U256::from_limbs([ - 0x00000000_00000001, - 0x00000000_00000000, - 0x00000000_00000000, - 0x00000001_00000000, -]); - -// ================================================================ -// Benchmarks -// ================================================================ - -fn bench_u256(c: &mut Criterion) { - let mut group = c.benchmark_group("u256"); - - // --- Primitives --- - - group.bench_function("add", |b| { - let a = ONE_ETH; - let b_val = U256::from(997_000_000_000_000_000u64); - b.iter(|| { - let result = black_box(a).wrapping_add(black_box(b_val)); - black_box(result); - }) - }); - - group.bench_function("mul_small", |b| { - let a = ONE_ETH; - b.iter(|| { - let result = black_box(a).wrapping_mul(U256::from(997u64)); - black_box(result); - }) - }); - - group.bench_function("mul_full", |b| { - b.iter(|| { - let result = black_box(FULL_A).wrapping_mul(black_box(FULL_B)); - black_box(result); - }) - }); - - group.bench_function("div_small", |b| { - let large = U256::from(997_000_000_000_000_000_000u128); - b.iter(|| { - let result = black_box(large) / black_box(ONE_ETH); - black_box(result); - }) - }); - - group.bench_function("div_full", |b| { - b.iter(|| { - let result = black_box(FULL_A) / black_box(FULL_C); - black_box(result); - }) - }); - - // --- UniswapV2 getAmountOut (naive: step-by-step u256 arithmetic) --- - // Both Zig and Rust do the exact same formula with wrapping u256 ops. - // This is the fair apples-to-apples comparison. - - group.bench_function("uniswapv2_naive", |b| { - let amount_in = ONE_ETH; - let reserve_in = RESERVE_IN; - let reserve_out = RESERVE_OUT; - b.iter(|| { - let amount_in_with_fee = black_box(amount_in).wrapping_mul(U256::from(997u64)); - let numerator = amount_in_with_fee.wrapping_mul(black_box(reserve_out)); - let denominator = - black_box(reserve_in).wrapping_mul(U256::from(1000u64)).wrapping_add(amount_in_with_fee); - let amount_out = numerator / denominator; - black_box(amount_out); - }) - }); - - // --- mulDiv: (a * b) / c with true 512-bit intermediate --- - - group.bench_function("mulDiv", |b| { - let liquidity = ONE_ETH; - let sqrt_price = SQRT_PRICE; - let denom = ONE_ETH.wrapping_add(U256::from(1_000_000u64)); - b.iter(|| { - let a = U512::from(black_box(liquidity)); - let b_val = U512::from(black_box(sqrt_price)); - let d = U512::from(black_box(denom)); - let result = U256::from((a * b_val) / d); - black_box(result); - }) - }); - - // --- UniswapV4 getNextSqrtPriceFromAmount0RoundingUp --- - // product = amount_in * sqrt_price (u256, no overflow for these values) - // denominator = liquidity + product - // next_sqrt_price = (liquidity * sqrt_price) / denominator (via U512) - - group.bench_function("uniswapv4_swap", |b| { - let liquidity = ONE_ETH; - let sqrt_price = SQRT_PRICE; - let amount_in = AMOUNT_IN_SMALL; - b.iter(|| { - let product = black_box(amount_in).wrapping_mul(black_box(sqrt_price)); - let denominator = black_box(liquidity).wrapping_add(product); - let num = U512::from(black_box(liquidity)) * U512::from(black_box(sqrt_price)); - let next_sqrt_price = U256::from(num / U512::from(denominator)); - black_box(next_sqrt_price); - }) - }); - - group.finish(); -} - -criterion_group!(benches, bench_u256); -criterion_main!(benches); diff --git a/bench/alloy-bench/src/lib.rs b/bench/alloy-bench/src/lib.rs deleted file mode 100644 index c1c9692..0000000 --- a/bench/alloy-bench/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -/// Shared test vectors for alloy.rs benchmarks. -/// All values are identical to those used in eth-zig's bench/bench.zig. - -use alloy_primitives::{address, Address, B256, U256}; - -/// Anvil account 0 private key -pub const TEST_PRIVKEY: [u8; 32] = [ - 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, - 0x94, 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfc, 0xae, 0x78, 0x4d, 0x7b, 0xf4, 0xf2, - 0xff, 0x80, -]; - -/// keccak256("") -- well-known hash -pub const TEST_MSG_HASH: [u8; 32] = [ - 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, - 0xc0, 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, - 0xa4, 0x70, -]; - -/// Derived address from TEST_PRIVKEY -pub const TEST_ADDR: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - -/// 1 ETH in wei -pub const ONE_ETH: U256 = U256::from_limbs([1_000_000_000_000_000_000u64, 0, 0, 0]); - -/// UniswapV2 test reserves -pub const RESERVE_IN: U256 = U256::from_limbs([100_000_000_000_000_000_000u128 as u64, 5, 0, 0]); -pub const RESERVE_OUT: U256 = U256::from_limbs([200_000_000_000u64, 0, 0, 0]); - -/// Keccak test inputs -pub const KECCAK_256B: [u8; 256] = [0xAB; 256]; -pub const KECCAK_1KB: [u8; 1024] = [0xAB; 1024]; -pub const KECCAK_4KB: [u8; 4096] = [0xAB; 4096]; - -/// Dynamic ABI test data -pub const ABI_DYNAMIC_STRING: &str = "The quick brown fox jumps over the lazy dog"; -pub const ABI_DYNAMIC_BYTES: &[u8] = - b"hello world, this is a dynamic bytes benchmark test payload"; - -/// Hex test data -pub const HEX_STRING_32B: &str = - "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"; - -pub const ADDRESS_HEX: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; - -/// Helper: B256 from test msg hash -pub fn test_msg_hash_b256() -> B256 { - B256::from(TEST_MSG_HASH) -} diff --git a/bench/bench.zig b/bench/bench.zig index 505e5b6..a9ce329 100644 --- a/bench/bench.zig +++ b/bench/bench.zig @@ -1,591 +1,409 @@ +//! eth.zig benchmark suite: the ten operations that dominate a trading bot, +//! liquidator or searcher hot loop, measured against eth.zig's own past +//! results rather than other libraries. +//! +//! zig build bench run all ten +//! zig build bench -- --filter keccak run a subset +//! zig build bench -- --save bench/baselines/x.json record a baseline +//! zig build bench -- --baseline bench/baselines/x.json [--fail-on-regression] +//! +//! Measurement: each benchmark is warmed up, then timed in batches sized to +//! ~10 ms. The reported figure is the median ns/op over `--samples` batches +//! (default 31), with the minimum and the median absolute deviation (as a +//! percentage of the median) alongside. Inputs are read through an optimizer +//! barrier on every call so the compiler cannot constant-fold the work away. +//! On macOS the process requests the user-interactive QoS class so it stays +//! on performance cores. Compare versions with bench/ab.py, which interleaves +//! runs so machine-load drift affects every binary equally. const std = @import("std"); const eth = @import("eth"); // ============================================================================ -// Test data (Anvil account 0 -- well-known test key) +// Inputs. Module-level `var`s read through `opaqueInput` on every call. // ============================================================================ -const TEST_PRIVKEY: [32]u8 = .{ - 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, - 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, - 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfc, - 0xae, 0x78, 0x4d, 0x7b, 0xf4, 0xf2, 0xff, 0x80, +// Anvil account 0 -- well-known test key. +var private_key: [32]u8 = .{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfc, 0xae, 0x78, 0x4d, 0x7b, 0xf4, 0xf2, 0xff, 0x80, }; -// keccak256("") -- a well-known hash -const TEST_MSG_HASH: [32]u8 = .{ - 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, - 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, 0xc0, - 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, - 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, 0xa4, 0x70, +// keccak256("") +var msg_hash: [32]u8 = .{ + 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, 0xc0, + 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, 0xa4, 0x70, }; -const TEST_ADDR: [20]u8 = .{ - 0xf3, 0x9F, 0xd6, 0xe5, 0x1a, 0xad, 0x88, 0xF6, - 0xF4, 0xce, 0x6a, 0xB8, 0x82, 0x72, 0x79, 0xcf, - 0xfF, 0xb9, 0x22, 0x66, +var test_addr: [20]u8 = .{ + 0xf3, 0x9F, 0xd6, 0xe5, 0x1a, 0xad, 0x88, 0xF6, 0xF4, 0xce, + 0x6a, 0xB8, 0x82, 0x72, 0x79, 0xcf, 0xfF, 0xb9, 0x22, 0x66, }; -// BIP-39 test seed from "abandon" x11 + "about" -const TEST_SEED: [64]u8 = .{ - 0x5e, 0xb0, 0x0b, 0xbd, 0xdc, 0xf0, 0x69, 0x08, - 0x48, 0x89, 0xa8, 0xab, 0x91, 0x55, 0x56, 0x81, - 0x65, 0xf5, 0xc4, 0x53, 0xcc, 0xb8, 0x5e, 0x70, - 0x81, 0x1a, 0xae, 0xd6, 0xf6, 0xda, 0x5f, 0xc1, - 0x9a, 0x5a, 0xc4, 0x0b, 0x38, 0x9c, 0xd3, 0x70, - 0xd0, 0x86, 0x20, 0x6d, 0xec, 0x8a, 0xa6, 0xc4, - 0x3d, 0xae, 0xa6, 0x69, 0x0f, 0x20, 0xad, 0x3d, - 0x8d, 0x48, 0xb2, 0xd2, 0xce, 0x9e, 0x38, 0xe4, -}; - -const TRANSFER_SELECTOR: [4]u8 = .{ 0xa9, 0x05, 0x9c, 0xbb }; - -// Pre-encoded data for decode benchmarks (initialized in main) -var precomputed_abi_dynamic: []const u8 = &.{}; -var precomputed_rlp_u256: []const u8 = &.{}; -var precomputed_pubkey: [65]u8 = undefined; - -// ============================================================================ -// Benchmark harness -- criterion-style: calibrate batch, measure wall time -// ============================================================================ - -const WARMUP_NS: u64 = 500_000_000; // 0.5s warmup -const BENCH_NS: u64 = 2_000_000_000; // 2s measurement +var data_1kb: [1024]u8 = @splat(0xAB); -/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. -const Timer = struct { - start_ns: i96, +const transfer_selector: [4]u8 = .{ 0xa9, 0x05, 0x9c, 0xbb }; - fn now() i96 { - const io = std.Io.Threaded.global_single_threaded.io(); - return std.Io.Clock.now(.awake, io).nanoseconds; - } - - fn start() error{}!Timer { - return .{ .start_ns = now() }; - } - - fn reset(self: *Timer) void { - self.start_ns = now(); - } - - fn read(self: *Timer) u64 { - const elapsed = now() - self.start_ns; - if (elapsed < 0) return 0; - return @intCast(elapsed); - } +// A real PancakeSwap Universal Router `execute` (BSC tx 0x6cf50c46..., +// block 123681598): stable swap, V2 swap, pay-portion and sweep commands. +var ur_execute: [1252]u8 = blk: { + @setEvalBranchQuota(100_000); + break :blk eth.hex.hexToBytesFixed(1252, "3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006ab4a0e400000000000000000000000000000000000000000000000000000000000000042208060400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000ea26b78255df2bbc31c1ebf60010d78670185bd0000000000000000000000000000000000000000000000002c3c465ca58ec0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000055d398326f99059ff775485246999027b31979550000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000430bd0d47ee2c000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d0000000000000000000000002170ed0880ac9a755fd29b2688956bd959f933f800000000000000000000000000000000000000000000000000000000000000600000000000000000000000002170ed0880ac9a755fd29b2688956bd959f933f80000000000000000000000006a80f57ac54123cb71e6c79b3935a381b87b4308000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000600000000000000000000000002170ed0880ac9a755fd29b2688956bd959f933f800000000000000000000000026a50b11214838d5b46060e6ddd851baee4ced810000000000000000000000000000000000000000000000000042e07a6a081c85") catch unreachable; }; -const BenchResult = struct { - ns_per_op: u64, - /// Sample standard deviation of the per-batch ns/op measurements. Turns the - /// "within measurement noise" claims into data callers can actually check. - stddev_ns: u64, - iters: u64, -}; - -fn runBench(comptime func: fn () void) BenchResult { - var timer = Timer.start() catch @panic("timer unsupported"); - - // Warmup: run until WARMUP_NS elapsed - timer.reset(); - while (true) { - inline for (0..64) |_| func(); - if (timer.read() >= WARMUP_NS) break; - } - - // Calibrate: find iteration count that fills ~100ms - var batch: u64 = 64; - while (true) { - timer.reset(); - for (0..batch) |_| func(); - if (timer.read() >= 100_000_000) break; // 100ms - batch *= 2; - } - - // Measure: collect one ns/op sample per ~100ms batch over BENCH_NS. With a - // 2s window and ~100ms batches this is ~20 samples -- enough for a stddev. - var samples: [4096]f64 = undefined; - var n_samples: usize = 0; - var total_iters: u64 = 0; - timer.reset(); - var elapsed: u64 = 0; - while (elapsed < BENCH_NS) { - const t0 = timer.read(); - for (0..batch) |_| func(); - const t1 = timer.read(); - elapsed = t1; - if (n_samples < samples.len) { - const batch_ns: f64 = @floatFromInt(t1 - t0); - samples[n_samples] = batch_ns / @as(f64, @floatFromInt(batch)); - n_samples += 1; - } - total_iters += batch; - } +// Filled in `main` before any benchmark runs. +var signature: eth.signature.Signature = undefined; +var abi_dynamic: []const u8 = &.{}; - const total_ns = timer.read(); - const ns_per_op = if (total_iters > 0) total_ns / total_iters else 0; - - // Sample standard deviation (Bessel-corrected) of the per-batch ns/op. - var stddev_ns: u64 = 0; - if (n_samples > 1) { - var mean: f64 = 0; - for (samples[0..n_samples]) |s| mean += s; - mean /= @floatFromInt(n_samples); - var acc: f64 = 0; - for (samples[0..n_samples]) |s| { - const d = s - mean; - acc += d * d; - } - const variance = acc / @as(f64, @floatFromInt(n_samples - 1)); - stddev_ns = @intFromFloat(@round(@sqrt(variance))); - } - - return .{ .ns_per_op = ns_per_op, .stddev_ns = stddev_ns, .iters = total_iters }; -} - -fn runAndPrint(comptime name: []const u8, comptime func: fn () void, stdout: anytype) !void { - const result = runBench(func); - try stdout.print("{s:<34} {d:>9} ns {d:>9} ns {d:>14}\n", .{ name, result.ns_per_op, result.stddev_ns, result.iters }); - // Machine-readable line for compare.sh and regression tooling. Filtered out - // of human views with `grep -v '^BENCH_JSON'`. - try stdout.print("BENCH_JSON|{{\"name\":\"{s}\",\"ns_per_op\":{d},\"stddev_ns\":{d},\"iters\":{d}}}\n", .{ name, result.ns_per_op, result.stddev_ns, result.iters }); +/// Hide `p` from the optimizer: after this, the compiler must assume the +/// pointee may have changed, so it reloads the input instead of folding it. +inline fn opaqueInput(p: anytype) @TypeOf(p) { + var q = p; + std.mem.doNotOptimizeAway(&q); + return q; } // ============================================================================ -// Benchmark functions -- Keccak256 +// The ten benchmarks // ============================================================================ -fn benchKeccakEmpty() void { - const data: [0]u8 = .{}; - const result = eth.keccak.hash(&data); - std.mem.doNotOptimizeAway(&result); -} +const Bench = struct { + name: []const u8, + /// What the number stands for, shown with `--list`. + what: []const u8, + func: *const fn () void, +}; -fn benchKeccak32() void { - const data: [32]u8 = TEST_MSG_HASH; - const result = eth.keccak.hash(&data); - std.mem.doNotOptimizeAway(&result); -} +const benches = [_]Bench{ + .{ .name = "keccak256_32b", .what = "selector / storage-slot / mapping-key hash", .func = benchKeccak32 }, + .{ .name = "keccak256_1kb", .what = "calldata and transaction-sized hash", .func = benchKeccak1k }, + .{ .name = "secp256k1_sign", .what = "sign a transaction hash", .func = benchSign }, + .{ .name = "secp256k1_recover", .what = "recover a sender from a signature", .func = benchRecover }, + .{ .name = "tx_hash_eip1559", .what = "RLP-encode an EIP-1559 tx and hash it", .func = benchTxHash }, + .{ .name = "abi_encode_transfer", .what = "encode transfer(address,uint256) calldata", .func = benchAbiEncodeTransfer }, + .{ .name = "abi_decode_dynamic", .what = "decode (string,bytes) return data", .func = benchAbiDecodeDynamic }, + .{ .name = "u256_mulDiv", .what = "full-precision a*b/c (512-bit intermediate)", .func = benchMulDiv }, + .{ .name = "u256_uniswapv4_swap", .what = "one exact-in swap step price update", .func = benchV4Swap }, + .{ .name = "dex_decode_universal_router", .what = "decode a 4-command Universal Router execute", .func = benchDexDecode }, +}; -fn benchKeccak256b() void { - const data: [256]u8 = @splat(0xAB); - const result = eth.keccak.hash(&data); - std.mem.doNotOptimizeAway(&result); +fn benchKeccak32() void { + const h = eth.keccak.hash(opaqueInput(&msg_hash)); + std.mem.doNotOptimizeAway(&h); } fn benchKeccak1k() void { - const data: [1024]u8 = @splat(0xAB); - const result = eth.keccak.hash(&data); - std.mem.doNotOptimizeAway(&result); + const h = eth.keccak.hash(opaqueInput(&data_1kb)); + std.mem.doNotOptimizeAway(&h); } -fn benchKeccak4k() void { - const data: [4096]u8 = @splat(0xAB); - const result = eth.keccak.hash(&data); - std.mem.doNotOptimizeAway(&result); -} - -// ============================================================================ -// Benchmark functions -- secp256k1 -// ============================================================================ - -fn benchSecp256k1Sign() void { - const sig = eth.secp256k1.sign(TEST_PRIVKEY, TEST_MSG_HASH) catch unreachable; +fn benchSign() void { + const sig = eth.secp256k1.sign(opaqueInput(&private_key).*, opaqueInput(&msg_hash).*) catch unreachable; std.mem.doNotOptimizeAway(&sig); } -fn benchSecp256k1Recover() void { - const sig = eth.secp256k1.sign(TEST_PRIVKEY, TEST_MSG_HASH) catch unreachable; - const pubkey = eth.secp256k1.recover(sig, TEST_MSG_HASH) catch unreachable; +fn benchRecover() void { + const pubkey = eth.secp256k1.recover(opaqueInput(&signature).*, opaqueInput(&msg_hash).*) catch unreachable; std.mem.doNotOptimizeAway(&pubkey); } -// ============================================================================ -// Benchmark functions -- Address -// ============================================================================ - -fn benchAddressDerivation() void { - const addr = eth.secp256k1.pubkeyToAddress(precomputed_pubkey); - std.mem.doNotOptimizeAway(&addr); -} - -fn benchAddressFromHex() void { - var hex_str: []const u8 = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; - std.mem.doNotOptimizeAway(&hex_str); - const addr = eth.primitives.addressFromHex(hex_str) catch unreachable; - std.mem.doNotOptimizeAway(&addr); -} - -fn benchChecksumAddress() void { - const addr = TEST_ADDR; - const checksum = eth.primitives.addressToChecksum(&addr); - std.mem.doNotOptimizeAway(&checksum); -} - -// ============================================================================ -// Benchmark functions -- ABI encoding -// ============================================================================ - -fn benchAbiEncodeTransfer() void { - var buf: [4096]u8 = undefined; +fn benchTxHash() void { + var buf: [1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); - const args = [_]eth.abi_encode.AbiValue{ - .{ .address = TEST_ADDR }, - .{ .uint256 = 1_000_000_000_000_000_000 }, - }; - const result = eth.abi_encode.encodeFunctionCall(alloc, TRANSFER_SELECTOR, &args) catch unreachable; - std.mem.doNotOptimizeAway(result.ptr); + const tx = eth.transaction.Transaction{ .eip1559 = .{ + .chain_id = 1, + .nonce = opaqueInput(&@as(u64, 42)).*, + .max_priority_fee_per_gas = 2_000_000_000, + .max_fee_per_gas = 100_000_000_000, + .gas_limit = 21000, + .to = opaqueInput(&test_addr).*, + .value = 1_000_000_000_000_000_000, + .data = &.{}, + .access_list = &.{}, + } }; + const h = eth.transaction.hashForSigning(fba.allocator(), tx) catch unreachable; + std.mem.doNotOptimizeAway(&h); } -fn benchAbiEncodeStatic() void { - var buf: [4096]u8 = undefined; +fn benchAbiEncodeTransfer() void { + var buf: [512]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); const args = [_]eth.abi_encode.AbiValue{ - .{ .address = TEST_ADDR }, + .{ .address = opaqueInput(&test_addr).* }, .{ .uint256 = 1_000_000_000_000_000_000 }, }; - const result = eth.abi_encode.encodeValues(alloc, &args) catch unreachable; - std.mem.doNotOptimizeAway(result.ptr); -} - -fn benchAbiEncodeDynamic() void { - var buf: [4096]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); - const array_items = [_]eth.abi_encode.AbiValue{ - .{ .uint256 = 1 }, - .{ .uint256 = 2 }, - .{ .uint256 = 3 }, - .{ .uint256 = 4 }, - .{ .uint256 = 5 }, - }; - const args = [_]eth.abi_encode.AbiValue{ - .{ .string = "The quick brown fox jumps over the lazy dog" }, - .{ .bytes = "hello world, this is a dynamic bytes benchmark test payload" }, - .{ .array = &array_items }, - }; - const result = eth.abi_encode.encodeValues(alloc, &args) catch unreachable; - std.mem.doNotOptimizeAway(result.ptr); -} - -// ============================================================================ -// Benchmark functions -- ABI decoding -// ============================================================================ - -fn benchAbiDecodeUint256() void { - var buf: [4096]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); - const encoded: [32]u8 = .{ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, - }; - const types = [_]eth.abi_types.AbiType{.uint256}; - const values = eth.abi_decode.decodeValues(&encoded, &types, alloc) catch unreachable; - std.mem.doNotOptimizeAway(values.ptr); + const out = eth.abi_encode.encodeFunctionCall(fba.allocator(), transfer_selector, &args) catch unreachable; + std.mem.doNotOptimizeAway(out.ptr); } fn benchAbiDecodeDynamic() void { - var buf: [4096]u8 = undefined; + var buf: [1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); const types = [_]eth.abi_types.AbiType{ .string, .bytes }; - const values = eth.abi_decode.decodeValues(precomputed_abi_dynamic, &types, alloc) catch unreachable; + const values = eth.abi_decode.decodeValues(opaqueInput(&abi_dynamic).*, &types, fba.allocator()) catch unreachable; std.mem.doNotOptimizeAway(values.ptr); } -// ============================================================================ -// Benchmark functions -- RLP -// ============================================================================ - -fn benchRlpEncodeTx() void { - var buf: [4096]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); - const tx = eth.transaction.Transaction{ - .eip1559 = .{ - .chain_id = 1, - .nonce = 42, - .max_priority_fee_per_gas = 2_000_000_000, - .max_fee_per_gas = 100_000_000_000, - .gas_limit = 21000, - .to = TEST_ADDR, - .value = 1_000_000_000_000_000_000, - .data = &.{}, - .access_list = &.{}, - }, - }; - const serialized = eth.transaction.serializeForSigning(alloc, tx) catch unreachable; - std.mem.doNotOptimizeAway(serialized.ptr); -} - -fn benchRlpDecodeU256() void { - const decoded = eth.rlp.decode(u256, precomputed_rlp_u256) catch unreachable; - std.mem.doNotOptimizeAway(&decoded.value); -} - -// ============================================================================ -// Benchmark functions -- u256 arithmetic -// ============================================================================ - -fn benchU256Add() void { +fn benchMulDiv() void { var a: u256 = 1_000_000_000_000_000_000; - var b: u256 = 997_000_000_000_000_000; + var b: u256 = 79228162514264337593543950336; + var c: u256 = 1_000_000_000_000_001_000; std.mem.doNotOptimizeAway(&a); std.mem.doNotOptimizeAway(&b); - const result = a +% b; - std.mem.doNotOptimizeAway(&result); + std.mem.doNotOptimizeAway(&c); + const r = eth.uint256.mulDiv(a, b, c); + std.mem.doNotOptimizeAway(&r); } -fn benchU256Mul() void { - var a: u256 = 1_000_000_000_000_000_000; - var b: u256 = 997; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = a *% b; - std.mem.doNotOptimizeAway(&result); +fn benchV4Swap() void { + var liquidity: u256 = 1_000_000_000_000_000_000; + var sqrt_price: u256 = 79228162514264337593543950336; + var amount_in: u256 = 1_000_000_000_000_000; + std.mem.doNotOptimizeAway(&liquidity); + std.mem.doNotOptimizeAway(&sqrt_price); + std.mem.doNotOptimizeAway(&amount_in); + const product = eth.uint256.fastMul(amount_in, sqrt_price); + const next = eth.uint256.mulDiv(liquidity, sqrt_price, liquidity +% product); + std.mem.doNotOptimizeAway(&next); } -fn benchU256Div() void { - var a: u256 = 997_000_000_000_000_000_000; - var b: u256 = 1_000_000_000_000_000_000; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = eth.uint256.fastDiv(a, b); - std.mem.doNotOptimizeAway(&result); +fn benchDexDecode() void { + const decoded = eth.dex.decodeFor(.pancake_ur, opaqueInput(&ur_execute)) orelse unreachable; + var it = decoded.universal_router_execute.iterator(); + var n: usize = 0; + while (it.next()) |cmd| : (n += 1) std.mem.doNotOptimizeAway(&cmd); + std.mem.doNotOptimizeAway(&n); } -fn benchU256UniswapV2AmountOut() void { - var amount_in: u256 = 1_000_000_000_000_000_000; // 1 ETH - var reserve_in: u256 = 100_000_000_000_000_000_000; // 100 ETH - var reserve_out: u256 = 200_000_000_000; // 200k USDC (6 decimals) - asm volatile ("" - : - : [a] "r" (&amount_in), - [b] "r" (&reserve_in), - [c] "r" (&reserve_out), - : .{ .memory = true }); - - // Use limb-based compound function that avoids __udivti3 - const amount_out = eth.uint256.getAmountOut(amount_in, reserve_in, reserve_out); - std.mem.doNotOptimizeAway(&amount_out); -} +// ============================================================================ +// Harness +// ============================================================================ -fn benchU256MulDiv() void { - var a: u256 = 1_000_000_000_000_000_000; - var b: u256 = 79228162514264337593543950336; - var c: u256 = 1_000_000_000_000_001_000; - asm volatile ("" - : - : [a] "r" (&a), - [b] "r" (&b), - [c] "r" (&c), - : .{ .memory = true }); - const result = eth.uint256.mulDiv(a, b, c); - std.mem.doNotOptimizeAway(&result); -} +const Options = struct { + filter: ?[]const u8 = null, + samples: usize = 31, + json: bool = false, + save: ?[]const u8 = null, + baseline: ?[]const u8 = null, + /// A benchmark regresses when its median is this much slower than the + /// baseline AND the slowdown exceeds its own measured noise. + threshold_pct: f64 = 5, + fail_on_regression: bool = false, +}; -fn benchU256UniswapV4Swap() void { - var liquidity: u256 = 1_000_000_000_000_000_000; - var sqrt_price: u256 = 79228162514264337593543950336; - var amount_in: u256 = 1_000_000_000_000_000; - asm volatile ("" - : - : [a] "r" (&liquidity), - [b] "r" (&sqrt_price), - [c] "r" (&amount_in), - : .{ .memory = true }); +const Result = struct { + name: []const u8, + median_ns: f64, + min_ns: f64, + /// Median absolute deviation as a percentage of the median. + mad_pct: f64, + iters: u64, +}; - const product = eth.uint256.fastMul(amount_in, sqrt_price); - const denominator = liquidity +% product; - const next_sqrt_price = eth.uint256.mulDiv(liquidity, sqrt_price, denominator); - std.mem.doNotOptimizeAway(&next_sqrt_price); +/// On macOS, ask the scheduler to keep the benchmark on performance cores; +/// without it a run can land partly on efficiency cores and read 1.5-2x slower. +fn pinToPerformanceCores() void { + if (@import("builtin").target.os.tag != .macos) return; + const QOS_CLASS_USER_INTERACTIVE: c_uint = 0x21; + const pthread_set_qos_class_self_np = @extern(*const fn (c_uint, c_int) callconv(.c) c_int, .{ .name = "pthread_set_qos_class_self_np" }); + _ = pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); } -// ============================================================================ -// Benchmark functions -- Hex -// ============================================================================ +const max_samples = 255; +const warmup_ns: u64 = 200_000_000; +const target_batch_ns: u64 = 10_000_000; -fn benchHexEncode32() void { - const data: [32]u8 = TEST_MSG_HASH; - const result = eth.hex.bytesToHexBuf(32, &data); - std.mem.doNotOptimizeAway(&result); +fn nowNs(io: std.Io) i96 { + return std.Io.Clock.now(.awake, io).nanoseconds; } -fn benchHexDecode32() void { - var hex_str: []const u8 = "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"; - std.mem.doNotOptimizeAway(&hex_str); - var buf: [32]u8 = undefined; - _ = eth.hex.hexToBytes(&buf, hex_str) catch unreachable; - std.mem.doNotOptimizeAway(&buf); +fn elapsedSince(io: std.Io, t0: i96) u64 { + const d = nowNs(io) - t0; + return if (d > 0) @intCast(d) else 0; } -// ============================================================================ -// Benchmark functions -- Transaction -// ============================================================================ +fn measure(io: std.Io, b: Bench, samples_wanted: usize) Result { + const n_samples = @min(samples_wanted, max_samples); -fn benchTxHashEip1559() void { - var buf: [4096]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); - const tx = eth.transaction.Transaction{ - .eip1559 = .{ - .chain_id = 1, - .nonce = 42, - .max_priority_fee_per_gas = 2_000_000_000, - .max_fee_per_gas = 100_000_000_000, - .gas_limit = 21000, - .to = TEST_ADDR, - .value = 1_000_000_000_000_000_000, - .data = &.{}, - .access_list = &.{}, - }, - }; - const hash = eth.transaction.hashForSigning(alloc, tx) catch unreachable; - std.mem.doNotOptimizeAway(&hash); -} + // Warm caches, branch predictors and CPU frequency. + var t0 = nowNs(io); + while (elapsedSince(io, t0) < warmup_ns) { + for (0..64) |_| b.func(); + } -// ============================================================================ -// Benchmark functions -- HD Wallet -// ============================================================================ + // Size a batch to ~target_batch_ns so clock overhead is negligible. + var batch: u64 = 16; + while (true) { + t0 = nowNs(io); + for (0..batch) |_| b.func(); + if (elapsedSince(io, t0) >= target_batch_ns or batch >= 1 << 30) break; + batch *= 2; + } -fn benchHdWalletDerive10() void { - const master = eth.hd_wallet.masterKeyFromSeed(TEST_SEED) catch unreachable; - for (0..10) |i| { - const child = eth.hd_wallet.deriveChild(master, @intCast(i)) catch unreachable; - std.mem.doNotOptimizeAway(&child); + var samples: [max_samples]f64 = undefined; + for (samples[0..n_samples]) |*s| { + t0 = nowNs(io); + for (0..batch) |_| b.func(); + s.* = @as(f64, @floatFromInt(elapsedSince(io, t0))) / @as(f64, @floatFromInt(batch)); } + const sorted = samples[0..n_samples]; + std.mem.sort(f64, sorted, {}, std.sort.asc(f64)); + const median = sorted[n_samples / 2]; + + var dev: [max_samples]f64 = undefined; + for (sorted, 0..) |s, i| dev[i] = @abs(s - median); + std.mem.sort(f64, dev[0..n_samples], {}, std.sort.asc(f64)); + const mad = dev[n_samples / 2]; + + return .{ + .name = b.name, + .median_ns = median, + .min_ns = sorted[0], + .mad_pct = if (median > 0) mad / median * 100 else 0, + .iters = batch * n_samples, + }; } -fn benchBtcP2wpkh() void { - const addr = eth.btc.p2wpkh(TEST_SEED, 0) catch unreachable; - std.mem.doNotOptimizeAway(&addr); +fn matches(opts: Options, name: []const u8) bool { + const f = opts.filter orelse return true; + return std.mem.indexOf(u8, name, f) != null; +} + +fn parseArgs(args: []const [:0]const u8) !Options { + var opts: Options = .{}; + var i: usize = 1; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--json")) { + opts.json = true; + } else if (std.mem.eql(u8, a, "--fail-on-regression")) { + opts.fail_on_regression = true; + } else if (i + 1 < args.len and std.mem.eql(u8, a, "--filter")) { + i += 1; + opts.filter = args[i]; + } else if (i + 1 < args.len and std.mem.eql(u8, a, "--save")) { + i += 1; + opts.save = args[i]; + } else if (i + 1 < args.len and std.mem.eql(u8, a, "--baseline")) { + i += 1; + opts.baseline = args[i]; + } else if (i + 1 < args.len and std.mem.eql(u8, a, "--samples")) { + i += 1; + opts.samples = try std.fmt.parseInt(usize, args[i], 10); + } else if (i + 1 < args.len and std.mem.eql(u8, a, "--threshold")) { + i += 1; + opts.threshold_pct = try std.fmt.parseFloat(f64, args[i]); + } else if (std.mem.eql(u8, a, "--list")) { + for (benches) |b| std.debug.print("{s:<30} {s}\n", .{ b.name, b.what }); + std.process.exit(0); + } else { + std.debug.print("unknown or incomplete argument: {s}\n", .{a}); + return error.InvalidArgument; + } + } + if (opts.samples < 3) return error.InvalidArgument; + return opts; } -var sol_addr_buf: [44]u8 = undefined; +const BaselineEntry = struct { name: []const u8, median_ns: f64, mad_pct: f64 = 0 }; +const BaselineFile = struct { results: []const BaselineEntry }; -fn benchSolSlip10() void { - const addr = eth.sol.address(TEST_SEED, &sol_addr_buf) catch unreachable; - std.mem.doNotOptimizeAway(addr); +fn findBaseline(base: []const BaselineEntry, name: []const u8) ?BaselineEntry { + for (base) |e| if (std.mem.eql(u8, e.name, name)) return e; + return null; } -// ============================================================================ -// Benchmark functions -- EIP-712 -// ============================================================================ +fn writeJson(w: *std.Io.Writer, results: []const Result) !void { + try w.print("{{\n \"zig\": \"{s}\",\n \"target\": \"{s}-{s}\",\n \"results\": [\n", .{ + @import("builtin").zig_version_string, + @tagName(@import("builtin").target.cpu.arch), + @tagName(@import("builtin").target.os.tag), + }); + for (results, 0..) |r, i| { + try w.print(" {{\"name\": \"{s}\", \"median_ns\": {d:.2}, \"min_ns\": {d:.2}, \"mad_pct\": {d:.2}, \"iters\": {d}}}{s}\n", .{ + r.name, r.median_ns, r.min_ns, r.mad_pct, r.iters, if (i + 1 < results.len) "," else "", + }); + } + try w.writeAll(" ]\n}\n"); +} -fn benchEip712Hash() void { - var buf: [8192]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - const alloc = fba.allocator(); +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const opts = try parseArgs(args); + pinToPerformanceCores(); - const domain = eth.eip712.DomainSeparator{ - .name = "TestDApp", - .version = "1", - .chain_id = 1, - .verifying_contract = TEST_ADDR, + // Inputs that need eth.zig to build. + signature = try eth.secp256k1.sign(private_key, msg_hash); + const dyn_args = [_]eth.abi_encode.AbiValue{ + .{ .string = "The quick brown fox jumps over the lazy dog" }, + .{ .bytes = "hello world, this is a dynamic bytes benchmark test payload" }, }; + const dyn = try eth.abi_encode.encodeValues(gpa, &dyn_args); + defer gpa.free(dyn); + abi_dynamic = dyn; + + var baseline_parsed: ?std.json.Parsed(BaselineFile) = null; + defer if (baseline_parsed) |*p| p.deinit(); + if (opts.baseline) |path| { + const raw = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1 << 20)); + defer gpa.free(raw); + baseline_parsed = try std.json.parseFromSlice(BaselineFile, gpa, raw, .{ .ignore_unknown_fields = true, .allocate = .alloc_always }); + } + const base: []const BaselineEntry = if (baseline_parsed) |p| p.value.results else &.{}; - const transfer_type = eth.eip712.TypeDef{ - .name = "Transfer", - .fields = &.{ - .{ .name = "to", .type_str = "address" }, - .{ .name = "amount", .type_str = "uint256" }, - }, - }; + var out_buf: [4096]u8 = undefined; + var out = std.Io.File.stdout().writerStreaming(io, &out_buf); + const w = &out.interface; - const message = eth.eip712.StructValue{ - .type_name = "Transfer", - .fields = &.{ - .{ .name = "to", .type_str = "address", .value = .{ .address = TEST_ADDR } }, - .{ .name = "amount", .type_str = "uint256", .value = .{ .uint256 = 1_000_000_000_000_000_000 } }, - }, - }; + var results: [benches.len]Result = undefined; + var n: usize = 0; + var regressions: usize = 0; - const result = eth.eip712.hashTypedData( - alloc, - domain, - message, - &.{transfer_type}, - ) catch unreachable; - std.mem.doNotOptimizeAway(&result); -} + if (!opts.json) { + try w.print("\n{s:<30} {s:>12} {s:>12} {s:>8}", .{ "benchmark", "median", "min", "+/-" }); + if (base.len > 0) try w.print(" {s:>12} {s:>9}", .{ "baseline", "change" }); + try w.writeAll("\n"); + try w.flush(); + } -// ============================================================================ -// Main -// ============================================================================ + for (benches) |b| { + if (!matches(opts, b.name)) continue; + const r = measure(io, b, opts.samples); + results[n] = r; + n += 1; + if (opts.json) continue; + + try w.print("{s:<30} {d:>9.2} ns {d:>9.2} ns {d:>7.1}%", .{ r.name, r.median_ns, r.min_ns, r.mad_pct }); + if (findBaseline(base, r.name)) |e| { + const change = (r.median_ns - e.median_ns) / e.median_ns * 100; + // Only call it a regression if it clears both the threshold and the + // combined noise of the two runs. + const noise = 2 * (r.mad_pct + e.mad_pct); + const tag: []const u8 = if (change > opts.threshold_pct and change > noise) blk: { + regressions += 1; + break :blk " REGRESSION"; + } else if (change < -opts.threshold_pct and -change > noise) " faster" else ""; + try w.print(" {d:>9.2} ns {s}{d:>7.1}%{s}", .{ e.median_ns, if (change >= 0) "+" else "-", @abs(change), tag }); + } else if (base.len > 0) { + try w.writeAll(" (not in baseline)"); + } + try w.writeAll("\n"); + try w.flush(); + } -pub fn main() !void { - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); + if (opts.json) try writeJson(w, results[0..n]); + if (!opts.json and base.len > 0) + try w.print("\n{d} regression(s) beyond {d:.0}% and noise\n", .{ regressions, opts.threshold_pct }); + try w.flush(); + + if (opts.save) |path| { + var file_buf: [8192]u8 = undefined; + var fw: std.Io.Writer = .fixed(&file_buf); + try writeJson(&fw, results[0..n]); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = fw.buffered() }); + std.debug.print("saved {d} results to {s}\n", .{ n, path }); + } - // Pre-compute data for decode benchmarks - const abi_dyn_args = [_]eth.abi_encode.AbiValue{ - .{ .string = "The quick brown fox jumps over the lazy dog" }, - .{ .bytes = "hello world, this is a dynamic bytes benchmark test payload" }, - }; - const abi_dyn_encoded = try eth.abi_encode.encodeValues(allocator, &abi_dyn_args); - precomputed_abi_dynamic = abi_dyn_encoded; - - const rlp_encoded = try eth.rlp.encode(allocator, @as(u256, 1_000_000_000_000_000_000)); - precomputed_rlp_u256 = rlp_encoded; - - precomputed_pubkey = eth.secp256k1.derivePublicKey(TEST_PRIVKEY) catch unreachable; - - var out_buf: [8192]u8 = undefined; - var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &out_buf); - const stdout = &w.interface; - - try stdout.print("\n{s:<34} {s:>12} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "stddev", "iters" }); - try stdout.print("{s}\n", .{"" ++ @as([72]u8, @splat('-'))}); - - // Keccak256 - try runAndPrint("keccak256_empty", benchKeccakEmpty, stdout); - try runAndPrint("keccak256_32b", benchKeccak32, stdout); - try runAndPrint("keccak256_256b", benchKeccak256b, stdout); - try runAndPrint("keccak256_1kb", benchKeccak1k, stdout); - try runAndPrint("keccak256_4kb", benchKeccak4k, stdout); - // secp256k1 - try runAndPrint("secp256k1_sign", benchSecp256k1Sign, stdout); - try runAndPrint("secp256k1_sign_recover", benchSecp256k1Recover, stdout); - // Address - try runAndPrint("address_derivation", benchAddressDerivation, stdout); - try runAndPrint("address_from_hex", benchAddressFromHex, stdout); - try runAndPrint("checksum_address", benchChecksumAddress, stdout); - // ABI encoding - try runAndPrint("abi_encode_transfer", benchAbiEncodeTransfer, stdout); - try runAndPrint("abi_encode_static", benchAbiEncodeStatic, stdout); - try runAndPrint("abi_encode_dynamic", benchAbiEncodeDynamic, stdout); - // ABI decoding - try runAndPrint("abi_decode_uint256", benchAbiDecodeUint256, stdout); - try runAndPrint("abi_decode_dynamic", benchAbiDecodeDynamic, stdout); - // RLP - try runAndPrint("rlp_encode_eip1559_tx", benchRlpEncodeTx, stdout); - try runAndPrint("rlp_decode_u256", benchRlpDecodeU256, stdout); - // u256 arithmetic - try runAndPrint("u256_add", benchU256Add, stdout); - try runAndPrint("u256_mul", benchU256Mul, stdout); - try runAndPrint("u256_div", benchU256Div, stdout); - try runAndPrint("u256_uniswapv2_amount_out", benchU256UniswapV2AmountOut, stdout); - try runAndPrint("u256_mulDiv", benchU256MulDiv, stdout); - try runAndPrint("u256_uniswapv4_swap", benchU256UniswapV4Swap, stdout); - // Hex - try runAndPrint("hex_encode_32b", benchHexEncode32, stdout); - try runAndPrint("hex_decode_32b", benchHexDecode32, stdout); - // Transaction - try runAndPrint("tx_hash_eip1559", benchTxHashEip1559, stdout); - // HD Wallet - try runAndPrint("hd_wallet_derive_10", benchHdWalletDerive10, stdout); - try runAndPrint("btc_p2wpkh", benchBtcP2wpkh, stdout); - try runAndPrint("sol_slip10", benchSolSlip10, stdout); - // EIP-712 - try runAndPrint("eip712_hash_typed_data", benchEip712Hash, stdout); - - try stdout.print("\n", .{}); - try stdout.flush(); + if (opts.fail_on_regression and regressions > 0) std.process.exit(1); } diff --git a/bench/compare.sh b/bench/compare.sh deleted file mode 100755 index 33b4540..0000000 --- a/bench/compare.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -ALLOY_DIR="$SCRIPT_DIR/alloy-bench" - -# Check prerequisites -command -v zig >/dev/null 2>&1 || { echo "ERROR: zig not found. Install from https://ziglang.org/"; exit 1; } -command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo not found. Install from https://rustup.rs/"; exit 1; } -command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 not found. Install from https://www.python.org/downloads/"; exit 1; } - -echo "" -echo "================================================================" -echo " eth-zig vs alloy.rs -- Benchmark Comparison" -echo "================================================================" -echo "" - -# -- Step 1: Run eth-zig benchmarks -- -echo "[1/3] Running eth-zig benchmarks (ReleaseFast)..." -ZIG_OUTPUT=$(cd "$ROOT_DIR" && zig build bench 2>&1) -# `|| true`: grep exits 1 if every line were filtered, which would abort the -# script under `set -o pipefail` even though the bench itself succeeded. -echo "$ZIG_OUTPUT" | grep -v "^BENCH_JSON" || true -echo "" - -# -- Step 2: Run alloy.rs benchmarks -- -echo "[2/3] Running alloy.rs benchmarks (cargo bench --release)..." -if [ ! -d "$ALLOY_DIR" ]; then - echo "ERROR: $ALLOY_DIR not found." - exit 1 -fi -RUST_OUTPUT=$(cd "$ALLOY_DIR" && cargo bench --bench eth_comparison 2>&1) -echo " Done." -echo "" - -# -- Step 3: Parse and compare using Python -- -echo "[3/3] Comparing results..." -echo "" - -python3 - "$ZIG_OUTPUT" "$RUST_OUTPUT" << 'PYTHON_SCRIPT' -import sys -import json -import re - -zig_output = sys.argv[1] -rust_output = sys.argv[2] - -# Parse custom harness output: "name NNN ns NNNN" -zig_ns = {} -for line in zig_output.split('\n'): - m = re.match(r'^(\S+)\s+(\d+)\s+ns', line) - if m: - zig_ns[m.group(1)] = int(m.group(2)) - -# Parse criterion output -alloy_ns = {} -current_bench = None -for line in rust_output.split('\n'): - # Match "Benchmarking group/name" (without trailing text like ": Warming up") - m = re.match(r'^Benchmarking ([a-zA-Z0-9_]+/[a-zA-Z0-9_]+)\s*$', line.strip()) - if m: - current_bench = m.group(1) - continue - # Match time line - if current_bench: - m = re.search(r'time:\s+\[[\d.]+ \w+\s+([\d.]+)\s+(ns|µs|ms)', line) - if m: - value = float(m.group(1)) - unit = m.group(2) - if unit == 'ns': - ns_val = round(value) - elif unit == 'µs': - ns_val = round(value * 1000) - elif unit == 'ms': - ns_val = round(value * 1000000) - alloy_ns[current_bench] = ns_val - current_bench = None - -# Name mapping: eth-zig -> criterion -name_map = { - 'keccak256_empty': 'keccak256/empty', - 'keccak256_32b': 'keccak256/32b', - 'keccak256_256b': 'keccak256/256b', - 'keccak256_1kb': 'keccak256/1kb', - 'keccak256_4kb': 'keccak256/4kb', - 'secp256k1_sign': 'secp256k1/sign', - 'secp256k1_sign_recover': 'secp256k1/sign_recover', - 'address_derivation': 'address/derivation', - 'address_from_hex': 'address/from_hex', - 'checksum_address': 'address/checksum', - 'abi_encode_transfer': 'abi_encode/transfer_call', - 'abi_encode_static': 'abi_encode/static_addr_uint256', - 'abi_encode_dynamic': 'abi_encode/dynamic', - 'abi_decode_uint256': 'abi_decode/uint256', - 'abi_decode_dynamic': 'abi_decode/dynamic', - 'rlp_encode_eip1559_tx': 'rlp/encode_eip1559_tx', - 'rlp_decode_u256': 'rlp/decode_u256', - 'u256_add': 'u256/add', - 'u256_mul': 'u256/mul', - 'u256_div': 'u256/div', - 'u256_uniswapv2_amount_out': 'u256/uniswap_v2_amountOut', - 'u256_mulDiv': 'u256/mulDiv', - 'u256_uniswapv4_swap': 'u256/uniswap_v4_swap', - 'hex_encode_32b': 'hex/encode_32b', - 'hex_decode_32b': 'hex/decode_32b', - 'tx_hash_eip1559': 'tx_hash/eip1559', -} - -bench_order = [ - 'keccak256_empty', 'keccak256_32b', 'keccak256_256b', 'keccak256_1kb', 'keccak256_4kb', - 'secp256k1_sign', 'secp256k1_sign_recover', - 'address_derivation', 'address_from_hex', 'checksum_address', - 'abi_encode_transfer', 'abi_encode_static', 'abi_encode_dynamic', - 'abi_decode_uint256', 'abi_decode_dynamic', - 'rlp_encode_eip1559_tx', 'rlp_decode_u256', - 'u256_add', 'u256_mul', 'u256_div', 'u256_uniswapv2_amount_out', 'u256_mulDiv', 'u256_uniswapv4_swap', - 'hex_encode_32b', 'hex_decode_32b', - 'tx_hash_eip1559', -] - -# Colors -GREEN = '\033[0;32m' -RED = '\033[0;31m' -BOLD = '\033[1m' -NC = '\033[0m' - -print(f"{BOLD}{'Benchmark':<34} {'eth-zig':>12} {'alloy.rs':>12} {'Result':>18}{NC}") -print(f"{'-'*34} {'-'*12} {'-'*12} {'-'*18}") - -zig_wins = 0 -alloy_wins = 0 -total = 0 - -for zig_name in bench_order: - alloy_name = name_map.get(zig_name, '') - z = zig_ns.get(zig_name) - a = alloy_ns.get(alloy_name) - - if z is not None and a is not None: - total += 1 - if z == 0 and a == 0: - label = 'equal' - color = NC - elif z < a: - ratio = a / z if z > 0 else 999.99 - label = f'{ratio:.2f}x faster (zig)' - color = GREEN - zig_wins += 1 - elif a < z: - ratio = z / a if a > 0 else 999.99 - label = f'{ratio:.2f}x faster (rs)' - color = RED - alloy_wins += 1 - else: - label = 'equal' - color = NC - print(f"{zig_name:<34} {z:>9} ns {a:>9} ns {color}{label:>18}{NC}") - elif z is not None: - print(f"{zig_name:<34} {z:>9} ns {'---':>12} {'(zig only)':>18}") - -print() -print(f"{'='*34} {'='*12} {'='*12} {'='*18}") -print() -print(f"Summary: eth-zig wins {zig_wins} / {total} | alloy.rs wins {alloy_wins} / {total}") -print() - -# eth-zig only benchmarks -print("eth-zig only benchmarks (no alloy equivalent):") -for name in ['hd_wallet_derive_10', 'eip712_hash_typed_data']: - z = zig_ns.get(name) - if z is not None: - print(f" {name:<32} {z:>9} ns") -print() -PYTHON_SCRIPT diff --git a/bench/compare_u256.sh b/bench/compare_u256.sh deleted file mode 100755 index 05831db..0000000 --- a/bench/compare_u256.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -ALLOY_DIR="$SCRIPT_DIR/alloy-bench" - -command -v zig >/dev/null 2>&1 || { echo "ERROR: zig not found"; exit 1; } -command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo not found"; exit 1; } -command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 not found"; exit 1; } - -echo "" -echo "================================================================" -echo " u256 Benchmark: eth.zig vs alloy.rs (ruint)" -echo "================================================================" -echo "" - -# -- Step 1: eth-zig u256 benchmarks -- -echo "[1/3] Running eth-zig u256 benchmarks (ReleaseFast)..." -ZIG_OUTPUT=$(cd "$ROOT_DIR" && zig build bench-u256 2>&1) -echo "$ZIG_OUTPUT" | grep -v "^BENCH_JSON" -echo "" - -# -- Step 2: alloy.rs u256 benchmarks -- -echo "[2/3] Running alloy.rs u256 benchmarks (cargo bench --release)..." -RUST_OUTPUT=$(cd "$ALLOY_DIR" && cargo bench --bench u256_comparison 2>&1) -echo " Done." -echo "" - -# -- Step 3: Compare -- -echo "[3/3] Comparing results..." -echo "" - -python3 - "$ZIG_OUTPUT" "$RUST_OUTPUT" << 'PYTHON_SCRIPT' -import sys -import json -import re - -zig_output = sys.argv[1] -rust_output = sys.argv[2] - -def parse_ns(value_str, unit_str): - v = float(value_str) - if unit_str == 'ns': - return round(v) - elif unit_str in ('us', 'µs'): - return round(v * 1000) - elif unit_str == 'ms': - return round(v * 1_000_000) - return round(v) - -# Parse BENCH_JSON lines from Zig output -zig_ns = {} -for line in zig_output.split('\n'): - if line.startswith('BENCH_JSON|'): - try: - data = json.loads(line[len('BENCH_JSON|'):]) - zig_ns[data['name']] = data['ns_per_op'] - except (json.JSONDecodeError, KeyError): - pass - -# Parse criterion output -alloy_ns = {} -for line in rust_output.split('\n'): - m = re.match(r'^([a-zA-Z0-9_]+/[a-zA-Z0-9_]+)\s+time:\s+\[[\d.]+ \w+\s+([\d.]+)\s+(ns|µs|ms)', line.strip()) - if m: - alloy_ns[m.group(1)] = parse_ns(m.group(2), m.group(3)) - -# Name mapping: zig -> criterion -name_map = { - 'u256_add': 'u256/add', - 'u256_mul_small': 'u256/mul_small', - 'u256_mul_full': 'u256/mul_full', - 'u256_div_small': 'u256/div_small', - 'u256_div_full': 'u256/div_full', - 'u256_uniswapv2_naive': 'u256/uniswapv2_naive', - 'u256_mulDiv': 'u256/mulDiv', - 'u256_uniswapv4_swap': 'u256/uniswapv4_swap', -} - -bench_order = [ - 'u256_add', - 'u256_mul_small', - 'u256_mul_full', - 'u256_div_small', - 'u256_div_full', - 'u256_uniswapv2_naive', - 'u256_mulDiv', - 'u256_uniswapv4_swap', -] - -GREEN = '\033[0;32m' -RED = '\033[0;31m' -YELLOW = '\033[0;33m' -BOLD = '\033[1m' -NC = '\033[0m' - -print(f"\n{BOLD}=== Apples-to-apples: same formula, same u256 ops ==={NC}\n") -print(f"{BOLD}{'Benchmark':<28} {'eth-zig':>10} {'alloy.rs':>10} {'Result':>20}{NC}") -print(f"{'-'*28} {'-'*10} {'-'*10} {'-'*20}") - -zig_wins = 0 -alloy_wins = 0 -ties = 0 -total = 0 - -for zig_name in bench_order: - alloy_name = name_map.get(zig_name, '') - z = zig_ns.get(zig_name) - a = alloy_ns.get(alloy_name) - - if z is not None and a is not None: - total += 1 - if z == a or (z > 0 and a > 0 and abs(z - a) / max(z, a) < 0.1): - label = 'tie' - color = NC - ties += 1 - elif z < a: - ratio = a / z if z > 0 else 999.99 - label = f'zig {ratio:.2f}x' - color = GREEN - zig_wins += 1 - else: - ratio = z / a if a > 0 else 999.99 - label = f'rs {ratio:.2f}x' - color = RED - alloy_wins += 1 - print(f"{zig_name:<28} {z:>7} ns {a:>7} ns {color}{label:>20}{NC}") - elif z is not None: - print(f"{zig_name:<28} {z:>7} ns {'---':>10} {'(zig only)':>20}") - -print(f"\n{BOLD}{'='*28} {'='*10} {'='*10} {'='*20}{NC}") -print(f"\n{BOLD}Score: eth-zig {zig_wins}/{total} | alloy.rs {alloy_wins}/{total} | tied {ties}/{total}{NC}") - -# Show zig-only optimized benchmark -z_opt = zig_ns.get('u256_uniswapv2_optimized') -z_naive = zig_ns.get('u256_uniswapv2_naive') -a_naive = alloy_ns.get('u256/uniswapv2_naive') - -if z_opt is not None: - print(f"\n{BOLD}=== eth.zig compound limb optimization ==={NC}\n") - print(f"{'u256_uniswapv2_optimized':<28} {z_opt:>7} ns (stays in [4]u64 limb space)") - if z_naive is not None and z_naive > 0: - print(f"{'u256_uniswapv2_naive':<28} {z_naive:>7} ns (step-by-step u256, same as alloy)") - speedup = z_naive / z_opt if z_opt > 0 else 0 - print(f"{'Optimization speedup':<28} {YELLOW}{speedup:.2f}x{NC}") - if a_naive is not None and z_opt > 0: - vs_rust = a_naive / z_opt - if vs_rust >= 1: - print(f"{'vs alloy.rs naive':<28} {GREEN}{vs_rust:.2f}x faster{NC}") - else: - print(f"{'vs alloy.rs naive':<28} {RED}{1/vs_rust:.2f}x slower{NC}") - -print() -PYTHON_SCRIPT diff --git a/bench/keccak_bench_cli.zig b/bench/keccak_bench_cli.zig deleted file mode 100644 index cfbdaf2..0000000 --- a/bench/keccak_bench_cli.zig +++ /dev/null @@ -1,35 +0,0 @@ -const std = @import("std"); -const eth = @import("eth"); - -pub fn main(init: std.process.Init.Minimal) !void { - var args = std.process.Args.Iterator.init(init.args); - _ = args.next(); // skip program name - - const size_str = args.next() orelse "32"; - const iters_str = args.next() orelse "1000000"; - const backend_str = args.next() orelse "xkcp"; - - const size = try std.fmt.parseInt(usize, size_str, 10); - const iters = try std.fmt.parseInt(usize, iters_str, 10); - - // Allocate input data - const allocator = std.heap.page_allocator; - const data = try allocator.alloc(u8, size); - defer allocator.free(data); - @memset(data, 0xAB); - - if (std.mem.eql(u8, backend_str, "xkcp")) { - // XKCP backend (our new implementation) - for (0..iters) |_| { - const result = eth.keccak.hash(data); - std.mem.doNotOptimizeAway(&result); - } - } else if (std.mem.eql(u8, backend_str, "stdlib")) { - // Zig stdlib backend - for (0..iters) |_| { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash(data, &result, .{}); - std.mem.doNotOptimizeAway(&result); - } - } -} diff --git a/bench/keccak_compare.zig b/bench/keccak_compare.zig deleted file mode 100644 index 244013e..0000000 --- a/bench/keccak_compare.zig +++ /dev/null @@ -1,160 +0,0 @@ -const std = @import("std"); -const eth = @import("eth"); - -// Test data -const DATA_32: [32]u8 = @splat(0xAB); -const DATA_256: [256]u8 = @splat(0xAB); -const DATA_1K: [1024]u8 = @splat(0xAB); -const DATA_4K: [4096]u8 = @splat(0xAB); - -// ============================================================================ -// Benchmark harness (same as bench.zig / u256_bench.zig) -// ============================================================================ - -const WARMUP_NS: u64 = 500_000_000; -const BENCH_NS: u64 = 2_000_000_000; -/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. -const Timer = struct { - start_ns: i96, - - fn now() i96 { - const io = std.Io.Threaded.global_single_threaded.io(); - return std.Io.Clock.now(.awake, io).nanoseconds; - } - - fn start() error{}!Timer { - return .{ .start_ns = now() }; - } - - fn reset(self: *Timer) void { - self.start_ns = now(); - } - - fn read(self: *Timer) u64 { - const elapsed = now() - self.start_ns; - if (elapsed < 0) return 0; - return @intCast(elapsed); - } -}; - -const BenchResult = struct { ns_per_op: u64, iters: u64 }; - -fn runBench(comptime func: fn () void) BenchResult { - var timer = Timer.start() catch @panic("timer unsupported"); - - timer.reset(); - while (true) { - inline for (0..64) |_| func(); - if (timer.read() >= WARMUP_NS) break; - } - - var batch: u64 = 64; - while (true) { - timer.reset(); - for (0..batch) |_| func(); - if (timer.read() >= 100_000_000) break; - batch *= 2; - } - - var total_iters: u64 = 0; - timer.reset(); - while (timer.read() < BENCH_NS) { - for (0..batch) |_| func(); - total_iters += batch; - } - - const total_ns = timer.read(); - return .{ .ns_per_op = if (total_iters > 0) total_ns / total_iters else 0, .iters = total_iters }; -} - -fn runAndPrint(comptime name: []const u8, comptime func: fn () void, stdout: anytype) !void { - const result = runBench(func); - try stdout.print("{s:<30} {d:>9} ns {d:>14}\n", .{ name, result.ns_per_op, result.iters }); -} - -// -- eth.zig keccak (lane-complementing optimized) -- - -fn benchEthKeccakEmpty() void { - const r = eth.keccak.hash(""); - std.mem.doNotOptimizeAway(&r); -} - -fn benchEthKeccak32() void { - const r = eth.keccak.hash(&DATA_32); - std.mem.doNotOptimizeAway(&r); -} - -fn benchEthKeccak256() void { - const r = eth.keccak.hash(&DATA_256); - std.mem.doNotOptimizeAway(&r); -} - -fn benchEthKeccak1k() void { - const r = eth.keccak.hash(&DATA_1K); - std.mem.doNotOptimizeAway(&r); -} - -fn benchEthKeccak4k() void { - const r = eth.keccak.hash(&DATA_4K); - std.mem.doNotOptimizeAway(&r); -} - -// -- Zig stdlib keccak -- - -fn benchStdlibKeccakEmpty() void { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash("", &result, .{}); - std.mem.doNotOptimizeAway(&result); -} - -fn benchStdlibKeccak32() void { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash(&DATA_32, &result, .{}); - std.mem.doNotOptimizeAway(&result); -} - -fn benchStdlibKeccak256() void { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash(&DATA_256, &result, .{}); - std.mem.doNotOptimizeAway(&result); -} - -fn benchStdlibKeccak1k() void { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash(&DATA_1K, &result, .{}); - std.mem.doNotOptimizeAway(&result); -} - -fn benchStdlibKeccak4k() void { - var result: [32]u8 = undefined; - std.crypto.hash.sha3.Keccak256.hash(&DATA_4K, &result, .{}); - std.mem.doNotOptimizeAway(&result); -} - -pub fn main() !void { - var out_buf: [8192]u8 = undefined; - var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &out_buf); - const stdout = &w.interface; - - try stdout.print("\n{s:<30} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "iters" }); - try stdout.print("{s}\n", .{"" ++ @as([60]u8, @splat('-'))}); - - // eth.zig - try runAndPrint("eth.zig keccak empty", benchEthKeccakEmpty, stdout); - try runAndPrint("eth.zig keccak 32b", benchEthKeccak32, stdout); - try runAndPrint("eth.zig keccak 256b", benchEthKeccak256, stdout); - try runAndPrint("eth.zig keccak 1kb", benchEthKeccak1k, stdout); - try runAndPrint("eth.zig keccak 4kb", benchEthKeccak4k, stdout); - - try stdout.print("\n", .{}); - - // stdlib - try runAndPrint("stdlib keccak empty", benchStdlibKeccakEmpty, stdout); - try runAndPrint("stdlib keccak 32b", benchStdlibKeccak32, stdout); - try runAndPrint("stdlib keccak 256b", benchStdlibKeccak256, stdout); - try runAndPrint("stdlib keccak 1kb", benchStdlibKeccak1k, stdout); - try runAndPrint("stdlib keccak 4kb", benchStdlibKeccak4k, stdout); - - try stdout.print("\n", .{}); - try stdout.flush(); -} diff --git a/bench/u256_bench.zig b/bench/u256_bench.zig deleted file mode 100644 index f4de0c2..0000000 --- a/bench/u256_bench.zig +++ /dev/null @@ -1,296 +0,0 @@ -const std = @import("std"); -const eth = @import("eth"); - -// ============================================================================ -// Test values -- identical to alloy-bench/benches/u256_comparison.rs -// ============================================================================ - -const ONE_ETH: u256 = 1_000_000_000_000_000_000; -const RESERVE_IN: u256 = 100_000_000_000_000_000_000; // 100 ETH -const RESERVE_OUT: u256 = 200_000_000_000; // 200k USDC (6 decimals) - -// 2^96 -- used in UniswapV3/V4 sqrtPriceX96 -const SQRT_PRICE: u256 = 79228162514264337593543950336; -const AMOUNT_IN_SMALL: u256 = 1_000_000_000_000_000; // 0.001 ETH - -// Full-width 256-bit values for mul/div stress tests -const FULL_A: u256 = 0xDEADBEEF_CAFEBABE_12345678_9ABCDEF0_DEADBEEF_CAFEBABE_12345678_9ABCDEF0; -const FULL_B: u256 = 0x12345678_9ABCDEF0_DEADBEEF_CAFEBABE_12345678_9ABCDEF0_DEADBEEF_CAFEBABE; -const FULL_C: u256 = 0x00000001_00000000_00000000_00000000_00000000_00000000_00000000_00000001; - -// ============================================================================ -// Benchmark harness -- criterion-style: run N iters, measure wall time, report ns/op -// ============================================================================ - -const WARMUP_NS: u64 = 500_000_000; // 0.5s warmup -const BENCH_NS: u64 = 2_000_000_000; // 2s measurement - -/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. -const Timer = struct { - start_ns: i96, - - fn now() i96 { - const io = std.Io.Threaded.global_single_threaded.io(); - return std.Io.Clock.now(.awake, io).nanoseconds; - } - - fn start() error{}!Timer { - return .{ .start_ns = now() }; - } - - fn reset(self: *Timer) void { - self.start_ns = now(); - } - - fn read(self: *Timer) u64 { - const elapsed = now() - self.start_ns; - if (elapsed < 0) return 0; - return @intCast(elapsed); - } -}; - -const BenchResult = struct { - ns_per_op: u64, - iters: u64, -}; - -fn runBench(comptime func: fn () void) BenchResult { - var timer = Timer.start() catch @panic("timer unsupported"); - - // Warmup: run until WARMUP_NS elapsed - timer.reset(); - while (true) { - inline for (0..64) |_| func(); - if (timer.read() >= WARMUP_NS) break; - } - - // Calibrate: find iteration count that fills ~100ms - var batch: u64 = 64; - while (true) { - timer.reset(); - for (0..batch) |_| func(); - if (timer.read() >= 100_000_000) break; // 100ms - batch *= 2; - } - - // Measure: collect samples over BENCH_NS - var total_iters: u64 = 0; - timer.reset(); - - while (timer.read() < BENCH_NS) { - for (0..batch) |_| func(); - total_iters += batch; - } - - const total_ns = timer.read(); - const ns_per_op = if (total_iters > 0) total_ns / total_iters else 0; - return .{ .ns_per_op = ns_per_op, .iters = total_iters }; -} - -// ============================================================================ -// Benchmark functions -// ============================================================================ - -fn benchAdd() void { - var a: u256 = ONE_ETH; - var b: u256 = 997_000_000_000_000_000; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = a +% b; - std.mem.doNotOptimizeAway(&result); -} - -fn benchMulSmall() void { - var a: u256 = ONE_ETH; - var b: u256 = 997; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = eth.uint256.fastMul(a, b); - std.mem.doNotOptimizeAway(&result); -} - -fn benchMulFull() void { - var a: u256 = FULL_A; - var b: u256 = FULL_B; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = eth.uint256.fastMul(a, b); - std.mem.doNotOptimizeAway(&result); -} - -fn benchDivSmall() void { - var a: u256 = 997_000_000_000_000_000_000; - var b: u256 = ONE_ETH; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = eth.uint256.fastDiv(a, b); - std.mem.doNotOptimizeAway(&result); -} - -fn benchDivFull() void { - var a: u256 = FULL_A; - var b: u256 = FULL_C; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - const result = eth.uint256.fastDiv(a, b); - std.mem.doNotOptimizeAway(&result); -} - -// UniswapV2 getAmountOut -- step-by-step on [4]u64 limbs (apples-to-apples with Rust's [u64; 4]) -// Uses fp256 hand-optimized aarch64 assembly for mul/add, u128 fast path for division. -fn benchUniswapV2Naive() void { - var amount_in = eth.uint256.u256ToLimbs(ONE_ETH); - var reserve_in = eth.uint256.u256ToLimbs(RESERVE_IN); - var reserve_out = eth.uint256.u256ToLimbs(RESERVE_OUT); - std.mem.doNotOptimizeAway(&amount_in); - std.mem.doNotOptimizeAway(&reserve_in); - std.mem.doNotOptimizeAway(&reserve_out); - - const amount_in_with_fee = eth.uint256.mulLimbScalar(amount_in, 997); - const numerator = eth.uint256.mulLimbs(amount_in_with_fee, reserve_out); - const denominator = eth.uint256.addLimbs(eth.uint256.mulLimbScalar(reserve_in, 1000), amount_in_with_fee); - const amount_out = eth.uint256.divLimbsDirect(numerator, denominator); - std.mem.doNotOptimizeAway(&amount_out); -} - -// UniswapV2 getAmountOut -- compound limb function (zig-only optimization) -fn benchUniswapV2Optimized() void { - var amount_in: u256 = ONE_ETH; - var reserve_in: u256 = RESERVE_IN; - var reserve_out: u256 = RESERVE_OUT; - std.mem.doNotOptimizeAway(&amount_in); - std.mem.doNotOptimizeAway(&reserve_in); - std.mem.doNotOptimizeAway(&reserve_out); - - const amount_out = eth.uint256.getAmountOut(amount_in, reserve_in, reserve_out); - std.mem.doNotOptimizeAway(&amount_out); -} - -// mulDiv: (a * b) / c with true 512-bit intermediate -fn benchMulDiv() void { - var a: u256 = ONE_ETH; - var b: u256 = SQRT_PRICE; - var c: u256 = ONE_ETH + 1_000_000; - std.mem.doNotOptimizeAway(&a); - std.mem.doNotOptimizeAway(&b); - std.mem.doNotOptimizeAway(&c); - const result = eth.uint256.mulDiv(a, b, c); - std.mem.doNotOptimizeAway(&result); -} - -// DEX V2 getAmountOut with configurable fee (dex/v2.zig) -fn benchDexV2AmountOut() void { - var amount_in: u256 = ONE_ETH; - var reserve_in: u256 = RESERVE_IN; - var reserve_out: u256 = RESERVE_OUT; - std.mem.doNotOptimizeAway(&amount_in); - std.mem.doNotOptimizeAway(&reserve_in); - std.mem.doNotOptimizeAway(&reserve_out); - - const amount_out = eth.dex_v2.getAmountOut(amount_in, reserve_in, reserve_out, 997, 1000); - std.mem.doNotOptimizeAway(&amount_out); -} - -// DEX V2 multi-hop: 3-hop path -fn benchDexV2MultiHop() void { - const path = [_]eth.dex_v2.Pair{ - .{ .reserve_in = RESERVE_IN, .reserve_out = RESERVE_OUT }, - .{ .reserve_in = 200_000_000_000, .reserve_out = 50_000_000_000_000_000_000 }, - .{ .reserve_in = 50_000_000_000_000_000_000, .reserve_out = 100_000_000_000 }, - }; - var amount_in: u256 = ONE_ETH; - std.mem.doNotOptimizeAway(&amount_in); - - const result = eth.dex_v2.getAmountsOut(amount_in, &path); - std.mem.doNotOptimizeAway(&result); -} - -// DEX V3 getSqrtRatioAtTick -fn benchDexV3TickToPrice() void { - var tick: i24 = 10000; - std.mem.doNotOptimizeAway(&tick); - const result = eth.dex_v3.getSqrtRatioAtTick(tick); - std.mem.doNotOptimizeAway(&result); -} - -// DEX V3 computeSwapStep -fn benchDexV3SwapStep() void { - var current: u256 = SQRT_PRICE; - var target: u256 = eth.dex_v3.getSqrtRatioAtTick(-100).?; - std.mem.doNotOptimizeAway(¤t); - std.mem.doNotOptimizeAway(&target); - const result = eth.dex_v3.computeSwapStep(current, target, 1_000_000_000_000_000_000, 1_000_000, 3000); - std.mem.doNotOptimizeAway(&result); -} - -// UniswapV4 getNextSqrtPriceFromAmount0RoundingUp -fn benchUniswapV4Swap() void { - var liquidity: u256 = ONE_ETH; - var sqrt_price: u256 = SQRT_PRICE; - var amount_in: u256 = AMOUNT_IN_SMALL; - std.mem.doNotOptimizeAway(&liquidity); - std.mem.doNotOptimizeAway(&sqrt_price); - std.mem.doNotOptimizeAway(&amount_in); - - const product = eth.uint256.fastMul(amount_in, sqrt_price); - const denominator = liquidity +% product; - const next_sqrt_price = eth.uint256.mulDiv(liquidity, sqrt_price, denominator); - std.mem.doNotOptimizeAway(&next_sqrt_price); -} - -// ============================================================================ -// Main -// ============================================================================ - -fn runAndPrint(comptime name: []const u8, comptime func: fn () void, stdout: anytype) !void { - const result = runBench(func); - try stdout.print("{s:<32} {d:>9} ns {d:>14}\n", .{ name, result.ns_per_op, result.iters }); -} - -fn runAndJson(comptime name: []const u8, comptime func: fn () void, stdout: anytype) !void { - const result = runBench(func); - try stdout.print("BENCH_JSON|{{\"name\":\"{s}\",\"ns_per_op\":{d}}}\n", .{ name, result.ns_per_op }); -} - -pub fn main() !void { - var buf: [8192]u8 = undefined; - var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &buf); - const stdout = &w.interface; - - try stdout.print("\n{s:<32} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "iters" }); - try stdout.print("{s}\n", .{"" ++ @as([62]u8, @splat('-'))}); - - try runAndPrint("u256_add", benchAdd, stdout); - try runAndPrint("u256_mul_small", benchMulSmall, stdout); - try runAndPrint("u256_mul_full", benchMulFull, stdout); - try runAndPrint("u256_div_small", benchDivSmall, stdout); - try runAndPrint("u256_div_full", benchDivFull, stdout); - try runAndPrint("u256_uniswapv2_naive", benchUniswapV2Naive, stdout); - try runAndPrint("u256_uniswapv2_optimized", benchUniswapV2Optimized, stdout); - try runAndPrint("u256_mulDiv", benchMulDiv, stdout); - try runAndPrint("u256_uniswapv4_swap", benchUniswapV4Swap, stdout); - // DEX benchmarks - try runAndPrint("dex_v2_amount_out", benchDexV2AmountOut, stdout); - try runAndPrint("dex_v2_multi_hop_3", benchDexV2MultiHop, stdout); - try runAndPrint("dex_v3_tick_to_price", benchDexV3TickToPrice, stdout); - try runAndPrint("dex_v3_swap_step", benchDexV3SwapStep, stdout); - - try stdout.print("\n", .{}); - - // Machine-readable JSON lines for compare script - try runAndJson("u256_add", benchAdd, stdout); - try runAndJson("u256_mul_small", benchMulSmall, stdout); - try runAndJson("u256_mul_full", benchMulFull, stdout); - try runAndJson("u256_div_small", benchDivSmall, stdout); - try runAndJson("u256_div_full", benchDivFull, stdout); - try runAndJson("u256_uniswapv2_naive", benchUniswapV2Naive, stdout); - try runAndJson("u256_uniswapv2_optimized", benchUniswapV2Optimized, stdout); - try runAndJson("u256_mulDiv", benchMulDiv, stdout); - try runAndJson("u256_uniswapv4_swap", benchUniswapV4Swap, stdout); - try runAndJson("dex_v2_amount_out", benchDexV2AmountOut, stdout); - try runAndJson("dex_v2_multi_hop_3", benchDexV2MultiHop, stdout); - try runAndJson("dex_v3_tick_to_price", benchDexV3TickToPrice, stdout); - try runAndJson("dex_v3_swap_step", benchDexV3SwapStep, stdout); - - try stdout.flush(); -} diff --git a/build.zig b/build.zig index 7a63059..10eb52e 100644 --- a/build.zig +++ b/build.zig @@ -102,7 +102,7 @@ pub fn build(b: *std.Build) void { const vector_step = b.step("vector-test", "Run ENS normalization conformance vectors"); vector_step.dependOn(&run_vector_tests.step); - // Benchmarks (always ReleaseFast for meaningful numbers) + // Benchmarks (always optimize=fast for meaningful numbers) const bench_module = b.addModule("eth_bench", .{ .root_source_file = b.path("src/root.zig"), .target = target, @@ -126,56 +126,17 @@ pub fn build(b: *std.Build) void { }); const run_bench = b.addRunArtifact(bench_exe); - const bench_step = b.step("bench", "Run benchmarks (ReleaseFast)"); + // Relative --save/--baseline paths resolve against the repository root. + run_bench.setCwd(b.path(".")); + run_bench.addPassthruArgs(); + const bench_step = b.step("bench", "Run benchmarks (ReleaseFast); pass options after --, see bench/README.md"); bench_step.dependOn(&run_bench.step); - // u256-only benchmark (custom harness, no zbench dependency) - const u256_bench_exe = b.addExecutable(.{ - .name = "u256-bench", - .root_module = b.createModule(.{ - .root_source_file = b.path("bench/u256_bench.zig"), - .target = target, - .optimize = .fast, - .imports = &.{ - .{ .name = "eth", .module = bench_module }, - }, - }), - }); - - const run_u256_bench = b.addRunArtifact(u256_bench_exe); - const u256_bench_step = b.step("bench-u256", "Run u256-only benchmarks (ReleaseFast)"); - u256_bench_step.dependOn(&run_u256_bench.step); - - // Keccak comparison benchmark (eth.zig vs stdlib) - const keccak_compare_exe = b.addExecutable(.{ - .name = "keccak-compare", - .root_module = b.createModule(.{ - .root_source_file = b.path("bench/keccak_compare.zig"), - .target = target, - .optimize = .fast, - .imports = &.{ - .{ .name = "eth", .module = bench_module }, - }, - }), - }); - - const run_keccak_compare = b.addRunArtifact(keccak_compare_exe); - const keccak_compare_step = b.step("bench-keccak", "Compare eth.zig Keccak vs stdlib (ReleaseFast)"); - keccak_compare_step.dependOn(&run_keccak_compare.step); - - // Keccak CLI benchmark (for hyperfine comparison) - const keccak_bench_exe = b.addExecutable(.{ - .name = "keccak-bench-zig", - .root_module = b.createModule(.{ - .root_source_file = b.path("bench/keccak_bench_cli.zig"), - .target = target, - .optimize = .fast, - .imports = &.{ - .{ .name = "eth", .module = bench_module }, - }, - }), - }); - b.installArtifact(keccak_bench_exe); + // `zig build bench-install --prefix DIR` leaves the binary at DIR/bin/bench, + // for interleaved version comparisons with bench/ab.py. + const install_bench = b.addInstallArtifact(bench_exe, .{}); + const bench_install_step = b.step("bench-install", "Install the benchmark binary (for bench/ab.py)"); + bench_install_step.dependOn(&install_bench.step); } /// Add XKCP keccak C sources to a module with CPU-appropriate backend selection. diff --git a/docs/content/docs/benchmarks.mdx b/docs/content/docs/benchmarks.mdx index df37781..e4da028 100644 --- a/docs/content/docs/benchmarks.mdx +++ b/docs/content/docs/benchmarks.mdx @@ -1,85 +1,49 @@ --- title: Benchmarks -description: eth.zig vs alloy.rs -- head-to-head performance comparison across 26 Ethereum operations. +description: The ten hot-path operations eth.zig tracks release over release, and how they are measured. --- -eth.zig vs Rust -- a head-to-head performance comparison of eth.zig and [alloy.rs](https://alloy.rs) across 26 core Ethereum operations. +eth.zig benchmarks itself against its own previous releases. The suite is the ten operations that dominate a trading bot, liquidator or searcher hot loop. -**Score: eth.zig wins 18/26 | alloy.rs wins 5/26 | tied 3/26** - -Run date: 2026-06-10 on Apple Silicon (aarch64-macos) with `ReleaseFast` (Zig) vs `--release` (Cargo). alloy crate versions: alloy-primitives 1.6.0, alloy-sol-types 1.6.0, alloy-dyn-abi 1.6.0, alloy-consensus 2.0.5, alloy-signer 2.0.5, alloy-rlp 0.3.15. Custom criterion-style harness with 0.5s warmup, calibrated batch sizes, and 2s measurement window. Both mulDiv benchmarks use true 512-bit intermediate arithmetic. - -## Full Results - -| Benchmark | eth.zig | alloy.rs | Winner | -|-----------|---------|----------|--------| -| keccak256_empty | 261 ns | 310 ns | **zig 1.19x** | -| keccak256_32b | 263 ns | 319 ns | **zig 1.21x** | -| keccak256_256b | 534 ns | 532 ns | rs 1.00x | -| keccak256_1kb | 2,073 ns | 2,039 ns | rs 1.02x | -| keccak256_4kb | 7,732 ns | 7,838 ns | **zig 1.01x** | -| secp256k1_sign | 22,033 ns | 51,490 ns | **zig 2.34x** | -| secp256k1_sign_recover | 52,095 ns | 220,150 ns | **zig 4.23x** | -| address_derivation | 265 ns | 328 ns | **zig 1.24x** | -| address_from_hex | 17 ns | 11 ns | rs 1.55x | -| checksum_address | 304 ns | 341 ns | **zig 1.12x** | -| abi_encode_transfer | 24 ns | 47 ns | **zig 1.96x** | -| abi_encode_static | 25 ns | 97 ns | **zig 3.88x** | -| abi_encode_dynamic | 171 ns | 337 ns | **zig 1.97x** | -| abi_decode_uint256 | 14 ns | 51 ns | **zig 3.64x** | -| abi_decode_dynamic | 32 ns | 269 ns | **zig 8.41x** | -| rlp_encode_eip1559_tx | 3 ns | 73 ns | **zig 24.33x** | -| rlp_decode_u256 | 7 ns | 7 ns | tie | -| u256_add | 4 ns | 4 ns | tie | -| u256_mul | 5 ns | 10 ns | **zig 2.00x** | -| u256_div | 7 ns | 24 ns | **zig 3.43x** | -| u256_uniswapv2_amount_out | 24 ns | 24 ns | tie | -| u256_mulDiv | 17 ns | 31 ns | **zig 1.82x** | -| u256_uniswapv4_swap | 45 ns | 42 ns | rs 1.07x | -| hex_encode_32b | 26 ns | 24 ns | rs 1.08x | -| hex_decode_32b | 24 ns | 28 ns | **zig 1.17x** | -| tx_hash_eip1559 | 271 ns | 361 ns | **zig 1.33x** | - -## Score Summary - -| | Count | +| Benchmark | What it measures | |---|---| -| eth.zig wins | 18 | -| alloy.rs wins | 5 | -| Tied | 3 | - -## Key Optimizations - -| Optimization | Impact | -|---|---| -| bitcoin-core/secp256k1 C backend (vendored) | secp256k1_sign: 2.34x faster than alloy; sign_recover: 4.23x faster | -| Lane-complementing Keccak-f[1600] (XKCP opt64) | keccak256_32b: 1.21x faster than alloy (larger inputs are now within ~2% of alloy 1.6) | -| Streamlined mulDiv with native u128 division | mulDiv: 1.82x faster than alloy (17ns vs 31ns) | -| mulWide + divWide (Knuth D for 512-bit) | Replaces 256-iteration binary long division with ~4-iteration Knuth D | -| Half-word division (`div128by64`) | u256_div: 7ns, 3.43x faster than alloy | -| FixedBufferAllocator in benchmarks | Eliminates allocator overhead for ABI/RLP/TX benchmarks | -| Vendored bitcoin-core/secp256k1 + XKCP keccak | Best-in-class C backends compiled by `zig build` | -| Custom criterion-style harness | Accurate timing in the sub-25ns regime; zbench had ~25ns floor on macOS | - -## Where alloy.rs Wins - -| Benchmark | Gap | Root Cause | -|---|---|---| -| address_from_hex | 1.55x | alloy uses SIMD hex parsing; eth.zig uses scalar loop | -| hex_encode_32b | 1.08x | alloy's const-hex crate uses vectorized encoding | -| u256_uniswapv4_swap | 1.07x | ruint's U512 widening mul/div edges out eth.zig on this path | -| keccak256_256b | 1.00x | within measurement noise (~0.4%) | -| keccak256_1kb | 1.02x | within measurement noise (~2%) | - -## Reproducing +| `keccak256_32b` | selector, storage-slot and mapping-key hashing | +| `keccak256_1kb` | calldata- and transaction-sized hashing | +| `secp256k1_sign` | signing a transaction hash | +| `secp256k1_recover` | recovering a sender from a signature | +| `tx_hash_eip1559` | RLP-encoding an EIP-1559 transaction and hashing it | +| `abi_encode_transfer` | encoding `transfer(address,uint256)` calldata | +| `abi_decode_dynamic` | decoding `(string,bytes)` return data | +| `u256_mulDiv` | full-precision `a*b/c` with a 512-bit intermediate | +| `u256_uniswapv4_swap` | one exact-in swap-step price update | +| `dex_decode_universal_router` | decoding a real 4-command Universal Router `execute` | + +## Latest results + +Apple M4, 2026-10-10, fastest of 7 interleaved runs. Baseline: v0.9.2 on Zig 0.16.0. + +| Benchmark | v0.9.2 (Zig 0.16) | v0.10.1 (Zig 0.17) | change | +|---|---:|---:|---:| +| `keccak256_32b` | 136.66 ns | 136.55 ns | -0.1% | +| `keccak256_1kb` | 1043.85 ns | 1047.39 ns | +0.3% | +| `secp256k1_sign` | 11421.14 ns | 11390.99 ns | -0.3% | +| `secp256k1_recover` | 15159.87 ns | 15158.16 ns | 0.0% | +| `tx_hash_eip1559` | 142.99 ns | 143.49 ns | +0.3% | +| `abi_encode_transfer` | 18.77 ns | 18.74 ns | -0.2% | +| `abi_decode_dynamic` | 15.77 ns | 16.95 ns | +7.5% | +| `u256_mulDiv` | 12.79 ns | 13.00 ns | +1.6% | +| `u256_uniswapv4_swap` | 24.09 ns | 21.94 ns | -8.9% | +| `dex_decode_universal_router` | (new) | 154.04 ns | | + +## Running ```bash -# Full comparison (requires Zig, Rust, Python 3) -bash bench/compare.sh +zig build bench # all ten +zig build bench -- --filter keccak # a subset +zig build bench -- --save results.json # save a run +zig build bench -- --baseline results.json # compare with a saved run +``` -# eth.zig benchmarks only -zig build bench +## Measurement -# alloy benchmarks only -(cd bench/alloy-bench && cargo bench --bench eth_comparison) -``` +Each benchmark warms up for 200 ms, then reports the median ns/op over 31 batches of about 10 ms, with the minimum and median absolute deviation alongside. Inputs are read through an optimizer barrier on every call so nothing is constant-folded. To compare two versions, build both with `zig build bench-install --prefix DIR` and interleave them with `python3 bench/ab.py OLD NEW`, which keeps each benchmark's fastest run so interference from the rest of the machine cancels out. Full details are in [bench/README.md](https://github.com/StrobeLabs/eth.zig/blob/main/bench/README.md). diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx deleted file mode 100644 index a678901..0000000 --- a/docs/content/docs/comparison.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Comparison -description: How eth.zig compares to alloy.rs, Zabi, and other Ethereum libraries. ---- - -## Performance vs alloy.rs (Rust) - -[alloy.rs](https://alloy.rs) is the leading Rust Ethereum library, backed by Paradigm. eth.zig outperforms it on 18 of 26 benchmarks (alloy-primitives 1.6.0, alloy-consensus 2.0.5; run 2026-06-10). - -| Category | eth.zig | alloy.rs | -|----------|---------|----------| -| Benchmarks won | **18/26** | 5/26 | -| secp256k1 signing | Faster (2.34-4.23x) | -- | -| ABI encoding/decoding | Faster (1.96-8.41x) | -- | -| Hashing (Keccak) | Faster on small inputs (1.19-1.21x) | Within ~2% on 256B-1KB | -| u256 arithmetic | Faster on mul/div (2.00-3.43x) | UniswapV4 swap (1.07x) | -| UniswapV4 mulDiv | Faster (1.82x) | -- | -| Hex operations | Faster decoding (1.17x) | Faster encoding (1.08x) and parsing (1.55x, SIMD) | - -See [full benchmark results](/benchmarks) for details. - -## Features vs Zabi (Zig) - -[Zabi](https://www.zabi.sh/) is another Zig Ethereum library. Here's how they compare: - -| Feature | eth.zig | Zabi | -|---------|---------|------| -| Dependencies | Vendored C (secp256k1, XKCP) | C binding (secp256k1) | -| Comptime selectors | Yes | No | -| secp256k1 ECDSA | Yes (libsecp256k1 + Zig fallback) | Yes (C binding) | -| ABI encode/decode | Yes | Yes | -| HD wallets (BIP-32/39/44) | Yes | Yes | -| ERC-20/721 wrappers | Yes | No | -| JSON ABI parsing | Yes | Yes | -| WebSocket transport | Yes | Yes | -| ENS resolution | Yes | Yes | -| EIP-712 typed data | Yes | Yes | -| Multicall3 | Yes | No | - -### Key Differences - -**Performance**: eth.zig beats alloy.rs (Rust) on 18 of 26 benchmarks, including secp256k1 signing (2.34x faster), ABI decoding (8.41x), and u256 mulDiv (1.82x). - -**Comptime selectors**: eth.zig computes function selectors and event topics at compile time. This is unique to eth.zig and eliminates runtime hashing overhead. See [Comptime Selectors](/comptime). - -**Typed token wrappers**: eth.zig provides ERC-20 and ERC-721 structs that wrap the contract address and provider for a clean API (`token.balanceOf(addr)` instead of raw ABI encoding). - -## vs ethers.js / viem (JavaScript) - -| Aspect | eth.zig | ethers.js / viem | -|--------|---------|-----------------| -| Language | Zig | JavaScript/TypeScript | -| Performance | Nanoseconds | Microseconds-milliseconds | -| Dependencies | Vendored C (compiled by `zig build`) | Many (npm packages) | -| Use case | Backend, infra, bots | Frontend, scripts, dApps | -| Binary size | Single static binary | Node.js runtime required | - -eth.zig is not a replacement for JavaScript libraries in browser environments. It targets backend infrastructure, MEV bots, indexers, and latency-sensitive applications where performance matters. diff --git a/docs/content/docs/dex-math.mdx b/docs/content/docs/dex-math.mdx index 9e4350b..93be9cc 100644 --- a/docs/content/docs/dex-math.mdx +++ b/docs/content/docs/dex-math.mdx @@ -182,4 +182,4 @@ if (eth.dex_router.findArbOpportunity(&circular_path, max_input)) |arb| { ## Performance -All math uses eth.zig's limb-native u256 arithmetic -- no heap allocation, no LLVM software division routines. Run `zig build bench-u256` to measure on your hardware. +All math uses eth.zig's limb-native u256 arithmetic -- no heap allocation, no LLVM software division routines. Run `zig build bench -- --filter u256` to measure on your hardware. diff --git a/docs/content/docs/faq.mdx b/docs/content/docs/faq.mdx index 98c9761..9363089 100644 --- a/docs/content/docs/faq.mdx +++ b/docs/content/docs/faq.mdx @@ -11,11 +11,9 @@ eth.zig is at v0.2.2. All listed features are complete and tested, but the libra Zig >= 0.17.0. The library tracks Zig stable releases; CI runs against the pinned minimum version. -## How does secp256k1 signing compare to alloy.rs? +## How fast is eth.zig, and how is that measured? -eth.zig wins on secp256k1 signing -- 2.34x faster than alloy.rs for signing and 4.23x faster for sign+recover (measured 2026-06-10 against alloy-primitives 1.6 / alloy 2.0). - -eth.zig wins 18 of 26 benchmarks overall. alloy.rs wins on address hex parsing (1.55x, SIMD), hex encoding (1.08x), UniswapV4 swap (1.07x), and Keccak-256 at 256B/1KB inputs (within ~2%). +The [benchmark suite](/benchmarks) tracks the ten operations that dominate bot and searcher hot loops (Keccak-256, secp256k1 sign/recover, transaction hashing, ABI encode/decode, u256 mulDiv, a Uniswap V4 swap step and Universal Router decoding). Each release is compared against eth.zig's previous releases on the same machine. ## Does eth.zig support all Ethereum transaction types? diff --git a/docs/content/docs/introduction.mdx b/docs/content/docs/introduction.mdx index 90872cc..ebe21cc 100644 --- a/docs/content/docs/introduction.mdx +++ b/docs/content/docs/introduction.mdx @@ -1,35 +1,17 @@ --- title: eth.zig -description: The fastest Ethereum library. Beats Rust on 18 of 26 benchmarks. +description: The fastest Ethereum library, in pure Zig. --- -**The fastest Ethereum library.** Beats alloy.rs on 18 of 26 benchmarks. +**The fastest Ethereum library.** eth.zig provides everything you need to interact with Ethereum from Zig -- signing transactions, encoding ABI calls, managing HD wallets, reading ERC-20 tokens, talking to nodes over JSON-RPC, and more. ## Why eth.zig? -- **Faster than Rust** -- eth.zig [beats alloy.rs](/benchmarks) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 26 benchmarks**, including secp256k1 signing (2.34x), ABI decoding (8.41x), mulDiv (1.82x), u256 division (3.43x), and Keccak-256 hashing of small inputs. +- **Fast, and kept fast** -- vendored bitcoin-core secp256k1, XKCP Keccak, limb-native u256 math and allocation-light ABI/RLP codecs, with ten hot-path [benchmarks](/benchmarks) tracked release over release. - **Comptime-first** -- Function selectors and event topics are computed at compile time with zero runtime cost. The compiler does the hashing so your program doesn't have to. -## Performance vs alloy.rs - -eth.zig wins **18/26 benchmarks** against [alloy.rs](https://alloy.rs) (alloy-primitives 1.6.0, alloy-consensus 2.0.5; run 2026-06-10). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). - -| Operation | eth.zig | alloy.rs | Winner | -|-----------|---------|----------|--------| -| secp256k1 sign | 22,033 ns | 51,490 ns | **zig 2.34x** | -| secp256k1 sign+recover | 52,095 ns | 220,150 ns | **zig 4.23x** | -| ABI decode (dynamic) | 32 ns | 269 ns | **zig 8.41x** | -| ABI encode (static) | 25 ns | 97 ns | **zig 3.88x** | -| RLP encode (EIP-1559 tx) | 3 ns | 73 ns | **zig 24.33x** | -| u256 division | 7 ns | 24 ns | **zig 3.43x** | -| u256 mulDiv (512-bit) | 17 ns | 31 ns | **zig 1.82x** | -| Keccak-256 (32B) | 263 ns | 319 ns | **zig 1.21x** | -| TX hash (EIP-1559) | 271 ns | 361 ns | **zig 1.33x** | - -See the [full benchmark results](/benchmarks) for all 26 operations. - ## Quick Start ```zig diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 962407a..fef2ca4 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -24,7 +24,6 @@ "---Reference---", "modules", "benchmarks", - "comparison", "---Community---", "contributing", "faq" diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index c4537e0..2aa3ccd 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -7,7 +7,7 @@ const inter = Inter({ subsets: ['latin'], }); -const SEO_DESCRIPTION = 'The fastest Ethereum library. Pure Zig. Zero dependencies. Beats alloy.rs on 18/26 benchmarks.'; +const SEO_DESCRIPTION = 'The fastest Ethereum library. Pure Zig. Zero dependencies.'; const webSiteSchema = JSON.stringify({ '@context': 'https://schema.org', @@ -40,7 +40,7 @@ export const metadata: Metadata = { template: '%s | eth.zig', }, description: SEO_DESCRIPTION, - keywords: ['eth.zig', 'ethereum', 'zig', 'blockchain', 'web3', 'abi', 'secp256k1', 'keccak', 'erc20', 'json-rpc', 'alloy', 'zero dependencies', 'comptime'], + keywords: ['eth.zig', 'ethereum', 'zig', 'blockchain', 'web3', 'abi', 'secp256k1', 'keccak', 'erc20', 'json-rpc', 'zero dependencies', 'comptime'], openGraph: { title: 'eth.zig -- The Fastest Ethereum Library', description: SEO_DESCRIPTION, diff --git a/src/abi_decode.zig b/src/abi_decode.zig index 8d0d513..d778590 100644 --- a/src/abi_decode.zig +++ b/src/abi_decode.zig @@ -97,12 +97,12 @@ fn decodeValuesAt(data: []const u8, base: usize, types: []const AbiType, allocat // Each value in the head takes 32 bytes. for (types, 0..) |abi_type, i| { const head_offset = base + i * 32; - if (head_offset + 32 > data.len) return error.DataTooShort; + if (head_offset > data.len or data.len - head_offset < 32) return error.DataTooShort; if (abi_type.isDynamic()) { // Dynamic type: head contains offset relative to base - const rel_offset = readUint256AsUsize(data[head_offset..][0..32]); - const abs_offset = base + rel_offset; + const rel_offset = try readWordUsize(data[head_offset..][0..32], error.OffsetOutOfBounds); + const abs_offset = std.math.add(usize, base, rel_offset) catch return error.OffsetOutOfBounds; result[i] = try decodeDynamicValue(data, abs_offset, abi_type, allocator); } else { result[i] = try decodeStaticValue(data[head_offset..][0..32], abi_type, allocator); @@ -174,13 +174,13 @@ fn decodeDynamicValue(data: []const u8, offset: usize, abi_type: AbiType, alloca } /// Decode dynamic bytes or string from the data at the given offset. -fn decodeDynamicBytes(data: []const u8, offset: usize, allocator: std.mem.Allocator, tag: enum { bytes, string }) DecodeError!AbiValue { - if (offset + 32 > data.len) return error.OffsetOutOfBounds; +inline fn decodeDynamicBytes(data: []const u8, offset: usize, allocator: std.mem.Allocator, tag: enum { bytes, string }) DecodeError!AbiValue { + if (offset > data.len or data.len - offset < 32) return error.OffsetOutOfBounds; - const length = readUint256AsUsize(data[offset..][0..32]); + const length = try readWordUsize(data[offset..][0..32], error.LengthOutOfBounds); const data_start = offset + 32; - if (data_start + length > data.len) return error.LengthOutOfBounds; + if (length > data.len - data_start) return error.LengthOutOfBounds; if (length == 0) { const empty: []const u8 = &.{}; @@ -202,10 +202,13 @@ fn decodeDynamicBytes(data: []const u8, offset: usize, allocator: std.mem.Alloca /// Decode a dynamic array of a given element type. fn decodeDynamicArray(data: []const u8, offset: usize, element_type: AbiType, allocator: std.mem.Allocator) DecodeError!AbiValue { - if (offset + 32 > data.len) return error.OffsetOutOfBounds; + if (offset > data.len or data.len - offset < 32) return error.OffsetOutOfBounds; - const length = readUint256AsUsize(data[offset..][0..32]); + const length = try readWordUsize(data[offset..][0..32], error.LengthOutOfBounds); const elements_start = offset + 32; + // Every element takes at least one 32-byte head word, so a length the + // remaining data cannot hold is rejected before allocating for it. + if (length > (data.len - elements_start) / 32) return error.LengthOutOfBounds; if (length == 0) { const empty: []const AbiValue = &.{}; @@ -222,12 +225,13 @@ fn decodeDynamicArray(data: []const u8, offset: usize, element_type: AbiType, al return .{ .array = items }; } -/// Read a 32-byte big-endian word as a usize. -/// Returns max usize if the value overflows, which will cause a bounds check later. -fn readUint256AsUsize(word: *const [32]u8) usize { - const val = uint256_mod.fromBigEndianBytes(word.*); - if (val > std.math.maxInt(usize)) return std.math.maxInt(usize); - return @intCast(val); +/// Read a 32-byte big-endian ABI word as a usize offset or length. Words that +/// do not fit in a usize are rejected with `err` rather than clamped, so callers +/// can do arithmetic on the result without overflowing. +inline fn readWordUsize(word: *const [32]u8, comptime err: DecodeError) DecodeError!usize { + const high = std.mem.readInt(u64, word[0..8], .little) | std.mem.readInt(u64, word[8..16], .little) | std.mem.readInt(u64, word[16..24], .little); + if (high != 0) return err; + return std.math.cast(usize, std.mem.readInt(u64, word[24..32], .big)) orelse err; } // ============================================================================ @@ -812,3 +816,31 @@ test "encode-decode ERC20 transfer return bool true" { try testing.expect(!decoded_false[0].boolean); } + +test "hostile offset word near maxInt(usize) is rejected, not wrapped" { + // Head word for a `bytes` value whose offset is 2^64 - 1. Before the fix + // the offset was clamped to maxInt(usize) and `offset + 32` overflowed. + var data: [64]u8 = @splat(0); + @memset(data[24..32], 0xff); + try testing.expectError(error.OffsetOutOfBounds, decodeValues(&data, &.{.bytes}, testing.allocator)); +} + +test "offset word wider than 64 bits is rejected" { + var data: [64]u8 = @splat(0); + data[0] = 1; // 2^248 + try testing.expectError(error.OffsetOutOfBounds, decodeValues(&data, &.{.string}, testing.allocator)); +} + +test "hostile bytes length near maxInt(usize) is rejected" { + var data: [64]u8 = @splat(0); + data[31] = 32; // offset -> word 1 + @memset(data[56..64], 0xff); // length 2^64 - 1 + try testing.expectError(error.LengthOutOfBounds, decodeValues(&data, &.{.bytes}, testing.allocator)); +} + +test "dynamic array length larger than the data is rejected before allocating" { + var data: [64]u8 = @splat(0); + data[31] = 32; // offset -> word 1 + data[60] = 0x10; // length 2^28: would allocate ~GBs of element types + try testing.expectError(error.LengthOutOfBounds, decodeValues(&data, &.{.dynamic_array}, testing.allocator)); +} diff --git a/src/dex/abi_reader.zig b/src/dex/abi_reader.zig index c0f87c0..9b4814a 100644 --- a/src/dex/abi_reader.zig +++ b/src/dex/abi_reader.zig @@ -181,11 +181,15 @@ pub fn wordToUsize(w: u256) ?usize { return @intCast(w); } +/// True if every byte of `s` is zero. Checks 8 bytes at a time: padding checks +/// run on every decoded word, and the compiler no longer vectorizes the +/// byte-at-a-time form on its own (LLVM 22 ships with loop vectorization off). pub fn isZeroSlice(s: []const u8) bool { - for (s) |b| { - if (b != 0) return false; - } - return true; + var acc: u64 = 0; + var i: usize = 0; + while (i + 8 <= s.len) : (i += 8) acc |= std.mem.readInt(u64, s[i..][0..8], .little); + while (i < s.len) : (i += 1) acc |= s[i]; + return acc == 0; } /// Read the 32-byte word at `offset`, or null if it runs past `data`. @@ -201,10 +205,16 @@ pub fn readU256At(data: []const u8, offset: usize) ?u256 { } /// Read a word meant to be used as an offset or length, range-checked into -/// `usize` before any arithmetic can touch it. +/// `usize` before any arithmetic can touch it. Null if the word is truncated +/// or does not fit in a `usize`. Reads the low 8 bytes directly instead of +/// building a u256, since offsets and lengths are on every decode path. pub fn readOffset(data: []const u8, word_pos: usize) ?usize { - const w = readU256At(data, word_pos) orelse return null; - return wordToUsize(w); + const end = addChecked(word_pos, 32) orelse return null; + if (end > data.len) return null; + const w = data[word_pos..][0..32]; + const high = std.mem.readInt(u64, w[0..8], .little) | std.mem.readInt(u64, w[8..16], .little) | std.mem.readInt(u64, w[16..24], .little); + if (high != 0) return null; + return std.math.cast(usize, std.mem.readInt(u64, w[24..32], .big)); } /// A clean ABI address word: 12 zero high bytes, address in the low 20. @@ -276,7 +286,7 @@ pub fn selU32(s: [4]u8) u32 { pub fn bytesAt(data: []const u8, base: usize, offset_word_pos: usize) ?[]const u8 { const off = readOffset(data, offset_word_pos) orelse return null; const start = addChecked(base, off) orelse return null; - const len = wordToUsize(readU256At(data, start) orelse return null) orelse return null; + const len = readOffset(data, start) orelse return null; const content_start = addChecked(start, 32) orelse return null; const content_end = addChecked(content_start, len) orelse return null; if (content_end > data.len) return null; @@ -295,7 +305,7 @@ pub const ArrayHead = struct { pub fn arrayHeadAt(data: []const u8, base: usize, offset_word_pos: usize) ?ArrayHead { const off = readOffset(data, offset_word_pos) orelse return null; const arr_start = addChecked(base, off) orelse return null; - const count = wordToUsize(readU256At(data, arr_start) orelse return null) orelse return null; + const count = readOffset(data, arr_start) orelse return null; const head_start = addChecked(arr_start, 32) orelse return null; const head_len = mulChecked(count, 32) orelse return null; const head_end = addChecked(head_start, head_len) orelse return null; @@ -337,7 +347,7 @@ pub fn bytesArrayAt(data: []const u8, base: usize, offset_word_pos: usize) ?Byte while (i < h.count) : (i += 1) { const off = readOffset(head, i * 32) orelse return null; if (i > 0 and off < prev_end) return null; - const elem_len = wordToUsize(readU256At(head, off) orelse return null) orelse return null; + const elem_len = readOffset(head, off) orelse return null; const content_start = addChecked(off, 32) orelse return null; const content_end = addChecked(content_start, elem_len) orelse return null; if (content_end > head.len) return null; diff --git a/src/dex/calldata.zig b/src/dex/calldata.zig index 70b7692..fbe5fd3 100644 --- a/src/dex/calldata.zig +++ b/src/dex/calldata.zig @@ -911,7 +911,7 @@ fn parsePermit2Permit(input: []const u8) ?Command.Permit2Permit { fn permitDetailsArrayAt(data: []const u8, base: usize, offset_word_pos: usize) ?Command.PermitDetailsArray { const off = readOffset(data, offset_word_pos) orelse return null; const arr_start = addChecked(base, off) orelse return null; - const count = wordToUsize(readU256At(data, arr_start) orelse return null) orelse return null; + const count = readOffset(data, arr_start) orelse return null; const head_start = addChecked(arr_start, 32) orelse return null; const head_len = mulChecked(count, 128) orelse return null; const head_end = addChecked(head_start, head_len) orelse return null; @@ -933,7 +933,7 @@ fn permitDetailsArrayAt(data: []const u8, base: usize, offset_word_pos: usize) ? fn allowanceTransferArrayAt(data: []const u8, base: usize, offset_word_pos: usize) ?Command.AllowanceTransferArray { const off = readOffset(data, offset_word_pos) orelse return null; const arr_start = addChecked(base, off) orelse return null; - const count = wordToUsize(readU256At(data, arr_start) orelse return null) orelse return null; + const count = readOffset(data, arr_start) orelse return null; const head_start = addChecked(arr_start, 32) orelse return null; const head_len = mulChecked(count, 128) orelse return null; const head_end = addChecked(head_start, head_len) orelse return null; diff --git a/src/dex/routers.zig b/src/dex/routers.zig index 03af5d2..6cf3f89 100644 --- a/src/dex/routers.zig +++ b/src/dex/routers.zig @@ -294,7 +294,7 @@ pub const PancakeStableExactOut = struct { fn routeArrayAt(data: []const u8, base: usize, offset_word_pos: usize) ?RouteArray { const off = reader.readOffset(data, offset_word_pos) orelse return null; const arr_start = reader.addChecked(base, off) orelse return null; - const count = reader.wordToUsize(reader.readU256At(data, arr_start) orelse return null) orelse return null; + const count = reader.readOffset(data, arr_start) orelse return null; if (count < 1) return null; const head_start = reader.addChecked(arr_start, 32) orelse return null; const elem_bytes = reader.mulChecked(count, 128) orelse return null; diff --git a/src/dex/v4.zig b/src/dex/v4.zig index 04ca9ad..6d15a4a 100644 --- a/src/dex/v4.zig +++ b/src/dex/v4.zig @@ -292,7 +292,7 @@ fn pathKeysAt(data: []const u8, base: usize, offset_word_pos: usize) ?PathKeys { const p_hook_data_off = addChecked(off, 0x80) orelse return null; const hd_off = readOffset(head, p_hook_data_off) orelse return null; const hd_start = addChecked(off, hd_off) orelse return null; - const hd_len = wordToUsize(readU256At(head, hd_start) orelse return null) orelse return null; + const hd_len = readOffset(head, hd_start) orelse return null; const hd_content_start = addChecked(hd_start, 32) orelse return null; const hd_content_end = addChecked(hd_content_start, hd_len) orelse return null; if (hd_content_end > head.len) return null; diff --git a/src/rlp.zig b/src/rlp.zig index f5b1944..d4c7fb5 100644 --- a/src/rlp.zig +++ b/src/rlp.zig @@ -195,13 +195,10 @@ fn encodeUint(allocator: std.mem.Allocator, list: *std.ArrayList(u8), value: any try encodeLength(allocator, list, byte_len, 0x80); - // Write big-endian bytes - var i: usize = byte_len; - while (i > 0) { - i -= 1; - const shift: u8 = @intCast(i * 8); - try list.append(allocator, @truncate(val >> shift)); - } + // One big-endian store, then append only the significant bytes. + var be: [32]u8 = undefined; + std.mem.writeInt(u256, &be, val, .big); + try list.appendSlice(allocator, be[32 - byte_len ..]); } fn encodeBytes(allocator: std.mem.Allocator, list: *std.ArrayList(u8), bytes: []const u8) std.mem.Allocator.Error!void { @@ -230,21 +227,11 @@ fn encodeLength(allocator: std.mem.Allocator, list: *std.ArrayList(u8), len: usi if (len < 56) { try list.append(allocator, offset + @as(u8, @intCast(len))); } else { - // Compute byte length of len - var len_bytes: usize = 0; - var temp = len; - while (temp > 0) : (temp >>= 8) { - len_bytes += 1; - } - - try list.append(allocator, offset + 55 + @as(u8, @intCast(len_bytes))); - - // Write length in big-endian - var i: usize = len_bytes; - while (i > 0) { - i -= 1; - try list.append(allocator, @intCast((len >> @intCast(i * 8)) & 0xff)); - } + const len_bytes = (@bitSizeOf(usize) - @as(usize, @clz(len)) + 7) / 8; + var be: [@sizeOf(usize) + 1]u8 = undefined; + be[@sizeOf(usize) - len_bytes] = offset + 55 + @as(u8, @intCast(len_bytes)); + std.mem.writeInt(usize, be[1..], len, .big); + try list.appendSlice(allocator, be[@sizeOf(usize) - len_bytes ..]); } } @@ -771,3 +758,21 @@ test "encode-decode roundtrip [32]u8" { try std.testing.expectEqual(@as(u8, @intCast(i)), decoded.value[i]); } } + +test "long string length prefixes (1, 2 and 3 length bytes)" { + const allocator = std.testing.allocator; + const cases = [_]struct { len: usize, prefix: []const u8 }{ + .{ .len = 56, .prefix = &.{ 0xb8, 0x38 } }, + .{ .len = 300, .prefix = &.{ 0xb9, 0x01, 0x2c } }, + .{ .len = 70_000, .prefix = &.{ 0xba, 0x01, 0x11, 0x70 } }, + }; + for (cases) |c| { + const payload = try allocator.alloc(u8, c.len); + defer allocator.free(payload); + @memset(payload, 0xaa); + const out = try encode(allocator, payload); + defer allocator.free(out); + try std.testing.expectEqualSlices(u8, c.prefix, out[0..c.prefix.len]); + try std.testing.expectEqual(c.prefix.len + c.len, out.len); + } +} From 1b342d734ed90010cc3d13226ba74ebdf5520322 Mon Sep 17 00:00:00 2001 From: Amlandeep Bhadra Date: Sat, 10 Oct 2026 03:29:16 -0400 Subject: [PATCH 2/2] fix(bench): label installed binaries in ab.py, fail on regression with --json --- bench/ab.py | 12 +++++++++++- bench/bench.zig | 24 +++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/bench/ab.py b/bench/ab.py index d52a6e1..01b9f0e 100644 --- a/bench/ab.py +++ b/bench/ab.py @@ -15,12 +15,22 @@ """ import argparse import json +import os import random import statistics import subprocess import sys +def label(path): + """Column name for a binary. `bench-install` names every binary `bench` + (DIR/bin/bench), so fall back to the install prefix's directory name.""" + parts = os.path.normpath(path).split(os.sep) + if len(parts) >= 3 and parts[-1] == "bench" and parts[-2] == "bin": + return parts[-3] + return parts[-1] + + def run(binary, samples, flt): cmd = [binary, "--json", "--samples", str(samples)] if flt: @@ -57,7 +67,7 @@ def med(b, n): vals = [r[n] for r in runs[b] if n in r] return (min(vals), (statistics.median(vals) - min(vals)) / min(vals) * 100) if vals else (None, None) - labels = [b.rsplit("/", 1)[-1] for b in args.binaries] + labels = [label(b) for b in args.binaries] print(f"\n{'benchmark':<30}" + "".join(f"{l[:20]:>22}" for l in labels)) for n in names: row = f"{n:<30}" diff --git a/bench/bench.zig b/bench/bench.zig index a9ce329..4bd9fab 100644 --- a/bench/bench.zig +++ b/bench/bench.zig @@ -372,18 +372,28 @@ pub fn main(init: std.process.Init) !void { const r = measure(io, b, opts.samples); results[n] = r; n += 1; - if (opts.json) continue; - try w.print("{s:<30} {d:>9.2} ns {d:>9.2} ns {d:>7.1}%", .{ r.name, r.median_ns, r.min_ns, r.mad_pct }); - if (findBaseline(base, r.name)) |e| { - const change = (r.median_ns - e.median_ns) / e.median_ns * 100; + // Classify against the baseline before any output decision, so + // --fail-on-regression also works together with --json. + var tag: []const u8 = ""; + var change: f64 = 0; + const baseline_entry = findBaseline(base, r.name); + if (baseline_entry) |e| { + change = (r.median_ns - e.median_ns) / e.median_ns * 100; // Only call it a regression if it clears both the threshold and the // combined noise of the two runs. const noise = 2 * (r.mad_pct + e.mad_pct); - const tag: []const u8 = if (change > opts.threshold_pct and change > noise) blk: { + if (change > opts.threshold_pct and change > noise) { regressions += 1; - break :blk " REGRESSION"; - } else if (change < -opts.threshold_pct and -change > noise) " faster" else ""; + tag = " REGRESSION"; + } else if (change < -opts.threshold_pct and -change > noise) { + tag = " faster"; + } + } + if (opts.json) continue; + + try w.print("{s:<30} {d:>9.2} ns {d:>9.2} ns {d:>7.1}%", .{ r.name, r.median_ns, r.min_ns, r.mad_pct }); + if (baseline_entry) |e| { try w.print(" {d:>9.2} ns {s}{d:>7.1}%{s}", .{ e.median_ns, if (change >= 0) "+" else "-", @abs(change), tag }); } else if (base.len > 0) { try w.writeAll(" (not in baseline)");