Skip to content

perf(ecc): compute the slope directly in affine Add and Double - #873

Draft
4waan wants to merge 6 commits into
Consensys-Incorporated:masterfrom
4waan:perf/affine-add-double
Draft

perf(ecc): compute the slope directly in affine Add and Double#873
4waan wants to merge 6 commits into
Consensys-Incorporated:masterfrom
4waan:perf/affine-add-double

Conversation

@4waan

@4waan 4waan commented Aug 20, 2026

Copy link
Copy Markdown

description

{Point}Affine.Add and {Point}Affine.Double build a Jacobian point and then call FromJacobian that inverts Z. Affine result means inversion is paid on every call whatever we do. Jacobian coordinates pay off when an inversion can be deferred across a chain of operations & here the chain has length 1, so the round only adds multiplications.

before after
Affine.Add 4M + 2S (mmadd-2007-bl) + 1I + 3M + 1S (FromJacobian) 1I + 2M + 1S
Affine.Double 1M + 5S (mdbl-2007-bl) + 1I + 3M + 1S (FromJacobian) 1I + 2M + 2S

removes 5M + 2S from Add and 2M + 4S from Double, using the affine formulas from EFD. The approach of #509 and #510, which moved these functions to mixed Jacobian arithmetic.. the inversion they left in FromJacobian is the one the slope can now share.

The existing ~Cost: comments counted only the Jacobian half of each function and so understated both they now include the inversion.

dead call:Affine.Double also called q.FromAffine(a) immediately before q.DoubleMixed(a) overwriting all three coordinates

These are the affine entry points used by callers (holding a single pair of points), which cannot kill an inversion. go-ethereum's EIP-196 ECADD and EIP-2537 G1ADD/G2ADD precompiles call Affine.Add directly.

Aliasing

instead of through a local Jacobian point both rewrites now write to p directly.. p.Add(p, q) and p.Double(p) still gonna work. go-ethereum's G1ADD calls p0.Add(p0, p1).

Points of order 2

Double needs one guard that the Jacobian version got for free. When y = 0 the tangent is vertical and [2]a = O; the Jacobian path produced Z = 2y = 0 which FromJacobian mapped to the point at infinity. The affine slope (3x²+a)/2y is undefined there, so the case is now explicit. One IsZero covers both the point at infinity, encoded as (0,0), and the points of order 2.

(Such points aint in the r torsion subgroup--- but they are on E(𝔽p) for 3 of the supported curves. x³ + b = 0 has a root in 𝔽p for G1 in all bls12-377 , bls24-315 and bw6-761.. Without the guard, Double((1,0)) would return (-2,0), which is not on the curve.)

Also in this change

Same file, all either free or strictly less work:

  • jacExtended.add computed A = U2-U1 and B = S2-S1 for the degenerate-case test,
    then recomputed both as P and R. The two subtractions are dropped by keeping
    EFD's P/R names for the values that were already there.
  • Jacobian.fromJacExtended used two Muls per coordinate where its
    unsafeFromJacExtended twin uses Square + Mul. Both are the same now.
  • Affine.fromJacExtended took two inversions. Since ZZ³ = ZZZ², with w = ZZ/ZZZ
    we have w² = 1/ZZ, so one inversion plus 3M + 1S does it.
  • IsInSubGroupBatchG2's doc says the threshold is 80 while the code has branched on
    160 since feat: batch subgroup membership testing #710 introduced it, on all 7 curves with a G2. The template now derives
    the doc text and the branch from one value so they cannot drift again.

Type of change

  • New feature (non-breaking change which adds functionality)

(Perf only and no API change) Add, Double and the helpers return the same values as before on every input, including the point at infinity.

Testing:

  • go test -short ./ecc/... passes: 173 packages, all 11 curves.
  • go test -tags=purego on bn254, bls12-381, bw6-761 and secp256r1 (the a = -3
    path through the doubling slope).
  • Added a property to Test{Point}AffineOps: doubling a point with Y = 0
    returns the point at infinity, and so does adding it to itself. It falsifies on
    the first sample against an implementation that only guards the point at
    infinity.
  • A differential test against the replaced implementations( that i kept out of this PR):
    300 random G1 pairs and 200 G2 pairs on bn254, plus bw6-761 G1 including its
    point of order 2, comparing Add, Double, both aliasing patterns, the point
    at infinity on either side, P + (-P) and P + P. Bit-identical throughout.
  • go generate ./... is repeatable.
  • GOOS=linux GOARCH=386 and linux/arm64 build.

Benchmarking:

mac M2 air, darwin/arm64, -count=10, benchstat. The library's own benchmarks, which alias the destination (a.Add(&a, &g1GenAff), a.Double(&a)):

                    │   before    │               after               │
bn254
G1AffineAdd-8         3.697µ ± 1%    3.453µ ± 4%   -6.60% (p=0.000 n=10)
G1AffineDouble-8      3.713µ ± 2%    3.484µ ± 0%   -6.17% (p=0.000 n=10)
G2AffineAdd-8         4.693µ ± 1%    3.832µ ± 2%  -18.35% (p=0.000 n=10)
G2AffineDouble-8      4.657µ ± 1%    3.952µ ± 0%  -15.15% (p=0.000 n=10)
bls12-381
G1AffineAdd-8         6.541µ ± 1%    6.138µ ± 6%   -6.17% (p=0.000 n=10)
G1AffineDouble-8      6.576µ ± 1%    6.215µ ± 4%   -5.49% (p=0.000 n=10)
G2AffineAdd-8         8.093µ ± 1%    6.734µ ± 0%  -16.80% (p=0.000 n=10)
G2AffineDouble-8      7.989µ ± 2%    6.918µ ± 0%  -13.41% (p=0.000 n=10)

G2 gains more because an 𝔽p2 mul is 3 𝔽p muls and an 𝔽p2 square is 2--> the removed ops weigh more against the same single inversion. The absolute saving matches the op count.. bls12-381 G1 saves 403ns for 5M + 2S at ~53ns per 𝔽p mul.

As an independent check that the delta survives a real call site, with decoding and encoding around it, go-ethereum's precompile benchmarks with its gnark-crypto pointed at this branch, -count=6:

                                          │  before    │              after
PrecompiledBn256Add/chfast1                 2.633µ ± 1%   2.432µ ± 1%   -7.65% (p=0.002)
PrecompiledBLS12381G1Add/matter_g1_add_0    5.017µ ± 1%   4.640µ ± 1%   -7.52% (p=0.002)
PrecompiledBLS12381G2Add/matter_g2_add_0    7.325µ ± 2%   6.272µ ± 6%  -14.38% (p=0.002)

(5 matter_g1_add_* cases: -7.1% to -8.0%; 5 matter_g2_add_*: -13.4% to -14.9%.)

Checklist:

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • I did not modify files generated from templates (the two hand edits are in
    internal/generator/ecc/template/point.go.tmpl and its test template while the other
    36 files are go generate ./... output)
  • golangci-lint does not output errors locally
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Note

High Risk
Changes the group law for affine Add/Double on every supported curve, which is security-critical ECC arithmetic used by precompiles and callers that alias destinations.

Overview
Affine Add/Double now compute the slope in place instead of mixed Jacobian add/double plus FromJacobian. The inversion was already required for an affine result, so the Jacobian round-trip only added multiplications (~5M+2S off Add, ~2M+4S off Double). Results are written after operands are fully read so p.Add(p, q) and p.Double(p) still work.

Double now guards y = 0 (vertical tangent → infinity). Jacobian doubling got that for free via Z = 2y. Without the guard, on-curve order-2 points on some G1s (BLS12-377, BLS24-315, BW6-761) would leave the curve.

Also cheaper fromJacExtended (one inversion for affine; Square+Mul for Jacobian) and jacExtended.add no longer recomputes P/R. IsInSubGroupBatchG2 docs now match the 160-point branch.

Tests pin the new formulas against the old Jacobian path (including aliasing, infinity, off-curve, y=0) and add order-2 coverage where those points exist.

Reviewed by Cursor Bugbot for commit 29c19c2. Bugbot is set up for automated code reviews on this repo. Configure here.

4waan and others added 6 commits August 19, 2026 23:58
Affine Add and Double built a Jacobian point and then called FromJacobian that inverts. since that result is affine the inversion is paid on every call and the Jacobian detour only adds multiplications. Computing the slope directly costs the same single inversion:

  Add:    4M+2S + 1I+3M+1S  ->  1I+2M+1S
  Double: 1M+5S + 1I+3M+1S  ->  1I+2M+2S

Double also called FromAffine immediately before DoubleMixed which overwrites all three coordinates that call was dead.

Both defer every write to p until a and b have been read for the last time, since callers alias the destination.

Double now guards y=0 explicitly. The Jacobian path returned Z=2y=0 there, which FromJacobian mapped to the point at infinity; the affine slope (3x²+a)/2y is undefined. One IsZero covers both the point at infinity, encoded as (0,0), and the points of order 2, which are on E(Fp) for bls12-377 G1, bls24-315 G1 and bw6-761 G1.

The ~cost comments counted only the Jacobian half and understated both functions.. they now include the inversion.
bw6-761 G1 is Y²=X³-1, so (1,0) is on the curve and has order 2. Check that
Double, and Add of the point to itself, return the point at infinity for a
point with Y=0. The property falsifies on the first sample against an
implementation that only guards the point at infinity.
jacExtended.add computed A=U2-U1 and B=S2-S1 for the degenerate-case test
and then recomputed both as P and R; keep EFD's P/R names for the values
that were already there.

Jacobian.fromJacExtended used two Muls per coordinate where its
unsafeFromJacExtended twin uses Square+Mul.

Affine.fromJacExtended took two inversions. ZZ³=ZZZ², so with w=ZZ/ZZZ we
have w²=1/ZZ, and one inversion plus 3M+1S is enough.
The doc said the threshold is 80 while the code has branched on 160 for G2
since Consensys-Incorporated#710, on all 7 curves with a G2. Generate the doc text and the branch
from the same template variable so they cannot drift apart again.
The property added with the affine Add/Double rewrite draws an arbitrary x and
asserts Double((x,0)) is infinity. That pins the y==0 guard, and it falsifies on
the first sample against a Double that guards only the point at infinity, but
(x,0) is on the curve only when x^3+ax+b=0, so for a random x it essentially
never is. The comment claimed otherwise.

Say what the property actually pins, and add TestG1Order2 for the curves that
have a genuine point of order 2: bls12-377 and bls24-315, where G1 is
Y^2=X^3+1 and the point is (-1,0), and bw6-761, where G1 is Y^2=X^3-1 and it is
(1,0). Those are the only three of the eleven curves whose G1 has one: for the
other a=0 curves -b is not a cube in Fp, and secp256r1 and stark-curve have
cofactor 1.

The test asserts the point is on the curve, is not infinity and is not in the
r-torsion subgroup, then that Double and Add(P,P) return infinity including when
the destination aliases the operand, and that (P+G)-G = P.
…ions

Now that the slope is computed directly, the Jacobian round trip that Add
and Double used to perform is the natural oracle. Keep it in the test file
and compare coordinate for coordinate, so a divergence in how the point at
infinity is encoded fails as well.

The domain covers ordinary pairs drawn from a randomized walk through the
subgroup, P+P, P+(-P), an operand at infinity on either side, y=0 (the
vertical tangent, which the Jacobian doubling absorbed as Z3=2y=0), points
off the curve, the on-curve point of order 2 on the curves that have one,
and every way the destination can alias an operand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@4waan
4waan marked this pull request as draft August 25, 2026 18:26
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