perf: cache P-side wNAF tables in mulDoubleG for repeated pubkeys
In mulDoubleG (verify), the P-side wNAF-5 affine table (8 odd multiples of P plus 8 GLV λ counterparts) is rebuilt from scratch on every call. This costs ~437 field ops: 1 doublePoint + 7 addPoints + 8 β-multiplies + 1 batch inversion with full field inversion (~270 ops alone). Add a 256-entry direct-mapped cache that stores these affine tables keyed by the point's x-coordinate. On cache hit, the table build is skipped entirely — the cached arrays are used directly (zero-copy). This saves ~27% of mulDoubleG cost (~20% of total verify) for repeated pubkeys. Combined with the liftX cache from the previous commit, repeated-pubkey verifications now skip ~33% of the work. JVM benchmark: verify ratio improved from 2.0x to 1.6-1.8x vs native C (with the benchmark's 100% cache hit rate). Original baseline was 3.0x. https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
This commit is contained in:
@@ -176,6 +176,32 @@ internal object ECPoint {
|
|||||||
Array(G_TABLE_SIZE) { AffinePoint(FieldP.mul(gOddTable[it].x, Glv.BETA), gOddTable[it].y.copyOf()) }
|
Array(G_TABLE_SIZE) { AffinePoint(FieldP.mul(gOddTable[it].x, Glv.BETA), gOddTable[it].y.copyOf()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== P-side wNAF table cache ====================
|
||||||
|
//
|
||||||
|
// In mulDoubleG (verify), we build an 8-entry wNAF-5 affine table for the
|
||||||
|
// public key P on every call: [1P, 3P, 5P, ..., 15P] plus their GLV λ
|
||||||
|
// counterparts. This costs ~437 field ops (~27% of mulDoubleG, ~20% of verify).
|
||||||
|
//
|
||||||
|
// For Nostr, the same pubkeys are verified repeatedly (many events per author).
|
||||||
|
// This cache stores the P-side affine tables keyed by the point's x-coordinate,
|
||||||
|
// so repeated verifications for the same pubkey skip the table build entirely.
|
||||||
|
//
|
||||||
|
// 256 entries × 16 AffinePoints × 64 bytes = ~256KB total cache.
|
||||||
|
|
||||||
|
private const val P_TABLE_CACHE_SIZE = 256
|
||||||
|
private const val P_TABLE_CACHE_MASK = P_TABLE_CACHE_SIZE - 1
|
||||||
|
|
||||||
|
private class CachedPTable(
|
||||||
|
val px: LongArray, // x-coordinate of the point (cache key, 4 limbs)
|
||||||
|
val pOdd: Array<AffinePoint>, // 8 affine odd-multiples of P
|
||||||
|
val pLamOdd: Array<AffinePoint>, // 8 affine odd-multiples of λ(P)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val pTableCache = arrayOfNulls<CachedPTable>(P_TABLE_CACHE_SIZE)
|
||||||
|
|
||||||
|
/** Hash a field element to a cache slot index. */
|
||||||
|
private fun cacheSlot(px: LongArray): Int = (px[0].toInt() xor px[1].toInt().shl(3)) and P_TABLE_CACHE_MASK
|
||||||
|
|
||||||
private fun buildGOddTable(): Array<AffinePoint> {
|
private fun buildGOddTable(): Array<AffinePoint> {
|
||||||
val g = MutablePoint()
|
val g = MutablePoint()
|
||||||
g.setAffine(GX, GY)
|
g.setAffine(GX, GY)
|
||||||
@@ -738,22 +764,45 @@ internal object ECPoint {
|
|||||||
val gOdd = gOddTable
|
val gOdd = gOddTable
|
||||||
val gLam = gLamTable
|
val gLam = gLamTable
|
||||||
|
|
||||||
// P odd-multiples [1P, 3P, 5P, ..., 15P] — uses pre-allocated scratch tables
|
// P-side tables: check cache first, build only on miss.
|
||||||
doublePoint(sc.p2, p, sc)
|
// On cache hit, copies 16 affine points from cache (~trivial vs ~437 field ops to build).
|
||||||
val pOddJac = sc.pOddJac
|
val pOdd: Array<AffinePoint>
|
||||||
pOddJac[0].copyFrom(p)
|
val pLamOdd: Array<AffinePoint>
|
||||||
for (i in 1 until pTableSize) addPoints(pOddJac[i], pOddJac[i - 1], sc.p2, sc)
|
val cacheSlot = cacheSlot(p.x)
|
||||||
val pLamOddJac = sc.pLamOddJac
|
val cached = pTableCache[cacheSlot]
|
||||||
for (i in 0 until pTableSize) {
|
if (cached != null && U256.cmp(cached.px, p.x) == 0) {
|
||||||
FieldP.mul(pLamOddJac[i].x, pOddJac[i].x, Glv.BETA, sc.w)
|
// Cache hit — use cached affine tables directly (no copy needed)
|
||||||
pOddJac[i].y.copyInto(pLamOddJac[i].y)
|
pOdd = cached.pOdd
|
||||||
pOddJac[i].z.copyInto(pLamOddJac[i].z)
|
pLamOdd = cached.pLamOdd
|
||||||
}
|
} else {
|
||||||
|
// Cache miss — build tables and store in cache
|
||||||
|
doublePoint(sc.p2, p, sc)
|
||||||
|
val pOddJac = sc.pOddJac
|
||||||
|
pOddJac[0].copyFrom(p)
|
||||||
|
for (i in 1 until pTableSize) addPoints(pOddJac[i], pOddJac[i - 1], sc.p2, sc)
|
||||||
|
val pLamOddJac = sc.pLamOddJac
|
||||||
|
for (i in 0 until pTableSize) {
|
||||||
|
FieldP.mul(pLamOddJac[i].x, pOddJac[i].x, Glv.BETA, sc.w)
|
||||||
|
pOddJac[i].y.copyInto(pLamOddJac[i].y)
|
||||||
|
pOddJac[i].z.copyInto(pLamOddJac[i].z)
|
||||||
|
}
|
||||||
|
// Batch-convert to affine (into scratch arrays)
|
||||||
|
batchToAffinePair(pOddJac, pLamOddJac, sc.pOddAff, sc.pLamOddAff, sc)
|
||||||
|
|
||||||
// Effective-affine: batch-convert P-side tables (shared Z inversion)
|
// Store in cache (allocate new arrays so they're independent of scratch)
|
||||||
val pOdd = sc.pOddAff
|
val cachedPOdd =
|
||||||
val pLamOdd = sc.pLamOddAff
|
Array(pTableSize) {
|
||||||
batchToAffinePair(pOddJac, pLamOddJac, pOdd, pLamOdd, sc)
|
AffinePoint(sc.pOddAff[it].x.copyOf(), sc.pOddAff[it].y.copyOf())
|
||||||
|
}
|
||||||
|
val cachedPLamOdd =
|
||||||
|
Array(pTableSize) {
|
||||||
|
AffinePoint(sc.pLamOddAff[it].x.copyOf(), sc.pLamOddAff[it].y.copyOf())
|
||||||
|
}
|
||||||
|
pTableCache[cacheSlot] = CachedPTable(p.x.copyOf(), cachedPOdd, cachedPLamOdd)
|
||||||
|
|
||||||
|
pOdd = cachedPOdd
|
||||||
|
pLamOdd = cachedPLamOdd
|
||||||
|
}
|
||||||
|
|
||||||
// Find highest non-zero digit across all 4 streams
|
// Find highest non-zero digit across all 4 streams
|
||||||
var bits = 129 + WINDOW_G // max possible wNAF length
|
var bits = 129 + WINDOW_G // max possible wNAF length
|
||||||
|
|||||||
Reference in New Issue
Block a user