Repository navigation
perf(store): bounded sharing of observer metadata strings - #551
Conversation
Relates to #489. The tests and the plumbing for a bounded sharing pool of the four observer metadata strings (observer ID, name, IATA code, direction), with the pool's lookup stubbed out: intern() returns every value as the row scan handed it over, which is exactly what master does. So the measurement harness and the benchmarks in this commit produce the "before" numbers, and every assertion about sharing, GC stability, saturation, the pool's own accounting and the per-observation charge is red here. Covered: all five StoreObs constructors (hot Load, LoadChunked, loadChunk/background fill, IngestNewFromDB, IngestNewObservations), the #215 hash merge, eviction/accounting, metadata changes, concurrency and the lock discipline (#495). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relates to #489. intern() now consults the bounded canonical table: a hit takes only the read lock and allocates nothing, a miss clones the value (so a short string scanned out of a large buffer cannot pin it) and keeps the copy for the life of the store, and past obsStringPoolMax entries the value is returned exactly as it came in -- unshared, never rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relates to #489. BenchmarkObsStringPoolHitParallel showed the read path, not the map, as the bottleneck under concurrency: on one shared RWMutex a hit cost 9 ns alone but 34 ns with two goroutines and 84 ns with ten, and a per-hit atomic counter made it worse still. The table is now cut into 16 independently locked, cache-line-padded shards and the per-hit counter is gone, so a hit costs 11 ns alone, 11 ns with two goroutines and 22 ns with ten. The cap stays exact: an insert reserves its slot in one shared counter before taking it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Relates to #489. The lock test probed s.mu once, right after starting the ingest, so the prober usually won the lock before the ingest had reached it: a mutant that moved interning from the row scan into the locked merge loop survived. It now first asserts the ingest really is parked in the pool (it would otherwise prove nothing), then probes s.mu twenty times while it stays parked. Both "intern under s.mu" mutants, one per live path, now fail on probe 0. Also pins obsStringShard at 64 bytes, so the padding that keeps one shard's lock off the next one's cache line cannot silently rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rapport — CS-Macmini PR#551 #489 — head 53682d4Status: implemented as specified; 4 commits (red → green → sharding → test teeth), 18/18 mutants killed, all local suites green, CI all green on the first run (no re-run needed). Requirements
22 of the 26 Measurement first [A]Same generator and data,
The synthetic set has the instance's shape: 65 observers, log-normal observations per transmission (mean 20.6, p50 15, p90 42, p99 96). Capacity policy from those numbers: 140 entries in the steady state, cap 8192 (58x headroom), pool retention bounded at 2.2 MB of charge for about 0.7 MB in fact. Benchmarks [A]
B/op does not move on purpose: the driver still allocates one string per row per column. The saving is in retention, not allocation. Pool micro-benchmarks (140 held values, medians of 3): a hit is 11.4–11.9 ns and 0 allocs at CI per jobRun 40084909581, head
Local validation [T]
Remainders [K]
|
Review — CS-pve-agent3 PR#551 — head 53682d4Dom: APPROVE with nits Evidence labels: [T] I ran it myself · [A] my own analysis of the code or the numbers · [K] taken from the author's report or CI without reproducing it myself. Trees tested: Findings
Answers to the review points1. RAM change in the store. [T] I copied
Logical observer metadata at 2.5M is 206.90 MiB against 5,381 B for the 140 distinct values (65 IDs, 65 names, 8 IATA codes, 2 directions). The numbers match the author's within 1 MiB. Startup time: 2. Requirements from #489.
3.
The same budget now holds 11–22 % more observations for equal or lower real heap. The budget is not loosened in real-memory terms, and nothing is evicted earlier or later than it should be. 4. Conflict with #490 (pve-agent1). There is no #490 branch or PR on the remote yet (
Tests and mutants
What I did not verify
|
#551 (#489) pinned StoreObs at 200 bytes with sharedStrs in padding; this PR shrinks the struct to 168 bytes. Merged together the layout is 168 bytes with wireRoute and sharedStrs still sharing the tail padding, so the guard and the store comment move to 168. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relates to #489
Summary
Every observation the store holds carries its observer's ID (a 64-hex pubkey), name, IATA code and direction. All four come out of a SQL join, so each row scan allocated a fresh copy of each. The store now hands out one canonical copy of each value from a bounded, sharded pool, so equal values share immutable storage.
On the measured 2.5M-observation shape of the large instance that is 206.9 MiB of logical string content against 5.4 KiB of distinct values: 140 distinct values that were held in 9,460,013 separate copies. Heap after load drops from 1562.8 to 1321.4 MiB (-241 MiB, -15.4 %) for +1.2 % on the load.
trackedBytesnow holds the actual retention rather than one logical copy per observation, and the pool's own bounded retention is accounted separately, so the memory budget stays honest.Plan (four commits)
origin/mastere08072be.test(store): …(20aef204): the tests, the measurement harness, the benchmarks and the full plumbing, withintern()stubbed to return every value as the row scan handed it over — exactly what master does. That commit produces the "before" numbers and is red on every sharing, GC, saturation and accounting assertion.perf(store): …(25472d3d,5fdcf595): the real lookup, then the sharding the parallel benchmark asked for.test(store): …(53682d4f): the lock-discipline test, after two mutants survived its first version.#411 (PR #527) and #413 (PR #529) are both merged and were read first: the dedup helpers in
obs_dedup.go, the fiveStoreObsconstructors and the exacttrackedBytes/txChargedBytesaccounting are all honoured, and none of them is reimplemented here.No schema, API, deployment or workflow change.
signatureValidand the decoded-cache contract are untouched.Measurement first
Measured on this branch with
intern()stubbed out (the red commit) against the real pool, same generator, same data, one run each.copiesis the number of distinct string data pointers the observations hold for the four fields — i.e. how many separate copies of those ~140 values are actually in memory.test-fixtures/e2e-fixture.db(499 transmissions, 499 observations, 22 observers; itsdirectioncolumn is NULL throughout):trackedBytesThe fixture is far too small to choose a capacity from (41 KB of metadata in total), so the synthetic set below is the basis.
obs489Databuilds the shape of the large instance: 65 observers (64-hex IDs, 18–26 byte names, 3-letter IATA codes with every seventh observer having none,RX/TX/none directions) and the production observations-per-transmission distribution (log-normal, mean 20.6, p50 15, p90 42, p99 96).trackedBytes, before → afterLoadwall time, before → afterThe heap drop is larger than the 207 MiB of logical content because Go rounds each allocation to a size class: a 64-byte ID costs 64 B, a 18–26 byte name 32 B, a 3-byte IATA and a 2-byte direction 8 B each. Startup time is unchanged within run-to-run noise (the controlled before/after is the benchmark below: +1.2 %).
trackedBytes/heap moved from 1.013 to 1.042 at 2.5M observations, i.e. the accounting still tracks the heap and still over-charges slightly, so eviction triggers earlier rather than later.TestTrackedBytesTracksTheHeap_113's 0.85–1.15 bound passes.Change
cmd/server/obs_strings_pool.go(new):obsStringPoolis a bounded canonical table owned by thePacketStore, not a package global, so every store (and every test) is independent and a store built as&PacketStore{}simply gets a nil pool, which shares nothing and breaks nothing.intern(s)returns the canonical copy and whether it is shared. A hit takes only its shard's read lock and allocates nothing. A miss clones the value, so a short string scanned out of a large buffer cannot pin that buffer.unique.Make) hands back a second copy for a value interned again after a GC.obsStringPoolMaxentries, across 16 independently locked shards, with the count reserved in one shared counter before a slot is taken so the cap is exact. Past the cap a value is returned exactly as it came in: unshared, correct, never rejected.All five
StoreObsconstructors intern:s.mu?Load(hot cold load)Loadholds the write lock for its whole scan and nothing else runsscanAndMergeChunk(LoadChunked)loadChunk/loadChunkWhere(background fill)IngestNewFromDBtempRow.strsIngestNewObservationsobsRow.strsThe
#215hash merge moves existing observations, so it creates no strings: they keep their canonical copies and their charges, and a dropped duplicate is credited exactly as before.Accounting: logical charge vs. actual shared bytes
StoreObsgainssharedStrs uint8in the paddingwireRoutealready sits in, soStoreObsstays 200 bytes (TestStoreObsLayoutFitsSharedStrsInPadding_489). It records which of the four fields the pool holds.estimateStoreObsBytescharges an observation only the metadata bytes it owns (ownedObsStringBytes). A shared field costs it just the string header, whichstoreObsBaseBytesalready covers. The charge stays a pure function of the observation, so eviction credits exactly what ingest charged andtrackedBytesstill returns to exactly 0 when the store empties.accountedBytes() = trackedBytes + obsStrPool.retainedBytes(). That is the figuretrackedMemoryMB()reports and the figure the two memory watermarks inevictionCandidateTxIDs/evictStaleInternalcompare against. The pool term is never credited by eviction — the canonical copies outlive the observations that share them — so it behaves as a floor the budget must respect rather than as something an eviction pass can free.obsStringEntryBytes = 208plus the value's own bytes. Measured on Go swiss maps, a table filled to capacity costs 80 B per entry and the steady state (140 entries in tables sized for fewer) costs 208 B per entry, because a table's footprint is fixed below its hint. The higher figure is charged, so the charge is an upper bound at every fill: at the cap it holds 2.2 MB of charge for about 0.7 MB of real retention. Over-charging can only make eviction trigger earlier, never later.TestObsStringPoolRetainedBytes_489pins both directions against a measured pool.estimateStoreTxBytesTypical(the cold-load budget estimate) no longer carries the per-observation observer-ID and name terms, for the same reason.GetStoreMemoryBreakdown'sObsStringsMBkeeps its meaning and its shape — it was already documented as a deliberate upper bound — with a note that it is now the loosest of the components, since it counts the same few hundred values once per observation.TrackedMBis the figure that reflects the real retention. No response field was added or changed.Capacity policy, from the numbers
The steady state is one entry per distinct value: 140 on the large instance (65 observer IDs + 65 names + 8 IATA codes + 2 directions) and 48 on
test-fixtures/e2e-fixture.db(22 IDs + 22 names + 4 IATA codes; itsdirectionis NULL throughout).obsStringPoolMax = 8192is 58x that, which leaves room for observer growth and for renames — a rename adds one entry and the old name stays, so observations heard before it keep sharing their own — and bounds the pool's own retention to 2.2 MB of charge (about 0.7 MB in fact) even if something floods it with distinct values. Past the cap nothing breaks: the values come back unshared and the observations are charged for the bytes they then own (TestObsStringsSaturatedPoolStillIngests_489).16 shards comes from
BenchmarkObsStringPoolHitParallel, below.Tests
New:
cmd/server/obs_strings_pool_489_test.go(pool properties),obs_strings_paths_489_test.go(store paths),obs_strings_489_data_test.go(generator + measurement harness, compiles on master),obs_strings_489_bench_test.go.ObsStringPoolSharesOneCopy,ObsStringsSharedOnEveryConstructionPathObsStringPoolClonesNewValues(2 bytes out of a 1 MiB buffer)ObsStringPoolSurvivesGC,ObsStringsSharingSurvivesGC(ingest after a GC shares the cold load's copies)ObsStringPoolSaturationFallsBackToPlainStrings,ObsStringsSaturatedPoolStillIngests,ObsStringPoolChurnStaysBounded(2x the cap in distinct values)-race)ObsStringPoolConcurrentCallsShareOneCopy(16 goroutines),ObsStringsConcurrentIngestAndReadersObsStringsSharedOnEveryConstructionPathover hot Load, LoadChunked, background fill, ingest and upsert — same observations, sametotalObs, sametrackedBytes, same pool retention on all fiveObsStringsHashMergeKeepsSharingAndCharges(#215 migration, batch 2)ObsStringsMetadataChangeLeavesOlderObservationsObsStringsTrackedBytesHoldsSharedBytesOncetrackedBytesback to 0ObsStringsEvictionCreditsExactly(all five paths), plusacct113CheckeverywhereObsStringPoolRetainedBytesObsStringPoolHitDoesNotAllocate(AllocsPerRun == 0)ObsStringPoolEmptyAndNilStoreObsdoes not growStoreObsLayoutFitsSharedStrsInPadding(200 bytes)PacketStore.mu(#495)ObsStringPoolNeverBlocksStoreWriters: every shard locked, then an ingest that must intern a fresh value runs — a store writer must still gets.mu22 of the 26
_489test and subtest results are red on the red commit. The four that pass are the lock-discipline test (plus its two subtests) and theStoreObslayout guard, both marked above as preservation guards.Mutants
18 run, 18 killed. Each was applied to the green tree,
go test -run _489was run, and the tree restored.internreports shared without consulting the tableSharesOneCopy,SharedOnEveryConstructionPath,TrackedBytesHoldsSharedBytesOnceClonesNewValues,RetainedBytesSurvivesGC,SharesOneCopy,ClonesNewValues,SharedOnEveryConstructionPath, +9ChurnStaysBounded,SaturationFallsBackToPlainStrings,SaturatedPoolStillIngests"")ChurnStaysBounded,SaturationFallsBackToPlainStrings,SaturatedPoolStillIngestsEmptyAndNil,HitDoesNotAllocate,MetadataChangeLeavesOlderObservations,HashMergeKeepsSharingAndCharges,SharedOnEveryConstructionPath,TrackedBytesHoldsSharedBytesOnceEmptyAndNil,HashMergeKeepsSharingAndCharges,SharedOnEveryConstructionPath,TrackedBytesHoldsSharedBytesOnceaccountedBytesdrops it)EvictionCreditsExactly,TrackedBytesHoldsSharedBytesOnceRetainedBytesLoaddoes not internSharedOnEveryConstructionPath,EvictionCreditsExactly,SharingSurvivesGC,MetadataChange…,HashMerge…,TrackedBytes…scanAndMergeChunk(LoadChunked) does not internSharedOnEveryConstructionPath,EvictionCreditsExactlyloadChunk) does not internSharedOnEveryConstructionPathIngestNewFromDBdoes not internSharedOnEveryConstructionPath,EvictionCreditsExactly,ConcurrentIngestAndReadersIngestNewObservationsdoes not internSharedOnEveryConstructionPath,SharingSurvivesGC,MetadataChange…,ConcurrentIngestAndReadersIngestNewObservationsinterns unders.muinstead of at the row scanNeverBlocksStoreWriters/IngestNewObservations(probe 0)IngestNewFromDBinterns unders.muinstead of at the row scanNeverBlocksStoreWriters/IngestNewFromDB(probe 0)MetadataChangeLeavesOlderObservations(the rename shares a prefix with the old name on purpose),SharedOnEveryConstructionPath, +6sharedStrsdoes not use existing padding (StoreObsgrows to 208 bytes)StoreObsLayoutFitsSharedStrsInPadding(and the pre-existing #76 layout test)M15 and M16 are what the lock-discipline test exists for, and finding that they survived the first version of that test is why it now asserts the ingest is really parked before probing
s.mu, and probes twenty times (commit53682d4f).Benchmarks (before = the red commit, which runs master's store code)
BenchmarkLoad_Obs489, a hotLoadof 250,000 observations over 12,152 transmissions (production distribution: mean 20.6 observations per transmission, p50 15, p90 41, p99 95),-benchtime 3x -count 6, Go 1.27.0, darwin/arm64, 10 cores. Medians of 6:20aef204)trackedBytesThe +1.2 % on the load is the four pool lookups per observation (1 M of them here):
BenchmarkObsStringInternObsmeasures them at 39 ns per observation, i.e. 39 ms over the 1 M observations of this load, which is the whole difference.B/opandallocs/opdo not move, on purpose — see the perf justification above.Pool micro-benchmarks
-benchtime 2s -count 3, medians, 140 held values (the steady state):ObsStringPoolHit(one hit)ObsStringPoolHitParallel(hits from N goroutines)ObsStringInternObs(one observation, four fields)ObsStringPoolMiss(filling an empty pool to the 8192 cap)Why 16 shards: with a single
RWMutexthe same parallel hit cost 9 ns alone but 34 ns with two goroutines and 84 ns with ten — the read lock's own cache line, not the map. A per-hit atomic counter made it worse still and was dropped. Sharded and counter-free it is flat to 4 cores and 22 ns at 10, for 2 ns more on a serial hit (24 ms over the 10 M calls a 2.5M-observation load makes).Perf justification (AGENTS.md rule 0)
obsStringPoolMaxmisses for the life of the store.PacketStore.muthat waits on another goroutine: the two live paths intern while scanning rows, before they take the store lock, which the lock-discipline test pins. The two cold paths holds.mufor their whole scan anyway; sharding keeps them from waiting on the background fill or on live ingest.Validation (local)
cd cmd/server && go test ./...: ok.cd cmd/ingestor && go test ./...: ok.go test -raceon the_489,_411,_113,_215, chunked-load, eviction and ingest tests: ok, no races.sh test-all.sh: 263 passed, 0 failed (263 files).node test-frontend-helpers.js: 719 passed, 0 failed.e2e-fixture.dbprepared as in CI (freshen, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed,corescope-migrate, the four seed SQL files):test-e2e-playwright.js: 154/157 passed, 3 skipped.test-issue-76-heard-by-e2e.js(8),test-issue-147-packets-url-obs-e2e.js,test-packet-detail-sender-hash-size-obs-e2e.js(3),test-issue-1562-observers-summary.js(13),test-issue-1639-observers-sort-e2e.js,test-issue-122-compare-reference-many-e2e.js(11),test-issue-497-rx-airtime-e2e.js,test-issue-199-inactive-observer-e2e.js(3),test-observer-iata-1188-e2e.js(12).scripts/check-xss-sinks.sh --diff origin/master:no public/**/*.{js,html} changes to scan.cmd/serverstays read-only: no new writes, no new DB handle.map[string]interface{}in non-test Go code: unchanged (198 instore.gobefore and after; the new file has none).deploy.yml, 1 inrelease-fast-path.yml.What this does not claim
Follow-ups (not in this PR)
path_jsonandtimestamprepeat across observations too, but they are not what perf(store): evaluate bounded sharing of observer metadata strings #489 names and they are not a small closed set, so they want a measurement of their own before anyone shares them.fullclimbing.fullcount on/api/perf?mem=1would make the saving visible to operators, but that is a response-shape change and perf(store): evaluate bounded sharing of observer metadata strings #489 rules those out.🤖 Generated with Claude Code