perf: implement comb method for G multiplication — 2x faster pubkeyCreate/sign

The comb method (Hamburg 2012) replaces GLV+wNAF for generator multiplication.
Instead of ~130 doublings + ~32 additions, it arranges the 256 scalar bits
into a 4×66 matrix and processes each of 4 rows with 11 table lookups.
Only 3 doublings are needed between rows.

Algorithm: COMB_BLOCKS=11, COMB_TEETH=6, COMB_SPACING=4
  - 11 blocks × 64 entries = 704 affine points (~45KB), lazily precomputed
  - Per mulG: 3 doublings + ~43 mixed additions ≈ 464 M-equiv
  - Previous GLV+wNAF: ~130 doublings + ~32 additions ≈ 1,035 M-equiv
  - Theoretical speedup: 2.2× (measured: 2.0-2.1×)

Table construction uses an efficient Gray-code-like ordering: each successive
mask differs by one bit, so each entry is built from the previous with one
point addition instead of summing all teeth from scratch.

Benchmark improvements:
  pubkeyCreate:          8,383 → 17,654 ops/s (2.1×, now 3.6× vs native)
  signSchnorr (cached): 7,411 → 13,642 ops/s (1.8×, now 2.3× vs native)
  signSchnorr:          3,257 →  5,856 ops/s (1.8×, now 4.8× vs native)
  compressedPubKeyFor:  8,475 → 16,695 ops/s (2.0×, now 3.1× vs native)

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 21:46:09 +00:00
parent 662d31dff5
commit 57b0219a71
@@ -180,6 +180,71 @@ internal object ECPoint {
}
}
// ==================== Comb Method for G Multiplication ====================
//
// The comb method (Hamburg 2012) is much faster than windowed scalar multiplication
// for a fixed base point because it avoids most doublings. Instead of processing the
// scalar bit-by-bit with doublings between each, it arranges the scalar bits into a
// 2D matrix (SPACING rows × BLOCKS*TEETH columns) and processes each row with table
// lookups. Only SPACING-1 doublings are needed between rows.
//
// With BLOCKS=11, TEETH=6, SPACING=4:
// - Doublings: 3 (vs ~130 for GLV+wNAF)
// - Additions: ~43 mixed (11 blocks × ~98% non-zero × 4 rows)
// - Total: ~43 mixed additions + 3 doublings ≈ 464 M-equiv
// - vs GLV+wNAF: ~130 doublings + ~32 additions ≈ 1,035 M-equiv (2.2× faster)
//
// The table has 11 blocks × 64 entries = 704 affine points (~45KB), lazily computed.
private const val COMB_BLOCKS = 11
private const val COMB_TEETH = 6
private const val COMB_SPACING = 4
private const val COMB_POINTS = 1 shl COMB_TEETH // 64
private val combTable: Array<AffinePoint> by lazy { buildCombTable() }
private fun buildCombTable(): Array<AffinePoint> {
// Tooth base points: toothG[i] = 2^(i * SPACING) * G
val numTeeth = COMB_BLOCKS * COMB_TEETH
val toothG = Array(numTeeth) { MutablePoint() }
toothG[0].setAffine(GX, GY)
for (i in 1 until numTeeth) {
toothG[i].copyFrom(toothG[i - 1])
repeat(COMB_SPACING) { doublePoint(toothG[i], toothG[i]) }
}
// For each block, build all 2^TEETH combinations of its teeth
val tableSize = COMB_BLOCKS * COMB_POINTS
val jac = Array(tableSize) { MutablePoint() }
val tmp = MutablePoint()
for (b in 0 until COMB_BLOCKS) {
val base = b * COMB_POINTS
jac[base].setInfinity()
for (m in 1 until COMB_POINTS) {
val changedBit = Integer.numberOfTrailingZeros(m)
if (m and (m - 1) == 0) {
jac[base + m].copyFrom(toothG[b * COMB_TEETH + changedBit])
} else {
val prev = m xor (1 shl changedBit)
addPoints(tmp, jac[base + prev], toothG[b * COMB_TEETH + changedBit])
jac[base + m].copyFrom(tmp)
}
}
}
return Array(tableSize) { i ->
if (jac[i].isInfinity()) {
AffinePoint(IntArray(8), IntArray(8))
} else {
val x = IntArray(8)
val y = IntArray(8)
toAffine(jac[i], x, y)
AffinePoint(x, y)
}
}
}
// ==================== Thread-local scratch buffers ====================
/**
@@ -436,12 +501,14 @@ internal object ECPoint {
}
/**
* Generator multiplication: out = scalar · G.
* Generator multiplication using the comb method: out = scalar · G.
*
* 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.
* Arranges the 256 scalar bits into a 2D matrix (4 rows × 66 columns) and
* processes each row with 11 table lookups (one per block of 6 bits).
* Only 3 doublings are needed between rows, vs ~130 for GLV+wNAF.
*
* Total: ~43 mixed additions + 3 doublings ≈ 464 M-equiv
* (vs ~1,035 for the previous GLV+wNAF approach — 2.2× faster)
*/
fun mulG(
out: MutablePoint,
@@ -452,28 +519,28 @@ internal object ECPoint {
return
}
val split = Glv.splitScalar(scalar)
val wnaf1 = Glv.wnaf(split.k1, WINDOW_G, 129)
val wnaf2 = Glv.wnaf(split.k2, WINDOW_G, 129)
val gOdd = gOddTable
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--
}
val table = combTable
out.setInfinity()
val tmp = MutablePoint()
val negY = IntArray(8)
for (i in bits - 1 downTo 0) {
doublePoint(out, out)
addWnafMixed(out, tmp, negY, wnaf1, i, gOdd, split.negK1)
addWnafMixed(out, tmp, negY, wnaf2, i, gLam, split.negK2)
for (combOff in COMB_SPACING - 1 downTo 0) {
if (combOff < COMB_SPACING - 1) {
doublePoint(out, out)
}
for (block in 0 until COMB_BLOCKS) {
var mask = 0
for (tooth in 0 until COMB_TEETH) {
val bitPos = (block * COMB_TEETH + tooth) * COMB_SPACING + combOff
if (bitPos < 256 && U256.testBit(scalar, bitPos)) {
mask = mask or (1 shl tooth)
}
}
if (mask != 0) {
val entry = table[block * COMB_POINTS + mask]
addMixed(tmp, out, entry.x, entry.y)
out.copyFrom(tmp)
}
}
}
}