v1.2.0 (on Maven Central) speeds up the library's own reconcile and fingerprint walk on top of the v1.1.1 PrefixSumStorageVector wiring. At the 1M relayBench slice shape (NegentropyReconcileBenchmark, converges exactly, need/have=200k): client reconcile 264 → 178 ms, seal 424 → 320 ms, and the library's O(range) fingerprint walk 447 → 252 ms (~1.8×). Our prefix-sum path still answers each range fingerprint in 0.7 ms (356× the now-faster walk). All NIP-77 tests pass. Update the reconcile-profiling plan: the fix shipped via the upstream IStorage.fingerprint seam (v1.1.1) rather than a quartz-side fast server; record the v1.0.2 → v1.1.1 → v1.2.0 progression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
6.0 KiB
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:
-
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_atdropped it to 200 ms / 14 rounds, matching strfry's round count exactly. -
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.
-
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 byHashMap.putVal/resizeaccumulating 400k have/need ids as hex strings.
- ~40% hex + UTF-8 + JSON serialization of the hundred-KB hex NEG
payload each round (
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.
Shipped — wired via kmp-negentropy v1.1.1+ (option 1)
The cleanest option landed upstream: kmp-negentropy v1.1.1 added the
IStorage.fingerprint(begin, end) seam and a drop-in PrefixSumStorageVector
that builds the additive prefix-sum table at seal() and answers any range
fingerprint in O(1). Quartz now seals a PrefixSumStorageVector in both
NegentropyServerSession.sealVector (server / relay-relay responder, also
backing the LiveNegentropyIndex snapshot cache) and NegentropySession
(initiator). Byte-identical to the plain vector; NegentropyPrefixFingerprintTest
asserts the library's accelerated path matches the plain walk over 2000 random
ranges + boundaries.
v1.2.0 then sped up the library's own reconcile/fingerprint internals. At
the 1M relayBench slice shape (NegentropyReconcileBenchmark -DnegBenchN=1000000,
converges exactly, need/have = 200k):
| pre-fix (v1.0.2) | v1.1.1 | v1.2.0 | |
|---|---|---|---|
| seal (NEG-OPEN) | 331 ms | 424 ms | 320 ms |
| server reconcile | 207 ms | 128 ms | 128 ms |
| client reconcile | 302 ms | 264 ms | 178 ms |
| library range-fingerprint walk | 465 ms | 447 ms | 252 ms |
The prefix-sum still answers each range fingerprint in 0.7 ms — 356× the (now faster) v1.2.0 walk. A quartz-side fast server (option 2 below) is no longer needed.
Option 2 (not taken): quartz-side fast server
Reimplementing the server reconcile against the prefix-sum index — larger and interop-critical (must match the wire byte for byte with strfry). Superseded by the upstream seam; the benchmarks + bit-exact test remain the safety net.
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=1000000for 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.