perf: Jacobian x-check + inline wNAF zero-checks in verify

Two optimizations for verifySchnorr:

1. Jacobian x-coordinate check before toAffine:
   Instead of converting to affine first (1 inversion = ~270 field ops),
   check X == r·Z² in Jacobian coordinates (1 sqr + 1 mul = 2 ops).
   Invalid signatures (x mismatch) are rejected immediately without
   paying the inversion cost. Valid signatures still need inversion for
   the y-parity check. For Nostr, ~all sigs are valid so this mainly
   helps adversarial/spam rejection.

2. Inline wNAF zero-checks in mulDoubleG inner loop:
   ~70% of wNAF digits are zero. Previously, each still called
   addWnafMixedPP (function call overhead: null checks, frame setup).
   Now the zero-check is inlined before the call, avoiding ~364
   function calls per verify. On ART (5-8ns per call), this saves
   ~2-3μs. On HotSpot, the JIT already optimized this — no change.

https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
This commit is contained in:
Claude
2026-04-09 01:17:25 +00:00
parent 41e54ca33e
commit ec087304ad
2 changed files with 78 additions and 12 deletions
@@ -677,29 +677,80 @@ internal object ECPoint {
cur.setInfinity()
val negY = sc.mixNegY
// Inner loop: double + conditionally add from 4 wNAF streams.
//
// The wNAF zero-check is INLINED here rather than delegated to
// addWnafMixedPP. ~70% of digits are zero, so this avoids ~364
// function calls per verify. Each function call on ART has ~5-8ns
// overhead (parameter null checks, frame setup), saving ~2-3μs total.
val negK1s = sSplit.negK1
val negK2s = sSplit.negK2
val negK1e = eSplit.negK1
val negK2e = eSplit.negK2
for (i in bits - 1 downTo 0) {
doublePoint(alt, cur, sc)
var t = cur
cur = alt
alt = t
// Streams 1-2: G-side (affine tables, mixed addition)
if (addWnafMixedPP(cur, alt, negY, wnafS1, i, gOdd, sSplit.negK1, sc)) {
var d: Int
// Stream 1: s₁ (G-side)
d = wnafS1[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK1s) {
FieldP.neg(negY, gOdd[idx].y)
addMixed(alt, cur, gOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, gOdd[idx].x, gOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
if (addWnafMixedPP(cur, alt, negY, wnafS2, i, gLam, sSplit.negK2, sc)) {
// Stream 2: s₂ (λ(G)-side)
d = wnafS2[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK2s) {
FieldP.neg(negY, gLam[idx].y)
addMixed(alt, cur, gLam[idx].x, negY, sc)
} else {
addMixed(alt, cur, gLam[idx].x, gLam[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
// Streams 3-4: P-side (affine tables via effective-affine, mixed addition)
if (addWnafMixedPP(cur, alt, negY, wnafE1, i, pOdd, eSplit.negK1, sc)) {
// Stream 3: e₁ (P-side)
d = wnafE1[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK1e) {
FieldP.neg(negY, pOdd[idx].y)
addMixed(alt, cur, pOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, pOdd[idx].x, pOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
if (addWnafMixedPP(cur, alt, negY, wnafE2, i, pLamOdd, eSplit.negK2, sc)) {
// Stream 4: e₂ (λ(P)-side)
d = wnafE2[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK2e) {
FieldP.neg(negY, pLamOdd[idx].y)
addMixed(alt, cur, pLamOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, pLamOdd[idx].x, pLamOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
@@ -391,20 +391,35 @@ object Secp256k1 {
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
val eHash = sha256(hashInput)
// Reuse entryPx for e (liftX result already copied into pPoint below)
val e = sc.zInv // safe: zInv not used until toAffine after mulDoubleG
// Reuse zInv for e (safe: zInv not used until toAffine, which we skip here)
val e = sc.zInv
U256.fromBytesInto(e, eHash, 0)
if (U256.cmp(e, ScalarN.N) >= 0) U256.subTo(e, e, ScalarN.N) // inline reduce
// R = s·G + (-e)·P via Shamir's trick
// Q = s·G + (-e)·P via Shamir's trick
ScalarN.negTo(e, e) // negate in-place
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy) // copies px/py, so entryPx is free
ECPoint.mulDoubleG(sc.entryResult, s, sc.entryPoint, e)
if (sc.entryResult.isInfinity()) return false
if (!ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc)) return false
if (!KeyCodec.hasEvenY(sc.entryPy)) return false
return U256.cmp(sc.entryPx, r) == 0
// Check x-coordinate in Jacobian FIRST (2 field ops, no inversion).
// This is what C libsecp256k1 does: X/Z² == r → X == r·Z².
// For invalid signatures (~any non-matching x), this saves the expensive
// toAffine inversion (~270 field ops). For valid signatures (Nostr's
// common case), we still need toAffine for the y-parity check.
val w = sc.w
FieldP.sqr(sc.zInv2, sc.entryResult.z, w) // Z²
FieldP.mul(sc.zInv3, r, sc.zInv2, w) // r·Z²
if (U256.cmp(sc.entryResult.x, sc.zInv3) != 0) return false // x mismatch → reject fast
// x matches — now check y-parity (requires inversion)
FieldP.inv(sc.zInv, sc.entryResult.z)
// We already have Z² from above; compute Z^(-2) = inv(Z)², Z^(-3) = Z^(-2) · Z^(-1)
FieldP.sqr(sc.zInv2, sc.zInv)
FieldP.mul(sc.zInv3, sc.zInv2, sc.zInv)
FieldP.mul(sc.entryPy, sc.entryResult.y, sc.zInv3)
return KeyCodec.hasEvenY(sc.entryPy)
}
// ==================== Tweak operations ====================