From c7b164b845772e2ed8927d77cb520b90efd4a046 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 00:55:54 +0000 Subject: [PATCH] perf: cache P-side wNAF tables in mulDoubleG for repeated pubkeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/utils/secp256k1/Point.kt | 79 +++++++++++++++---- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt index 96dca2fc0..5ebf477ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Point.kt @@ -176,6 +176,32 @@ internal object ECPoint { 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, // 8 affine odd-multiples of P + val pLamOdd: Array, // 8 affine odd-multiples of λ(P) + ) + + private val pTableCache = arrayOfNulls(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 { val g = MutablePoint() g.setAffine(GX, GY) @@ -738,22 +764,45 @@ internal object ECPoint { val gOdd = gOddTable val gLam = gLamTable - // P odd-multiples [1P, 3P, 5P, ..., 15P] — uses pre-allocated scratch tables - 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) - } + // P-side tables: check cache first, build only on miss. + // On cache hit, copies 16 affine points from cache (~trivial vs ~437 field ops to build). + val pOdd: Array + val pLamOdd: Array + val cacheSlot = cacheSlot(p.x) + val cached = pTableCache[cacheSlot] + if (cached != null && U256.cmp(cached.px, p.x) == 0) { + // Cache hit — use cached affine tables directly (no copy needed) + pOdd = cached.pOdd + 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) - val pOdd = sc.pOddAff - val pLamOdd = sc.pLamOddAff - batchToAffinePair(pOddJac, pLamOddJac, pOdd, pLamOdd, sc) + // Store in cache (allocate new arrays so they're independent of scratch) + val cachedPOdd = + Array(pTableSize) { + 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 var bits = 129 + WINDOW_G // max possible wNAF length