perf: implement wNAF-5 Shamir for verify — 35% faster (10.6x vs native)

Fixed the wNAF encoding bug and wired it into the verification hot path.

Bug fix: The wNAF encoding silently dropped carry bits that overflowed past
the 256-bit scalar boundary. When a negative digit near bit 251 caused a
carry to bit 256, the extraction window was clamped to 0 bits by
`w.coerceAtMost(maxBits - bit)`, causing the carry digit to be lost.
Fixed by extending the working copy and result arrays to accommodate
carries up to bit (maxBits + w), and using totalBits instead of maxBits
for the window size clamp.

Performance: wNAF-5 (windowed Non-Adjacent Form, width 5) encodes scalars
using signed odd digits {±1, ±3, ..., ±15} with guaranteed ≥4 zero-runs
between non-zero digits. This reduces point additions in mulDoubleG from
~120 (4-bit window) to ~86 for two 256-bit scalars, a ~28% reduction in
additions while the 256 doublings remain the same.

Benchmark improvement for verifySchnorr:
  Before: 1,940 ops/s (12.9x vs native)
  After:  2,626 ops/s (10.6x vs native)

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 17:39:52 +00:00
parent e26ccb23cf
commit ec1833b346
@@ -161,7 +161,7 @@ internal object ECPoint {
* Used by mulG and mulDoubleG for fast generator multiplication.
* Lazily initialized on first use (costs ~16 point additions + 16 inversions).
*/
private val gTable: Array<AffinePoint> by lazy { buildGTable() }
internal val gTable: Array<AffinePoint> by lazy { buildGTable() }
private fun buildGTable(): Array<AffinePoint> {
val jac = Array(16) { MutablePoint() }
@@ -365,6 +365,77 @@ internal object ECPoint {
FieldP.mul(out.z, out.z, t[6])
}
// ==================== wNAF Encoding ====================
/**
* Convert scalar to width-w wNAF (windowed Non-Adjacent Form).
*
* wNAF is an encoding where each non-zero digit is odd and separated by at least
* w-1 zero digits. For width 5, digits are in {±1, ±3, ±5, ..., ±15} with a
* precomputed table of 8 odd multiples. The guaranteed zero runs mean fewer
* point additions than simple windowing.
*
* Returns IntArray where result[i] is the signed digit at bit position i.
*/
internal fun wnaf(
scalar: IntArray,
w: Int,
maxBits: Int,
): IntArray {
// wNAF can carry up to bit (maxBits + w - 1), so extend both arrays
val totalBits = maxBits + w
val sLimbs = (totalBits + 31) / 32
val result = IntArray(totalBits)
val s = IntArray(sLimbs)
scalar.copyInto(s)
var bit = 0
while (bit < totalBits) {
if (s[bit / 32] ushr (bit % 32) and 1 == 0) {
bit++
continue
}
var word = getBitsVar(s, bit, w.coerceAtMost(totalBits - bit))
if (word >= (1 shl (w - 1))) {
word -= (1 shl w)
addBitTo(s, bit + w) // Propagate the borrow
}
result[bit] = word
bit += w
}
return result
}
private fun getBitsVar(
s: IntArray,
bitPos: Int,
count: Int,
): Int {
if (count == 0) return 0
val limb = bitPos / 32
val shift = bitPos % 32
var r = (s[limb] ushr shift)
if (shift + count > 32 && limb + 1 < s.size) {
r = r or (s[limb + 1] shl (32 - shift))
}
return r and ((1 shl count) - 1)
}
private fun addBitTo(
s: IntArray,
bitPos: Int,
) {
val limb = bitPos / 32
if (limb >= s.size) return
val bit = bitPos % 32
var carry = (1L shl bit)
for (i in limb until s.size) {
carry += (s[i].toLong() and 0xFFFFFFFFL)
s[i] = carry.toInt()
carry = carry ushr 32
if (carry == 0L) break
}
}
// ==================== Scalar Multiplication ====================
/**
@@ -438,15 +509,14 @@ internal object ECPoint {
}
/**
* Shamir's trick: out = s·G + e·P in a single scalar multiplication pass.
* Shamir's trick with wNAF-5: out = s·G + e·P in a single pass.
*
* Instead of computing s·G and e·P separately (two full scalar muls) and adding
* the results, we interleave them: process both scalars simultaneously from MSB
* to LSB, sharing the doublings. This roughly halves the total work for signature
* verification, where we need to compute R = s·G - e·P.
* Uses width-5 wNAF encoding for both scalars, processing 1 bit at a time with
* shared doublings. wNAF guarantees at least 4 zeros between non-zero digits,
* reducing additions from ~120 (4-bit window) to ~86 for two 256-bit scalars.
*
* The G-side uses mixed addition (from precomputed affine table), while the P-side
* uses full Jacobian addition (building the P table on-the-fly avoids 16 inversions).
* The G-side uses mixed addition (from precomputed affine odd-multiples table).
* The P-side uses full Jacobian addition (building the table avoids 8 inversions).
*/
fun mulDoubleG(
out: MutablePoint,
@@ -454,27 +524,63 @@ internal object ECPoint {
p: MutablePoint,
e: IntArray,
) {
val gTab = gTable
val pTab = Array(16) { MutablePoint() }
pTab[0].copyFrom(p)
for (i in 1 until 16) addPoints(pTab[i], pTab[i - 1], pTab[0])
// G odd-multiples [1G, 3G, 5G, ..., 15G] as affine
val gOdd = Array(8) { gTable[it * 2] }
// P odd-multiples [1P, 3P, ..., 15P] as Jacobian (avoids 8 inversions)
val pAll = Array(16) { MutablePoint() }
pAll[0].copyFrom(p)
for (i in 1 until 16) addPoints(pAll[i], pAll[i - 1], pAll[0])
val pOdd = Array(8) { pAll[it * 2] }
// Build wNAF representations
val wnafS = wnaf(s, 5, 256)
val wnafE = wnaf(e, 5, 256)
// Find highest non-zero digit across both
var bits = maxOf(wnafS.size, wnafE.size)
while (bits > 0 && (bits > wnafS.size || wnafS[bits - 1] == 0) &&
(bits > wnafE.size || wnafE[bits - 1] == 0)
) {
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 sNib = U256.getNibble(s, nibbleIdx)
if (sNib != 0) {
addMixed(tmp, out, gTab[sNib - 1].x, gTab[sNib - 1].y)
out.copyFrom(tmp)
if (i < wnafS.size) {
val ds = wnafS[i]
if (ds != 0) {
val idx = (if (ds > 0) ds else -ds) / 2
if (ds > 0) {
addMixed(tmp, out, gOdd[idx].x, gOdd[idx].y)
} else {
FieldP.neg(negY, gOdd[idx].y)
addMixed(tmp, out, gOdd[idx].x, negY)
}
out.copyFrom(tmp)
}
}
val eNib = U256.getNibble(e, nibbleIdx)
if (eNib != 0) {
addPoints(tmp, out, pTab[eNib - 1])
out.copyFrom(tmp)
if (i < wnafE.size) {
val de = wnafE[i]
if (de != 0) {
val idx = (if (de > 0) de else -de) / 2
if (de < 0) {
val neg = MutablePoint()
pOdd[idx].x.copyInto(neg.x)
FieldP.neg(neg.y, pOdd[idx].y)
pOdd[idx].z.copyInto(neg.z)
addPoints(tmp, out, neg)
} else {
addPoints(tmp, out, pOdd[idx])
}
out.copyFrom(tmp)
}
}
}
}