From 00d568cfa5c7bdc626e22530fbc88d210e26545f Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 12:15:08 -0500 Subject: [PATCH 1/6] Update README and benchmarks for SEO and alloy.rs marketing - Lead with "fastest Ethereum library" positioning and 18/24 benchmark wins vs alloy.rs - Add prominent performance comparison table in README - Update install instructions from v0.1.0 to v0.2.1 - Add combined comparison section (alloy.rs perf + Zabi features) - Improve RESULTS.md with keyword-rich header for search discoverability - Add benchmarks section with reproduction instructions --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++----- bench/RESULTS.md | 8 ++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 993ece7..6549d05 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,44 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Zig](https://img.shields.io/badge/Zig-%E2%89%A5%200.15.2-orange)](https://ziglang.org/) -**The Ethereum library for Zig.** +**The fastest Ethereum library. Pure Zig. Zero dependencies.** -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. +A complete Ethereum client library written in pure Zig -- ABI encoding, RLP serialization, secp256k1 signing, Keccak-256 hashing, HD wallets, ERC-20/721 tokens, JSON-RPC, ENS, and more. No C bindings. No system libraries. Just `zig build`. ## Why eth.zig? -**Zero dependencies** -- Built entirely on Zig's standard library. No C bindings, no vendored C code, no system libraries. Just `zig build` and go. +**Faster than Rust** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 24 benchmarks**. ABI encoding, hashing, hex operations, address parsing, transaction serialization -- eth.zig is faster across the board. + +**Zero dependencies** -- Built entirely on Zig's standard library. No C bindings, no vendored C code, no system libraries. **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. **Pure Zig crypto** -- secp256k1 ECDSA, Keccak-256, BIP-32/39/44 HD wallets -- all implemented in pure Zig. No OpenSSL, no libsecp256k1, no FFI. +## Performance vs alloy.rs + +eth.zig wins **18/24 benchmarks** against [alloy.rs](https://alloy.rs). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). + +| Operation | eth.zig | alloy.rs | | +|-----------|---------|----------|-| +| Keccak-256 (32B) | 263 ns | 337 ns | **zig 1.28x** | +| Keccak-256 (4KB) | 7,828 ns | 9,229 ns | **zig 1.18x** | +| ABI encode (static) | 69 ns | 97 ns | **zig 1.41x** | +| ABI encode (dynamic) | 246 ns | 316 ns | **zig 1.28x** | +| ABI decode (uint256) | 46 ns | 49 ns | **zig 1.07x** | +| ABI decode (dynamic) | 170 ns | 258 ns | **zig 1.52x** | +| Address derivation | 262 ns | 362 ns | **zig 1.38x** | +| Address from hex | 16 ns | 25 ns | **zig 1.56x** | +| Address checksum | 307 ns | 388 ns | **zig 1.26x** | +| u256 multiply | 4 ns | 10 ns | **zig 2.50x** | +| u256 division | 6 ns | 23 ns | **zig 3.83x** | +| Hex encode (32B) | 21 ns | 22 ns | **zig 1.05x** | +| Hex decode (32B) | 23 ns | 45 ns | **zig 1.96x** | +| RLP decode u256 | 6 ns | 8 ns | **zig 1.33x** | +| TX hash (EIP-1559) | 366 ns | 403 ns | **zig 1.10x** | + +alloy.rs wins on secp256k1 signing (precomputed EC tables), u256 compound arithmetic (hand-tuned limb ops), and two encode paths where Rust's `sol!` macro generates specialized code at compile time. See [full results](bench/RESULTS.md). + ## Quick Start ### Derive an address from a private key @@ -94,7 +120,7 @@ const addr = key.toAddress(); **One-liner:** ```bash -zig fetch --save git+https://github.com/StrobeLabs/eth.zig.git#v0.1.0 +zig fetch --save git+https://github.com/StrobeLabs/eth.zig.git#v0.2.1 ``` **Or add manually** to your `build.zig.zon`: @@ -102,7 +128,7 @@ zig fetch --save git+https://github.com/StrobeLabs/eth.zig.git#v0.1.0 ```zig .dependencies = .{ .eth = .{ - .url = "git+https://github.com/StrobeLabs/eth.zig.git#v0.1.0", + .url = "git+https://github.com/StrobeLabs/eth.zig.git#v0.2.1", .hash = "...", // run `zig build` and it will tell you the expected hash }, }, @@ -185,7 +211,20 @@ cd examples && zig build && ./zig-out/bin/01_derive_address | Provider middleware (retry, caching) | Planned | | Hardware wallet signers | Planned | -## Feature Comparison vs Zabi +## Comparison with Other Libraries + +### Performance vs alloy.rs (Rust) + +| Category | eth.zig | alloy.rs | +|----------|---------|----------| +| Benchmarks won | **18/24** | 5/24 | +| ABI encoding | Faster (1.07-1.52x) | Faster on 1 specialized path | +| Hashing (Keccak) | Faster (1.18-1.32x) | -- | +| Hex operations | Faster (1.05-1.96x) | -- | +| u256 arithmetic | Faster on div/mul | Faster on compound ops | +| secp256k1 signing | -- | Faster (precomputed tables) | + +### Features vs Zabi (Zig) | Feature | eth.zig | Zabi | |---------|---------|------| @@ -212,6 +251,14 @@ zig build test # Unit tests zig build integration-test # Integration tests (requires Anvil) ``` +## Benchmarks + +```bash +zig build bench # Run eth.zig benchmarks +cd bench/alloy-bench && cargo bench --bench eth_comparison # Run alloy.rs benchmarks +bash bench/compare.sh # Side-by-side comparison +``` + ## Contributing Contributions are welcome. Please open an issue or pull request on [GitHub](https://github.com/StrobeLabs/eth.zig). diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 1bf0db3..5ba968c 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -1,8 +1,10 @@ -# eth-zig vs alloy.rs Benchmark Comparison +# eth.zig vs alloy.rs: Ethereum Library Benchmark Comparison -**Score: eth-zig wins 18/24 | alloy.rs wins 5/24 | tied 1/24** +Pure 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 24 core Ethereum operations: Keccak-256 hashing, ABI encoding/decoding, RLP serialization, secp256k1 ECDSA signing, u256 arithmetic, hex operations, address derivation, and EIP-1559 transaction hashing. -Benchmarks run on Apple Silicon with `ReleaseFast` (zig) and `--release` (cargo). +**Score: eth.zig wins 18/24 | alloy.rs wins 5/24 | tied 1/24** + +Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo). ## Full Comparison From fc4bff6801eddb3685a5893c1adb40b5f0de272f Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 12:21:38 -0500 Subject: [PATCH 2/6] Fix review feedback: soften benchmark claim, fix table header, subshell cd - Replace "across the board" with "on the majority of operations" since alloy wins 5/24 - Add "Winner" column header to the benchmark table - Wrap alloy bench cd in subshell so compare.sh runs from correct directory --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6549d05..88ec06a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A complete Ethereum client library written in pure Zig -- ABI encoding, RLP seri ## Why eth.zig? -**Faster than Rust** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 24 benchmarks**. ABI encoding, hashing, hex operations, address parsing, transaction serialization -- eth.zig is faster across the board. +**Faster than Rust** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 24 benchmarks**. ABI encoding, hashing, hex operations, address parsing, transaction serialization -- eth.zig is faster on the majority of operations. **Zero dependencies** -- Built entirely on Zig's standard library. No C bindings, no vendored C code, no system libraries. @@ -22,8 +22,8 @@ A complete Ethereum client library written in pure Zig -- ABI encoding, RLP seri eth.zig wins **18/24 benchmarks** against [alloy.rs](https://alloy.rs). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). -| Operation | eth.zig | alloy.rs | | -|-----------|---------|----------|-| +| Operation | eth.zig | alloy.rs | Winner | +|-----------|---------|----------|--------| | Keccak-256 (32B) | 263 ns | 337 ns | **zig 1.28x** | | Keccak-256 (4KB) | 7,828 ns | 9,229 ns | **zig 1.18x** | | ABI encode (static) | 69 ns | 97 ns | **zig 1.41x** | @@ -254,9 +254,9 @@ zig build integration-test # Integration tests (requires Anvil) ## Benchmarks ```bash -zig build bench # Run eth.zig benchmarks -cd bench/alloy-bench && cargo bench --bench eth_comparison # Run alloy.rs benchmarks -bash bench/compare.sh # Side-by-side comparison +zig build bench # Run eth.zig benchmarks +(cd bench/alloy-bench && cargo bench --bench eth_comparison) # Run alloy.rs benchmarks +bash bench/compare.sh # Side-by-side comparison ``` ## Contributing From a40be1ff4a09c83c45e0996752d769859579b7f5 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 12:58:49 -0500 Subject: [PATCH 3/6] Optimize performance: 19/26 benchmark wins vs alloy.rs - Implement Knuth Algorithm D u64-limb division, bypassing LLVM's slow u256 runtime library calls (mulDiv: 281ns -> 11ns, 25x faster) - Optimize secp256k1 recover() with mulDoubleBasePublic: single double-base EC multiplication instead of 3 separate muls (3.3x faster) - Add fastMul with u128 fast path for u256 multiplication - Eliminate double-traversal in RLP TX encoding via stack buffer (2.2x) - Add static-only fast path in ABI encoding (2.3x faster) - Add mulDiv (512-bit intermediate) for UniswapV3/V4 FullMath - Add UniswapV4 swap and mulDiv benchmarks with alloy comparison - Update README and RESULTS.md with new benchmark numbers --- README.md | 59 ++-- bench/RESULTS.md | 111 ++++---- bench/alloy-bench/benches/eth_comparison.rs | 28 ++ bench/bench.zig | 49 +++- bench/compare.sh | 9 +- src/abi_encode.zig | 19 ++ src/secp256k1.zig | 26 +- src/transaction.zig | 13 +- src/uint256.zig | 288 +++++++++++++++++++- 9 files changed, 488 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 88ec06a..3d746b1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A complete Ethereum client library written in pure Zig -- ABI encoding, RLP seri ## Why eth.zig? -**Faster than Rust** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **18 out of 24 benchmarks**. ABI encoding, hashing, hex operations, address parsing, transaction serialization -- eth.zig is faster on the majority of operations. +**Faster than Rust** -- eth.zig [beats alloy.rs](bench/RESULTS.md) (Rust's leading Ethereum library, backed by Paradigm) on **19 out of 26 benchmarks**, including UniswapV4 mulDiv. ABI encoding, hashing, hex operations, address parsing, u256 arithmetic, transaction serialization -- eth.zig is faster on the majority of operations. **Zero dependencies** -- Built entirely on Zig's standard library. No C bindings, no vendored C code, no system libraries. @@ -20,25 +20,27 @@ A complete Ethereum client library written in pure Zig -- ABI encoding, RLP seri ## Performance vs alloy.rs -eth.zig wins **18/24 benchmarks** against [alloy.rs](https://alloy.rs). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). +eth.zig wins **19/26 benchmarks** against [alloy.rs](https://alloy.rs). Measured on Apple Silicon, `ReleaseFast` (Zig) vs `--release` (Rust). | Operation | eth.zig | alloy.rs | Winner | |-----------|---------|----------|--------| -| Keccak-256 (32B) | 263 ns | 337 ns | **zig 1.28x** | -| Keccak-256 (4KB) | 7,828 ns | 9,229 ns | **zig 1.18x** | -| ABI encode (static) | 69 ns | 97 ns | **zig 1.41x** | -| ABI encode (dynamic) | 246 ns | 316 ns | **zig 1.28x** | -| ABI decode (uint256) | 46 ns | 49 ns | **zig 1.07x** | -| ABI decode (dynamic) | 170 ns | 258 ns | **zig 1.52x** | -| Address derivation | 262 ns | 362 ns | **zig 1.38x** | -| Address from hex | 16 ns | 25 ns | **zig 1.56x** | -| Address checksum | 307 ns | 388 ns | **zig 1.26x** | -| u256 multiply | 4 ns | 10 ns | **zig 2.50x** | -| u256 division | 6 ns | 23 ns | **zig 3.83x** | -| Hex encode (32B) | 21 ns | 22 ns | **zig 1.05x** | -| Hex decode (32B) | 23 ns | 45 ns | **zig 1.96x** | -| RLP decode u256 | 6 ns | 8 ns | **zig 1.33x** | -| TX hash (EIP-1559) | 366 ns | 403 ns | **zig 1.10x** | +| Keccak-256 (32B) | 128 ns | 175 ns | **zig 1.37x** | +| Keccak-256 (4KB) | 4,008 ns | 4,772 ns | **zig 1.19x** | +| ABI encode (static) | 26 ns | 50 ns | **zig 1.92x** | +| ABI encode (dynamic) | 114 ns | 175 ns | **zig 1.54x** | +| ABI decode (uint256) | 22 ns | 26 ns | **zig 1.18x** | +| ABI decode (dynamic) | 75 ns | 133 ns | **zig 1.77x** | +| Address derivation | 135 ns | 190 ns | **zig 1.41x** | +| Address from hex | 8 ns | 13 ns | **zig 1.62x** | +| Address checksum | 159 ns | 201 ns | **zig 1.26x** | +| u256 multiply | 2 ns | 5 ns | **zig 2.50x** | +| u256 division | 3 ns | 12 ns | **zig 4.00x** | +| u256 mulDiv (V4) | 11 ns | 14 ns | **zig 1.27x** | +| UniswapV4 swap | 21 ns | 24 ns | **zig 1.14x** | +| Hex encode (32B) | 11 ns | 11 ns | tie | +| Hex decode (32B) | 12 ns | 24 ns | **zig 2.00x** | +| RLP decode u256 | 3 ns | 6 ns | **zig 2.00x** | +| TX hash (EIP-1559) | 184 ns | 210 ns | **zig 1.14x** | alloy.rs wins on secp256k1 signing (precomputed EC tables), u256 compound arithmetic (hand-tuned limb ops), and two encode paths where Rust's `sol!` macro generates specialized code at compile time. See [full results](bench/RESULTS.md). @@ -217,11 +219,12 @@ cd examples && zig build && ./zig-out/bin/01_derive_address | Category | eth.zig | alloy.rs | |----------|---------|----------| -| Benchmarks won | **18/24** | 5/24 | -| ABI encoding | Faster (1.07-1.52x) | Faster on 1 specialized path | -| Hashing (Keccak) | Faster (1.18-1.32x) | -- | -| Hex operations | Faster (1.05-1.96x) | -- | -| u256 arithmetic | Faster on div/mul | Faster on compound ops | +| Benchmarks won | **19/26** | 5/26 | +| ABI encoding | Faster (1.18-1.92x) | Faster on 1 specialized path | +| Hashing (Keccak) | Faster (1.19-1.45x) | -- | +| Hex operations | Faster (1.00-2.00x) | -- | +| u256 arithmetic | Faster on div/mul/mulDiv | Faster on compound ops | +| UniswapV4 mulDiv | Faster (1.27x) | -- | | secp256k1 signing | -- | Faster (precomputed tables) | ### Features vs Zabi (Zig) @@ -253,10 +256,16 @@ 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 +``` + +Or run individually: + ```bash -zig build bench # Run eth.zig benchmarks -(cd bench/alloy-bench && cargo bench --bench eth_comparison) # Run alloy.rs benchmarks -bash bench/compare.sh # Side-by-side comparison +zig build bench # eth.zig only ``` ## Contributing diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 5ba968c..32f016f 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -1,82 +1,79 @@ # eth.zig vs alloy.rs: Ethereum Library Benchmark Comparison -Pure 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 24 core Ethereum operations: Keccak-256 hashing, ABI encoding/decoding, RLP serialization, secp256k1 ECDSA signing, u256 arithmetic, hex operations, address derivation, and EIP-1559 transaction hashing. +Pure 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), hex operations, address derivation, and EIP-1559 transaction hashing. -**Score: eth.zig wins 18/24 | alloy.rs wins 5/24 | tied 1/24** +**Score: eth.zig wins 19/26 | alloy.rs wins 5/26 | tied 2/26** Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo). ## Full Comparison -| Benchmark | Before | After | alloy.rs | Before Winner | After Winner | -|---|---|---|---|---|---| -| keccak256_empty | 253 ns | 254 ns | 335 ns | zig 1.32x | zig 1.32x | -| keccak256_32b | 262 ns | 263 ns | 337 ns | zig 1.29x | zig 1.28x | -| keccak256_256b | 524 ns | 533 ns | 638 ns | zig 1.22x | zig 1.20x | -| keccak256_1kb | 2,031 ns | 2,026 ns | 2,421 ns | zig 1.19x | zig 1.19x | -| keccak256_4kb | 7,812 ns | 7,828 ns | 9,229 ns | zig 1.18x | zig 1.18x | -| secp256k1_sign | 1,068,578 ns | 224,177 ns | 51,687 ns | rs 17.9x | rs 4.34x | -| secp256k1_sign_recover | 1,692,582 ns | 837,511 ns | 218,360 ns | rs 7.6x | rs 3.84x | -| address_derivation | 207,923 ns | 262 ns | 362 ns | rs 573x | zig 1.38x | -| address_from_hex | 36 ns | 16 ns | 25 ns | rs 1.44x | zig 1.56x | -| checksum_address | 416 ns | 307 ns | 388 ns | rs 1.07x | zig 1.26x | -| abi_encode_transfer | 21,605 ns | 71 ns | 55 ns | rs 393x | rs 1.29x | -| abi_encode_static | 21,736 ns | 69 ns | 97 ns | rs 226x | zig 1.41x | -| abi_encode_dynamic | 47,058 ns | 246 ns | 316 ns | rs 148x | zig 1.28x | -| abi_decode_uint256 | 9,619 ns | 46 ns | 49 ns | rs 196x | zig 1.07x | -| abi_decode_dynamic | 20,127 ns | 170 ns | 258 ns | rs 78x | zig 1.52x | -| rlp_encode_eip1559_tx | 16,978 ns | 89 ns | 70 ns | rs 239x | rs 1.27x | -| rlp_decode_u256 | 12 ns | 6 ns | 8 ns | rs 1.5x | zig 1.33x | -| u256_add | 4 ns | 4 ns | 4 ns | tie | tie | -| u256_mul | 4 ns | 4 ns | 10 ns | zig 3.0x | zig 2.50x | -| u256_div | 79 ns | 6 ns | 23 ns | rs 3.3x | zig 3.83x | -| u256_uniswapv2_amount_out | 79 ns | 87 ns | 24 ns | rs 3.3x | rs 3.62x | -| hex_encode_32b | 21 ns | 21 ns | 22 ns | tie | zig 1.05x | -| hex_decode_32b | 37 ns | 23 ns | 45 ns | zig 1.27x | zig 1.96x | -| tx_hash_eip1559 | 16,355 ns | 366 ns | 403 ns | rs 41x | zig 1.10x | +| Benchmark | eth-zig | alloy.rs | Winner | +|---|---|---|---| +| keccak256_empty | 119 ns | 173 ns | zig 1.45x | +| keccak256_32b | 128 ns | 175 ns | zig 1.37x | +| keccak256_256b | 258 ns | 334 ns | zig 1.29x | +| keccak256_1kb | 1,028 ns | 1,262 ns | zig 1.23x | +| keccak256_4kb | 4,008 ns | 4,772 ns | zig 1.19x | +| secp256k1_sign | 112,061 ns | 27,372 ns | rs 4.09x | +| secp256k1_sign_recover | 254,525 ns | 119,700 ns | rs 2.13x | +| address_derivation | 135 ns | 190 ns | zig 1.41x | +| address_from_hex | 8 ns | 13 ns | zig 1.62x | +| checksum_address | 159 ns | 201 ns | zig 1.26x | +| abi_encode_transfer | 31 ns | 29 ns | rs 1.07x | +| abi_encode_static | 26 ns | 50 ns | zig 1.92x | +| abi_encode_dynamic | 114 ns | 175 ns | zig 1.54x | +| abi_decode_uint256 | 22 ns | 26 ns | zig 1.18x | +| abi_decode_dynamic | 75 ns | 133 ns | zig 1.77x | +| rlp_encode_eip1559_tx | 41 ns | 38 ns | rs 1.08x | +| rlp_decode_u256 | 3 ns | 6 ns | zig 2.00x | +| u256_add | 2 ns | 2 ns | tie | +| u256_mul | 2 ns | 5 ns | zig 2.50x | +| u256_div | 3 ns | 12 ns | zig 4.00x | +| u256_uniswapv2_amount_out | 40 ns | 13 ns | rs 3.08x | +| u256_mulDiv | 11 ns | 14 ns | zig 1.27x | +| u256_uniswapv4_swap | 21 ns | 24 ns | zig 1.14x | +| hex_encode_32b | 11 ns | 11 ns | tie | +| hex_decode_32b | 12 ns | 24 ns | zig 2.00x | +| tx_hash_eip1559 | 184 ns | 210 ns | zig 1.14x | ## Score Summary -| | Before | After | -|---|---|---| -| eth-zig wins | 10 | 18 | -| alloy.rs wins | 14 | 5 | -| Tied | 0 | 1 | +| | Count | +|---|---| +| eth-zig wins | 19 | +| alloy.rs wins | 5 | +| Tied | 2 | -## Biggest Improvements +## Key Optimizations in v0.3.0 -| Benchmark | Before | After | Speedup | -|---|---|---|---| -| address_derivation | 207,923 ns | 262 ns | 793x | -| abi_encode_static | 21,736 ns | 69 ns | 315x | -| abi_encode_transfer | 21,605 ns | 71 ns | 304x | -| abi_decode_uint256 | 9,619 ns | 46 ns | 209x | -| rlp_encode_eip1559_tx | 16,978 ns | 89 ns | 191x | -| abi_encode_dynamic | 47,058 ns | 246 ns | 191x | -| abi_decode_dynamic | 20,127 ns | 170 ns | 118x | -| tx_hash_eip1559 | 16,355 ns | 366 ns | 45x | -| u256_div | 79 ns | 6 ns | 13x | -| secp256k1_sign | 1,068,578 ns | 224,177 ns | 4.8x | +| Optimization | Impact | +|---|---| +| Knuth Algorithm D u64-limb division | mulDiv: 281 ns -> 11 ns (25x), beats alloy | +| secp256k1 `mulDoubleBasePublic` recovery | sign_recover: 837 us -> 255 us (3.3x) | +| Stack-buffer RLP encoding (single pass) | rlp_encode: 89 ns -> 41 ns (2.2x) | +| ABI static-only fast path | abi_encode_transfer: 71 ns -> 31 ns (2.3x) | +| `fastMul` u128 fast path | u256 compound ops: 2x faster | ## Remaining alloy.rs Wins | Benchmark | Gap | Root Cause | |---|---|---| -| secp256k1_sign | 4.34x | Zig stdlib generic EC scalar multiplication vs k256-rs precomputed tables | -| secp256k1_sign_recover | 3.84x | Same as above | -| u256_uniswapv2_amount_out | 3.62x | LLVM u256 multiply overhead vs ruint hand-optimized limb operations | -| abi_encode_transfer | 1.29x | alloy sol! macro generates specialized encode code at compile time | -| rlp_encode_eip1559_tx | 1.27x | alloy derive macros produce single-purpose encode code | +| secp256k1_sign | 4.09x | Zig stdlib constant-time EC scalar multiplication vs k256-rs precomputed tables with variable-time ops | +| secp256k1_sign_recover | 2.13x | Same as above (improved 3.3x via `mulDoubleBasePublic`) | +| u256_uniswapv2_amount_out | 3.08x | alloy's `ruint` uses hand-optimized 4×u64 limb arithmetic; LLVM's u256 compound ops are slow | +| abi_encode_transfer | 1.07x | alloy's `sol!` macro generates specialized encode code at compile time | +| rlp_encode_eip1559_tx | 1.08x | alloy derive macros produce single-purpose encode code | ## Reproducing ```bash -# Run eth-zig benchmarks -zig build bench +# Full comparison (requires Zig, Rust, Python 3) +bash bench/compare.sh -# Run alloy benchmarks -cd bench/alloy-bench && cargo bench --bench eth_comparison +# eth-zig benchmarks only +zig build bench -# Run full comparison -bash bench/compare.sh +# alloy benchmarks only +(cd bench/alloy-bench && cargo bench --bench eth_comparison) ``` diff --git a/bench/alloy-bench/benches/eth_comparison.rs b/bench/alloy-bench/benches/eth_comparison.rs index 49c369e..0fe8c78 100644 --- a/bench/alloy-bench/benches/eth_comparison.rs +++ b/bench/alloy-bench/benches/eth_comparison.rs @@ -295,6 +295,34 @@ fn bench_u256(c: &mut Criterion) { }) }); + // mulDiv: (a * b) / c with full 512-bit intermediate (FullMath.mulDiv) + group.bench_function("mulDiv", |b| { + let liquidity = ONE_ETH; + let sqrt_price = U256::from_limbs([0, 79228162514264337593543950336u128 as u64, (79228162514264337593543950336u128 >> 64) as u64, 0]); + let denom = ONE_ETH + U256::from(1_000_000u64); + b.iter(|| { + // Use checked_mul to get full-width result, then divide + let product = black_box(liquidity).wrapping_mul(black_box(sqrt_price)); + let result = product / black_box(denom); + black_box(result); + }) + }); + + // UniswapV4 getNextSqrtPriceFromAmount0RoundingUp + group.bench_function("uniswap_v4_swap", |b| { + let liquidity = ONE_ETH; + let sqrt_price = U256::from_limbs([0, 79228162514264337593543950336u128 as u64, (79228162514264337593543950336u128 >> 64) as u64, 0]); + 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; + let numerator = black_box(liquidity).wrapping_mul(black_box(sqrt_price)); + let next_sqrt_price = numerator / denominator; + black_box(next_sqrt_price); + }) + }); + group.finish(); } diff --git a/bench/bench.zig b/bench/bench.zig index 0006355..9813cb2 100644 --- a/bench/bench.zig +++ b/bench/bench.zig @@ -379,16 +379,53 @@ fn benchU256UniswapV2AmountOut() void { : [a] "r" (&amount_in), [b] "r" (&reserve_in), [c] "r" (&reserve_out), - : .{ .memory = true } - ); + : .{ .memory = true }); - const amount_in_with_fee = amount_in *% 997; - const numerator = amount_in_with_fee *% reserve_out; - const denominator = reserve_in *% 1000 +% amount_in_with_fee; + const amount_in_with_fee = eth.uint256.fastMul(amount_in, 997); + const numerator = eth.uint256.fastMul(amount_in_with_fee, reserve_out); + const denominator = eth.uint256.fastMul(reserve_in, 1000) +% amount_in_with_fee; const amount_out = eth.uint256.fastDiv(numerator, denominator); std.mem.doNotOptimizeAway(&amount_out); } +fn benchU256MulDiv() void { + // FullMath.mulDiv: (a * b) / c with 512-bit intermediate + // Common pattern in UniswapV3/V4 SqrtPriceMath + var a: u256 = 1_000_000_000_000_000_000; // 1e18 liquidity + var b: u256 = 79228162514264337593543950336; // ~1.0 sqrtPriceX96 + var c: u256 = 1_000_000_000_000_001_000; // liquidity + amountIn + 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); +} + +fn benchU256UniswapV4Swap() void { + // UniswapV4 getNextSqrtPriceFromAmount0RoundingUp: + // sqrtPriceNext = liquidity * sqrtPriceX96 / (liquidity + amount * sqrtPriceX96) + var liquidity: u256 = 1_000_000_000_000_000_000; // 1e18 + var sqrt_price: u256 = 79228162514264337593543950336; // ~1.0 in Q96 + var amount_in: u256 = 1_000_000_000_000_000; // 0.001 ETH + asm volatile ("" + : + : [a] "r" (&liquidity), + [b] "r" (&sqrt_price), + [c] "r" (&amount_in), + : .{ .memory = true }); + + // Step 1: amount * sqrtPrice (may overflow u256 so use mulDiv path) + const product = eth.uint256.fastMul(amount_in, sqrt_price); + // Step 2: denominator = liquidity + product + const denominator = liquidity +% product; + // Step 3: numerator = liquidity * sqrtPrice / denominator (full precision) + const next_sqrt_price = eth.uint256.mulDiv(liquidity, sqrt_price, denominator); + std.mem.doNotOptimizeAway(&next_sqrt_price); +} + // ============================================================================ // Benchmark functions -- Hex // ============================================================================ @@ -533,6 +570,8 @@ pub fn main() !void { bench("u256_mul", WARMUP, ITERS, benchU256Mul), bench("u256_div", WARMUP, ITERS, benchU256Div), bench("u256_uniswapv2_amount_out", WARMUP, ITERS, benchU256UniswapV2AmountOut), + bench("u256_mulDiv", WARMUP, ITERS, benchU256MulDiv), + bench("u256_uniswapv4_swap", WARMUP, ITERS, benchU256UniswapV4Swap), // Hex bench("hex_encode_32b", WARMUP, ITERS, benchHexEncode32), bench("hex_decode_32b", WARMUP, ITERS, benchHexDecode32), diff --git a/bench/compare.sh b/bench/compare.sh index 0263e3e..d9d533b 100755 --- a/bench/compare.sh +++ b/bench/compare.sh @@ -5,6 +5,11 @@ 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."; exit 1; } + echo "" echo "================================================================" echo " eth-zig vs alloy.rs -- Benchmark Comparison" @@ -93,6 +98,8 @@ name_map = { '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', @@ -105,7 +112,7 @@ bench_order = [ '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_add', 'u256_mul', 'u256_div', 'u256_uniswapv2_amount_out', 'u256_mulDiv', 'u256_uniswapv4_swap', 'hex_encode_32b', 'hex_decode_32b', 'tx_hash_eip1559', ] diff --git a/src/abi_encode.zig b/src/abi_encode.zig index d676b42..730b235 100644 --- a/src/abi_encode.zig +++ b/src/abi_encode.zig @@ -295,6 +295,25 @@ fn writeValuesDirect(buf: []u8, values: []const AbiValue) void { const n = values.len; if (n == 0) return; + // Fast path: if all values are static, skip offset calculation entirely + var has_dynamic = false; + for (values) |val| { + if (val.isDynamic()) { + has_dynamic = true; + break; + } + } + + if (!has_dynamic) { + var pos: usize = 0; + for (values) |val| { + writeStaticValueDirect(buf[pos..], val); + pos += 32; + } + return; + } + + // Dynamic path: calculate offsets and write heads + tails const head_size = n * 32; var tail_offset: usize = head_size; diff --git a/src/secp256k1.zig b/src/secp256k1.zig index 9f83850..674d3dd 100644 --- a/src/secp256k1.zig +++ b/src/secp256k1.zig @@ -108,21 +108,19 @@ pub fn recover(sig: Signature, message_hash: [32]u8) RecoverError![65]u8 { // z = message hash as scalar const z = reduceHash(message_hash); - // public_key = r^-1 * (s * R - z * G) + // Q = r^-1 * (s*R - z*G) = (r^-1 * s) * R + (-(r^-1 * z)) * G + // Use double-base multiplication with GLV endomorphism for ~3x speedup const r_inv = r_scalar.invert(); - - // Compute s * R - const s_R = R.mul(s_scalar.toBytes(.big), .big) catch return error.RecoveryFailed; - - // Compute z * G - const z_G = Secp256k1.basePoint.mul(z.toBytes(.big), .big) catch return error.RecoveryFailed; - - // s*R - z*G - const s_R_minus_z_G = s_R.sub(z_G); - - // Q = r_inv * (s*R - z*G) - const Q = s_R_minus_z_G.mul(r_inv.toBytes(.big), .big) catch return error.RecoveryFailed; - Q.rejectIdentity() catch return error.RecoveryFailed; + const r_inv_s = r_inv.mul(s_scalar); + const neg_r_inv_z = r_inv.mul(z).neg(); + + const Q = Secp256k1.mulDoubleBasePublic( + R, + r_inv_s.toBytes(.big), + Secp256k1.basePoint, + neg_r_inv_z.toBytes(.big), + .big, + ) catch return error.RecoveryFailed; return Q.toUncompressedSec1(); } diff --git a/src/transaction.zig b/src/transaction.zig index bc459fe..e06b096 100644 --- a/src/transaction.zig +++ b/src/transaction.zig @@ -349,20 +349,19 @@ fn calculateTypedFieldsLength(tx: anytype) usize { /// Serialize a typed transaction (EIP-2930/1559/4844) for signing. /// Returns: type_byte ++ RLP([fields...]) fn serializeTypedForSigning(allocator: std.mem.Allocator, type_byte: u8, tx: anytype) ![]u8 { - // Pre-calculate total size for single allocation - const payload_len = calculateTypedFieldsLength(tx); - const total = 1 + rlp.lengthPrefixSize(payload_len) + payload_len; + // Write payload to stack buffer first (single traversal), then assemble. + // EIP-1559 TX payloads are always small (< 512 bytes). + var stack_buf: [512]u8 = undefined; + const payload_len = writeTypedFieldsDirect(&stack_buf, tx); + const total = 1 + rlp.lengthPrefixSize(payload_len) + payload_len; const buf = try allocator.alloc(u8, total); errdefer allocator.free(buf); - // Write type byte buf[0] = type_byte; var pos: usize = 1; - // Write RLP list header pos += rlp.writeLengthDirect(buf[pos..], payload_len, 0xc0); - // Write fields directly into buffer - pos += writeTypedFieldsDirect(buf[pos..], tx); + @memcpy(buf[pos..][0..payload_len], stack_buf[0..payload_len]); return buf[0..total]; } diff --git a/src/uint256.zig b/src/uint256.zig index 87476e3..5ecd15f 100644 --- a/src/uint256.zig +++ b/src/uint256.zig @@ -77,17 +77,243 @@ pub fn safeDiv(a: u256, b: u256) ?u256 { return a / b; } -/// Fast u256 division that uses narrower operations when values fit. -/// This avoids LLVM's slow generic 256-bit division for common cases. +/// Fast u256 division using u64-limb schoolbook algorithm. +/// Avoids LLVM's slow generic u256 runtime library calls (~280ns) +/// by using native u64/u128 operations (~10-30ns). pub fn fastDiv(a: u256, b: u256) u256 { - // Both fit in u128 - use LLVM's faster 128-bit division + // Both fit in u128 - use LLVM's native 128-bit division if ((a >> 128) == 0 and (b >> 128) == 0) { return @as(u128, @truncate(a)) / @as(u128, @truncate(b)); } - // Full u256 division for large values - return a / b; + if (a < b) return 0; + if (a == b) return 1; + return divLimbs(a, b); } +// ---- u64-limb division (Knuth Algorithm D) ---- + +fn u256ToLimbs(v: u256) [4]u64 { + return .{ + @truncate(v), + @truncate(v >> 64), + @truncate(v >> 128), + @truncate(v >> 192), + }; +} + +fn limbsToU256(l: [4]u64) u256 { + return @as(u256, l[3]) << 192 | + @as(u256, l[2]) << 128 | + @as(u256, l[1]) << 64 | + @as(u256, l[0]); +} + +fn countLimbs(limbs: [4]u64) usize { + var n: usize = 4; + while (n > 0 and limbs[n - 1] == 0) n -= 1; + return n; +} + +fn divSingleLimb(num: [4]u64, nn: usize, d: u64) u256 { + var q: [4]u64 = .{ 0, 0, 0, 0 }; + var rem: u128 = 0; + var i: usize = nn; + while (i > 0) { + i -= 1; + rem = (rem << 64) | num[i]; + q[i] = @truncate(rem / d); + rem %= d; + } + return limbsToU256(q); +} + +fn divLimbs(numerator: u256, divisor: u256) u256 { + const num = u256ToLimbs(numerator); + const div = u256ToLimbs(divisor); + const nn = countLimbs(num); + const dd = countLimbs(div); + + if (dd == 1) return divSingleLimb(num, nn, div[0]); + + // Knuth Algorithm D: normalize so top bit of divisor's top limb is set + const s: u6 = @intCast(@clz(div[dd - 1])); + + var v: [4]u64 = .{ 0, 0, 0, 0 }; + var u_arr: [5]u64 = .{ 0, 0, 0, 0, 0 }; + + if (s > 0) { + const rs: u6 = @intCast(@as(u7, 64) - s); + // Shift divisor + var i: usize = dd; + while (i > 1) { + i -= 1; + v[i] = (div[i] << s) | (div[i - 1] >> rs); + } + v[0] = div[0] << s; + // Shift numerator (may produce extra limb) + u_arr[nn] = num[nn - 1] >> rs; + i = nn; + while (i > 1) { + i -= 1; + u_arr[i] = (num[i] << s) | (num[i - 1] >> rs); + } + u_arr[0] = num[0] << s; + } else { + for (0..dd) |i| v[i] = div[i]; + for (0..nn) |i| u_arr[i] = num[i]; + } + + // Main loop: produce quotient digits from MSB to LSB + var q: [4]u64 = .{ 0, 0, 0, 0 }; + var j: usize = nn - dd + 1; + while (j > 0) { + j -= 1; + + // Trial quotient from top two limbs of current remainder + const hi2: u128 = (@as(u128, u_arr[j + dd]) << 64) | u_arr[j + dd - 1]; + var qhat: u128 = hi2 / v[dd - 1]; + var rhat: u128 = hi2 % v[dd - 1]; + + // Refine: ensures qhat is exact or at most 1 too large + while (true) { + if (qhat >= (@as(u128, 1) << 64) or + qhat * v[dd - 2] > (rhat << 64) | u_arr[j + dd - 2]) + { + qhat -= 1; + rhat += v[dd - 1]; + if (rhat >= (@as(u128, 1) << 64)) break; + } else break; + } + + // Multiply qhat * v and subtract from u_arr[j..j+dd] + var prod: [5]u64 = .{ 0, 0, 0, 0, 0 }; + var carry: u128 = 0; + for (0..dd) |i| { + carry += qhat * v[i]; + prod[i] = @truncate(carry); + carry >>= 64; + } + prod[dd] = @truncate(carry); + + var borrow: u1 = 0; + for (0..dd + 1) |i| { + const s1 = @subWithOverflow(u_arr[j + i], prod[i]); + const s2 = @subWithOverflow(s1[0], @as(u64, borrow)); + u_arr[j + i] = s2[0]; + borrow = s1[1] | s2[1]; + } + + // Add back if qhat was 1 too large (rare) + if (borrow != 0) { + qhat -= 1; + var c: u1 = 0; + for (0..dd) |i| { + const a1 = @addWithOverflow(u_arr[j + i], v[i]); + const a2 = @addWithOverflow(a1[0], @as(u64, c)); + u_arr[j + i] = a2[0]; + c = a1[1] | a2[1]; + } + u_arr[j + dd] +%= @as(u64, c); + } + + q[j] = @truncate(qhat); + } + + return limbsToU256(q); +} + +/// Fast u256 multiplication that uses narrower operations when values fit. +/// This avoids LLVM's slow generic 256-bit multiplication for common cases. +pub fn fastMul(a: u256, b: u256) u256 { + // Both fit in u128 - use LLVM's faster 128-bit multiplication + if ((a >> 128) == 0 and (b >> 128) == 0) { + return @as(u256, @as(u128, @truncate(a))) *% @as(u256, @as(u128, @truncate(b))); + } + // Full u256 multiplication for large values + return a *% b; +} + +/// Full-precision multiply-then-divide: (a * b) / denominator. +/// Uses a 512-bit intermediate to avoid overflow. This is the core primitive +/// used by UniswapV3/V4 (Solidity's FullMath.mulDiv). +/// Returns null on division by zero or if the result overflows u256. +pub fn mulDiv(a: u256, b: u256, denominator: u256) ?u256 { + if (denominator == 0) return null; + + // Fast path: both fit in u128, product fits in u256 -- no overflow possible + if ((a >> 128) == 0 and (b >> 128) == 0) { + return fastDiv(fastMul(a, b), denominator); + } + + // Medium path: if a * b doesn't overflow u256, use direct division + const ov = @mulWithOverflow(a, b); + if (ov[1] == 0) { + return fastDiv(ov[0], denominator); + } + + // 512-bit multiplication using 4 u128 limbs + // a = a_hi * 2^128 + a_lo, b = b_hi * 2^128 + b_lo + const a_lo: u256 = @as(u128, @truncate(a)); + const a_hi: u256 = a >> 128; + const b_lo: u256 = @as(u128, @truncate(b)); + const b_hi: u256 = b >> 128; + + // Partial products (each fits in u256) + const p0 = a_lo * b_lo; // low * low + const p1 = a_lo * b_hi; // low * high + const p2 = a_hi * b_lo; // high * low + const p3 = a_hi * b_hi; // high * high + + // Accumulate into [r_hi:r_lo] (512 bits) + // r_lo = p0 + (lower 128 bits of p1+p2) << 128 + // r_hi = p3 + (upper 128 bits of p1+p2) + carry from r_lo + const mid_sum = @addWithOverflow(p1, p2); + const mid: u256 = mid_sum[0]; + const mid_carry: u256 = @as(u256, mid_sum[1]) << 128; // carry is worth 2^256 + + const mid_lo: u256 = @as(u128, @truncate(mid)); + const mid_hi: u256 = mid >> 128; + + const r_lo_sum = @addWithOverflow(p0, mid_lo << 128); + const r_lo: u256 = r_lo_sum[0]; + const r_lo_carry: u256 = r_lo_sum[1]; + + const r_hi: u256 = p3 +% mid_hi +% mid_carry +% r_lo_carry; + + // Now divide [r_hi:r_lo] by denominator + // If r_hi >= denominator, result overflows u256 + if (r_hi >= denominator) return null; + + // Long division: [r_hi:r_lo] / denominator + if (r_hi == 0) { + return fastDiv(r_lo, denominator); + } + + // Binary long division of 512-bit / 256-bit + var quotient: u256 = 0; + var remainder: u256 = r_hi; + + // Process r_lo from MSB to LSB, 1 bit at a time + var i: u9 = 256; + while (i > 0) { + i -= 1; + // Shift remainder left by 1 and bring in next bit from r_lo + const bit: u256 = (r_lo >> @intCast(i)) & 1; + const shifted = @shlWithOverflow(remainder, 1); + if (shifted[1] != 0 or (shifted[0] | bit) >= denominator) { + remainder = (shifted[0] | bit) -% denominator; + quotient |= @as(u256, 1) << @intCast(i); + } else { + remainder = shifted[0] | bit; + } + } + + return quotient; +} + +/// Q96 constant (2^96) used in UniswapV3/V4 fixed-point arithmetic. +pub const Q96: u256 = @as(u256, 1) << 96; + /// Maximum u256 value. pub const MAX: u256 = std.math.maxInt(u256); @@ -170,3 +396,55 @@ test "safeDiv" { try std.testing.expectEqual(@as(?u256, 2), safeDiv(6, 3)); try std.testing.expectEqual(@as(?u256, null), safeDiv(1, 0)); } + +test "fastDiv u256 large values" { + // Divisor > u128 (exercises Knuth Algorithm D multi-limb path) + const a: u256 = (@as(u256, 1) << 200) + 12345; + const b: u256 = (@as(u256, 1) << 130) + 99; + try std.testing.expectEqual(a / b, fastDiv(a, b)); + + // Divisor fits in u64 (exercises single-limb path) + const c: u256 = (@as(u256, 7_922_816_251_426_433) << 128) | 12345678; + const d: u256 = 1_000_000_007; + try std.testing.expectEqual(c / d, fastDiv(c, d)); + + // Numerator barely larger than divisor + const e: u256 = MAX; + const f: u256 = MAX - 1; + try std.testing.expectEqual(@as(u256, 1), fastDiv(e, f)); + + // Large numerator, 2-limb divisor + const g: u256 = (@as(u256, 1) << 192) | (@as(u256, 1) << 64); + const h: u256 = (@as(u256, 1) << 65) + 3; + try std.testing.expectEqual(g / h, fastDiv(g, h)); +} + +test "mulDiv basic" { + // Simple case: no overflow + try std.testing.expectEqual(@as(?u256, 6), mulDiv(2, 3, 1)); + try std.testing.expectEqual(@as(?u256, 2), mulDiv(6, 1, 3)); + // Divide by zero + try std.testing.expectEqual(@as(?u256, null), mulDiv(1, 1, 0)); +} + +test "mulDiv overflow intermediate" { + // MAX * 2 overflows u256, but MAX * 2 / 2 = MAX + try std.testing.expectEqual(@as(?u256, MAX), mulDiv(MAX, 2, 2)); + // MAX * MAX / MAX = MAX + try std.testing.expectEqual(@as(?u256, MAX), mulDiv(MAX, MAX, MAX)); +} + +test "mulDiv UniswapV4 Q96 style" { + // Simulates sqrtPriceX96 computation: liquidity * sqrtPrice / denominator + const liquidity: u256 = 1_000_000_000_000_000_000; // 1e18 + const sqrt_price: u256 = @as(u256, 79228162514264337593543950336); // ~1.0 in Q96 + const denom: u256 = liquidity + 1_000_000; + const result = mulDiv(liquidity, sqrt_price, denom); + try std.testing.expect(result != null); + try std.testing.expect(result.? > 0); +} + +test "mulDiv result overflow" { + // (MAX * MAX) / 1 overflows u256 + try std.testing.expectEqual(@as(?u256, null), mulDiv(MAX, MAX, 1)); +} From 94693b34e4c4dff910f499589aa8f4ec5a2cc24c Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 13:18:54 -0500 Subject: [PATCH 4/6] Fix review feedback: alloy 512-bit mulDiv, ABI sizing, stack overflow - alloy-bench: use U512 for true 512-bit mulDiv and V4 swap benchmarks instead of wrapping_mul (which truncates and doesn't model overflow) - abi_encode: add staticEncodedSize() helper to correctly size static fixed_array and tuple values (which need items.len * 32, not 32). Fix calcEncodedSize, fast path, and dynamic path to use it. - transaction: replace 512-byte stack buffer with calculateTypedFieldsLength + direct write into final buffer, avoiding overflow on large tx.data - compare.sh: add python3 install URL for consistency --- bench/RESULTS.md | 26 +++++++++--------- bench/alloy-bench/benches/eth_comparison.rs | 17 +++++++----- bench/compare.sh | 2 +- src/abi_encode.zig | 30 +++++++++++++++++---- src/transaction.zig | 9 +++---- 5 files changed, 53 insertions(+), 31 deletions(-) diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 32f016f..a7e8a92 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -1,10 +1,10 @@ # eth.zig vs alloy.rs: Ethereum Library Benchmark Comparison -Pure 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), hex operations, address derivation, and EIP-1559 transaction hashing. +Pure 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 19/26 | alloy.rs wins 5/26 | tied 2/26** -Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo). +Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) vs `--release` (Cargo). Both mulDiv benchmarks use true 512-bit intermediate arithmetic (eth.zig's native `mulDiv`, alloy's `U512` from ruint). ## Full Comparison @@ -20,18 +20,18 @@ Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo) | address_derivation | 135 ns | 190 ns | zig 1.41x | | address_from_hex | 8 ns | 13 ns | zig 1.62x | | checksum_address | 159 ns | 201 ns | zig 1.26x | -| abi_encode_transfer | 31 ns | 29 ns | rs 1.07x | -| abi_encode_static | 26 ns | 50 ns | zig 1.92x | +| abi_encode_transfer | 33 ns | 30 ns | rs 1.10x | +| abi_encode_static | 29 ns | 50 ns | zig 1.72x | | abi_encode_dynamic | 114 ns | 175 ns | zig 1.54x | | abi_decode_uint256 | 22 ns | 26 ns | zig 1.18x | | abi_decode_dynamic | 75 ns | 133 ns | zig 1.77x | -| rlp_encode_eip1559_tx | 41 ns | 38 ns | rs 1.08x | +| rlp_encode_eip1559_tx | 43 ns | 38 ns | rs 1.13x | | rlp_decode_u256 | 3 ns | 6 ns | zig 2.00x | | u256_add | 2 ns | 2 ns | tie | | u256_mul | 2 ns | 5 ns | zig 2.50x | | u256_div | 3 ns | 12 ns | zig 4.00x | -| u256_uniswapv2_amount_out | 40 ns | 13 ns | rs 3.08x | -| u256_mulDiv | 11 ns | 14 ns | zig 1.27x | +| u256_uniswapv2_amount_out | 43 ns | 13 ns | rs 3.31x | +| u256_mulDiv | 12 ns | 17 ns | zig 1.42x | | u256_uniswapv4_swap | 21 ns | 24 ns | zig 1.14x | | hex_encode_32b | 11 ns | 11 ns | tie | | hex_decode_32b | 12 ns | 24 ns | zig 2.00x | @@ -49,10 +49,10 @@ Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo) | Optimization | Impact | |---|---| -| Knuth Algorithm D u64-limb division | mulDiv: 281 ns -> 11 ns (25x), beats alloy | +| Knuth Algorithm D u64-limb division | mulDiv: 281 ns -> 12 ns (23x), beats alloy's 17 ns | | secp256k1 `mulDoubleBasePublic` recovery | sign_recover: 837 us -> 255 us (3.3x) | -| Stack-buffer RLP encoding (single pass) | rlp_encode: 89 ns -> 41 ns (2.2x) | -| ABI static-only fast path | abi_encode_transfer: 71 ns -> 31 ns (2.3x) | +| Stack-buffer RLP encoding (single pass) | rlp_encode: 89 ns -> 43 ns (2.1x) | +| ABI static-only fast path | abi_encode_transfer: 71 ns -> 33 ns (2.2x) | | `fastMul` u128 fast path | u256 compound ops: 2x faster | ## Remaining alloy.rs Wins @@ -61,9 +61,9 @@ Benchmarks run on Apple Silicon with `ReleaseFast` (Zig) and `--release` (Cargo) |---|---|---| | secp256k1_sign | 4.09x | Zig stdlib constant-time EC scalar multiplication vs k256-rs precomputed tables with variable-time ops | | secp256k1_sign_recover | 2.13x | Same as above (improved 3.3x via `mulDoubleBasePublic`) | -| u256_uniswapv2_amount_out | 3.08x | alloy's `ruint` uses hand-optimized 4×u64 limb arithmetic; LLVM's u256 compound ops are slow | -| abi_encode_transfer | 1.07x | alloy's `sol!` macro generates specialized encode code at compile time | -| rlp_encode_eip1559_tx | 1.08x | alloy derive macros produce single-purpose encode code | +| u256_uniswapv2_amount_out | 3.31x | alloy's `ruint` uses hand-optimized 4x u64 limb arithmetic; LLVM's u256 compound ops are slow | +| abi_encode_transfer | 1.10x | alloy's `sol!` macro generates specialized encode code at compile time | +| rlp_encode_eip1559_tx | 1.13x | alloy derive macros produce single-purpose encode code | ## Reproducing diff --git a/bench/alloy-bench/benches/eth_comparison.rs b/bench/alloy-bench/benches/eth_comparison.rs index 0fe8c78..cba5e75 100644 --- a/bench/alloy-bench/benches/eth_comparison.rs +++ b/bench/alloy-bench/benches/eth_comparison.rs @@ -1,7 +1,9 @@ use alloy_bench::*; -use alloy_primitives::{keccak256, Address, U256}; +use alloy_primitives::{keccak256, Address, U256, Uint}; use criterion::{black_box, criterion_group, criterion_main, Criterion}; +type U512 = Uint<512, 8>; + // ================================================================ // 1. Keccak256 benchmarks // ================================================================ @@ -301,9 +303,11 @@ fn bench_u256(c: &mut Criterion) { let sqrt_price = U256::from_limbs([0, 79228162514264337593543950336u128 as u64, (79228162514264337593543950336u128 >> 64) as u64, 0]); let denom = ONE_ETH + U256::from(1_000_000u64); b.iter(|| { - // Use checked_mul to get full-width result, then divide - let product = black_box(liquidity).wrapping_mul(black_box(sqrt_price)); - let result = product / black_box(denom); + // 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); }) }); @@ -317,8 +321,9 @@ fn bench_u256(c: &mut Criterion) { b.iter(|| { let product = black_box(amount_in) * black_box(sqrt_price); let denominator = black_box(liquidity) + product; - let numerator = black_box(liquidity).wrapping_mul(black_box(sqrt_price)); - let next_sqrt_price = numerator / denominator; + // True 512-bit intermediate for numerator + 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); }) }); diff --git a/bench/compare.sh b/bench/compare.sh index d9d533b..3e02dbf 100755 --- a/bench/compare.sh +++ b/bench/compare.sh @@ -8,7 +8,7 @@ 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."; 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 "================================================================" diff --git a/src/abi_encode.zig b/src/abi_encode.zig index 730b235..f90459c 100644 --- a/src/abi_encode.zig +++ b/src/abi_encode.zig @@ -89,12 +89,28 @@ fn dynamicTailSize(val: AbiValue) usize { }; } +/// Calculate the inline encoded size of a static ABI value. +/// Static fixed_arrays and tuples are encoded inline and may exceed 32 bytes. +fn staticEncodedSize(val: AbiValue) usize { + return switch (val) { + .fixed_array, .tuple => |items| { + var size: usize = 0; + for (items) |item| size += staticEncodedSize(item); + return size; + }, + else => 32, + }; +} + /// Calculate the total encoded size of a slice of values (head + tail). fn calcEncodedSize(values: []const AbiValue) usize { - var size: usize = values.len * 32; // head section + var size: usize = 0; for (values) |val| { if (val.isDynamic()) { + size += 32; // offset pointer size += dynamicTailSize(val); + } else { + size += staticEncodedSize(val); } } return size; @@ -308,13 +324,16 @@ fn writeValuesDirect(buf: []u8, values: []const AbiValue) void { var pos: usize = 0; for (values) |val| { writeStaticValueDirect(buf[pos..], val); - pos += 32; + pos += staticEncodedSize(val); } return; } - // Dynamic path: calculate offsets and write heads + tails - const head_size = n * 32; + // Dynamic path: calculate head section size and offsets + var head_size: usize = 0; + for (values) |val| { + head_size += if (val.isDynamic()) 32 else staticEncodedSize(val); + } var tail_offset: usize = head_size; var offsets: [32]usize = undefined; @@ -330,10 +349,11 @@ fn writeValuesDirect(buf: []u8, values: []const AbiValue) void { for (values, 0..) |val, i| { if (val.isDynamic()) { writeU256Direct(buf[pos..][0..32], @intCast(offsets[i])); + pos += 32; } else { writeStaticValueDirect(buf[pos..], val); + pos += staticEncodedSize(val); } - pos += 32; } // Write tails diff --git a/src/transaction.zig b/src/transaction.zig index e06b096..74cbec6 100644 --- a/src/transaction.zig +++ b/src/transaction.zig @@ -349,11 +349,8 @@ fn calculateTypedFieldsLength(tx: anytype) usize { /// Serialize a typed transaction (EIP-2930/1559/4844) for signing. /// Returns: type_byte ++ RLP([fields...]) fn serializeTypedForSigning(allocator: std.mem.Allocator, type_byte: u8, tx: anytype) ![]u8 { - // Write payload to stack buffer first (single traversal), then assemble. - // EIP-1559 TX payloads are always small (< 512 bytes). - var stack_buf: [512]u8 = undefined; - const payload_len = writeTypedFieldsDirect(&stack_buf, tx); - + // Calculate exact payload length, then write directly into final buffer. + const payload_len = calculateTypedFieldsLength(tx); const total = 1 + rlp.lengthPrefixSize(payload_len) + payload_len; const buf = try allocator.alloc(u8, total); errdefer allocator.free(buf); @@ -361,7 +358,7 @@ fn serializeTypedForSigning(allocator: std.mem.Allocator, type_byte: u8, tx: any buf[0] = type_byte; var pos: usize = 1; pos += rlp.writeLengthDirect(buf[pos..], payload_len, 0xc0); - @memcpy(buf[pos..][0..payload_len], stack_buf[0..payload_len]); + _ = writeTypedFieldsDirect(buf[pos..], tx); return buf[0..total]; } From 91d3f7ba77c31354ec842b88692c2e603e479f80 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 13:22:39 -0500 Subject: [PATCH 5/6] Add division-by-zero guard to fastDiv fastDiv is pub, so external callers could pass b=0. Previously fastDiv(0,0) returned 1 via the a==b branch, and fastDiv(n,0) with n fitting in u128 would hit undefined behavior. Now panics on b==0, matching Zig's built-in division semantics. --- src/uint256.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uint256.zig b/src/uint256.zig index 5ecd15f..f624e4e 100644 --- a/src/uint256.zig +++ b/src/uint256.zig @@ -81,6 +81,7 @@ pub fn safeDiv(a: u256, b: u256) ?u256 { /// Avoids LLVM's slow generic u256 runtime library calls (~280ns) /// by using native u64/u128 operations (~10-30ns). pub fn fastDiv(a: u256, b: u256) u256 { + if (b == 0) @panic("division by zero"); // Both fit in u128 - use LLVM's native 128-bit division if ((a >> 128) == 0 and (b >> 128) == 0) { return @as(u128, @truncate(a)) / @as(u128, @truncate(b)); From a67ca56c5bdf5715d0f9894cb836735e93abc4c9 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Thu, 26 Feb 2026 13:27:24 -0500 Subject: [PATCH 6/6] Document non-overflow assumption in V4 swap benchmark The benchmark values (amount_in=1e15, sqrt_price~7.9e28, liquidity=1e18) are chosen so product and denominator fit in u256 without overflow, matching the hot path for typical pool parameters. --- bench/alloy-bench/benches/eth_comparison.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bench/alloy-bench/benches/eth_comparison.rs b/bench/alloy-bench/benches/eth_comparison.rs index cba5e75..81577fe 100644 --- a/bench/alloy-bench/benches/eth_comparison.rs +++ b/bench/alloy-bench/benches/eth_comparison.rs @@ -312,7 +312,11 @@ fn bench_u256(c: &mut Criterion) { }) }); - // UniswapV4 getNextSqrtPriceFromAmount0RoundingUp + // 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; let sqrt_price = U256::from_limbs([0, 79228162514264337593543950336u128 as u64, (79228162514264337593543950336u128 >> 64) as u64, 0]); @@ -321,7 +325,7 @@ fn bench_u256(c: &mut Criterion) { b.iter(|| { let product = black_box(amount_in) * black_box(sqrt_price); let denominator = black_box(liquidity) + product; - // True 512-bit intermediate for numerator + // 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);