perf(ecsm): 7.7x faster witness generation - #866
Conversation
compute_witness: 20.8ms -> 2.7ms per ECSM ecall (measured, this machine, high-popcount scalar ~380 steps). Three changes: - Replay in hand-rolled Jacobian coordinates (dbl-2009-l / madd-2007-bl) over k256's public field arithmetic, with the crate's own Montgomery batch-inversion for z-normalization and slope denominators. Replaces k256's ProjectivePoint::batch_normalize, which measured ~5-6ms for the ~760 points of one witness -- no better than per-point to_affine. Intermediates are normalized before subtractions: k256's lazy-magnitude negate(1) is only correct below ~2p (same reason k256's own formulas call normalize_weak). Parity with the BigUint reference replay is covered by tests::curve_tests. - shifted_quotient: one div_rem instead of separate % and / (halves the 512/256-bit BigInt divisions, 6 -> 3 per step). - build_step loop runs on rayon (steps are independent witnesses). Verification: 15/15 ecsm tests, 8/8 prover ecsm tests (incl. full prove+verify of the ecsm guests and the forged-witness rejection tests). Adds examples/bench_witness.rs as the timing harness.
|
/bench |
Benchmark — ethrex 20 transfers (median of 3)Table parallelism: auto (cores / 3)
Commit: 65b8952 · Baseline: cached · Runner: self-hosted bench |
|
/bench |
There was a problem hiding this comment.
⚠️ AI-generated review. Produced by an automated agent and posted from this account — not written by hand. Findings below are leads to verify, not confirmed conclusions.
This PR swaps the projective double-and-add replay in ECSM witness generation for a hand-rolled Jacobian group law over k256's field arithmetic plus batch inversion, and leaves useful measurement notes (why batch_normalize was dropped). Witness gen is untrusted, so the hand-rolled group law does not move the soundness surface, and the BigUint parity oracle is the right guard. Four issues below, one of which breaks debug builds today.
crypto/ecsm/src/curve.rs:176 and :200 — f - d.double() and r * r - hhh - x1hh.double() negate a magnitude-2 value, which panics in any debug build. k256 0.13.4 lowers Sub to self + -other and Neg to self.negate(1), whose documented precondition is that the supplied magnitude is >= the operand's actual magnitude; double() is add(self, self) and doubles the tracked magnitude. Under cfg(debug_assertions) k256 compiles in the magnitude-tracking FieldElementImpl, whose negate opens with debug_assert!(self.magnitude <= magnitude) — here debug_assert!(2 <= 1). schedule() always emits op = 0 first for k >= 2, so step 0 of every replay hits :176. These are the only two out-of-contract sites; the mul chains, batch_invert, the affine conversion and the denominator/lambda loops all stay within magnitude limits.
CI stays green because everything touching this runs --release (cargo nextest archive --release), so the blast radius is developers: the workspace [profile.dev] (Cargo.toml:24-28) raises opt-level under the comment "Optimized dev builds: tests run fast without --release" but leaves debug-assertions at its default with no per-package override, so make test, make test-fast, make test-prover*, and any dev-profile cargo run -p cli over an ecrecover guest panic inside k256. Release output is correct by construction, not by luck — negate(1)'s per-limb constants are 2*(m+1)*p_limb, ~4x the limb bound versus a double-of-normalized's <=2x, leaving the actual magnitude far inside max_magnitude() = 2047 — but that is correctness resting on unspecified slack, and it disables the one checker for exactly the lazy-reduction bug class the PR description says bit the first draft. Fix is one token each: f - d - d and r * r - hhh - x1hh - x1hh (both keep every negate(1) on a magnitude-1 operand), or d.double().normalize_weak(). Then confirm the ecsm and prover ECSM tests pass without --release.
crypto/ecsm/Cargo.toml:12 — rayon = "1.8.0" as a hard dep breaks the convention of every neighbouring crate: crypto/crypto, crypto/math, crypto/stark and prover declare it optional = true behind parallel = ["dep:rayon"], prover forwards it (default = ["parallel"]), crypto/stark has par.rs abstracting over cfg(feature = "parallel"), and prover/src/tables/trace_builder.rs — the file calling compute_witness at :851 — uses the paired #[cfg(feature = "parallel")] / #[cfg(not(...))] idiom. Two consequences: executor depends on ecsm unconditionally and declares no features, so it now carries a transitive hard rayon dep it never had; and the unguarded .par_iter() at witness.rs:334 means a --no-default-features build still spins up a rayon pool in the ECSM path — a config actively exercised by Makefile:428/:438 (cargo clippy --workspace --all-targets --no-default-features). Nothing breaks today; it is hygiene. Make it optional = true with parallel = ["dep:rayon"], gate witness.rs:334 with a cfg(not(feature = "parallel")) .iter() fallback, and add "ecsm/parallel" to prover's parallel list.
crypto/ecsm/src/tests/curve_tests.rs:30 — the new replay is never exercised with a non-generator base point. Every test reaching it (curve_tests.rs:30/68, witness_tests.rs, ecdas_tests.rs:32, ecsm_tests.rs:41/252, the e2e ECSM guest tests) passes G; the one non-G call, ecsm_tests.rs:223, uses k=1 and returns at the sched.is_empty() early exit before any Jacobian arithmetic runs. Production is the opposite: trace_builder.rs:851 feeds compute_witness a guest-supplied xG, and the ecrecover workload this PR targets (ecsm_lincomb2(GENERATOR, u1, r_point, u2) in crypto/ethrex-crypto/src/lib.rs:121) uses a recovered R as one of the two bases. Nothing pre-merge covers it either — the ethrex CI tests are execution-only and go through scalar_mul_x (untouched k256 native path), both ethrex proving tests are #[ignore], and the merge-queue --run-ignored job whitelists two unrelated tests. The op graph and its magnitude bookkeeping are identical for any base point (schedule(k) is pure bit logic, magnitude is a per-op counter), so this is not a specific hazard — it is the one production input axis the oracle does not sample, and closing it costs one outer loop over a small Vec of bases (e.g. recover_y_canonical(x(5·G))) around the existing sweep.
crypto/ecsm/src/curve.rs:240 — the replay materializes each intermediate twice. Step 1 pushes a_i at 2i and r_i at 2i+1, then carries r_i forward so it is re-pushed bit-identically at 2i+2: pts holds 2n entries when only n+1 are distinct. Step 2 therefore runs the batch_invert chain, the 4-mul Jacobian-to-affine conversion, and two biguint_from_fe calls for ~n-1 exact duplicates — about 2x the necessary work in that block, on a serial stretch of the function this PR exists to speed up. Store the n+1 distinct points and index a_i = pts[i], r_i = pts[i+1]. Allocations are unchanged (StepPts clones an owned a and r per step either way); the win is the redundant inversion-chain muls, conversions, and fe->BigUint parses.
crypto/ecsm/src/curve.rs:258 — related: step 2 computes each affine coordinate as a FieldElement (x * zi2, y * zi2 * zi) and immediately discards the field form, then steps 3-4 decode the same values right back via fe_from_biguint at :258, :260, :271, :274 — to_bytes_be alloc + be32 copy + from_bytes canonical check + .expect, exactly 2n times per witness (~760 for the bench scalar). Keeping a parallel Vec<(FieldElement, FieldElement)> of the normalized coordinates in step 2 and reading it directly is safe on the magnitude rules: to_bytes fully normalizes so the round trip is a pure normalize(), and both retained values are mul outputs (magnitude 1), so gx - ... / gy - ... need no compensating call. The 2 * i indexing stays regardless — it comes from the interleaved step-1 layout and is still needed at :280/:285/:291.
Nits:
- crypto/ecsm/src/curve.rs:65 — the section header still claims the replay
batch_normalizes "all points to affine in one shot", butbatch_normalizenow has zero call sites, its only other mention being the perf note at :212-214 explaining why it was dropped; reword to hand-rolled Jacobian (dbl-2009-l / madd-2007-bl) over k256's field arithmetic plus two Montgomery batch inversions, keeping the projective/k256 mention sinceProjectivePointis still live at :155 backingscalar_mul_affine_x. The same stale sentence sits at crypto/ecsm/Cargo.toml:13-15 ("the projective double-and-add replay + batch inversion"), directly above the lines this PR adds; fix both.
…feature-gate rayon
Review findings, all verified fixed:
- jac_double/jac_madd: F - 2D and R^2 - HHH - 2*X1*HH are now two
subtractions of the normalized operand (f - d - d, ... - x1hh - x1hh)
instead of negating a magnitude-2 double. k256's negate(1) requires
operand magnitude <= 1 and ENFORCES it in debug builds
(field_impl.rs:106): dev-profile cargo test -p ecsm panicked on the
first double before this change; release was correct only via
undocumented slack. Dev and release suites now both pass (16/16).
- Replay stores the n+1 distinct ladder points instead of 2n entries
with n-1 exact duplicates (r_i == a_{i+1}), and keeps the affine
coordinates in FieldElement form for the slope algebra instead of
converting to BigUint and re-parsing 2n times per witness. Serial
witness time roughly halves (22.5ms -> 11.4ms); parallel unchanged
(~2.9ms).
- rayon is now optional behind ecsm/parallel (matching neighbouring
crates); witness.rs falls back to .iter() without the feature, and
prover's parallel feature forwards ecsm/parallel. --no-default-features
checks clean for ecsm and prover.
- New parity test sweeping a non-generator base point (production feeds
the replay guest-supplied points, e.g. the recovered R in ecrecover).
- Stale docs updated (section header + crate description) to the
hand-rolled Jacobian path.
Verification: cargo test -p ecsm (dev) 16/16, --release 16/16,
8/8 prover ECSM tests (prove+verify + forged rejection), bench
2.96ms/call with parallel, 11.4ms serial.
|
Review addressed in 65b8952 (pushed). Point by point:
Verified: |
|
/bench |
Summary
compute_witness(one ECSM ecall's witness): 20.8ms → 2.7ms measured (high-popcount scalar, ~380 steps). At 4 ecalls per ecrecover that's ~83ms → ~11ms host per recovery; ~8.3s → ~1.1s for a 100-signature block.Three changes:
ProjectivePoint::batch_normalize, which measured ~5-6ms for the ~760 points of one witness — no better than 760 individualto_affine()calls (its innerBatchInvertis fine at ~60µs; the total never gets the batching benefit).shifted_quotient: onediv_reminstead of separate%(divisibility assert) and/— halves the 512/256-bit BigInt divisions (6 → 3 per step).build_steploop on rayon — steps are independent witnesses.The subtle part (caught by the parity tests)
The first Jacobian version produced wrong points: k256's lazy-magnitude
negate(1)is only correct below ~2p, so subtraction chains over unnormalized intermediates miscompute (this is why k256's own formulas callnormalize_weakafter every add-chain). Fixed with strategicnormalize()on intermediates feeding subtractions, documented in the code. Parity with the BigUint reference replay is covered bytests::curve_tests.Verification
ecsmcrate tests (parity vs the reference replay).cargo run --release --example bench_witness -p ecsm.