Skip to content

feat: pairing-protocol port — bn254 ecPairing + BLS12-381/KZG on OpenVM's HintFinalExp - #675

Open
yi-sun wants to merge 3 commits into
feat/zilkworm-benchmarkfrom
feat/zilkworm-pairing
Open

feat: pairing-protocol port — bn254 ecPairing + BLS12-381/KZG on OpenVM's HintFinalExp#675
yi-sun wants to merge 3 commits into
feat/zilkworm-benchmarkfrom
feat/zilkworm-pairing

Conversation

@yi-sun

@yi-sun yi-sun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #673 (feat/zilkworm-benchmark) — review that first; this PR's diff against it is the pairing work plus the guest optimization pass that followed it.

Summary

Two things land here.

1. The pairing protocol port, so the two pairing-based precompiles stop paying for a software final exponentiation:

  • bn254 ecPairing (0x08) — was: Miller loop + full final exponentiation in evmone C++, with only the field ops accelerated. Now: OpenVM's HintFinalExp residue-witness protocol (host computes (c, u) out of circuit; the guest verifies f · c^-{6x+2} · u · c^-{q³-q²+q} == 1 per Theorem 3 of eprint 2024/640), which removes the final exponentiation entirely.
  • KZG point evaluation (0x0a) — was: blst compiled as portable C, entirely unaccelerated. Now: compressed-G1 parsing with the eprint 2021/1130 §6 subgroup check, G1 ops on the native short-Weierstrass instructions, G2 arithmetic over the Fp2 instructions, and a 2-pair bls12-381 pairing_check on the same hint protocol (gnark's f · s == c^q variant).

Both keep a square-and-multiply f^FINAL_EXPONENT fallback, so a dishonest hint degrades to the slow-but-correct path rather than a wrong answer — matching the Rust guest lib's exp_check_fallback.

2. A guest optimization pass driven by the observation that Zilkworm was behind the Reth guest on the same block. It is now ahead. On mainnet block 24001988, measured locally with one host binary throughout (see "Measurement" below):

step instructions segments
this PR's pairing work alone 854,294,530 79
misaligned accesses + -mtune=generic-ooo + OpenVM-specific memcpy 603,344,555 63
keccak XORIN on the sponge absorb 598,930,293 63
Int256 for intx::uint256 (EVM stack arithmetic) 586,804,615 62
secp256k1 base-field arithmetic on the modular instructions 582,965,489 62
hint-based square root for point decompression 581,058,419 62

Reth on the same block, for reference: 664,702,189 instructions / 74 segments.

End to end on CI, prove-stark on block 24001988 (run 30140506209): 62 segments, 60.13s total proof time, against Reth's 74 segments / 69.62s — about 14% faster.

Implementation

Guest side lives on feat/openvm-pairing, shipped as patches applied to the fetched tree at build time.

Pairing

  • openvm_pairing.{hpp,cpp} — Fp2 via the complex-extension instructions; software Fp6/Fp12 tower in the sextic w-basis, matching the Rust SexticExtField memory layout (and therefore the hint-stream layout byte for byte); the shared multi-Miller loop with per-curve pseudo-binary encodings, embedded exponent, and D-type (013) / M-type (023) sparse line multiplication; HintFinalExp phantom at custom-1 funct3=0b011, funct7 = PAIRING_IDX*16 with (ptr, len) fat-pointer operands.
  • openvm_pairing_constants.hpp — Frobenius tables, XI, final exponents, pseudo-binary encodings, bls12-381 generators/τ·G2/β/sqrt-exponent, extracted mechanically from the openvm sources and blst's Montgomery limbs (τ·G2 converted out of Montgomery form).
  • Host: openvm.toml gains bls12-381 Fp/Fr, Fp2, G1 and [app_vm_config.pairing] supported_curves = ["Bn254", "Bls12_381"].

Optimization pass

  • Misaligned accesses. OpenVM's RV64 load/store adapters resolve an arbitrary byte offset inside the 8-byte block, and a block-crossing access, in one instruction — a misaligned ld/sd costs what an aligned one costs. -mno-strict-align alone changes nothing, because GCC's cost model still avoids unaligned accesses; -mtune=generic-ooo is what makes it emit them.
  • memcpy. Both SP1's and OpenVM's own memcpy spend their structure dodging unaligned accesses — an alignment preamble, then shift-merge loops that reassemble each word. None of that is needed here. The replacement is a 32-byte unrolled body plus a branch-free descending size dispatch, so the short copies that dominate (hashes, MPT node fragments, pointers) resolve in one or two wide accesses. The size dispatch is the load-bearing part: an earlier attempt that kept a byte-loop tail cost +193M instructions, because small copies always landed in it.
  • XORIN. keccak's absorb was xoring the rate into the state a byte at a time.
  • Int256. intx::uint256's + - * | & ^ and unsigned < now issue one instruction instead of 4-limb carry chains, matching the acceleration the Rust guest gets through revm's U256. Guarded on std::is_constant_evaluated() so constant folding is unaffected.
  • secp256k1 field arithmetic. evmone's ModArith was templated so ecrecover's base- and scalar-field work runs on the modular instructions, not just bn254's.
  • Point decompression. field_sqrt now asks the host for the root and verifies it (reduced, and squares back to the input). A negative answer is not trusted — it falls through to the software addition chain — so a dishonest hint cannot produce a wrong recovery.
  • modexp (0x05). When the caller's modulus is one the VM was configured with, square-and-multiply runs directly on MulMod. That set is narrow but covers what contracts actually use modexp for: a field inversion a^(N-2) over the bn254 or secp256k1 primes. The base is reduced via AddMod(base, 0) (the chip's quotient witness is sized for any 32-byte limb value, so that is a real reduction), and the result is pinned with IsEqMod against itself, whose AIR constrains both operands to be below the modulus — without that a dishonest prover could return N + r as returndata. Anything else falls through to the software path unchanged.

Validation

The self-test (Z6M_ECC_SELFTEST) runs inside the VM before block validation and halts the guest on any mismatch, so a green run is a real assertion. It covers bn254 G+G / [7]G / G+(−G)=∞, the pairing identity e(G1,G2)·e(−G1,G2)=1, a bilinearity check on non-generator inputs, a negative single-pair check, a real EIP-4844 consensus KZG vector (verify_kzg_proof_case_correct_proof_26b753dec0560daa, non-infinity proof, non-zero y) plus a corrupted-y negative case, and four modexp cases (configured modulus, a padded 40-byte modulus, a modulus the VM does not carry, and a zero exponent).

  • prove-app on the self-test guest passes. This is the check that matters — it is what caught the Fp2-setup bug earlier in this stack, where the guest executed fine but failed constraint verification.
  • Mainnet block 24001988 byte-exact against the expected success output; mock vector byte-exact.
  • The accelerated guest reproduces the native, entirely unaccelerated x86 build case for case on the EEST stateless pairs that touch this work: 0/129 on the ecrecover/ecadd/ecmul/ecpairing/point-eval set and 37/175 on the modexp set — the same numbers, the same cases, from make conformance-native. Those pairs have a large standing failure baseline (the post-state root is byte-identical; the SSZ success flag is not), which predates and is independent of the acceleration. The point of the comparison is that acceleration changes nothing about it.
  • The 175 modexp fixtures also give identical results with the fast path compiled out, and a temporary canary confirmed the fast path is the code actually executing rather than silently falling through.

Measurement

Segment count is the proxy for proving time, and segmentation depends on a trace-cell budget that differs by host build — the same guest ELF gave 79 segments in CI and 119 locally. Every number in the table above was therefore produced by one local host binary in --mode execute-metered against the same input, and CI numbers are only ever compared against other CI numbers.

Notes

  • MAX_PAIRS-style fixed buffers were deliberately avoided: ecPairing input length is only gas-bounded, so the loop scratch is heap-allocated per call.
  • bls12-381 G2 arithmetic (used only for KZG's [τ−z]G2) is software over the Fp2 instructions — OpenVM has no G2 curve extension; G1 uses the native instructions.
  • map_fp_to_g1 / map_fp2_to_g2 (0x10/0x11) remain unaccelerated, matching the reth guest.
  • LTO was tried and is a measured regression (602.0M / 63 vs 586.8M / 62); the toolchain file records why.
  • Accelerating intx::umul (256×256→512) with Int256 was tried and reverted: evmone's generic modexp works on uint64_t limbs through mul_amm_256 and never reaches intx::uint<256>, so it bought −50 instructions on a modexp-heavy workload while costing +33k on the block through codegen perturbation alone.

yi-sun added 3 commits July 25, 2026 00:24
…nfig

Adds the bls12-381 moduli (Fp, Fr), Fp2, G1 curve and the pairing
extension to the guest VM config, matching the pairing-protocol port on
zilkworm-stateless feat/openvm-pairing: bn254 ecpairing (0x08) and KZG
point evaluation (0x0a) now run on OpenVM's HintFinalExp protocol rather
than software final exponentiation / blst.

The moduli/fp2/curve order stays ABI with the guest's funct7 indices;
PAIRING_IDX is fixed by the PairingCurve enum (Bn254 = 0, Bls12_381 = 1)
independent of the supported_curves order.
…dard)

Replaces the hand-written openvm.toml with SdkVmConfig::standard(), the
same config reth_vm_config() uses on develop-v2.1.0. This makes the two
benchmarks run on an identical circuit — same extension set, same
modulus/curve ordering, same constraint degree — so segment counts are
directly comparable, and it enables the Int256 (bigint) extension that
Reth already had and Zilkworm was missing.

Public values are the one deliberate difference: Reth reveals a 32-byte
block hash (32 cells), while the Zilkworm guest reveals the 105-byte SSZ
StatelessValidationResult, so it needs 64 cells (128 bytes) — reduced
from the previous 256.
Local execute-metered estimates did not match the GPU benchmark runs:
the same guest gave 119 segments locally but 79 in CI, at the same
15 GiB --segment-max-memory. The cause is a segmentation setting, not
the backend: ProvingMemoryConfig::cache_rs_code_matrix is true for the
CPU engine (stark-backend Engine::proving_memory_config) and false in
GpuProverConfig::default(). Caching the Reed-Solomon code matrix leaves
less of the budget for trace, so more segments.

Default to the GPU model so local estimates predict the benchmark runs,
with --cache-rs-code-matrix to model a CPU prover. Verified: the
optimized guest estimates 63 segments locally and CI reported 62.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant