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:
@@ -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 ====================
|
||||
|
||||
Reference in New Issue
Block a user