eevm's benchmark suite uses Benchee. Run it from the repository root:

mix bench                  # every suite in bench/*_bench.exs except state_fixtures
mix bench interpreter      # only bench/interpreter_bench.exs
mix bench precompiles      # only bench/precompiles_bench.exs
mix bench ecdsa            # only bench/ecdsa_bench.exs
mix bench state_fixtures   # the slow ethereum/tests fixtures (needs the submodule)

mix bench always runs with MIX_ENV=prod, the build users run. Before timing, each suite runs every job once and checks the result, so a broken workload fails instead of benchmarking an error path. After Benchee's own report, each suite prints a gas-throughput table: gas per run, runs per second (ips), and Mgas/s (gas × ips).

Workloads

bench/interpreter_bench.exs runs complete programs through EEVM.Interpreter.run/2, plus one transaction through EEVM.Handler.execute/4. The programs are written with the small assembler in bench/support/asm.exs.

ScenarioWhat it runs
compute: arithmetic loop (50k)ADD/MUL/AND on a stack accumulator with DUP/SWAP/POP and a JUMPI loop
compute: jump loop (100k)The smallest loop body: decrement and JUMPI
memory: MSTORE/MLOAD 32 KiBMSTORE then MLOAD at 1,024 word offsets, growing memory to 32 KiB
memory: 1k MSTOREs over 1 MiB1,024 MSTOREs spaced 1 KiB apart, growing memory to 1 MiB
memory: CALLDATACOPY+MCOPY+RETURN 32 KiBCopies 32 KiB of calldata into memory, MCOPYs it, RETURNs 64 KiB
keccak: 5k x 32 BKECCAK256 of a fresh 32-byte word, 5,000 times
keccak: 64 KiBOne KECCAK256 over 64 KiB of memory
storage: SSTORE/SLOAD 500 slotsCold SSTORE then warm SLOAD of 500 slots
calls: STATICCALL loop (2k)2,000 STATICCALLs to a contract that SLOADs and MSTOREs (stStaticCall/static_Call50000-style)
calls: CALL 100 distinct 24 KB contracts that STOP100 CALLs, each to a different contract of the EIP-170 maximum size that stops at its first byte
calls: CALL 100 distinct 24 KB contracts that JUMP onceThe same, but each contract first jumps to a JUMPDEST at its end
erc20: transfer via HandlerA signed legacy transfer(address,uint256) with two SLOAD/SSTOREs and a LOG3

bench/precompiles_bench.exs calls EEVM.Precompiles.execute/4 directly with valid inputs: ecrecover, sha256, ripemd160, and identity (1 KiB), a 2048-bit ModExp, BN256 ecAdd, ecMul with a random 256-bit scalar, a two-pair BN256 pairing, BLAKE2F at 12 and 1M rounds, KZG point evaluation, and every BLS12-381 precompile: G1/G2 ADD, MSM with 1 and 128 pairs, 1- and 2-pair pairings, and both maps to the curve.

bench/ecdsa_bench.exs times EEVM.Crypto.ECDSA on its own: secp256k1 recovery (ECRECOVER and every signed transaction), secp256k1 signing, and P-256 verification, plus the P256VERIFY precompile (0x100), which is Osaka-only and so not in the precompiles suite.

bench/state_fixtures_bench.exs times the ethereum/tests GeneralStateTests fixtures that test/fixtures/state_tests/skip.txt skipped as too slow for the conformance runner: the stQuadraticComplexityTest and stStaticCall 50,000-call and 50,000-return loops, static_Call1MB1024Calldepth, and VMTests/vmPerformance/loopMul, a 10-million-iteration Solidity loop. It needs the ethereum-tests submodule. Their cases run for seconds to minutes, too long for Benchee's repeated sampling, so the suite runs each fixture's heaviest Prague case once, checks its post-state root, and prints gas used, seconds, and Mgas/s. Cases that end in an exceptional halt burn their remaining gas at once, so for those compare seconds, not Mgas/s. The BLAKE2F fixtures, also skipped, measure the precompile instead (#171).

Profiling

bench/profile.exs runs a 20k-iteration arithmetic loop for profiling:

MIX_ENV=prod mix profile.tprof --warmup --sort time bench/profile.exs

Baseline

Measured at c8e3c45 on an Amp orb: Intel Xeon @ 2.60 GHz, 4 vCPUs, Elixir 1.18.3, OTP 27, MIX_ENV=prod. Expect about ±10% between runs on shared hardware.

Interpreter

Scenariogas/runipsMgas/sMemory/run
compute: arithmetic loop (50k)2,700,0062.286.15754 MB
compute: jump loop (100k)2,600,0032.747.12621 MB
memory: MSTORE/MLOAD 32 KiB61,45041.62.5633.2 MB
memory: CALLDATACOPY+MCOPY+RETURN 32 KiB20,51021.10.4338.3 MB
keccak: 5k x 32 B395,00617.16.7581.2 MB
keccak: 64 KiB26,66049313.11.01 MB
storage: SSTORE/SLOAD 500 slots11,118,5032432,7056.88 MB
calls: STATICCALL loop (2k)520,50324.712.965.0 MB
erc20: transfer via Handler50,8493,2401650.11 MB

The storage scenario's Mgas/s is high because cold SSTOREs are priced for disk access, which an in-memory database doesn't pay. Compare the compute scenarios with other EVMs.

Precompiles

Precompilegas/runipsMgas/s
ecrecover (0x01)3,0004,54813.6
sha256 1 KiB (0x02)444573,514255
ripemd160 1 KiB (0x03)4,440210,151933
identity 1 KiB (0x04)1112,347,937261
modexp 2048-bit (0x05), before #188698,368145101
modexp 2048-bit (0x05)698,368481336
bn256 add (0x06), before #18215011,5971.74
bn256 add (0x06)150175,44826.3
bn256 mul (0x07), before #1826,00068.00.41
bn256 mul (0x07)6,0001,0286.17
bn256 pairing, 2 pairs (0x08), before #181113,0000.410.05
bn256 pairing, 2 pairs (0x08)113,00011913.5
blake2f 12 rounds (0x09)1254,8680.66
blake2f 1M rounds (0x09)1,000,0002.402.40
bls12-381 pairing, 1 pair (0x0F)70,30055739.2

The BN256 and BLAKE2F rows were measured later, on the same kind of orb: after #169 and #171 were fixed, and before and after #181 and #182 replaced the bn library's pairing, ecAdd, and ecMul with EEVM.Precompiles.BN256.Curve.

The ModExp job's random modulus is even. OpenSSL's BN_mod_exp, behind :crypto.mod_pow/3, uses Montgomery multiplication only for odd moduli, so even ones took about 7 ms against 2.1 ms for odd ones until #188 split them as q·2^k and recombined the results by CRT. Odd moduli take the same path as before. A pure-Elixir square-and-multiply was 3-9x slower than OpenSSL at 256-4096 bits.

Hot spots

tprof on bench/profile.exs (320k opcodes) at the baseline attributed the time like this:

ShareWhereWhy
10.8%MachineState.update_frame/2920k calls, ~3 per opcode: gas, stack, and pc are each updated through a separate closure
9.5% + 6.1%Interpreter.run_loop/1, execute_opcode/2The dispatch loop itself
~8%EEVM.Interpreter.Instructions.Registry.info/1, String.CharsPUSH/DUP/SWAP metadata is rebuilt on every call, including "PUSH#{n}" name strings (180k to_string calls)
6.6%ControlFlow.execute/2PUSH decodes its immediate through bin_to_list + Enum.reduce
~7%Enum.at, Enum.slice, List.replace_atStack.peek/2 and Stack.swap/2 walk the list through generic Enum

The memory scenarios were dominated by EEVM.Interpreter.Memory, which stored one map entry per byte (#157).

After the first optimisations

The per-fork instruction table (6d891f8) took the Registry lookup off the hot path. Two changes then addressed the other hot spots: cheaper PUSH decoding, direct frame updates for gas and pc, and list-native stack peek/swap ("hot path" below); then memory stored as 4 KiB binary chunks (#157). Measured back to back from the same base commit (339e0f0), same machine and settings, ips at each step (expect ±10% noise between runs):

Scenario339e0f0hot pathchunked memoryMgas/sMemory/run
compute: arithmetic loop (50k)2.803.112.907.57 → 7.83637 → 586 MB
compute: jump loop (100k)3.313.903.658.61 → 9.48518 → 462 MB
memory: MSTORE/MLOAD 32 KiB44.644.71192.74 → 7.3130.4 → 13.5 MB
memory: 1k MSTOREs over 1 MiB57.856.3125129 → 28125.6 → 9.6 MB
memory: CALLDATACOPY+MCOPY+RETURN 32 KiB19.422.453,0600.40 → 1,08839.0 MB → 21 KB
keccak: 5k x 32 B19.621.325.17.73 → 9.9171.0 → 54.7 MB
keccak: 64 KiB4664564,72212.4 → 1261.01 MB → 6.4 KB
storage: SSTORE/SLOAD 500 slots2572992792,855 → 3,1006.03 → 5.54 MB
calls: STATICCALL loop (2k)27.527.335.414.3 → 18.450.0 → 41.1 MB
erc20: transfer via Handler3,2883,3183,472166 → 175105 → 74 KB

Precompiles are unchanged; they don't run through the interpreter.

Slow state-test fixtures

mix bench state_fixtures, heaviest Prague case of each fixture, one run. Before is be1ad18, capped at 120 s; after is 339e0f0 plus this series. be1ad18 still zero-filled a call's whole output area (fixed upstream in 85ed58a), so static_Call50000_identity2 ran out of gas there; it passes now.

Fixturegas usedbe1ad18after
stQuadraticComplexityTest/Call50000250,000,000107 s0.75 s
stQuadraticComplexityTest/Call50000_identity2250,000,000> 120 s0.11 s
stQuadraticComplexityTest/Callcode50000250,000,000106 s0.71 s
stQuadraticComplexityTest/QuadraticComplexitySolidity_CallDataCopy250,000,000> 120 s0.59 s
stQuadraticComplexityTest/Return5000014,777,099> 120 s1.13 s
stQuadraticComplexityTest/Return50000_215,377,099> 120 s1.25 s
stStaticCall/static_Call1MB1024Calldepth855,137,385,7700.18 s< 0.01 s
stStaticCall/static_Call500005,014,061,098> 120 s1.06 s
stStaticCall/static_Call50000_ecrec39,058,598> 120 s0.79 s
stStaticCall/static_Call50000_identity86,895,919> 120 s0.79 s
stStaticCall/static_Call50000_identity292,280,716> 120 s0.82 s
stStaticCall/static_Call50000_rip1603,863,696,700> 120 s0.83 s
stStaticCall/static_Return50000_215,273,928> 120 s1.40 s
stTimeConsuming/static_Call50000_sha256954,878,498> 120 s3.06 s
VMTests/vmPerformance/loopMul2,470,045,378est. 345 s308 s

Several cases end out of gas by design, which burns the remaining gas at once, so gas used overstates the work done; compare seconds. The 50,000-call loops were slow because every call copied its input and output through the byte-per-entry memory map; all their cases now run in the conformance suite. loopMul barely got faster (7.3–8.2 Mgas/s before and 7.9–8.1 after at 100k iterations, from which its before time is estimated). It is a tight Solidity loop, bounded by the dispatch loop itself, and stays skipped.

Profile after the changes

On loopMul (20k iterations, 6.9M opcodes) the time is spread across the dispatch loop: run_loop/1 15%, ControlFlow.execute/2 11%, MachineState.consume_gas/2 8%, execute_opcode/2 7%, current_opcode/1 + :binary.at/2 13%, MachineState.update_frame/2 6% (the stack update most opcodes still make), and advance_pc 7%. The compute loops still allocate about 770 bytes per opcode, because each step rebuilds the frame and machine-state structs. The next gains need a structural change, for example keeping pc, gas, and the stack in loop arguments instead of the MachineState struct, and decoding the code once into a tuple.

The register loop

#193 made that structural change. EEVM.Interpreter's loop keeps pc, gas, and the stack (a list) in its own arguments and writes them back into the frame only when an opcode needs the whole MachineState: calls, storage, memory, logs, halts. Pure stack opcodes and PUSH, DUP, SWAP, JUMP, JUMPI, PC, GAS, and JUMPDEST never touch the structs. Three smaller changes followed the profile: one list pattern per DUPn and SWAPn instead of :lists.nth/2 and :lists.split/2, and a gas register kept below 2^59, so the 2^63 - 1 gas that state tests give a transaction no longer makes every subtraction allocate a bignum.

mix bench interpreter, f35ac13 against the four #193 commits on top of it, run alternately twice each on the same orb. Mgas/s is the mean of the two runs.

ScenarioMgas/s beforeMgas/s afterSpeedupMemory/run
compute: arithmetic loop (50k)8.7156.16.4x588 → 88.1 MB
compute: jump loop (100k)9.7465.16.7x482 → 67.1 MB
memory: MSTORE/MLOAD 32 KiB7.0619.72.8x13.9 → 4.94 MB
memory: 1k MSTOREs over 1 MiB2797162.6x9.88 → 3.11 MB
memory: CALLDATACOPY+MCOPY+RETURN 32 KiB1,1161,2541.1x20.8 → 17.1 KB
keccak: 5k x 32 B10.721.12.0x56.0 → 22.6 MB
keccak: 64 KiB1221231.0x6.6 → 6.1 KB
storage: SSTORE/SLOAD 500 slots2,8465,2801.9x5.77 → 2.81 MB
calls: STATICCALL loop (2k)17.926.11.5x43.1 → 27.1 MB
erc20: transfer via Handler1651691.0x83 → 65 KB

The compute loops now allocate about 110 bytes per opcode, down from about 735: the new stack cell and, for JUMP and JUMPI, the result tuple of the JUMPDEST check. keccak 64 KiB and the ERC-20 transfer are dominated by hashing and transaction setup, which this did not touch.

loopMul's heaviest case (loopMul@prague:d1g0v0, about 1.9 billion opcodes) takes 142 s in mix eevm.state_tests, down from 308 s. That is still past the 60 s the skip list allows and too close to CI's 180 s per-case timeout, so it stays skipped (#208). What remains is the dispatch itself: loop/7 and step/8 take 60% of its time under tprof, and the tracer hook's call another 16%.

The decoded program

#208 took the rest of the dispatch cost out of the loop. EEVM.Interpreter.InstructionTable.decode/3 turns a contract's code into a program once per transaction: a tuple with one instruction per code byte, PUSH values already read, PUSH data marked so jumps cannot land in it, and PUSH+JUMP(I) pairs fused into one instruction. The loop fetches elem(program, pc), checks gas and stack bounds from the instruction, and dispatches on its opcode. Traced runs decode a program whose instructions are wrapped for the tracer, so untraced runs make no tracer check.

Two details of the loop's shape carried most of the gain, both measured on loopMul: matching the instruction in a case inside loop/6 rather than in a separate function head (a 3x difference), and giving step/8 the loop's own arguments first so the calls between them move no registers (12%). Fusing PUSH+JUMP(I) adds 2-5%.

mix bench interpreter, d0164ce against the series, run alternately twice each on the same orb, MIX_ENV=prod. Mgas/s is the mean of the two runs. The baseline's ERC-20 and keccak rows are lower than in the table above because Keccak-256 and ECDSA became pure Elixir in between.

ScenarioMgas/s beforeMgas/s afterSpeedupMemory/run
compute: arithmetic loop (50k)54.61522.8x88.1 → 11.5 MB
compute: jump loop (100k)64.72023.1x67.1 → 7.63 MB
memory: MSTORE/MLOAD 32 KiB20.527.41.3x4.94 → 3.37 MB
memory: 1k MSTOREs over 1 MiB8141,0651.3x3.11 → 2.06 MB
memory: CALLDATACOPY+MCOPY+RETURN 32 KiB1,2511,2361.0x17.1 → 17.7 KB
keccak: 5k x 32 B6.266.451.0x27.4 → 21.3 MB
keccak: 64 KiB6.016.001.0x0.23 → 0.23 MB
storage: SSTORE/SLOAD 500 slots5,1326,0691.2x2.81 → 2.30 MB
calls: STATICCALL loop (2k)28.833.71.2x27.4 → 24.3 MB
erc20: transfer via Handler33.934.71.0x110 → 110 KB

The compute loops now allocate about two words per opcode, the stack's new cons cell, down from about 110 bytes.

loopMul's heaviest case (loopMul@prague:d1g0v0) takes 36 s on its own in mix eevm.state_tests --jobs 1, down from 142 s, and 38 s in the full GeneralStateTests run, so it is off the skip list.

Decoding is not free. A program for 24 KB of code takes about 0.5 ms to build, and the garbage collector copies the new tuple until it is promoted, where #187's JUMPDEST analysis took about 0.3 ms on a frame's first jump. A contract that loops pays that back quickly, but a short run of a large contract now costs up to about 0.9 ms more per transaction (#210). Times for EEVM.Interpreter.run/2 on loopMul's contract, alone and copied to 24 KB, MIX_ENV=prod, median of 51 runs, averaged over separate runs:

Rund0164ceafter
1 KB contract, 1 loop iteration66 µs62 µs
1 KB contract, 100 loop iterations1,266 µs443 µs
24 KB contract, 1 loop iteration359 µs1,225 µs
24 KB contract that STOPs at once6 µs557 µs

Decoding only code that loops

#210 removed that cost. A frame now decodes its code only once decoding pays for itself. Until then it runs the code undecoded: the loop reads each instruction straight from the bytes (EEVM.Interpreter.InstructionTable.fetch/3), at about 50 Mgas/s against 170 decoded, and checks jumps against the code's JUMPDEST map, which takes about a sixth of the time decoding does to build. The first jump after the frame has used 3 gas per code byte decodes the code. By then, running undecoded has cost about as much extra time as decoding would, so either way a frame spends at most about twice what the better choice would have, and the time decoding can add stays below about 12 ns per gas. The gas paid to reach the code counts too: 21,000 for a transaction's own code, and 2,600 (a cold account, EIP-2929) for a callee's. So a transaction's code up to about 7 KB and a callee's up to about 870 bytes still decode at once.

The JUMPDEST map also got 2.5x faster on Solidity code: it now grows at each JUMPDEST rather than once per byte.

EEVM.Interpreter.run/2 on the same contracts as above, plus 24 KB of code that jumps once, to a JUMPDEST at its end. MIX_ENV=prod, median of 201 runs (501 to 1,001 for the 1 KB rows, each in a fresh process):

Rund0164cecdbd45d (#208)after
1 KB contract, 1 loop iteration62 µs48 µs54 µs
1 KB contract, 100 loop iterations1,267 µs445 µs446 µs
24 KB contract, 1 loop iteration353 µs1,170 µs215 µs
24 KB contract that STOPs at once5 µs530 µs6 µs
24 KB contract that jumps once262 µs1,299 µs110 µs

mix bench interpreter, cdbd45d against this change, MIX_ENV=prod, Mgas/s as the mean of six runs before and two after:

ScenarioMgas/s beforeMgas/s afterSpeedupMemory/run
calls: CALL 100 distinct 24 KB contracts that STOP0.65228349x150 → 1.0 MB
calls: CALL 100 distinct 24 KB contracts that JUMP once0.6218.330x150 → 1.1 MB

Every other scenario, and its memory per run, is unchanged within the ±5% these runs vary by. With 100 decoded 24 KB programs alive at once, cdbd45d spent most of each call copying them in the garbage collector, about 4 ms per call that stops at once. Now such a call takes about 12 µs, and one that jumps once about 150 µs, most of it building the JUMPDEST map.

Pure-Elixir BLS12-381, KZG, and ModExp

#199 replaced the last native code in the precompiles. The eevm_bls Rust NIF (bls12_381_plus and blst) backed the EIP-2537 precompiles and KZG point evaluation, and OpenSSL's :crypto.mod_pow/3 backed ModExp. Both are now Elixir: EEVM.Precompiles.Bls12381.Curve and a fixed-window square-and-multiply. Before the NIF was deleted, a scratch script ran each workload through both implementations: same inputs (random points and scalars from a fixed seed, c-kzg-4844's correct_proof_2_2 for KZG), the same orb, MIX_ENV=prod, and Osaka gas. "native" is the NIF (or the previous :crypto ModExp); ips are calls per second.

Precompilegasnative ipsnative Mgas/sElixir ipsElixir Mgas/sElixir / native
kzg point evaluation (0x0A)50,0001869.3259.92.990.32×
g1add (0x0B)37521,7078.1412,8604.820.59×
g1msm, 1 pair (0x0C)12,0001,78321.44295.150.24×
g1msm, 2 pairs (0x0C)22,77693221.22084.740.22×
g1msm, 8 pairs (0x0C)69,88824317.072.65.070.30×
g1msm, 32 pairs (0x0C)240,76859.414.321.15.090.36×
g1msm, 128 pairs (0x0C)797,18415.011.97.105.660.47×
g2add (0x0D)60018,08410.910,2276.140.57×
g2msm, 1 pair (0x0E)22,50066114.92355.290.36×
g2msm, 2 pairs (0x0E)45,00033014.897.84.400.30×
g2msm, 8 pairs (0x0E)143,28085.912.334.95.000.41×
g2msm, 32 pairs (0x0E)465,12022.010.211.15.150.50×
g2msm, 128 pairs (0x0E)1,509,1205.428.183.655.510.67×
pairing, 1 pair (0x0F)70,30056839.991.26.410.16×
pairing, 2 pairs (0x0F)102,90028529.365.36.720.23×
pairing, 8 pairs (0x0F)298,50069.020.625.27.530.37×
map fp to g1 (0x10)5,50015,76686.71,1936.560.08×
map fp2 to g2 (0x11)23,8004,9841193768.940.08×
modexp 2048-bit, even modulus (0x05)7,862,2724843,80787.26850.18×
modexp 2048-bit, odd modulus (0x05)7,862,2724663,66668.75400.15×
modexp 4096-bit, odd modulus (0x05)65,003,52065.64,26111.07140.17×
modexp 256-bit, odd modulus (0x05)4,08033,11613510,82944.20.33×

Every EIP-2537 workload runs at 4.4-9 Mgas/s in Elixir, about the interpreter's own throughput on compute loops before #193 (6-9 Mgas/s, above), so no input to those precompiles is much cheaper per gas than ordinary bytecode, though all are slower than native. KZG point evaluation is the lowest at 3.0 Mgas/s, and the native version (9.3 Mgas/s) was lowest too: of its 16.7 ms, the two-pair pairing check takes 12.7 ms (about 6.5 ms of final exponentiation and 3.1 ms of Miller loop per pair), the three-term MSM 2.6 ms, and decompression and the two subgroup checks about 1.8 ms.

The furthest below native are:

  • map-to-curve (0.08×; blst's SSWU and square roots are heavily optimised);
  • the one-pair pairing (0.16×);
  • single-point G1 MSM (0.24×);
  • KZG (0.32×).

Large MSMs come closest (0.5-0.7×), because Pippenger's bucket method amortises the work. ModExp drops to 0.15-0.33× of OpenSSL but stays far above the interpreter's Mgas/s. The NIF itself was not native-fast: bls12_381_plus multiplies with constant-time double-and-add, so a G1 multiplication took 0.56 ms.

mix bench precompiles now includes the BLS12-381 and KZG workloads.

Faster pure-Elixir ECDSA

#197 replaced ex_secp256k1 and :crypto.verify/5 with EEVM.Crypto.ECDSA, and recovering a transaction sender went from about 0.2-0.35 ms to 1.2 ms. #209 cuts the number of field operations per scalar multiplication:

  • GLV on secp256k1: the endomorphism φ(x, y) = (β·x, y) = λ·P splits each 256-bit scalar into two halves of about 128 bits, so the doubling chain is half as long.
  • Width-w NAF: signed odd digits, so about one bit in six (w = 5) or nine (w = 8) costs an addition, and tables hold only odd multiples.
  • A compile-time table of the base point's odd multiples G … 127G (and their φ images), with Z = 1, so each addition uses the cheaper mixed formula.
  • Shamir's trick, as before: u₁·G + u₂·Q (up to four GLV halves) shares one doubling chain.
  • P-256's a = −3 doubling, 3(X − Z²)(X + Z²), and a 4-bit window for the square root in recovery.

Before (2e1bedf) and after (the same base plus this change, so before the interpreter work that follows 2e1bedf), same orb, MIX_ENV=prod, two alternating rounds each (mix bench ecdsa and mix bench interpreter). ips are calls per second; native is ex_secp256k1 or :crypto as #197 measured it.

Workloadnativebeforeafterafter / before
secp256k1 recover0.18-0.35 ms1.27 / 1.19 ms0.71 / 0.73 ms1.7×
secp256k1 sign0.18-0.25 ms0.97 / 0.92 ms0.48 / 0.54 ms1.9×
P-256 verify0.10-0.17 ms1.14 / 1.15 ms0.86 / 0.86 ms1.3×
ecrecover (0x01)2,704 / 4,792 ips730 / 748 ips1,336 / 1,358 ips1.8×
p256verify (0x100)924 / 926 ips1,230 / 1,193 ips1.3×
erc20: transfer via Handler3,220 / 1,595 ips633 / 667 ips1,070 / 951 ips1.55×

Recovery stays 2-4× slower than libsecp256k1. P-256 gains least: it has no endomorphism, so its doubling chain stays 256 long. The six conformance ratchets went from 767 s to 705 s on the orb, mostly in the blockchain suites, which recover one sender per transaction (EEST blockchain 259 → 232 s, Osaka blockchain 283 → 251 s). Before #197 they took about 597 s on the same orb, so this wins back about a third of the 170 s that pure-Elixir recovery added.

Tried and not kept: a special-form reduction for p = 2²⁵⁶ − 0x1000003D1 was no faster than rem/2 on the bignum product (#209), and the profile now splits mostly between double/2 (about 23%) and add/3 (about 28%), each a handful of 256-bit rem calls.