perf: optimize secp256k1 verify bytecode — fix JIT/branch issues

Three bytecode-level optimizations to the Schnorr verify path:

1. Replace `by lazy` delegates with direct field init for tag hash
   prefixes (CHALLENGE_PREFIX, AUX_PREFIX, NONCE_PREFIX). Eliminates
   Lazy.getValue() interface dispatch + checkcast on every call.

2. Use explicit copyInto parameters everywhere, eliminating the
   copyInto$default bridge method (bitmask + 3 branches + arraylength
   per call). 4 calls per verify × 3 using defaults = 12 extra branches
   removed from the hot path.

3. Extract shared verify computation into verifySchnorrCore(), called
   by both verifySchnorr() and verifySchnorrFast(). Previously, two
   ~400-bytecode near-identical methods competed for JIT optimization.
   Now one hot method gets compiled, and both public methods are thin
   wrappers (34 and 130 bytecodes).

JVM benchmark before: verifySchnorrFast was 5% SLOWER than verifySchnorr
(60,930 vs 57,876 ns) due to JIT warmup ordering bias.

JVM benchmark after: verifySchnorrFast is 11% faster (50,345 vs 56,143 ns),
matching the expected ~14% savings from skipping the field inversion.

https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
This commit is contained in:
Claude
2026-04-09 16:43:20 +00:00
parent 8710c30261
commit 39ce3ad260
@@ -65,18 +65,9 @@ object Secp256k1 {
// The tag prefixes SHA256(tag) || SHA256(tag) are constant per tag string.
// We precompute them once to save 2 SHA256 calls per sign/verify operation.
private val CHALLENGE_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/challenge".encodeToByteArray())
h + h
}
private val AUX_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/aux".encodeToByteArray())
h + h
}
private val NONCE_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/nonce".encodeToByteArray())
h + h
}
private val CHALLENGE_PREFIX: ByteArray = sha256("BIP0340/challenge".encodeToByteArray()).let { it + it }
private val AUX_PREFIX: ByteArray = sha256("BIP0340/aux".encodeToByteArray()).let { it + it }
private val NONCE_PREFIX: ByteArray = sha256("BIP0340/nonce".encodeToByteArray()).let { it + it }
// ==================== Pubkey decompression cache ====================
//
@@ -119,8 +110,8 @@ object Secp256k1 {
val cached = pubkeyCache[slot]
if (cached != null && cached.keyBytes.contentEquals(pub)) {
// Cache hit — copy pre-computed coordinates
cached.px.copyInto(outX)
cached.py.copyInto(outY)
cached.px.copyInto(outX, 0, 0, 4)
cached.py.copyInto(outY, 0, 0, 4)
return true
}
@@ -337,8 +328,8 @@ object Secp256k1 {
if (auxrand != null) {
require(auxrand.size == 32)
// Build AUX_PREFIX + auxrand in scratch hashBuf (avoids concatenation alloc)
AUX_PREFIX.copyInto(sc.hashBuf, 0)
auxrand.copyInto(sc.hashBuf, 64)
AUX_PREFIX.copyInto(sc.hashBuf, 0, 0, 64)
auxrand.copyInto(sc.hashBuf, 64, 0, 32)
sha256Into(sc.bytesTmp2, sc.hashBuf, 96)
// XOR d with auxHash — reuse limb scratch
U256.fromBytesInto(sc.scalarTmp1, dBytes, 0)
@@ -353,10 +344,10 @@ object Secp256k1 {
// Build nonce input. Reuse hashBuf if it fits.
val nonceLen = 64 + 32 + 32 + data.size
val nonceInput = if (nonceLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(nonceLen)
NONCE_PREFIX.copyInto(nonceInput, 0)
NONCE_PREFIX.copyInto(nonceInput, 0, 0, 64)
tBytes.copyInto(nonceInput, 64, 0, 32)
pBytes.copyInto(nonceInput, 96)
data.copyInto(nonceInput, 128)
pBytes.copyInto(nonceInput, 96, 0, 32)
data.copyInto(nonceInput, 128, 0, data.size)
sha256Into(sc.bytesTmp2, nonceInput, nonceLen) // rand → bytesTmp2
U256.fromBytesInto(sc.scalarTmp1, sc.bytesTmp2, 0)
ScalarN.reduceTo(sc.scalarTmp1, sc.scalarTmp1)
@@ -380,10 +371,10 @@ object Secp256k1 {
// Challenge: e = H(R || P || msg) — reuse hashBuf
val chalLen = 64 + 32 + 32 + data.size
val chalInput = if (chalLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(chalLen)
CHALLENGE_PREFIX.copyInto(chalInput, 0)
CHALLENGE_PREFIX.copyInto(chalInput, 0, 0, 64)
U256.toBytesInto(rx, chalInput, 64)
pBytes.copyInto(chalInput, 96)
data.copyInto(chalInput, 128)
pBytes.copyInto(chalInput, 96, 0, 32)
data.copyInto(chalInput, 128, 0, data.size)
sha256Into(sc.bytesTmp1, chalInput, chalLen) // eHash → bytesTmp1
U256.fromBytesInto(sc.scalarTmp3, sc.bytesTmp1, 0)
ScalarN.reduceTo(sc.scalarTmp3, sc.scalarTmp3)
@@ -422,45 +413,8 @@ object Secp256k1 {
pub: ByteArray,
): Boolean {
if (signature.size != 64 || pub.size != 32) return false
// Use thread-local scratch to avoid per-verify allocations.
// Saves ~10 LongArray(4) + 2 MutablePoint = ~14 object allocations per call.
val sc = ECPoint.getScratch()
if (!liftXCached(sc.entryPx, sc.entryPy, pub)) return false
val r = sc.entryTmp
U256.fromBytesInto(r, signature, 0)
if (U256.cmp(r, FieldP.P) >= 0) return false
val s = sc.entryTmp2
U256.fromBytesInto(s, signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Build challenge hash input. Reuse scratch byte buffer if message fits,
// otherwise allocate (rare for Nostr: event IDs are 32 bytes → total 160).
val hashLen = 64 + 32 + 32 + data.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
signature.copyInto(hashInput, 64, 0, 32) // r bytes from signature
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen)
// 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
// 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
// Check x-coordinate in Jacobian FIRST (2 field ops, no inversion): X/Z² == r → X == r·Z².
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
if (!verifySchnorrCore(signature, data, pub, sc)) return false
// x matches — check y-parity (requires inversion, ~270 field ops)
FieldP.inv(sc.zInv, sc.entryResult.z)
@@ -497,8 +451,29 @@ object Secp256k1 {
pub: ByteArray,
): Boolean {
if (signature.size != 64 || pub.size != 32) return false
val sc = ECPoint.getScratch()
return verifySchnorrCore(signature, data, pub, sc)
}
/**
* Shared core of Schnorr verification: validates inputs, computes
* Q = s·G + (-e)·P via Shamir's trick, and checks that Q.x matches
* the signature's r value in Jacobian coordinates (no inversion).
*
* Leaves the Jacobian result point in [sc].entryResult for callers
* that need additional checks (e.g., y-parity in [verifySchnorr]).
*
* By extracting this into a single method, the JIT compiles one hot path
* for the expensive mulDoubleG instead of two near-identical method bodies.
* All copyInto calls use explicit parameters to avoid the Kotlin
* copyInto$default bridge (bitmask + 3 branches + arraylength per call).
*/
private fun verifySchnorrCore(
signature: ByteArray,
data: ByteArray,
pub: ByteArray,
sc: PointScratch,
): Boolean {
if (!liftXCached(sc.entryPx, sc.entryPy, pub)) return false
val r = sc.entryTmp
@@ -508,30 +483,31 @@ object Secp256k1 {
U256.fromBytesInto(s, signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Build challenge hash
// Build challenge hash input. Reuse scratch byte buffer if message fits,
// otherwise allocate (rare for Nostr: event IDs are 32 bytes → total 160).
val hashLen = 64 + 32 + 32 + data.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
CHALLENGE_PREFIX.copyInto(hashInput, 0, 0, 64)
signature.copyInto(hashInput, 64, 0, 32)
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
pub.copyInto(hashInput, 96, 0, 32)
data.copyInto(hashInput, 128, 0, data.size)
val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen)
// 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)
if (U256.cmp(e, ScalarN.N) >= 0) U256.subTo(e, e, ScalarN.N) // inline reduce
// Q = s·G + (-e)·P
ScalarN.negTo(e, e)
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
// 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
// Jacobian x-check only — no inversion, no y-parity check.
// Saves ~270 field ops (~14% of verify).
// Jacobian x-check: X == r·Z² (2 field ops, no inversion)
val w = sc.w
FieldP.sqr(sc.zInv2, sc.entryResult.z, w)
FieldP.mul(sc.zInv3, r, sc.zInv2, w)
FieldP.sqr(sc.zInv2, sc.entryResult.z, w) // Z²
FieldP.mul(sc.zInv3, r, sc.zInv2, w) // r·Z²
return U256.cmp(sc.entryResult.x, sc.zInv3) == 0
}
@@ -696,10 +672,10 @@ object Secp256k1 {
// Compute challenge eᵢ = H(rᵢ || pub || msgᵢ) using scratch buffers
val hashLen = 64 + 32 + 32 + msg.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
CHALLENGE_PREFIX.copyInto(hashInput, 0, 0, 64)
sig.copyInto(hashInput, 64, 0, 32)
pub.copyInto(hashInput, 96)
msg.copyInto(hashInput, 128)
pub.copyInto(hashInput, 96, 0, 32)
msg.copyInto(hashInput, 128, 0, msg.size)
sha256Into(sc.bytesTmp1, hashInput, hashLen)
U256.fromBytesInto(e, sc.bytesTmp1, 0)
ScalarN.reduceTo(e, e)