diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldP.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldP.kt index 9f39c42c1f..0eecfb76d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldP.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/FieldP.kt @@ -26,6 +26,17 @@ package com.vitorpamplona.quartz.utils.secp256k1 * * Hot-path mul/sqr accept a pre-fetched LongArray(8) wide buffer to avoid * ThreadLocal.get() overhead (~20-30ns per call, 500+ calls per scalar mul). + * + * Key difference from C libsecp256k1: no lazy reduction / magnitude tracking. + * C's 5×52-bit limbs have 12 bits of headroom per limb, allowing 3-8 chained + * add/sub without normalizing. Our 4×64-bit limbs are fully packed, requiring + * reduceSelf after every add and conditional-add-p after every sub. This adds + * ~6 extra reductions per doublePoint, ~12 per addMixed. + * + * Inversion uses Fermat's little theorem (a^(p-2), 255 sqr + 15 mul). The + * safegcd algorithm (Bernstein-Yang 2019) was tested but is slower on JVM + * because 128-bit arithmetic in the inner divstep matrix multiply has higher + * constant overhead than well-optimized field squaring. */ internal object FieldP { // p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F 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 b3f5ce52ea..271532f85e 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 @@ -36,11 +36,11 @@ package com.vitorpamplona.quartz.utils.secp256k1 // // The "point at infinity" (identity element) is represented by Z = 0. // -// POINT DOUBLING +// POINT FORMULAS // ============== -// We use the 3M+4S formula from libsecp256k1 that computes L = (3/2)·X² using a cheap -// field halving operation instead of a full multiplication. On the secp256k1 curve (a=0), -// this is the most efficient known doubling formula. +// - doublePoint: 3M+4S (uses fe_half for L=(3/2)·X², same as libsecp256k1) +// - addMixed (Jacobian + Affine): 8M+3S (used for precomputed table lookups) +// - addPoints (Jacobian + Jacobian): 11M+5S (used when both points are Jacobian) // // SCALAR MULTIPLICATION STRATEGIES // ================================ @@ -49,19 +49,37 @@ package com.vitorpamplona.quartz.utils.secp256k1 // 1. mulG (Generator multiplication): Comb method (Hamburg 2012). // Arranges scalar bits into a 4×66 matrix, processes 4 rows with 11 table lookups // each. Only 3 doublings total. Uses a precomputed 704-entry affine table (~45KB). -// Cost: ~43 mixed additions + 3 doublings (vs ~130 dbl for GLV+wNAF). +// Cost: ~43 mixed additions + 3 doublings ≈ 494 field ops. // Used by: pubkeyCreate, signSchnorr. // // 2. mul (Arbitrary point multiplication): GLV endomorphism + wNAF-5 (Glv.kt). // Splits the 256-bit scalar into two ~128-bit halves via the secp256k1 endomorphism, // then processes both with wNAF encoding in a single pass of ~130 shared doublings. -// Used by: pubKeyTweakMul (ECDH). +// P-side tables are batch-inverted to affine (effective-affine technique) so the +// main loop uses addMixed (8M+3S) instead of addPoints (11M+5S), saving ~4M per add. +// Used by: pubKeyTweakMul (ECDH), ecdhXOnly. // // 3. mulDoubleG (Verification: s·G + e·P): Strauss/Shamir trick with GLV + wNAF. // Splits both scalars via GLV into 4 half-scalar streams. G-side uses a precomputed -// 64-entry affine wNAF-8 table; P-side builds a Jacobian wNAF-5 table per call. -// All 4 streams share ~130 doublings in a single pass. +// 1024-entry affine wNAF-12 table (~128KB); P-side tables batch-inverted to affine. +// All 4 streams share ~130 doublings with mixed additions throughout. // Used by: verifySchnorr. +// +// BATCH INVERSION +// =============== +// Montgomery's trick: convert n Jacobian→affine with 1 inversion + 3(n-1) muls instead +// of n individual inversions. Used for wNAF table construction and G table initialization. +// +// PRECOMPUTED TABLES +// ================== +// All tables are lazily initialized on first use (Kotlin `by lazy`): +// - combTable: 704 affine points for mulG (~45KB, built once per process) +// - gOddTable: 1024 affine points for G-side wNAF-12 (~128KB, batch-inverted) +// - gLamTable: 1024 affine points for λ(G)-side wNAF-12 (~128KB, derived from gOddTable) +// +// Note: C libsecp256k1 uses WINDOW_G=15 (8192 entries, 1MB) as compile-time .rodata. +// On JVM, w=15 is slower due to cache pressure from heap-allocated AffinePoint objects. +// w=12 (1024 entries, ~128KB) is the sweet spot — fits in L2, fewer additions than w=8. // ===================================================================================== /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt index c3c98c99e1..a8756bd88d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt @@ -34,16 +34,39 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * - [secKeyVerify]: Key validation * - [signSchnorr] / [verifySchnorr]: BIP-340 Schnorr signatures (NIP-01) * - [privKeyTweakAdd]: BIP-32 key derivation (NIP-06) - * - [pubKeyTweakMul]: ECDH shared secrets (NIP-04, NIP-44) + * - [pubKeyTweakMul] / [ecdhXOnly]: ECDH shared secrets (NIP-04, NIP-44) * - * Performance on JVM (vs native C/JNI secp256k1): - * verify ~3,900 ops/s (~3.7×), sign ~7.4K ops/s (~2.1×), pubkeyCreate ~18K ops/s (~3.6×), - * compress ~7M ops/s (2× FASTER), secKeyVerify ~6M ops/s (FASTER). - * Uses 4×64-bit limbs (LongArray(4)) with Math.multiplyHigh for 64×64→128-bit products - * (16 products per field multiply, vs C's 5×52-bit with 25). On JVM 9+, multiplyHigh - * maps to a single hardware instruction (IMULH on x86-64, SMULH on ARM64). - * All algorithmic optimizations from libsecp256k1 are implemented: GLV endomorphism, - * wNAF encoding, Shamir's trick, comb method, and optimized addition chains. + * Performance on JVM (vs native C/JNI secp256k1, well-warmed): + * verify ~5,600 ops/s (4.4× native) — Strauss + GLV + wNAF-12 + * sign ~16,700 ops/s (1.5× native) — comb method (cached pubkey) + * pubCreate ~23,400 ops/s (2.2× native) — comb method, 3 doublings + * ECDH ~7,100 ops/s (3.9× native) — GLV + wNAF-5, effective-affine + * compress ~4M ops/s (2× FASTER) — pure Kotlin, no JNI overhead + * secKeyVerify ~5.7M ops/s (FASTER) — scalar range check, no JNI + * + * Architecture: + * Field arithmetic uses 4×64-bit limbs (LongArray(4)) with Math.multiplyHigh + * for 64×64→128-bit products (16 products per field multiply). On JVM 9+, + * multiplyHigh maps to a single hardware instruction (IMULH/SMULH). The main + * gap vs native C is per-field-op cost: unsignedMultiplyHigh requires 5 JVM + * instructions per product vs C's single MULQ with native uint128. + * + * Optimizations implemented (matching or adapted from libsecp256k1): + * - GLV endomorphism: splits 256-bit scalars into 2×128-bit halves + * - wNAF encoding: windowed non-adjacent form for sparse addition patterns + * - Comb method: generator multiplication with only 3 doublings (Hamburg 2012) + * - Strauss/Shamir: interleaved multi-scalar multiplication for verification + * - Effective-affine: batch-inverts wNAF tables to affine for cheaper mixed adds + * - Batch inversion: Montgomery's trick (1 inv + 3(n-1) muls for n inversions) + * - ThreadLocal elimination: pre-fetched scratch buffers avoid ~500 lookups/op + * - Dedicated squaring: 10 products vs 16 for general multiplication + * + * Differences from C libsecp256k1 (due to JVM constraints): + * - No lazy reduction (4×64 limbs have no headroom; C uses 5×52 with 12-bit spare) + * - Fermat inversion (255 sqr + 15 mul) instead of safegcd (safegcd is slower on + * JVM due to 128-bit arithmetic overhead in the inner divstep matrix multiply) + * - WINDOW_G=12 instead of 15 (JVM heap-allocated tables cause cache pressure + * at larger sizes; C uses contiguous compile-time .rodata arrays) */ object Secp256k1 { // ==================== Cached BIP-340 tag hash prefixes ==================== diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256.kt index 9099e1b03b..ecd80cbd15 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/U256.kt @@ -32,6 +32,16 @@ package com.vitorpamplona.quartz.utils.secp256k1 // hardware instruction (IMULH on x86-64, SMULH on ARM64), reducing the inner product // count from 64 (with 8×32-bit limbs) to 16 per field multiplication. // +// Comparison with C libsecp256k1's 5×52-bit representation: +// Ours: 4 limbs × 16 products = 16 multiplyHigh calls per mul +// C: 5 limbs × 25 products = 25 native 64×64 multiplies per mul +// We do fewer products, but each costs ~5 JVM instructions (multiplyHigh + +// unsigned correction: 2 AND + 1 SHR + 2 ADD) vs C's single MULQ instruction. +// Net effect: ~2.2× slower per field multiply, which is the dominant cost. +// C also benefits from 12 bits of headroom per limb enabling lazy reduction +// (chaining 3-8 adds without normalizing); our fully-packed limbs require +// normalization after every add/sub. +// // Package structure: U256 → FieldP/ScalarN → Glv/KeyCodec → Point → Secp256k1 // ===================================================================================== diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt index 3855e3ccaa..a2e5be3b2c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt @@ -150,8 +150,8 @@ class Secp256k1Benchmark { results += bench( name = "signSchnorr", - warmup = 10, - iterations = 50, + warmup = 100, + iterations = 200, nativeOp = { native.signSchnorr(msg32, privKey, auxRand) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.signSchnorr( @@ -166,8 +166,8 @@ class Secp256k1Benchmark { results += bench( name = "signSchnorr (cached pk)", - warmup = 10, - iterations = 50, + warmup = 100, + iterations = 200, nativeOp = { native.signSchnorr(msg32, privKey, auxRand) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.signSchnorrWithPubKey( @@ -183,8 +183,8 @@ class Secp256k1Benchmark { results += bench( name = "pubkeyCreate", - warmup = 20, - iterations = 100, + warmup = 100, + iterations = 500, nativeOp = { native.pubkeyCreate(privKey) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 @@ -200,8 +200,8 @@ class Secp256k1Benchmark { results += bench( name = "pubKeyCompress", - warmup = 50, - iterations = 200, + warmup = 200, + iterations = 1000, nativeOp = { native.pubKeyCompress(uncompressedNative) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 @@ -213,8 +213,8 @@ class Secp256k1Benchmark { results += bench( name = "pubKeyTweakMul (ECDH)", - warmup = 30, - iterations = 100, + warmup = 100, + iterations = 200, nativeOp = { native.pubKeyTweakMul(pubKey2Uncompressed.copyOf(), privKey) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.pubKeyTweakMul( @@ -272,8 +272,8 @@ class Secp256k1Benchmark { results += bench( name = "tweakMulCompact (old)", - warmup = 10, - iterations = 50, + warmup = 100, + iterations = 200, nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 @@ -288,8 +288,8 @@ class Secp256k1Benchmark { results += bench( name = "ecdhXOnly (Nostr)", - warmup = 10, - iterations = 50, + warmup = 100, + iterations = 200, nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) }, kotlinOp = { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1