Repository navigation
perf(store): read resolved_path pubkeys in place, one copy each - #2197
Merged
efiten merged 5 commits intoOct 17, 2026
Merged
Conversation
efiten
requested changes
Oct 15, 2026
efiten
left a comment
Collaborator
There was a problem hiding this comment.
The scanner and the pubkey interning hold up, and the gains reproduce. One part should go before merge, and the interner fold should happen in this PR.
What I checked
- The scanner. It only takes the fast path for plain, escape-free ASCII strings and nulls; everything else falls back to the old decode.
TestResolvedPubkeysFromJSONMatchesDecoding(26 edge cases plus 20,000 random inputs) is good evidence that the two agree. - The interner.
- It never stores a substring: a lookup by
subdoes not copy, and only a new key is cloned. - It is bounded at
1<<16entries.
- It never stores a substring: a lookup by
- Eviction behaviour is unchanged:
- first-seen order stays;
- deduplication still works per packet across observations;
- NULL and empty values are skipped;
- the SQL fetch still runs outside
s.mu.
- Throwaway benchmarks (
-benchmem -count=6, medians):- One path with 5 pubkeys and 3 nulls, from a pool of 2,000 relays: allocations go from 19 to 1, bytes from 1176 B to 128 B, time from about 6.4 µs to 0.79 µs.
resolvedPubkeysForEvictionBatch, 1,000 packets × 3 observations: allocations go from 80,495 to 20,504, bytes from 5.97 MB to 2.18 MB.- Both are consistent with your figures.
- Suite:
go vetandgo test ./cmd/serverpass on the head and on the head merged into current master.
Requested changes
- Drop the
sql.RawBytes+unsafe.Stringpart (resolved_index.go:521and:535).- Why it doesn't help: mattn/go-sqlite3 v1.14.52 already allocates a Go string per TEXT column (
C.GoStringN). Scanning that intosql.NullStringtakes it without a copy. Scanning intoRawBytesadds a memcpy into database/sql's reusedrawbuf, so the comment "rpJSON is the driver's buffer" is not accurate either. - Measured: I reverted only that part to
NullStringon your head. Allocations were the same (20,501 against 20,504 per op), bytes slightly lower, and time not worse. - So the
unsafeimport adds an aliasing risk with no measured gain.
- Why it doesn't help: mattn/go-sqlite3 v1.14.52 already allocates a Go string per TEXT column (
- Fold
pubkeyInternerintointernStrnow. #2183 has landed, so this is the "whichever lands second" point from your description.- Order matters: past its cap,
internStrreturnssas passed, without cloning (intern.go:44-45). With theunsafe.Stringview still in eviction, such a returned key would alias the reusedrawbufand be overwritten by the next row. That would put wrong keys inrpBatchand leave stale entries inbyNodeand the resolved-pubkey index. - So item 1 has to come before the fold, or the fold needs a variant that clones on overflow.
- Your allocation tests should still pass afterwards, since
internStr's hit path does not allocate.
- Order matters: past its cap,
Notes, not blocking
- Interned pubkeys live for the whole process, including keys of nodes that have since been evicted. It is bounded at 64k, so this is acceptable, but worth a sentence in the comment, as
intern.gohas. - A real
Benchmarkwith realistic sizes would make the numbers rerunnable. The startup totals (236 MB to 61 MB, CPU 175.6 s to 170.6 s) need your dataset, and the CPU difference is under 3% from a single run.
Red: resolvedPubkeysFromJSON (a stub over unmarshalResolvedPath + extractResolvedPubkeys) makes 13 allocations for three pubkeys. The equivalence test pins what decoding returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
Load, loadChunk and scanAndMergeChunk decode each observation's resolved_path (a JSON array of pubkeys and nulls, ~420 chars, on most observations of live data) only to collect its non-empty pubkeys. The decode into []*string allocated a string and a pointer per hop and two slices. resolvedPubkeysFromJSON reads the usual array of escape-free strings and nulls in place and anything else falls back to decoding, so it returns exactly extractResolvedPubkeys(unmarshalResolvedPath(s)) (pinned on 20k random inputs plus edge cases). Relay pubkeys repeat across nearly every observation, so a bounded table holds one copy of each: a pubkey seen before is found by substring without copying, and the indexes it feeds share that copy. On a copy of a busy week: allocations here over startup 236MB -> 61MB, server CPU over the run 175.6s -> 170.6s. Three known pubkeys: 13 -> 1 allocations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
Red: resolvedPubkeysForEvictionBatch makes about 51 allocations per evicted packet (3 observations, 4 hops) by decoding each resolved_path into pointers and building a de-dup map per packet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
resolvedPubkeysForEvictionBatch runs for every eviction batch, once a minute in steady state. It scanned each resolved_path into a string, decoded it into a pointer per hop and built a de-dup map per packet. It now scans the column as raw bytes, reads it with resolvedPubkeysFromJSON without copying (nothing keeps a slice of the buffer: new pubkeys are cloned into the shared table, the decoder fallback makes its own strings), and de-duplicates each packet's short list by scanning it. Same keys, same first-seen order. Fixture: 51 -> 15 allocations per evicted packet. A five-minute steady window on a copy of a busy week spent 130MB here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
Review feedback on Kpa-clawbot#2197: - Eviction scans resolved_path into sql.NullString again. mattn/go-sqlite3 already allocates a string per TEXT column, so RawBytes + unsafe.String only added a copy into database/sql's buffer and an aliasing risk. - pubkeyInterner is folded into internStr, now on master. With the NullString scan, a key returned uninterned past internMax is a slice of that row's own string, not of a reused buffer. - Benchmarks for resolvedPubkeysFromJSON and the eviction batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
A13xB0
force-pushed
the
perf/resolved-path-keys-without-pointers
branch
from
October 15, 2026 09:17
058d8e5 to
a7f00ad
Compare
Contributor
Author
|
Review feedback addressed (commit a7f00ad):
Notes:
The branch is rebased onto current master. |
Contributor
Author
|
Bump |
efiten
approved these changes
Oct 17, 2026
efiten
left a comment
Collaborator
There was a problem hiding this comment.
Thanks, both requested changes are in, in the right order. Approving.
What I checked at a7f00ad6
git range-diffagainst the series I reviewed: the first four commits are unchanged,a7f00ad6is the only new one.- Eviction scans into
sql.NullStringagain (resolved_index.go:520), andunsafeis gone.scanResolvedPathcallsinternStr(s[start:i])(neighbor_persist.go:292), so a key returned past the cap is a slice of its own row string, not of a reused buffer.internMax(intern.go:11) bounds the table, andinternStris safe for concurrent callers. go vetandgofmt -lare clean;go test -count=1 ./...incmd/serverand-raceon the Resolved, Eviction and Intern tests pass.- Benchmarks on Windows, interleaved base/head, medians of 3, with your eviction benchmark copied onto the base:
BenchmarkResolvedPubkeysForEvictionBatch7.57 ms / 3.96 MB / 65,495 allocs on master against 3.10 ms / 1.36 MB / 18,501 allocs here. Bytes and allocations match your figures exactly; the time ratio is 2.4x.BenchmarkResolvedPubkeysFromJSON: 392-420 ns, 128 B, 1 alloc. - Mutations on the scanner: dropping the
>= 0x80,< 0x20or trailing-garbage checks, keeping empty strings, disabling eviction dedup, or cloning instead of interning each makes a test fail.
Small points, not blocking
- Past the cap, a key pins its row. Once the table is full,
internStrreturnss[start:i]as passed, and that key is kept innodeHashes/byNode, so it holds the whole ~420-byte row string. The oldpubkeyInternercloned on overflow. Only reachable after 65,536 interned strings; a clone at the call site when the result aliasess, or a sentence in the comment, would settle it. - The
intern.gocomment (:8-10,:23-24) still says the table holds observer IDs, names, IATA codes and directions, "in the hundreds". It now also holds every resolved relay pubkey. - Two test gaps. Replacing
internStr(s[start:i])with plains[start:i]passes every test, so nothing pins "one copy each"; comparing the backing pointer of two calls would. Removing the backslash check atneighbor_persist.go:280also passes;["a\\b"]and["\\"]as edge cases would cover it. Real pubkeys are hex, so neither affects users.
Not checked: the startup figures, which need your dataset.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #1870.
Problem
Load,loadChunkandscanAndMergeChunkdecode each observation'sresolved_pathonly to collect its non-empty pubkeys. On live data that's a JSON array of pubkeys and nulls, about 420 characters, present on most observations. Decoding into[]*stringallocated a string and a pointer for every hop, plus two slices.Change
resolvedPubkeysFromJSONreads the usual array of escape-free strings and nulls in place. Anything else falls back to decoding, so it returns exactlyextractResolvedPubkeys(unmarshalResolvedPath(s)).internStr(from perf(store): share one copy of each observer string across observations #2183) and is kept once. A pubkey seen before is found by substring, without copying, and the indexes it feeds (byNodeand friends) share that copy. Interned keys live for the process, evicted nodes' included; the table is bounded at 64k entries.resolvedPubkeysForEvictionBatchruns on every eviction batch, about once a minute in steady state, and spent about 130 MB per five minutes on a busy week. It now reads each pubkey in place and de-duplicates each packet's short list by scanning it instead of building a map per packet.unmarshalResolvedPathare unchanged.Measured
The startup rows need that dataset and are from a single run. The benchmarks are rerunnable (
go test -run X -bench ResolvedPubkeys -benchmem ./cmd/server, medians of 3):BenchmarkResolvedPubkeysFromJSON(5 relays + 3 nulls, pool of 2,000)BenchmarkResolvedPubkeysForEvictionBatch(1,000 packets × 3 observations)Tests
TestResolvedPubkeysFromJSONAllocations: red commit first, with the bound tightened in green.TestResolvedPubkeysForEvictionBatch: keys, order, and an allocation bound. It has its own red commit.TestResolvedPubkeysFromJSONMatchesDecodingcompares against decode-then-extract on 26 edge cases and 20,000 random inputs.go test ./cmd/serverpasses.No config or UI changes.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H