Files
amethyst/quartz/plans/2026-07-04-negentropy-reconcile-profiling.md
Claude 469220de77 perf(nip77): bump kmp-negentropy to v1.2.0 (faster reconcile/fingerprint internals)
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
2026-07-04 19:33:54 +00:00

118 lines
6.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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=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`.