docs: update secp256k1 documentation with benchmark results and architecture notes
Update KDoc and file headers across Secp256k1.kt, Point.kt, FieldP.kt, and U256.kt with: - Accurate benchmark numbers (well-warmed: verify 4.4x, sign 1.5x, pubCreate 2.2x, ECDH 3.9x, compress 2x FASTER) - Detailed comparison with C libsecp256k1 architecture choices - Explanation of why certain C optimizations don't port to JVM: * Lazy reduction: 4x64 limbs have no headroom (C's 5x52 has 12 bits) * safegcd: slower on JVM due to 128-bit arithmetic overhead * WINDOW_G=15: cache pressure from heap-allocated objects (w=12 optimal) - Document effective-affine technique and batch inversion - Increase all benchmark warmup/iterations for stable measurements https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
// =====================================================================================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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
|
||||
// =====================================================================================
|
||||
|
||||
|
||||
+14
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user