feat: pairing-protocol port — bn254 ecPairing + BLS12-381/KZG on OpenVM's HintFinalExp - #675
Open
yi-sun wants to merge 3 commits into
Open
feat: pairing-protocol port — bn254 ecPairing + BLS12-381/KZG on OpenVM's HintFinalExp#675yi-sun wants to merge 3 commits into
yi-sun wants to merge 3 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
HintFinalExpresidue-witness protocol (host computes(c, u)out of circuit; the guest verifiesf · c^-{6x+2} · u · c^-{q³-q²+q} == 1per Theorem 3 of eprint 2024/640), which removes the final exponentiation entirely.pairing_checkon the same hint protocol (gnark'sf · s == c^qvariant).Both keep a square-and-multiply
f^FINAL_EXPONENTfallback, so a dishonest hint degrades to the slow-but-correct path rather than a wrong answer — matching the Rust guest lib'sexp_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):
-mtune=generic-ooo+ OpenVM-specificmemcpyXORINon the sponge absorbintx::uint256(EVM stack arithmetic)Reth on the same block, for reference: 664,702,189 instructions / 74 segments.
End to end on CI,
prove-starkon 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 RustSexticExtFieldmemory 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;HintFinalExpphantom at custom-1funct3=0b011,funct7 = PAIRING_IDX*16with(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).openvm.tomlgains bls12-381 Fp/Fr, Fp2, G1 and[app_vm_config.pairing] supported_curves = ["Bn254", "Bls12_381"].Optimization pass
ld/sdcosts what an aligned one costs.-mno-strict-alignalone changes nothing, because GCC's cost model still avoids unaligned accesses;-mtune=generic-ooois 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.intx::uint256's+ - * | & ^and unsigned<now issue one instruction instead of 4-limb carry chains, matching the acceleration the Rust guest gets through revm'sU256. Guarded onstd::is_constant_evaluated()so constant folding is unaffected.ModArithwas templated so ecrecover's base- and scalar-field work runs on the modular instructions, not just bn254's.field_sqrtnow 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.MulMod. That set is narrow but covers what contracts actually use modexp for: a field inversiona^(N-2)over the bn254 or secp256k1 primes. The base is reduced viaAddMod(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 withIsEqModagainst itself, whose AIR constrains both operands to be below the modulus — without that a dishonest prover could returnN + ras 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 identitye(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-ynegative case, and four modexp cases (configured modulus, a padded 40-byte modulus, a modulus the VM does not carry, and a zero exponent).prove-appon 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.make conformance-native. Those pairs have a large standing failure baseline (the post-state root is byte-identical; the SSZsuccessflag is not), which predates and is independent of the acceleration. The point of the comparison is that acceleration changes nothing about it.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-meteredagainst 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.[τ−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.intx::umul(256×256→512) with Int256 was tried and reverted: evmone's generic modexp works onuint64_tlimbs throughmul_amm_256and never reachesintx::uint<256>, so it bought −50 instructions on a modexp-heavy workload while costing +33k on the block through codegen perturbation alone.