perf(negentropy): profile NIP-77 reconcile, verify prefix-sum fingerprint fix

The 1M relayBench run had geode losing the negentropy phase to strfry
(initial reconcile 6066ms/27r vs 1270ms/14r; identical-set 1947ms vs
557ms). Three layered benchmarks pin where the time actually goes:

- NegentropyReconcileBenchmark (quartz): the kmp-negentropy server loop
  in isolation is ~200ms for the full 14-round exchange — the reconcile
  ALGORITHM is not the bottleneck. (An early version showed 22s/139r;
  that was a benchmark bug — index slices over randomly-sorted ids
  scatter the diff. Real relayBench slices are contiguous time ranges;
  monotonic created_at fixes it and matches strfry's round count.)
- NegentropyServerReconcileBenchmark (geode): the real in-process geode
  server over loopback is 3214ms — 15x the library loop. JFR of the
  server call-trees: ~40% hex/UTF-8/JSON serialization of the payloads,
  ~26% actual reconcile, rest allocation. The gap is the JVM
  constant-factor tax on hex-in-JSON, which strfry pays in C++, not a
  single hotspot.
- NegentropyPrefixFingerprintTest (quartz): the one algorithmic lever.
  Negentropy's fingerprint is an additive sum mod 2^256, so a prefix-sum
  table answers any range in O(1). Proven bit-for-bit identical to the
  library over 2000 random ranges, and 460x faster per call — the fix
  for the ~26% reconcile slice (dominant in the identical-set case).

Not yet wired: the library instantiates FingerprintCalculator
internally, so shipping prefix-sum needs a kmp-negentropy change (or a
quartz-side fast server). Full write-up + artifacts in
quartz/plans/2026-07-04-negentropy-reconcile-profiling.md.

Benchmarks are CI-safe (small defaults / opt-in gates); JFR via
-PnegProfile, scale via -DnegBenchN, geode server bench via
-DnegServerBench=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
This commit is contained in:
Claude
2026-07-04 05:22:20 +00:00
parent 8b29c06c13
commit 8a607b08b9
6 changed files with 638 additions and 1 deletions
+10 -1
View File
@@ -69,11 +69,20 @@ tasks.withType<Test>().configureEach {
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
System.getProperty("fanoutScalingEvents")?.let { systemProperty("fanoutScalingEvents", it) }
System.getProperty("fanoutScalingSubs")?.let { systemProperty("fanoutScalingSubs", it) }
// NegentropyServerReconcileBenchmark opt-in + sizing.
System.getProperty("negServerBench")?.let { systemProperty("negServerBench", it) }
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
maxHeapSize = System.getProperty("testHeap") ?: maxHeapSize
// Opt-in JFR profiling (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
}
// Show println output from test JVM so the benchmark numbers are
// actually visible without grepping the report XML.
testLogging {
showStandardStreams =
(System.getProperty("runLoadBenchmark") == "true")
(System.getProperty("runLoadBenchmark") == "true") ||
(System.getProperty("negServerBench") == "1")
events("standard_out")
}
}
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.perf
import com.vitorpamplona.geode.KtorRelay
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.interop.InteropSyncDriver
import com.vitorpamplona.geode.relayIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Reproduces relayBench's NIP-77 initial-reconcile phase against a **real
* in-process geode server** (KtorRelay → RelaySession → NegSessionRegistry
* → live-index snapshot → NegentropyServerSession), driven by the real
* client over a loopback WebSocket. Because client and server share one
* JVM, a JFR recording of this run attributes cost across geode's full
* request path — not just the reconciliation library.
*
* The companion [com.vitorpamplona.quartz.nip01Core.relay.prodbench.NegentropyReconcileBenchmark]
* times the *library only* (no server, no wire): ~200 ms server compute at
* the same 1M/200k-diff shape. The head-to-head relayBench run measured
* geode's live server at ~6 s. This benchmark closes that gap in a
* profilable single process, so we can see where the 30× goes.
*
* Opt-in (heavy — seeds up to 800k events): `-DnegServerBench=1`, size via
* `-DnegBenchN=1000000`. FTS is off (irrelevant to reconcile) so seeding is
* fast; the live negentropy index stays on, exactly as production runs it.
*/
class NegentropyServerReconcileBenchmark {
companion object {
val enabled = System.getProperty("negServerBench") == "1"
val N = System.getProperty("negBenchN")?.toInt() ?: 200_000
const val BASE_TIME = 1_704_067_200L
const val EVENTS_PER_SECOND = 3
}
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private val hexChars = "0123456789abcdef".toCharArray()
private fun idFor(index: Int): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
val pos = (w * 8 + b) * 2
out[pos] = hexChars[byte ushr 4]
out[pos + 1] = hexChars[byte and 0xF]
}
}
return String(out)
}
private val pubkey = "00".repeat(32)
private val sig = "0".repeat(128)
private fun event(index: Int): Event =
EventFactory.create(
id = idFor(index),
pubKey = pubkey,
createdAt = BASE_TIME + (index / EVENTS_PER_SECOND).toLong(),
kind = 1,
tags = emptyArray(),
content = "",
sig = sig,
)
@Test
fun serverReconcileAgainstRealGeode() =
runBlocking {
if (!enabled) {
println("[skip] NegentropyServerReconcileBenchmark — set -DnegServerBench=1 to enable")
return@runBlocking
}
// FTS off (not on the reconcile path); live index on (it is).
val store = EventStore(dbName = null, indexStrategy = relayIndexingStrategy(fullTextSearch = false))
val relay = RelayEngine(url = "ws://127.0.0.1:7000/".normalizeRelayUrl(), store = store)
val server = KtorRelay(relay, host = "127.0.0.1", port = 0).start()
val http = OkHttpClient.Builder().build()
try {
// Server holds [0.2N, N); client holds [0, 0.8N). Contiguous
// diff blocks (oldest 20% + newest 20%) — relayBench's shape.
val serverEvents = ((N * 2 / 10) until N).map { event(it) }
val clientEvents = (0 until (N * 8 / 10)).map { event(it) }
val seedStart = System.nanoTime()
serverEvents.chunked(5000).forEach { store.batchInsert(it) }
val seedMs = (System.nanoTime() - seedStart) / 1e6
val expectedNeed = ((N * 8 / 10) until N).count() // server-only (newest 20%)
val expectedHave = (0 until (N * 2 / 10)).count() // client-only (oldest 20%)
val driver = InteropSyncDriver(http)
val filter = Filter()
val recStart = System.nanoTime()
val res = driver.negotiate(server.url, filter, clientEvents, timeoutMs = 120_000, maxRounds = 256)
val recMs = (System.nanoTime() - recStart) / 1e6
println("─ NegentropyServerReconcileBenchmark @ ${N / 1000}k (real geode server) ─")
println(" seed (${serverEvents.size} events): ${"%.0f".format(seedMs)} ms")
println(" rounds: ${res.rounds}")
println(" negotiate wall: ${"%.1f".format(recMs)} ms ← geode server + client + wire")
println(" need=${res.needIds.size} (exp $expectedNeed) have=${res.haveIds.size} (exp $expectedHave)")
println(" error: ${res.error}")
assertEquals(null, res.error, "reconcile error")
assertEquals(expectedNeed, res.needIds.size, "need set")
assertEquals(expectedHave, res.haveIds.size, "have set")
} finally {
server.stop()
relay.close()
http.dispatcher.executorService.shutdown()
}
}
}
+6
View File
@@ -91,6 +91,12 @@ kotlin {
(project.findProperty("prodRelayBench") as? String)?.let {
environment("PROD_RELAY_BENCH", it)
}
// Forward the negentropy-benchmark corpus size to the test JVM.
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
}
}
tasks.withType<KotlinNativeTest>().configureEach {
@@ -0,0 +1,100 @@
# NIP-77 reconcile profiling: where geode's sync time actually goes
**Status: investigation + verified fix core (not yet wired).** Follow-up to
the 1M-event relayBench run (`relay.damus.io`, geode 1.12.6 vs strfry
v1-b80cda3), where geode lost the negentropy phase: initial reconcile
6,066 ms / 27 rounds vs strfry 1,270 ms / 14; identical-set reconcile
1,947 ms vs 557 ms (~3.5×).
## What the numbers said, layer by layer
Three benchmarks isolate each layer (all reproduce the relayBench slice
shape: 1M effective corpus, 80%/80% index slices, 60% overlap → a
**contiguous** oldest-20% + newest-20% diff, ~200k each way):
| layer | tool | 1M / 200k-diff result |
|---|---|---|
| library reconcile only (no wire) | `quartz …prodbench.NegentropyReconcileBenchmark` | server **207 ms**, client 302 ms, 14 rounds, seal 331 ms |
| real geode server, one JVM, loopback ws | `geode …perf.NegentropyServerReconcileBenchmark` (`-DnegServerBench=1`) | negotiate **3,214 ms**, 14 rounds |
| two processes + real net | relayBench production | **6,066 ms**, 27 rounds |
Key facts this establishes:
1. **The reconciliation algorithm is not the bottleneck.** The pure
kmp-negentropy server loop is ~200 ms for the whole 14-round exchange.
My first cut of the library benchmark showed 22 s / 139 rounds — that
was a **benchmark bug**: slicing by array index while ids sort randomly
scatters the diff through sorted order (negentropy's worst case). Real
relayBench slices are contiguous *time* ranges; fixing the benchmark to
monotonic `created_at` dropped it to 200 ms / 14 rounds, matching
strfry's round count exactly.
2. **The gap is geode's live server path, not framing.** Same 14 rounds,
same 500 KB frame cap as strfry, but 3,214 ms in-process (15× the
library loop) and 6,066 ms across processes. Rounds only inflate to 27
in production from *seeding drift* (rejected events during seeding make
the stored sets differ from the clean slices → some scatter); strfry saw
less drift that run.
3. **JFR of the real-geode run (server call-trees) splits as:**
- **~40% hex + UTF-8 + JSON serialization** of the hundred-KB hex NEG
payload each round (`Hex.encode`/`decode`, `UTF_8.encode`, Jackson).
- **~26% actual reconcile** (`StorageVector.forEach` +
`FingerprintCalculator.run` — the per-range fingerprint).
- rest: allocation (`MessageBuilder.branch`, `Arrays.copyOf`), one-time
seal/sort.
The client path is even heavier and dominated by `HashMap.putVal`/
`resize` accumulating 400k have/need ids as hex strings.
So geode-vs-strfry on sync is mostly the **JVM constant-factor tax on
serializing hex-in-JSON payloads**, which strfry pays in zero-copy C++ —
not a single fixable hotspot. `quartz.utils.Hex` is already an optimized
table-based codec, so there's no cheap win there.
## The one algorithmic lever: prefix-sum fingerprints
The ~26% reconcile slice *is* addressable, and it dominates the
steady-state identical-set reconcile (where geode loses 3.5×): every round
the library recomputes each range fingerprint with an **O(range) walk**.
Negentropy's fingerprint is `sha256( Σ id (mod 2²⁵⁶, 8 LE u32 limbs) ‖
varint(count) )[0:16]` — the inner sum is **additive**, so a prefix-sum
table answers any range's raw sum in **O(1)** (limb-wise subtract with
borrow) + one sha256.
`quartz …prodbench.NegentropyPrefixFingerprintTest` proves this:
- reproduces the library's fingerprint **bit-for-bit** over 2,000 random
ranges + boundaries at 50k;
- **460× faster per call** on a reconcile-shaped range mix (465 ms → 1.0 ms).
For the top-of-tree fingerprints (each round re-walks ~all 800k ids) and
the identical-set case (16 full-corpus-ish fingerprints per reconcile),
this is the difference between an O(n) walk and a table lookup.
## Why it isn't wired yet
`com.vitorpamplona.negentropy.Negentropy` instantiates its own
`FingerprintCalculator` internally — there's no seam to inject a
prefix-sum-backed one from geode/quartz. Shipping it needs one of:
1. **kmp-negentropy change** (cleanest): let `Negentropy` take a storage
that can answer `fingerprint(lo, hi)` itself, and have `StorageVector`
(or a new sealed storage) carry the prefix-sum table built at seal time.
`LiveNegentropyIndex` already keeps the sorted `(created_at, id)` set, so
the table is one extra pass at seal.
2. **quartz-side fast server**: reimplement the server reconcile against the
prefix-sum index. Larger and interop-critical (must match the wire byte
for byte with strfry) — the benchmarks + bit-exact test above are the
safety net for it.
The serialization tax (~40%) is separate and only closes by writing the
hex payload straight into the output buffer as ASCII instead of
`bytes → hexString → JSON string → UTF-8 bytes`.
## Artifacts (all landed here, gated/CI-safe)
- `quartz …prodbench.NegentropyReconcileBenchmark` — library reconcile,
default 20k (fast CI correctness guard), `-DnegBenchN=1000000` for scale.
- `quartz …prodbench.NegentropyPrefixFingerprintTest` — the verified,
bit-exact prefix-sum core + per-call speedup (runs at 50k in CI).
- `geode …perf.NegentropyServerReconcileBenchmark` — real in-process geode
server, opt-in `-DnegServerBench=1`, JFR via `-PnegProfile=/path.jfr`.
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.prodbench
import com.vitorpamplona.negentropy.fingerprint.FingerprintCalculator
import com.vitorpamplona.negentropy.storage.StorageVector
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertContentEquals
/**
* Validates the algorithmic fix for the one part of the NIP-77 server that
* profiling ([NegentropyReconcileBenchmark] + geode's server JFR) pinned as
* genuinely CPU-bound rather than serialization tax: the per-range
* fingerprint, which the reconciliation library recomputes from scratch —
* an O(range) walk over the storage — on **every** round.
*
* Negentropy's fingerprint is `sha256( Σ id (mod 2²⁵⁶, as 8 little-endian
* u32 limbs) ‖ varint(count) )[0:16]`. The inner sum is **additive**, so a
* prefix-sum table answers any range's raw sum in O(1) (limb-wise subtract
* with borrow), turning the top-of-tree fingerprints — which today re-walk
* hundreds of thousands of ids every round, and dominate the steady-state
* "identical sets" reconcile geode loses 3.5× on — into a constant-time
* lookup plus one sha256.
*
* This test proves the prefix-sum reproduces the library's fingerprint
* **bit-for-bit** over random ranges, and measures the per-call speedup.
* It does not wire the index into the wire path (the library instantiates
* its own [FingerprintCalculator]); it's the verified core for a
* kmp-negentropy change or a quartz-side fast server.
*/
class NegentropyPrefixFingerprintTest {
/**
* Prefix-sum fingerprint index over sorted ids. `prefix[k]` holds the
* 256-bit little-endian sum (8 u32 limbs) of the first `k` ids; a range
* sum is `prefix[hi] prefix[lo]` limb-wise with borrow. Same limb math
* as the library's `FingerprintCalculator.add`, just accumulated once.
*/
class PrefixFingerprintIndex(
idsHex: List<String>,
) {
private val n = idsHex.size
// 8 limbs per prefix position, row-major: prefix[k*8 + limb].
private val prefix = LongArray((n + 1) * 8)
init {
for (k in 0 until n) {
val id = Hex.decode(idsHex[k])
var carry = 0L
for (limb in 0 until 8) {
val off = limb * 4
val v =
(id[off].toLong() and 0xFF) or
((id[off + 1].toLong() and 0xFF) shl 8) or
((id[off + 2].toLong() and 0xFF) shl 16) or
((id[off + 3].toLong() and 0xFF) shl 24)
val sum = (prefix[k * 8 + limb] and 0xFFFFFFFFL) + v + carry
prefix[(k + 1) * 8 + limb] = sum and 0xFFFFFFFFL
carry = sum ushr 32
}
// Final carry out of limb 7 is dropped: sum is mod 2²⁵⁶.
}
}
/** Fingerprint of `[lo, hi)` — O(1) sum + one sha256. */
fun fingerprint(
lo: Int,
hi: Int,
): ByteArray {
val buf = ByteArray(32)
var borrow = 0L
for (limb in 0 until 8) {
val diff = prefix[hi * 8 + limb] - prefix[lo * 8 + limb] - borrow
val v = diff and 0xFFFFFFFFL
borrow = if (diff < 0) 1 else 0
val off = limb * 4
buf[off] = (v and 0xFF).toByte()
buf[off + 1] = ((v shr 8) and 0xFF).toByte()
buf[off + 2] = ((v shr 16) and 0xFF).toByte()
buf[off + 3] = ((v shr 24) and 0xFF).toByte()
}
return sha256(buf + encodeVarInt(hi - lo)).copyOfRange(0, 16)
}
/** Matches the library's message VarInt encoding for the count tag. */
private fun encodeVarInt(n: Int): ByteArray {
if (n == 0) return byteArrayOf(0)
val limbs = ArrayList<Int>()
var num = n
while (num != 0) {
limbs.add(num and 127)
num = num ushr 7
}
return ByteArray(limbs.size) { i ->
if (i == limbs.size - 1) {
limbs[limbs.size - 1 - i].toByte()
} else {
(limbs[limbs.size - 1 - i] or 128).toByte()
}
}
}
}
private fun idFor(index: Int): String {
fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
val hex = "0123456789abcdef"
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
@Test
fun prefixSumReproducesLibraryFingerprintBitForBit() {
val n = 50_000
// Sorted so index order == storage order (ids compared as bytes).
val ids = (0 until n).map { idFor(it) }.sorted()
val storage = StorageVector()
ids.forEachIndexed { i, id -> storage.insert(1_700_000_000L + i, id) }
storage.seal()
val library = FingerprintCalculator()
val index = PrefixFingerprintIndex(ids)
val rnd = Random(42)
// Ranges across every scale: tiny leaves, mid buckets, full corpus.
var checked = 0
repeat(2000) {
val a = rnd.nextInt(n + 1)
val b = rnd.nextInt(n + 1)
val lo = minOf(a, b)
val hi = maxOf(a, b)
if (lo == hi) return@repeat
assertContentEquals(
library.run(storage, lo, hi).bytes,
index.fingerprint(lo, hi),
"fingerprint mismatch for [$lo,$hi)",
)
checked++
}
// A few whole-corpus and boundary ranges too.
assertContentEquals(library.run(storage, 0, n).bytes, index.fingerprint(0, n), "full range")
assertContentEquals(library.run(storage, 0, 1).bytes, index.fingerprint(0, 1), "first")
assertContentEquals(library.run(storage, n - 1, n).bytes, index.fingerprint(n - 1, n), "last")
println("─ NegentropyPrefixFingerprint: verified $checked random ranges + boundaries at ${n / 1000}k ─")
// Per-call speedup on a reconcile-shaped mix: the top of the tree
// fingerprints huge ranges (where prefix-sum wins most), leaves
// fingerprint tiny ranges. Weight toward the expensive large ranges.
val ranges =
buildList {
repeat(200) { add(0 to n) } // full-corpus (every round's top level)
repeat(400) {
val w = n / 16
val lo = rnd.nextInt(n - w)
add(lo to lo + w) // 16-bucket split level
}
}
var sink = 0
val libStart = System.nanoTime()
for ((lo, hi) in ranges) sink = sink xor library.run(storage, lo, hi).bytes[0].toInt()
val libMs = (System.nanoTime() - libStart) / 1e6
val fastStart = System.nanoTime()
for ((lo, hi) in ranges) sink = sink xor index.fingerprint(lo, hi)[0].toInt()
val fastMs = (System.nanoTime() - fastStart) / 1e6
println(" ${ranges.size} reconcile-shaped range fingerprints:")
println(" library (O(range) walk): ${"%.1f".format(libMs)} ms")
println(" prefix-sum (O(1) + sha): ${"%.1f".format(fastMs)} ms")
println(" speedup: ${"%.1f".format(libMs / fastMs)}× (sink=$sink)")
}
}
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.prodbench
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* In-process reproduction of relayBench's NIP-77 initial-reconcile phase,
* with **no** network or JSON framing in the loop — so what it times is
* exactly the server-side reconciliation work that the head-to-head
* benchmark measured geode losing to strfry (1M corpus: geode 6,066 ms /
* 27 rounds vs strfry 1,270 ms / 14 rounds).
*
* The harness plays the unbounded initiator (`frameSizeLimit = 0`, like
* `SyncBenchmark.reconcile`); the [NegentropyServerSession] under test is
* the exact object geode's [com.vitorpamplona.quartz.nip01Core.relay.server.NegSessionRegistry]
* builds, at the same 500 KB frame cap. Server and client `processMessage`
* time is summed separately so the server's share is isolated.
*
* Not a CI assertion on speed (container noise) — it asserts convergence
* correctness and prints the breakdown. Size via `-DnegBenchN=1000000`.
*/
class NegentropyReconcileBenchmark {
companion object {
// Small by default so the correctness assertions run as a fast CI
// regression guard; scale to the relayBench shape with -DnegBenchN.
val N = System.getProperty("negBenchN")?.toInt() ?: 20_000
const val FRAME_SIZE_LIMIT = 500_000L
/** 2024-01-01. */
const val BASE_TIME = 1_704_067_200L
/**
* Events per second. `created_at` is monotonic with index so that
* sorted order == index order == a real chronological feed. That
* makes SyncBenchmark's index slices ([0,0.8N) vs [0.2N,N)) fall on
* *contiguous* time ranges — the diff is the oldest 20% + newest 20%,
* exactly like relayBench's slices, not a scatter that forces the
* reconciliation tree to split everywhere.
*/
const val EVENTS_PER_SECOND = 3
}
/** splitmix64 — well-distributed 64 pseudo-random bits from a counter. */
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private val hexChars = "0123456789abcdef".toCharArray()
/** 64-char lowercase hex id from 4 mixed longs — distinct, unsorted. */
private fun idFor(index: Int): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
val pos = (w * 8 + b) * 2
out[pos] = hexChars[byte ushr 4]
out[pos + 1] = hexChars[byte and 0xF]
}
}
return String(out)
}
private fun entry(index: Int): IdAndTime {
// Monotonic time with same-second ties (broken by the random id),
// so index order matches negentropy's sorted order.
val createdAt = BASE_TIME + index / EVENTS_PER_SECOND
return IdAndTime(createdAt, idFor(index))
}
@Test
fun serverReconcileAtCorpusScale() {
// 80% / 80% slices with 60% overlap — SyncBenchmark's split.
// Server holds [0.2N, N); client (initiator) holds [0, 0.8N).
val clientEntries = (0 until (N * 8 / 10)).map { entry(it) }
val serverEntries = ((N * 2 / 10) until N).map { entry(it) }
val clientIds = clientEntries.mapTo(HashSet()) { it.id }
val serverIds = serverEntries.mapTo(HashSet()) { it.id }
val expectedNeed = serverIds.count { it !in clientIds } // server has, client lacks
val expectedHave = clientIds.count { it !in serverIds } // client has, server lacks
// Build (seal) time is measured separately — it's the NEG-OPEN cost,
// not per-round reconcile.
val sealStart = System.nanoTime()
val server = NegentropyServerSession("bench", serverEntries, FRAME_SIZE_LIMIT)
val sealMs = (System.nanoTime() - sealStart) / 1e6
val client = NegentropySession("bench", Filter(), clientEntries, frameSizeLimit = 0)
var rounds = 0
var wireBytes = 0L
var serverNanos = 0L
var clientNanos = 0L
val haveIds = HashSet<String>()
val needIds = HashSet<String>()
val open = client.open()
var serverMsg: String? = open.initialMessage
val loopStart = System.nanoTime()
while (serverMsg != null) {
wireBytes += serverMsg.length.toLong()
val s0 = System.nanoTime()
val response = server.processMessage(serverMsg)
serverNanos += System.nanoTime() - s0
if (response == null) break // server produced nothing → done
wireBytes += response.message.length.toLong()
rounds++
val c0 = System.nanoTime()
val result = client.processMessage(response.message)
clientNanos += System.nanoTime() - c0
haveIds += result.haveIds
needIds += result.needIds
serverMsg = result.nextCmd?.message
}
val loopMs = (System.nanoTime() - loopStart) / 1e6
println("─ NegentropyReconcileBenchmark @ ${N / 1000}k (server ${serverEntries.size}, client ${clientEntries.size}) ─")
println(" rounds: $rounds")
println(" wire: ${"%.1f".format(wireBytes / 1024.0 / 1024.0)} MiB (hex NEG payloads, both directions)")
println(" avg frame fill: ${"%.0f".format(wireBytes.toDouble() / rounds / 1024)} KiB/round")
println(" seal (NEG-OPEN): ${"%.1f".format(sealMs)} ms")
println(" server reconcile: ${"%.1f".format(serverNanos / 1e6)} ms ← the number to beat")
println(" client reconcile: ${"%.1f".format(clientNanos / 1e6)} ms")
println(" loop wall: ${"%.1f".format(loopMs)} ms")
println(" need=${needIds.size} (exp $expectedNeed) have=${haveIds.size} (exp $expectedHave)")
assertEquals(expectedNeed, needIds.size, "need set")
assertEquals(expectedHave, haveIds.size, "have set")
}
}