perf: apply GLV+wNAF to mul and mulG — all scalar muls now half-speed

Previously only mulDoubleG (verification) used GLV endomorphism + wNAF-5.
The generic mul() and mulG() still used the old 4-bit window with 256
doublings and full Jacobian addition. Now all three scalar multiplication
functions use GLV to split scalars into ~128-bit halves.

mul() (arbitrary point, used by pubKeyTweakMul/ECDH):
- Was: 4-bit window, 256 doublings, 15-entry Jacobian table, ~60 full adds
- Now: GLV split + wNAF-5, ~130 doublings, 2×8-entry Jacobian tables

mulG() (generator point, used by pubkeyCreate and signSchnorr):
- Was: 4-bit window, 256 doublings, precomputed affine table, ~60 mixed adds
- Now: GLV split + wNAF-5, ~130 doublings, precomputed affine G + λ(G) tables

Benchmark improvements:
  pubkeyCreate:         4,391 → 6,689 ops/s (52% faster, 12.5x → 7.7x)
  pubKeyTweakMul:       3,427 → 5,233 ops/s (53% faster, 8.6x → 6.5x)
  pubKeyTweakMulCompact:2,941 → 4,593 ops/s (56% faster, 8.7x → 5.6x)
  signSchnorr:          1,178 → 1,516 ops/s (29% faster, 23.1x → 17.3x)
  compressedPubKeyFor:  4,358 → 6,919 ops/s (59% faster, 12.3x → 7.1x)

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 21:03:39 +00:00
parent 78d8914ee1
commit 2dab449d62
@@ -358,13 +358,12 @@ internal object ECPoint {
/**
* General scalar multiplication: out = scalar · p.
*
* Uses a 4-bit windowed method: precomputes [1P, 2P, ..., 16P], then processes
* the scalar 4 bits (one nibble) at a time from MSB to LSB:
* for each nibble: double 4 times, then add table[nibble] if non-zero.
* Uses GLV endomorphism + wNAF-5 to halve doublings from 256 to ~130:
* scalar = k₁ + k₂·λ (mod n), where k₁, k₂ are ~128 bits
* scalar·P = k₁·P + k₂·λ(P), with λ(P) = (β·X, Y, Z)
*
* This requires 64 iterations with 4 doublings each (256 total) and ~60 additions
* (on average 15/16 of nibbles are non-zero). All operations use full Jacobian
* arithmetic since the P table is built on-the-fly without inversions.
* The two ~128-bit half-scalars are wNAF-5 encoded and processed in a single
* pass of ~130 shared doublings. P-side uses Jacobian tables (no inversions).
*/
fun mul(
out: MutablePoint,
@@ -376,30 +375,57 @@ internal object ECPoint {
return
}
val table = Array(16) { MutablePoint() }
table[0].copyFrom(p)
for (i in 1 until 16) addPoints(table[i], table[i - 1], p)
val w = 5
val tableSize = 1 shl (w - 2) // 8 entries
// Split scalar via GLV: scalar = k₁ + k₂·λ
val split = Glv.splitScalar(scalar)
val wnaf1 = Glv.wnaf(split.k1, w, 129)
val wnaf2 = Glv.wnaf(split.k2, w, 129)
// P odd-multiples [1P, 3P, 5P, ..., 15P] via 2P stepping
val p2 = MutablePoint()
doublePoint(p2, p)
val pOdd = Array(tableSize) { MutablePoint() }
pOdd[0].copyFrom(p)
for (i in 1 until tableSize) addPoints(pOdd[i], pOdd[i - 1], p2)
// λ(P) odd-multiples: (β·X, Y, Z) in Jacobian
val pLamOdd =
Array(tableSize) { i ->
val lp = MutablePoint()
FieldP.mul(lp.x, pOdd[i].x, Glv.BETA)
pOdd[i].y.copyInto(lp.y)
pOdd[i].z.copyInto(lp.z)
lp
}
// Find highest non-zero digit
var bits = maxOf(wnaf1.size, wnaf2.size)
while (bits > 0) {
val b = bits - 1
if ((b < wnaf1.size && wnaf1[b] != 0) || (b < wnaf2.size && wnaf2[b] != 0)) break
bits--
}
out.setInfinity()
val tmp = MutablePoint()
for (nibbleIdx in 63 downTo 0) {
val negJac = MutablePoint()
for (i in bits - 1 downTo 0) {
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
val nib = U256.getNibble(scalar, nibbleIdx)
if (nib != 0) {
addPoints(tmp, out, table[nib - 1])
out.copyFrom(tmp)
}
addWnafJacobian(out, tmp, negJac, wnaf1, i, pOdd, split.negK1)
addWnafJacobian(out, tmp, negJac, wnaf2, i, pLamOdd, split.negK2)
}
}
/**
* Generator multiplication: out = scalar · G.
*
* Same 4-bit windowed algorithm as [mul], but uses the precomputed affine G table
* for faster mixed addition (8M+3S instead of 11M+5S per addition).
* Uses GLV endomorphism + wNAF-5 with precomputed affine G and λ(G) tables:
* scalar = k₁ + k₂·λ, then scalar·G = k₁·G + k₂·λ(G)
* Both tables are cached (lazy static), so no per-call table building.
* Mixed Jacobian+Affine addition (8M+3S) for all lookups.
*/
fun mulG(
out: MutablePoint,
@@ -409,19 +435,30 @@ internal object ECPoint {
out.setInfinity()
return
}
val table = gTable
val w = 5
val split = Glv.splitScalar(scalar)
val wnaf1 = Glv.wnaf(split.k1, w, 129)
val wnaf2 = Glv.wnaf(split.k2, w, 129)
val gOdd = Array(8) { gTable[it * 2] }
val gLam = gLamTable
var bits = maxOf(wnaf1.size, wnaf2.size)
while (bits > 0) {
val b = bits - 1
if ((b < wnaf1.size && wnaf1[b] != 0) || (b < wnaf2.size && wnaf2[b] != 0)) break
bits--
}
out.setInfinity()
val tmp = MutablePoint()
for (nibbleIdx in 63 downTo 0) {
val negY = IntArray(8)
for (i in bits - 1 downTo 0) {
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
val nib = U256.getNibble(scalar, nibbleIdx)
if (nib != 0) {
addMixed(tmp, out, table[nib - 1].x, table[nib - 1].y)
out.copyFrom(tmp)
}
addWnafMixed(out, tmp, negY, wnaf1, i, gOdd, split.negK1)
addWnafMixed(out, tmp, negY, wnaf2, i, gLam, split.negK2)
}
}