diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Secp256k1Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Secp256k1Benchmark.kt index 2909e8d5f..7210c36cd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Secp256k1Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Secp256k1Benchmark.kt @@ -25,7 +25,7 @@ import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.Secp256k1Instance -import com.vitorpamplona.quartz.utils.Secp256k1InstanceOurs +import com.vitorpamplona.quartz.utils.Secp256k1InstanceKotlin import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Rule @@ -70,13 +70,15 @@ class Secp256k1Benchmark { // Batch verification data private val batch8 = buildBatch(8) private val batch16 = buildBatch(16) + private val batch32 = buildBatch(32) + private val batch200 = buildBatch(200) private fun buildBatch(size: Int): Pair, List> { val sigs = mutableListOf() val msgs = mutableListOf() for (i in 0 until size) { val m = ByteArray(32) { (i * 7 + it).toByte() } - sigs.add(Secp256k1InstanceOurs.signSchnorr(m, privKey, auxRand)) + sigs.add(Secp256k1InstanceKotlin.signSchnorr(m, privKey, auxRand)) msgs.add(m) } return Pair(sigs, msgs) @@ -99,7 +101,7 @@ class Secp256k1Benchmark { } @Test - fun compressedPubKeyFor() { + fun xPubKeyFor() { benchmarkRule.measureRepeated { assertNotNull(Secp256k1Instance.compressedPubKeyFor(privKey)) } @@ -113,14 +115,14 @@ class Secp256k1Benchmark { } @Test - fun privateKeyAdd() { + fun privateKeyTweakAdd() { benchmarkRule.measureRepeated { assertNotNull(Secp256k1Instance.privateKeyAdd(privKey, privKey2)) } } @Test - fun ecdhXOnly() { + fun pubKeyTweakMul() { benchmarkRule.measureRepeated { assertNotNull(Secp256k1Instance.pubKeyTweakMulCompact(pubKey2XOnly, privKey)) } @@ -129,58 +131,58 @@ class Secp256k1Benchmark { // ==================== Pure Kotlin ==================== @Test - fun verifySchnorrOurs() { + fun verifySchnorrKotlin() { benchmarkRule.measureRepeated { - assertTrue(Secp256k1InstanceOurs.verifySchnorr(signature, msg32, xOnlyPub)) + assertTrue(Secp256k1InstanceKotlin.verifySchnorr(signature, msg32, xOnlyPub)) } } @Test - fun verifySchnorrFastOurs() { + fun verifySchnorrXPubkeyKotlin() { benchmarkRule.measureRepeated { - assertTrue(Secp256k1InstanceOurs.verifySchnorrFast(signature, msg32, xOnlyPub)) + assertTrue(Secp256k1InstanceKotlin.verifySchnorrFast(signature, msg32, xOnlyPub)) } } @Test - fun signSchnorrOurs() { + fun signSchnorrKotlin() { benchmarkRule.measureRepeated { - assertNotNull(Secp256k1InstanceOurs.signSchnorr(msg32, privKey, auxRand)) + assertNotNull(Secp256k1InstanceKotlin.signSchnorr(msg32, privKey, auxRand)) } } @Test - fun signSchnorrCachedPkOurs() { + fun signSchnorrXPubkeyKotlin() { benchmarkRule.measureRepeated { - assertNotNull(Secp256k1InstanceOurs.signSchnorrWithXOnlyPubKey(msg32, privKey, xOnlyPub, auxRand)) + assertNotNull(Secp256k1InstanceKotlin.signSchnorrWithXOnlyPubKey(msg32, privKey, xOnlyPub, auxRand)) } } @Test - fun compressedPubKeyForOurs() { + fun xPubKeyForKotlin() { benchmarkRule.measureRepeated { - assertNotNull(Secp256k1InstanceOurs.compressedPubKeyFor(privKey)) + assertNotNull(Secp256k1InstanceKotlin.compressedPubKeyFor(privKey)) } } @Test - fun secKeyVerifyOurs() { + fun secKeyVerifyKotlin() { benchmarkRule.measureRepeated { - assertTrue(Secp256k1InstanceOurs.isPrivateKeyValid(privKey)) + assertTrue(Secp256k1InstanceKotlin.isPrivateKeyValid(privKey)) } } @Test - fun privateKeyAddOurs() { + fun privateKeyTweakAddKotlin() { benchmarkRule.measureRepeated { - assertNotNull(Secp256k1InstanceOurs.privateKeyAdd(privKey, privKey2)) + assertNotNull(Secp256k1InstanceKotlin.privateKeyAdd(privKey, privKey2)) } } @Test - fun ecdhXOnlyOurs() { + fun pubKeyTweakMulKotlin() { benchmarkRule.measureRepeated { - assertNotNull(Secp256k1InstanceOurs.pubKeyTweakMulCompact(pubKey2XOnly, privKey)) + assertNotNull(Secp256k1InstanceKotlin.pubKeyTweakMulCompact(pubKey2XOnly, privKey)) } } @@ -189,16 +191,30 @@ class Secp256k1Benchmark { // Per-event cost = ns/op ÷ batchSize. @Test - fun verifyBatch8Ours() { + fun verifyBatch8Kotlin() { benchmarkRule.measureRepeated { - assertTrue(Secp256k1InstanceOurs.verifySchnorrBatch(xOnlyPub, batch8.first, batch8.second)) + assertTrue(Secp256k1InstanceKotlin.verifySchnorrBatch(xOnlyPub, batch8.first, batch8.second)) } } @Test - fun verifyBatch16Ours() { + fun verifyBatch16Kotlin() { benchmarkRule.measureRepeated { - assertTrue(Secp256k1InstanceOurs.verifySchnorrBatch(xOnlyPub, batch16.first, batch16.second)) + assertTrue(Secp256k1InstanceKotlin.verifySchnorrBatch(xOnlyPub, batch16.first, batch16.second)) + } + } + + @Test + fun verifyBatch32Kotlin() { + benchmarkRule.measureRepeated { + assertTrue(Secp256k1InstanceKotlin.verifySchnorrBatch(xOnlyPub, batch32.first, batch32.second)) + } + } + + @Test + fun verifyBatch200Kotlin() { + benchmarkRule.measureRepeated { + assertTrue(Secp256k1InstanceKotlin.verifySchnorrBatch(xOnlyPub, batch200.first, batch200.second)) } } } diff --git a/quartz/benchmarks/run_all.sh b/quartz/benchmarks/run_all.sh index b0bf22386..f91cf4d59 100755 --- a/quartz/benchmarks/run_all.sh +++ b/quartz/benchmarks/run_all.sh @@ -51,7 +51,6 @@ if [ -z "$SO_PATH" ]; then fi WORK=$(mktemp -d) -trap "rm -rf $WORK" EXIT (cd "$WORK" && jar xf "$SO_PATH") 2>/dev/null || true SO_FILE=$(find "$WORK" -name "$SO_NAME" -path "$SO_PATH_PATTERN" 2>/dev/null | head -1) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceOurs.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceKotlin.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceOurs.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceKotlin.kt index 0f73bd6de..afb4d38b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceOurs.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1InstanceKotlin.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 -object Secp256k1InstanceOurs { +object Secp256k1InstanceKotlin { private val h02 = Hex.decode("02") fun compressedPubKeyFor(privKey: ByteArray) = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privKey)) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt index 03a7356b2..1aa305208 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1Benchmark.kt @@ -79,13 +79,23 @@ class Secp256k1Benchmark { val ratio get() = kotlinNanos.toDouble() / nativeNanos.toDouble() override fun toString(): String = - String.format( - "%-25s Native: %,8d ops/s Kotlin: %,8d ops/s Ratio: %.1fx", - name, - nativeOpsPerSec, - kotlinOpsPerSec, - ratio, - ) + if (ratio > 1) { + String.format( + "%-25s %,10d ops/s %,10d ops/s %.1fx slower", + name, + nativeOpsPerSec, + kotlinOpsPerSec, + ratio, + ) + } else { + String.format( + "%-25s %,10d ops/s %,10d ops/s %.1fx faster", + name, + nativeOpsPerSec, + kotlinOpsPerSec, + ratio, + ) + } } private inline fun bench( @@ -135,7 +145,7 @@ class Secp256k1Benchmark { // --- verifySchnorrFast (Nostr: x-check only, no y-parity inversion) --- results += bench( - name = "verifySchnorrFast", + name = "verifySchnorr (XPubKey)", warmup = 2000, iterations = 5000, nativeOp = { native.verifySchnorr(nativeSig, msg32, nativeXOnlyPub) }, @@ -164,6 +174,27 @@ class Secp256k1Benchmark { }, ) + // --- signSchnorr (cached pk) --- + // NOTE: The C library's signSchnorr always derives the pubkey internally. + // Our signSchnorrWithPubKey skips that derivation. This is NOT an apples-to-apples + // comparison — it shows what's possible when the caller caches the compressed pubkey. + // The native baseline here is the same signSchnorr (with pubkey derivation). + results += + bench( + name = "signSchnorr (XPubKey)", + warmup = 1000, + iterations = 5000, + nativeOp = { native.signSchnorr(msg32, privKey, auxRand) }, + kotlinOp = { + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.signSchnorrWithPubKey( + msg32, + privKey, + nativePubKey, + auxRand, + ) + }, + ) + // --- signSchnorr (derives pubkey from seckey each time — both sides do the same work) --- results += bench( @@ -180,31 +211,10 @@ class Secp256k1Benchmark { }, ) - // --- signSchnorr (cached pk) --- - // NOTE: The C library's signSchnorr always derives the pubkey internally. - // Our signSchnorrWithPubKey skips that derivation. This is NOT an apples-to-apples - // comparison — it shows what's possible when the caller caches the compressed pubkey. - // The native baseline here is the same signSchnorr (with pubkey derivation). - results += - bench( - name = "signSchnorr (cached pk)", - warmup = 1000, - iterations = 5000, - nativeOp = { native.signSchnorr(msg32, privKey, auxRand) }, - kotlinOp = { - com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.signSchnorrWithPubKey( - msg32, - privKey, - nativePubKey, - auxRand, - ) - }, - ) - // --- compressedPubKeyFor (create + compress combined) --- results += bench( - name = "compressedPubKeyFor", + name = "xPubKeyFor (Login)", warmup = 1000, iterations = 5000, nativeOp = { native.pubKeyCompress(native.pubkeyCreate(privKey)) }, @@ -219,7 +229,7 @@ class Secp256k1Benchmark { // --- secKeyVerify --- results += bench( - name = "secKeyVerify", + name = "secKeyVerify (NIP-06)", warmup = 5000, iterations = 200000, nativeOp = { native.secKeyVerify(privKey) }, @@ -234,7 +244,7 @@ class Secp256k1Benchmark { // the native side. This adds one allocation to the native measurement. results += bench( - name = "privKeyTweakAdd", + name = "privKeyTweakAdd (NIP-06)", warmup = 1000, iterations = 50000, nativeOp = { native.privKeyTweakAdd(privKey.copyOf(), privKey2) }, @@ -250,7 +260,7 @@ class Secp256k1Benchmark { // Native has no ecdhXOnly — compare against pubKeyTweakMul + extract x. results += bench( - name = "ecdhXOnly (Nostr)", + name = "pubKeyTweakMul (NIP-44)", warmup = 1000, iterations = 3000, nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) }, @@ -262,20 +272,24 @@ class Secp256k1Benchmark { // Print results println() - println("=".repeat(90)) - println("secp256k1 Benchmark: Native (ACINQ/JNI) vs Pure Kotlin on JVM (HotSpot C2)") - println("=".repeat(90)) + println("=".repeat(76)) + println("secp256k1 Benchmark: C (ACINQ/JNI) vs Kotlin (JVM21/HotSpot C2)") + println("=".repeat(76)) + println("Method JVM->JNI->C Pure Kotlin Ratio") + println("-".repeat(76)) for (r in results) { println(r) } - println("=".repeat(90)) + println("=".repeat(76)) + println("Verify Batched Kotlin JVM->JNI->C Pure Kotlin Ratio") + println("-".repeat(76)) // ==================== Batch verification benchmark ==================== // Same pubkey, n events — the typical Nostr pattern (feed from one author). // Batch verify uses scalar+point summation: one mulDoubleG for the whole batch // instead of n individual mulDoubleG calls. val batchPub = kotlinXOnlyPub - for (batchSize in intArrayOf(4, 8, 16, 32)) { + for (batchSize in intArrayOf(4, 8, 16, 32, 200)) { val sigs = mutableListOf() val msgs = mutableListOf() for (i in 0 until batchSize) { @@ -297,17 +311,29 @@ class Secp256k1Benchmark { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 .verifySchnorrBatch(batchPub, sigs, msgs) } - // Time individual + // Time individual native val iters = 1000 - val indivStart = System.nanoTime() + + val indivNativeStart = System.nanoTime() repeat(iters) { for (j in 0 until batchSize) { com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 .verifySchnorr(sigs[j], msgs[j], batchPub) } } - val indivNs = System.nanoTime() - indivStart - val indivPerEvent = iters.toLong() * batchSize * 1_000_000_000L / indivNs + val indivNativeNs = System.nanoTime() - indivNativeStart + val indivNativePerEvent = iters.toLong() * batchSize * 1_000_000_000L / indivNativeNs + + val indivKotlinStart = System.nanoTime() + repeat(iters) { + for (j in 0 until batchSize) { + com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 + .verifySchnorr(sigs[j], msgs[j], batchPub) + } + } + val indivKotlinNs = System.nanoTime() - indivKotlinStart + val indivKotlinPerEvent = iters.toLong() * batchSize * 1_000_000_000L / indivKotlinNs + // Time batch val batchStart = System.nanoTime() repeat(iters) { @@ -316,18 +342,19 @@ class Secp256k1Benchmark { } val batchNs = System.nanoTime() - batchStart val batchPerEvent = iters.toLong() * batchSize * 1_000_000_000L / batchNs - val speedup = indivNs.toDouble() / batchNs.toDouble() + val speedup = indivNativeNs.toDouble() / batchNs.toDouble() println( String.format( - " batch(%2d): individual %,7d ev/s batch %,7d ev/s speedup %.1fx", + "%3d evts %,10d ev/s %,8d ev/s %,8d ev/s %.1fx faster", batchSize, - indivPerEvent, batchPerEvent, + indivNativePerEvent, + indivKotlinPerEvent, speedup, ), ) } - println("=".repeat(90)) + println("=".repeat(76)) println() }