perf: eliminate ~15 allocations per signSchnorr, ~3 per verifySchnorr
Allocation audit from Android benchmark showed 19 allocs in signSchnorr and 4 in verifySchnorr. Most were intermediate ByteArray/LongArray that can be replaced with pre-allocated scratch buffers. Changes: - Add sha256Into() (expect/actual) that writes digest into existing buffer instead of allocating a new ByteArray(32) per call. Uses MessageDigest.digest(buf,off,len) on JVM/Android, CC_SHA256 on Apple. - Add scratch byte buffers to PointScratch: hashBuf(256), bytesTmp1/2(32), scalarTmp1/2/3 for intermediate scalar results. - Add ScalarN.reduceTo() allocation-free variant. - Rewrite signSchnorrInternal to reuse scratch buffers for: - dBytes serialization (bytesTmp1 instead of U256.toBytes alloc) - AUX_PREFIX+auxrand hash (hashBuf instead of array concatenation) - auxHash XOR (scalarTmp1/2/3 instead of U256.fromBytes allocs) - nonce/challenge hash inputs (hashBuf instead of ByteArray alloc) - nonce scalar (scalarTmp1 instead of ScalarN.reduce alloc) - challenge scalar (scalarTmp3 instead of allocs) - e*d and k+e*d (splitK1/entryTmp2 instead of ScalarN.mul/add allocs) - Rewrite verifySchnorr to use hashBuf and sha256Into for challenge hash. Only the 64-byte output signature is allocated per sign call. Verify allocates nothing for 32-byte messages (hashBuf is large enough). https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
This commit is contained in:
@@ -43,3 +43,21 @@ actual fun sha256(data: ByteArray): ByteArray {
|
||||
|
||||
return digest.toByteArray()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun sha256Into(
|
||||
out: ByteArray,
|
||||
data: ByteArray,
|
||||
len: Int,
|
||||
): ByteArray {
|
||||
data.usePinned { inputPinned ->
|
||||
out.asUByteArray().usePinned { digestPinned ->
|
||||
CC_SHA256(
|
||||
inputPinned.addressOf(0),
|
||||
len.convert(),
|
||||
digestPinned.addressOf(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -171,4 +171,22 @@ internal class PointScratch {
|
||||
@JvmField val entryTmp = LongArray(4)
|
||||
|
||||
@JvmField val entryTmp2 = LongArray(4)
|
||||
|
||||
// Pre-allocated byte buffers for sign/verify (eliminates ByteArray allocations).
|
||||
// hashBuf: reusable buffer for BIP-340 tagged hash inputs (prefix(64) + fields).
|
||||
// The max size is 64 + 32 + 32 + msgLen. For 32-byte messages (event IDs), that's 160.
|
||||
// For larger messages, signSchnorrInternal must still allocate.
|
||||
@JvmField val hashBuf = ByteArray(256)
|
||||
|
||||
// 32-byte scratch for serialized field elements / scalars (avoids U256.toBytes allocs)
|
||||
@JvmField val bytesTmp1 = ByteArray(32)
|
||||
|
||||
@JvmField val bytesTmp2 = ByteArray(32)
|
||||
|
||||
// Scratch LongArray(4) for intermediate scalar results (avoids ScalarN alloc)
|
||||
@JvmField val scalarTmp1 = LongArray(4)
|
||||
|
||||
@JvmField val scalarTmp2 = LongArray(4)
|
||||
|
||||
@JvmField val scalarTmp3 = LongArray(4)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ internal object ScalarN {
|
||||
a
|
||||
}
|
||||
|
||||
/** Allocation-free reduce: out = a mod n. Safe for out === a. */
|
||||
fun reduceTo(
|
||||
out: LongArray,
|
||||
a: LongArray,
|
||||
) {
|
||||
if (U256.cmp(a, N) >= 0) {
|
||||
U256.subTo(out, a, N)
|
||||
} else if (out !== a) {
|
||||
U256.copyInto(out, a)
|
||||
}
|
||||
}
|
||||
|
||||
fun add(
|
||||
a: LongArray,
|
||||
b: LongArray,
|
||||
|
||||
+52
-21
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.utils.secp256k1
|
||||
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256Into
|
||||
|
||||
/**
|
||||
* Pure Kotlin implementation of secp256k1 elliptic curve operations for Nostr.
|
||||
@@ -241,6 +242,7 @@ object Secp256k1 {
|
||||
auxrand: ByteArray?,
|
||||
): ByteArray {
|
||||
require(seckey.size == 32)
|
||||
// Allocate d0 separately — signSchnorrInternal uses all scalar scratch buffers.
|
||||
val d0 = U256.fromBytes(seckey)
|
||||
require(ScalarN.isValid(d0))
|
||||
|
||||
@@ -249,6 +251,8 @@ object Secp256k1 {
|
||||
ECPoint.mulG(sc.entryResult, d0)
|
||||
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
|
||||
|
||||
// Allocate xOnlyPub — signSchnorrInternal reuses bytesTmp1/2 internally.
|
||||
// This 32-byte allocation is negligible next to the mulG cost (~100μs).
|
||||
val xOnlyPub = U256.toBytes(sc.entryPx)
|
||||
return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(sc.entryPy), auxrand)
|
||||
}
|
||||
@@ -317,34 +321,46 @@ object Secp256k1 {
|
||||
auxrand: ByteArray?,
|
||||
): ByteArray {
|
||||
val sc = ECPoint.getScratch()
|
||||
val tmp = sc.entryTmp
|
||||
|
||||
val d =
|
||||
if (pubKeyHasEvenY) {
|
||||
d0
|
||||
} else {
|
||||
ScalarN.negTo(tmp, d0)
|
||||
tmp
|
||||
ScalarN.negTo(sc.entryTmp, d0)
|
||||
sc.entryTmp
|
||||
}
|
||||
val dBytes = U256.toBytes(d)
|
||||
// Serialize d into scratch byte buffer (avoids U256.toBytes allocation)
|
||||
val dBytes = sc.bytesTmp1
|
||||
U256.toBytesInto(d, dBytes, 0)
|
||||
|
||||
val tBytes: ByteArray
|
||||
if (auxrand != null) {
|
||||
require(auxrand.size == 32)
|
||||
val auxHash = sha256(AUX_PREFIX + auxrand)
|
||||
U256.xorTo(sc.entryTmp2, U256.fromBytes(dBytes), U256.fromBytes(auxHash))
|
||||
tBytes = U256.toBytes(sc.entryTmp2)
|
||||
// Build AUX_PREFIX + auxrand in scratch hashBuf (avoids concatenation alloc)
|
||||
AUX_PREFIX.copyInto(sc.hashBuf, 0)
|
||||
auxrand.copyInto(sc.hashBuf, 64)
|
||||
sha256Into(sc.bytesTmp2, sc.hashBuf, 96)
|
||||
// XOR d with auxHash — reuse limb scratch
|
||||
U256.fromBytesInto(sc.scalarTmp1, dBytes, 0)
|
||||
U256.fromBytesInto(sc.scalarTmp2, sc.bytesTmp2, 0)
|
||||
U256.xorTo(sc.scalarTmp3, sc.scalarTmp1, sc.scalarTmp2)
|
||||
tBytes = sc.bytesTmp2 // reuse bytesTmp2 for tBytes
|
||||
U256.toBytesInto(sc.scalarTmp3, tBytes, 0)
|
||||
} else {
|
||||
tBytes = dBytes
|
||||
}
|
||||
|
||||
val nonceInput = ByteArray(64 + 32 + 32 + data.size)
|
||||
// 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)
|
||||
tBytes.copyInto(nonceInput, 64)
|
||||
tBytes.copyInto(nonceInput, 64, 0, 32)
|
||||
pBytes.copyInto(nonceInput, 96)
|
||||
data.copyInto(nonceInput, 128)
|
||||
val rand = sha256(nonceInput)
|
||||
val k0 = ScalarN.reduce(U256.fromBytes(rand))
|
||||
sha256Into(sc.bytesTmp2, nonceInput, nonceLen) // rand → bytesTmp2
|
||||
U256.fromBytesInto(sc.scalarTmp1, sc.bytesTmp2, 0)
|
||||
ScalarN.reduceTo(sc.scalarTmp1, sc.scalarTmp1)
|
||||
val k0 = sc.scalarTmp1
|
||||
require(!U256.isZero(k0))
|
||||
|
||||
// R = k0·G
|
||||
@@ -353,22 +369,35 @@ object Secp256k1 {
|
||||
val ry = sc.entryPy
|
||||
check(ECPoint.toAffine(sc.entryResult, rx, ry, sc))
|
||||
|
||||
val k = if (KeyCodec.hasEvenY(ry)) k0 else ScalarN.neg(k0)
|
||||
val k =
|
||||
if (KeyCodec.hasEvenY(ry)) {
|
||||
k0
|
||||
} else {
|
||||
ScalarN.negTo(sc.scalarTmp2, k0)
|
||||
sc.scalarTmp2
|
||||
}
|
||||
|
||||
// Challenge: e = H(R || P || msg)
|
||||
val chalInput = ByteArray(64 + 32 + 32 + data.size)
|
||||
// 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)
|
||||
U256.toBytesInto(rx, chalInput, 64)
|
||||
pBytes.copyInto(chalInput, 96)
|
||||
data.copyInto(chalInput, 128)
|
||||
val eHash = sha256(chalInput)
|
||||
val e = ScalarN.reduce(U256.fromBytes(eHash))
|
||||
sha256Into(sc.bytesTmp1, chalInput, chalLen) // eHash → bytesTmp1
|
||||
U256.fromBytesInto(sc.scalarTmp3, sc.bytesTmp1, 0)
|
||||
ScalarN.reduceTo(sc.scalarTmp3, sc.scalarTmp3)
|
||||
val e = sc.scalarTmp3
|
||||
|
||||
// s = k + e·d mod n
|
||||
val sScalar = ScalarN.add(k, ScalarN.mul(e, d))
|
||||
// Note: d may alias sc.entryTmp (when !pubKeyHasEvenY), so use splitK1 for mulTo output.
|
||||
ScalarN.mulTo(sc.splitK1, e, d, sc.splitWide) // e·d → splitK1
|
||||
ScalarN.addTo(sc.entryTmp2, k, sc.splitK1) // k + e·d → entryTmp2
|
||||
|
||||
// Build output signature (the only required allocation)
|
||||
val sig = ByteArray(64)
|
||||
U256.toBytesInto(rx, sig, 0)
|
||||
U256.toBytesInto(sScalar, sig, 32)
|
||||
U256.toBytesInto(sc.entryTmp2, sig, 32)
|
||||
return sig
|
||||
}
|
||||
|
||||
@@ -406,13 +435,15 @@ object Secp256k1 {
|
||||
U256.fromBytesInto(s, signature, 32)
|
||||
if (U256.cmp(s, ScalarN.N) >= 0) return false
|
||||
|
||||
// Build challenge hash input in a single array: prefix(64) + r(32) + pub(32) + data(N)
|
||||
val hashInput = ByteArray(64 + 32 + 32 + data.size)
|
||||
// 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 = sha256(hashInput)
|
||||
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)
|
||||
|
||||
@@ -21,3 +21,14 @@
|
||||
package com.vitorpamplona.quartz.utils.sha256
|
||||
|
||||
expect fun sha256(data: ByteArray): ByteArray
|
||||
|
||||
/**
|
||||
* Allocation-free SHA-256: hash `data[0..len)` and write the 32-byte digest into `out`.
|
||||
* Returns `out` for convenience. `out` must be at least 32 bytes.
|
||||
* `data` may be longer than `len` (only the first `len` bytes are hashed).
|
||||
*/
|
||||
expect fun sha256Into(
|
||||
out: ByteArray,
|
||||
data: ByteArray,
|
||||
len: Int = data.size,
|
||||
): ByteArray
|
||||
|
||||
+11
@@ -37,6 +37,17 @@ val threadLocalDigest =
|
||||
|
||||
actual fun sha256(data: ByteArray): ByteArray = threadLocalDigest.get().digest(data)
|
||||
|
||||
actual fun sha256Into(
|
||||
out: ByteArray,
|
||||
data: ByteArray,
|
||||
len: Int,
|
||||
): ByteArray {
|
||||
val md = threadLocalDigest.get()
|
||||
md.update(data, 0, len)
|
||||
md.digest(out, 0, 32)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA256 hash while counting bytes read from the stream.
|
||||
* Returns both the hash and the number of bytes processed.
|
||||
|
||||
@@ -28,3 +28,15 @@ actual fun sha256(data: ByteArray): ByteArray {
|
||||
val hasher = provider.get(SHA256).hasher()
|
||||
return hasher.hashBlocking(data)
|
||||
}
|
||||
|
||||
actual fun sha256Into(
|
||||
out: ByteArray,
|
||||
data: ByteArray,
|
||||
len: Int,
|
||||
): ByteArray {
|
||||
// Linux cryptography provider doesn't support writing into existing buffer.
|
||||
// Fall back to allocating and copying.
|
||||
val hash = sha256(if (len == data.size) data else data.copyOfRange(0, len))
|
||||
hash.copyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user