Skip to content

crypto: Pseudo-Mersenne field multiplication for secp256k1 - #1649

Draft
chfast wants to merge 4 commits into
masterfrom
crypto/field-arith-specialization
Draft

crypto: Pseudo-Mersenne field multiplication for secp256k1#1649
chfast wants to merge 4 commits into
masterfrom
crypto/field-arith-specialization

Conversation

@chfast

@chfast chfast commented Aug 12, 2026

Copy link
Copy Markdown
Member

Proof of concept: specialize the modular multiplication for the secp256k1 field prime,
which is the pseudo-Mersenne prime 2²⁵⁶-2³²-977, instead of running the generic Montgomery
CIOS loop on it.

The verdict up front: this is a wash on ecrecover cycles today. It is roughly 2x faster
where there is instruction-level parallelism to exploit, and exactly break-even where there
is not — and ecrecover turns out to be latency-bound. Opening it as a draft because the
mechanism is worth reviewing on its own terms and it is the prerequisite for the follow-ups
below.

What it does

For a modulus 2ⁿ-c with a single-word c, the product is reduced by folding the high half
instead of by Montgomery reduction, with values kept in the plain representation:

  1. t, carry = l + h⋅c — one addmul pass over the high half
  2. r, overflow = t + carry⋅ccarry ≤ c, so this product spans two words at most
  3. if (overflow) r += c — cannot overflow again
  4. if (r >= Mod) r -= Mod — the excess is less than c

Because the representation is plain rather than Montgomery, the conversions to and from the
internal form become the identity, so constructing a field element and reading its value are
free.

The backend, and with it the representation, is selected from the value of the modulus,
never from a declaration:

field 2ⁿ - mod selected
secp256k1 p 0x1000003d1 (33 bits) pseudo-Mersenne
secp256k1 n ~2¹²⁹ Montgomery
secp256r1 p 2²²⁴-2¹⁹²-2⁹⁶+1 Montgomery
bn254 q ~2²⁵⁴ Montgomery

A hand-written tag would be redundant with the modulus and could be set inconsistently,
silently producing wrong arithmetic; deriving it cannot desync, and adding a curve needs no
knowledge of which backend to pick.

Numbers

Per-mul cost on the secp256k1 field, min of 5 reps, perf two-point subtraction:

regime instructions cycles
serial chain (latency-bound) -38.7% -0.5%
4 independent chains (throughput-bound) -52.9% -48.5%

ecrecover: -33.4% instructions, cycles unchanged (median 906k → 909k, IPC 1.88 → 1.18).
bn254 moves by -0.01% instructions, confirming the Montgomery path is untouched.

Multiply counts from the generated code: 21 for the pseudo-Mersenne reduction against 36 for
CIOS in general — but only 28 for this modulus, because three of its four words are all-ones
and the compiler strength-reduces m⋅(2⁶⁴-1) to a shift and a subtract. So the baseline was
already better than generic CIOS here.

Why ecrecover does not move

Per-mul latency is identical (~118 cycles, measured directly), and ecrecover's critical path is
a chain of dependent field multiplications — ~5000 of them, of which field_sqrt alone is 253
sequential squarings.

I first suspected generic mulq was serializing the parallel partial products through the fixed
rdx:rax pair. Rebuilding both sides with -march=native, so mulx/adcx/adox are
available, leaves ecrecover at -33.4% instructions / -0.3% cycles. Refuted — neither the
multiply count nor its encoding is what bounds ecrecover.

The most promising lever for ecrecover is therefore not arithmetic at all: ecc::decompose()
and the 4-way MSM already exist and are used by bn254, but secp256k1::Curve defines no
LAMBDA/BETA/X1…, so ecrecover runs a full 256-doubling ladder. GLV would roughly halve
the critical path. After that, redundant limbs (5×52, how libsecp256k1 wins on latency too) and
a dedicated squaring for FieldElement, which it lacks unlike ExtFieldElem.

Correctness

  • 1221 unit and integration tests pass, and 1176 in a Debug build with asserts enabled.
  • 22.8M-vector differential of the reduction against a udivrem reference: zero mismatches,
    every result canonical. Corners, random canonical operands, operands near p, random raw
    512-bit inputs, and crafted inputs for the rare branches.
  • Steps 3 and 4 above cannot be reached by any product of canonical operands (~2⁻¹⁹⁰ and
    ~2⁻²²³), so neither ctest nor EEST will ever exercise them. The added unit test constructs
    inputs for both, and it is mutation-tested: deleting either branch makes it fail on exactly
    the intended input.
  • The plain representation cannot reduce an out-of-range input the way the conversion to the
    Montgomery form does, so assert(v < Mod) now enforces the precondition. Note that
    secp256k1.cpp deliberately relies on the Montgomery conversion to reduce the message hash
    mod n — that still holds only because n is not pseudo-Mersenne.

Structure

Four commits, each independently reviewable and landable in order; the last is the optimization:

  1. crypto: Let the binary GCD inversion start from a given coefficient — exposes the inversion
    loop as inv_scaled() so a non-Montgomery representation can seed it with 1 instead of R².
  2. crypto: Add the pseudo-Mersenne modular reduction — the primitive and its tests, unused.
  3. crypto: Introduce the field arithmetic backend — the FieldArith concept, MontArith
    implementing it, and the per-modulus selection. No functional change: every field still
    selects Montgomery, and the instruction counts of ecrecover and ecmul move by ≤0.02%.
  4. crypto: Use the pseudo-Mersenne reduction for the secp256k1 field — adds PMArith and lets
    the selection pick it.

FieldElement delegates to the backend and contains no algorithm dispatch of its own, so it no
longer knows the internal representation. Two abstractions: the backend family and the element.
ModArith keeps its name for now and becomes the runtime-modulus Montgomery backend later.

Scope stops deliberately at two backends. An interface is validated by implementations that
differ along its axes, and these two differ on the representation axis — the one that produced
every constraint here (inv_scaled, the to_internal precondition). secp256r1 comes along free
as another MontArith user: a third user, not a third shape. Left for follow-ups, in order: the
ModArith rename, mandatory sqr (dead code without a real implementation), the sparse-modulus
refinement (which needs the CIOS loop extracted from ModArith, touching every curve's
multiply), asm kernels with cpuid dispatch, and SolinasArith.

FpSpec is left exactly as it was; folding it away is a separate change.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.72%. Comparing base (ebc25ef) to head (5102be4).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1649   +/-   ##
=======================================
  Coverage   97.72%   97.72%           
=======================================
  Files         171      171           
  Lines       15631    15673   +42     
  Branches     3617     3622    +5     
=======================================
+ Hits        15275    15317   +42     
  Misses        269      269           
  Partials       87       87           
Flag Coverage Δ
eest-develop 88.61% <94.87%> (+0.02%) ⬆️
eest-develop-gmp 26.61% <69.81%> (+0.09%) ⬆️
eest-legacy 17.10% <0.00%> (-0.05%) ⬇️
eest-libsecp256k1 28.90% <69.81%> (+0.08%) ⬆️
eest-stable 88.61% <94.87%> (+0.02%) ⬆️
evmone-unittests 93.45% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
core 96.12% <100.00%> (+0.02%) ⬆️
tooling 91.92% <ø> (ø)
tests 99.80% <100.00%> (+<0.01%) ⬆️
Files with missing lines Coverage Δ
include/evmmax/evmmax.hpp 100.00% <100.00%> (ø)
lib/evmone_precompiles/ecc.hpp 97.24% <100.00%> (ø)
test/unittests/evmmax_test.cpp 90.80% <100.00%> (+1.76%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chfast
chfast force-pushed the crypto/field-arith-specialization branch 3 times, most recently from 1aca914 to a944895 Compare August 12, 2026 19:52
chfast added 2 commits August 13, 2026 12:17
The inversion folds the R² factor in by initializing the Bézout coefficient u
to R² instead of 1, which is specific to inputs in the Montgomery form. Expose
the underlying loop as inv_scaled() so a representation that needs a different
initial value can reuse it, and keep inv() as the Montgomery-form wrapper.
For a modulus 2ⁿ-c with a single-word c the reduction of a double-width value
is a fold of the high half instead of a division: h⋅2ⁿ + l ≡ l + h⋅c. It takes
21 single-word multiplications for a 256-bit modulus, where the Montgomery
multiplication takes 36.

Two of the carry foldings cannot be reached by any product of two canonical
operands, at probabilities of about 2⁻¹⁹⁰ and 2⁻²²³, so the test constructs
inputs for them directly rather than searching.
@chfast
chfast force-pushed the crypto/field-arith-specialization branch 2 times, most recently from 1370827 to fdf923e Compare August 13, 2026 10:23
chfast added 2 commits August 13, 2026 13:06
The modular arithmetic of a prime field can be implemented in more than one way
depending on the structure of the modulus, and the choice also decides the
internal representation of the value. Define the FieldArith concept for such a
backend, wrap the Montgomery multiplication as the one implementing it, and let
FieldElement delegate to the backend selected for its modulus.

FieldElement no longer knows the representation, so the naming of the
conversions loses the Montgomery reference. No functional change: every field
still selects the Montgomery backend, and the instruction counts of ecrecover
and ecmul are unchanged.
The secp256k1 field prime is 2²⁵⁶-2³²-977, so its multiplication can fold the
high half of the product instead of running the Montgomery reduction. Add the
backend doing that and select it from the structure of the modulus. Of the
fields in use only this one qualifies, the rest keep the Montgomery form.
Deriving the choice from the modulus value rather than declaring it per field
keeps the two from ever disagreeing.

Values are kept plain, which makes the conversions to and from the internal
form free, but they cannot reduce an out-of-range input the way the conversion
to the Montgomery form does, hence the added assertion.

The reduction is roughly 2x faster where there is instruction-level
parallelism to exploit, but it has the same latency as the Montgomery
multiplication. So ecrecover, which is dominated by dependent
multiplications, drops about a third of its instructions at unchanged cycles.
@chfast
chfast force-pushed the crypto/field-arith-specialization branch from fdf923e to 5102be4 Compare August 13, 2026 11:08
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