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:
Claude
2026-04-06 17:32:50 +00:00
parent beaf452d55
commit ef7790b775
5 changed files with 93 additions and 31 deletions
@@ -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×64128-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×64128-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
// =====================================================================================
@@ -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