From de7e63ff58bdecb8d4b5c4e1ad4b59839306fd42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:01:21 +0000 Subject: [PATCH] perf(nip13): keep the PoW hot loop allocation-free without relying on the JIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nonce search enumerated a `List` alphabet, which compiles to an `ArrayList$Itr` allocation per recursion level plus a `Number.byteValue()` unbox per candidate, and re-read `buffer.bytes` / `buffer.nonceEnds` through their getters on every single candidate. Measured on HotSpot, none of that showed up: escape analysis erased the iterator and the loop already allocated ~0 B/hash. That is the problem — the hot loop's allocation behaviour was left to the JIT, and mining runs on ART, whose escape analysis makes no such guarantee. Switching the alphabet to a ByteArray and hoisting the payload and the last-index test out of the per-candidate path removes the iterator, the unboxing and the getter calls from the bytecode outright, so the loop is allocation-free by construction on every platform. Verified: the iterator, `byteValue()` and per-candidate `MiningBuffer` getters are gone from the disassembled `runDigit`; steady-state allocation stays at ~0.0002 B/hash and throughput went from 1.70M to ~1.75M h/s on a 510 B payload. All 25 nip13Pow tests pass, including the determinism and created_at invariants that pin the search order. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011vgXumByZ7E7ftfiLZCsRx --- .../quartz/nip13Pow/miner/PoWMiner.kt | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt index 73deadf86f..351a8c7cd9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException +import kotlin.random.Random import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -51,6 +52,12 @@ class PoWMiner( // 32-byte array per hash, keeping the hot loop allocation-free. private val hashOut = ByteArray(32) + // hoisted out of the search: neither changes once the buffer is built, and + // reaching them through the MiningBuffer getters on every single candidate + // is pure overhead in the innermost loop. + private val payload = buffer.bytes + private val lastNonceIndex = buffer.nonceEnds - 1 + /** * True when the last [run] stopped because [isPassOver] flipped rather than * because the nonce space ran out — the space is still unexplored, so the @@ -78,17 +85,28 @@ class PoWMiner( } } - for (testByte in VALID_BYTES) { - // replaces the background base by the nonce integers - buffer.bytes[index] = testByte + // pulled into locals so the innermost loop touches no fields at all. + // `buffer.bytes` is a getter call per candidate otherwise, and folding it + // is left to the JIT — which HotSpot does and ART is not guaranteed to. + val alphabet = VALID_BYTES + val bytes = payload - if (index + 1 < buffer.nonceEnds) { + // whether this position is the last one is a property of the position, + // not of the candidate — deciding it once per level instead of once per + // candidate keeps the innermost loop down to a store and a hash. + if (index < lastNonceIndex) { + for (i in alphabet.indices) { + // replaces the background base by the nonce integers + bytes[index] = alphabet[i] if (runDigit(index + 1)) return true // unwind the whole recursion rather than stepping to the next // byte: the pass is over, not just this branch. if (passedOver) return false - } else { - if (reachedDesiredPoW(buffer.bytes)) return true + } + } else { + for (i in alphabet.indices) { + bytes[index] = alphabet[i] + if (reachedDesiredPoW(bytes)) return true } } return false @@ -106,12 +124,20 @@ class PoWMiner( private val PASS_BUDGET = 1.seconds // make sure these chars are not escaped by the JSON stringifier - private val VALID_CHARS: List = - ('0'..'9') + ('a'..'z') + ('A'..'Z') + "-()[]{}$@!*=;:?,".toCharArray().toList() + private val VALID_CHARS: CharArray = + (('0'..'9') + ('a'..'z') + ('A'..'Z') + "-()[]{}$@!*=;:?,".toCharArray().toList()).toCharArray() - private val VALID_BYTES = VALID_CHARS.map { it.code.toByte() } + /** + * The alphabet the search enumerates, unboxed. A `List` here emits + * an iterator allocation per recursion level and a `Byte.byteValue()` + * unbox per candidate. HotSpot's escape analysis removes both, so the + * cost never showed up on the JVM — but that leaves the hot loop's + * allocation behaviour up to the JIT, and Android is where mining + * actually runs. A ByteArray needs no escape analysis to be free. + */ + private val VALID_BYTES: ByteArray = ByteArray(VALID_CHARS.size) { VALID_CHARS[it].code.toByte() } - private fun randomBase(size: Int): String = CharArray(size) { VALID_CHARS.random() }.concatToString() + private fun randomBase(size: Int): String = CharArray(size) { VALID_CHARS[Random.nextInt(VALID_CHARS.size)] }.concatToString() /** * The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation