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:
Claude
2026-04-09 02:32:09 +00:00
parent 65652d1256
commit c915c2b883
7 changed files with 134 additions and 21 deletions
@@ -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,
@@ -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
@@ -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
}