05 · Benchmarks
Status: recorded evidence against Doc 01 §6 criterion 2 (“The contract benchmarks true: C1–C4 demonstrated with numbers … ns-scale p99, zero allocations, linear scaling with writer threads”).
Scope: the production engine (repo-root package), not the D2 simulation harness — Doc 04 already covers that decision (Option A, chip-mask schedule M3).
Hardware: Apple M4 Pro (12 logical CPUs), via go test -bench. Numbers will vary by machine; the shape of each result (flat/scaling/degrading) is what matters.
1. Single-threaded Observe (C2: ns-scale, allocation-free)
Section titled “1. Single-threaded Observe (C2: ns-scale, allocation-free)”Steady-state cost with a large table and a small cycling keyset, so calls land on the Phase 1 refresh path after the first pass:
BenchmarkSketch_Observe_SingleThreaded-12 1000000 20.95 ns/op 0 B/op 0 allocs/op~21 ns/op, zero allocations — comparable to a handful of memory accesses, meeting C2 directly.
2. Parallel throughput across GOMAXPROCS (C4: linear scaling)
Section titled “2. Parallel throughput across GOMAXPROCS (C4: linear scaling)”Each worker goroutine observes a disjoint window of keys (low contention by design — the best-case scaling story), run at -cpu=1,2,4,8,12:
| GOMAXPROCS | ns/op | allocs/op |
|---|---|---|
| 1 | 39.46 | 0 |
| 2 | 38.47 | 0 |
| 4 | 30.80 | 0 |
| 8 | 31.75 | 0 |
| 12 | 32.68 | 0 |
Per-op latency stays flat (even improves slightly) as core count rises, which means aggregate throughput scales near-linearly with writer threads — zero allocations at every level.
3. Worst-case single-key contention (edge case: every goroutine, one word)
Section titled “3. Worst-case single-key contention (edge case: every goroutine, one word)”All goroutines racing the exact same slot on every call — the pathological case bounded CAS retry (≤3, then drop-and-continue) exists for:
| GOMAXPROCS | ns/op | allocs/op |
|---|---|---|
| 1 | 10.46 | 0 |
| 2 | 18.06 | 0 |
| 4 | 36.35 | 0 |
| 8 | 78.09 | 0 |
| 12 | 144.5 | 0 |
Cost grows with contention, as expected, but stays bounded — no livelock, no unbounded blowup, and zero allocations even under maximal contention. The correctness side of this same scenario (bounded, non-catastrophic loss rate, not corruption) is validated separately by the concurrency stress test.
4. Eviction salt-contention isolation
Section titled “4. Eviction salt-contention isolation”Superseded by Doc 06 (
06-salt-contention-decision.md). The table below predates two benchmark-isolation fixes — disjoint per-worker bucket windows and per-worker stats — without which lockstep bucket CAS and shared stats adds contaminated both variants and understated the salt’s true cost. Doc 06 has the corrected measurements (the real gap is ~12× at 8 cores, anti-scaling) and closes the call left open here: the shared salt stays. The original run is preserved below for history.
The structure-wide eviction salt (Doc 02 §4.1’s Option A / schedule M3 chip-mask lottery) is a single cache-line-padded atomic.Uint64 incremented by every evicting goroutine, across every bucket — a known contention point even after padding, since padding only prevents false sharing with unrelated fields, not contention on the shared word itself. Compared here against a variant giving each goroutine its own independent salt (not a shippable alternative — the anti-replay fix this salt exists for needs a genuinely shared, structure-wide counter — purely an isolation measurement):
| GOMAXPROCS | Shared salt (ns/op) | Per-goroutine salt (ns/op) | Overhead |
|---|---|---|---|
| 1 | 12.49 | 12.68 | ~0% |
| 2 | 45.07 | 25.10 | ~80% |
| 4 | 50.97 | 37.98 | ~34% |
| 8 | 60.39 | 44.89 | ~35% |
| 12 | 78.35 | 65.36 | ~20% |
The shared salt is a real, measurable contention cost under eviction-heavy load — cache-line padding alone does not eliminate it, confirming the concern it was written to flag. The overhead is moderate (20–35% at higher core counts, not a multiple), not catastrophic, but it is real. Whether to escalate (shard the salt, or drop the per-attempt increment on the concurrent path — keeping it sequenced only on the serial differential-test path) is an open call, not made here.
5. Methodology notes
Section titled “5. Methodology notes”- All benchmarks use
testing.B.ReportAllocs(); every result above is 0 allocs/op. - Single-threaded and parallel-distinct-key benchmarks pre-generate their key pools outside the timed region (
b.ResetTimer()after setup) so reported costs reflectObserveitself, not key formatting. - The parallel and contention benchmarks use
b.RunParallel, which Go’s testing package fans out acrossGOMAXPROCSautomatically;-cpu=1,2,4,8,12reruns the whole binary once per value to produce the scaling tables above.