perf: add toAffineX for x-only ECDH, benchmark ecdhXOnly path

- Add ECPoint.toAffineX that computes only x = X/Z² (saves 2M vs full
  toAffine which also computes Y/Z³)
- Use toAffineX in ecdhXOnly since only the x-coordinate is needed
- Add ecdhXOnly benchmark measuring the actual Nostr ECDH production
  path (Secp256k1Instance.pubKeyTweakMulCompact delegates to ecdhXOnly)
- Fix ktlint KDoc-inside-class-body violations

The old benchmark measured pubKeyTweakMul(02||x, key) which pays for:
array allocation, compressed key parsing (sqrt), full toAffine, and
re-serialization. ecdhXOnly avoids the array overhead and serialization.

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-06 14:27:30 +00:00
parent 547be89577
commit 2661bf783d
3 changed files with 48 additions and 30 deletions
@@ -265,20 +265,16 @@ internal object ECPoint {
// ==================== Point Doubling (3M + 4S) ====================
/**
* Point doubling: out = 2·p.
*
* Uses the optimized formula from libsecp256k1 for curves with a=0:
* S = Y², L = (3/2)·X², T = -X·S
* X₃ = L² + 2T, Y₃ = -(L·(X₃+T) + S²), Z₃ = Y·Z
*
* Cost: 3 multiplications + 4 squarings + field halving + adds/negations.
* The key trick is computing L = (3/2)·X² using fe_half instead of an extra
* multiplication — halving is just a carry-propagating right shift.
*
* Safe for out === inp (in-place doubling) via internal copy buffer.
*/
/** doublePoint with ThreadLocal scratch (convenience for non-hot paths). */
// Point doubling: out = 2·p.
//
// Uses the optimized formula from libsecp256k1 for curves with a=0:
// S = Y², L = (3/2)·X², T = -X·S
// X₃ = L² + 2T, Y₃ = -(L·(X₃+T) + S²), Z₃ = Y·Z
//
// Cost: 3 multiplications + 4 squarings + field halving + adds/negations.
// Safe for out === inp (in-place doubling) via internal copy buffer.
// doublePoint with ThreadLocal scratch (convenience for non-hot paths).
fun doublePoint(
out: MutablePoint,
inp: MutablePoint,
@@ -324,18 +320,10 @@ internal object ECPoint {
// ==================== Mixed Addition: Jacobian + Affine (8M + 3S) ====================
/**
* Mixed point addition: out = p + (qx, qy) where q is an affine point (Z=1).
*
* When one input is affine, we can skip computing Z₂², Z₂³, and the Z₃ formula
* simplifies. This saves 4 multiplications and 1 squaring vs full Jacobian addition.
*
* Cost: 8 multiplications + 3 squarings + adds/subtractions.
* Used for additions from the precomputed G table (always stored in affine form).
*
* Handles degenerate cases: p is infinity, or p equals/negates q.
*/
/** addMixed with ThreadLocal scratch (convenience for non-hot paths). */
// Mixed point addition: out = p + (qx, qy) where q is an affine point (Z=1).
// Cost: 8M + 3S. Saves 4M+1S vs full Jacobian by exploiting Z₂=1.
// addMixed with ThreadLocal scratch (convenience for non-hot paths).
fun addMixed(
out: MutablePoint,
p: MutablePoint,
@@ -729,6 +717,24 @@ internal object ECPoint {
return true
}
/**
* Convert from Jacobian to affine, returning only the x-coordinate: x = X/Z².
* Saves 2 multiplications vs full toAffine (no zInv3, no outY computation).
* Used by ecdhXOnly where only the x-coordinate of the shared point is needed.
*/
fun toAffineX(
p: MutablePoint,
outX: LongArray,
): Boolean {
if (p.isInfinity()) return false
val zInv = LongArray(4)
val zInv2 = LongArray(4)
FieldP.inv(zInv, p.z)
FieldP.sqr(zInv2, zInv)
FieldP.mul(outX, p.x, zInv2)
return true
}
// ==================== Key Encoding (delegates to KeyCodec) ====================
fun liftX(
@@ -412,8 +412,7 @@ object Secp256k1 {
val result = MutablePoint()
ECPoint.mul(result, p, k)
val rx = LongArray(4)
val ry = LongArray(4)
check(ECPoint.toAffine(result, rx, ry))
check(ECPoint.toAffineX(result, rx))
return U256.toBytes(rx)
}
@@ -267,11 +267,11 @@ class Secp256k1Benchmark {
},
)
// --- pubKeyTweakMulCompact (the actual Secp256k1Instance pattern) ---
// --- pubKeyTweakMulCompact (old pattern via pubKeyTweakMul) ---
val pub2xOnly = hexToBytes("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
results +=
bench(
name = "pubKeyTweakMulCompact",
name = "tweakMulCompact (old)",
warmup = 10,
iterations = 50,
nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) },
@@ -284,6 +284,19 @@ class Secp256k1Benchmark {
},
)
// --- ecdhXOnly (actual Nostr ECDH path) ---
results +=
bench(
name = "ecdhXOnly (Nostr)",
warmup = 10,
iterations = 50,
nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.ecdhXOnly(pub2xOnly, privKey)
},
)
// Print results
println()
println("=".repeat(90))