Files
meshcore-analyzer/cmd/server/relay_candidates_alloc_test.go
T
Alex BandClaude Opus 5.5 b8e571c9c5 perf(store): build the relay-filtered candidate lists once (#2195)
## Problem
`relayCandidates` runs once per hop of every observation the cold loader
resolves, and again from the direct-heard and neighbor analytics. Once
any listener-only observer was marked (#1290), every call built a
filtered copy of the candidate `nodeInfo` structs, even when none of the
candidates was a listener. On a week of a busy regional network that
accounted for 1.1 GB of the 5.4 GB allocated over one startup, the
biggest single source of startup churn.

## Change
`markNonRelay` now builds a map once, holding the filtered list for each
prefix whose candidates include a listener. A listener can only be in
the lists of its own prefixes and its full key, so only those are
filtered: at most 9 lists per listener, not every list in the prefix
map. `relayCandidates` returns either that list or the prefix map's own
list, without copying. Every caller only reads the slice (checked:
direct-heard, neighbor persist, node hop analytics and
`resolveWithContext`).

## Measured on a copy of that week
| | before | after |
|---|---|---|
| allocated over startup | 5.44 GB | 4.32 GB |
| server CPU over the run | 175.6 s | 169.1 s |
| peak RSS | 1074 MB | 1077 MB (unchanged; the GC limit sets it) |

## Cost per rebuild against cost per lookup
`markNonRelay` now does once per node cache rebuild (as often as every
30 s, `nodeCacheTTL`) the filtering that master did on every
`relayCandidates` call. 5,000 repeaters and 3 listeners, median of 6
runs (`BenchmarkMarkNonRelay5000`, `BenchmarkRelayCandidates256`):

| | master | first version of this PR | this PR now |
|---|---|---|---|
| `markNonRelay`, once per rebuild | 0.59 µs, 320 B, 3 allocs | 14.8 ms,
6.57 MB, 36,285 allocs | 20 µs, 20.6 KB, 33 allocs |
| one `relayCandidates` lookup | ~7.8 µs, 6.7 KB | ~30 ns, 0 B | ~30 ns,
0 B |
| 256 lookups (one per 2-character hop) | 2.0 ms, 1.71 MB, 1,453 allocs
| 7.7 µs, 0 B | 7.7 µs, 0 B |

The extra ~20 µs per rebuild is paid back after about 3 lookups. Every
ingested observation looks up each hop of its path, so a running server
makes far more than that between rebuilds, and a cold load makes
millions (the 1.1 GB above).

## Tests
- `TestRelayCandidatesDoesNotAllocate`: zero allocations per call, and
listeners still filtered.
- `TestRelayCandidatesAfterSecondMark`.
- `TestMarkNonRelayVisitsOnlyListenerPrefixes`: at most 80 allocations
for 3 listeners among 5,000 repeaters (36,285 before the fix). It has
its own red commit.
- `BenchmarkMarkNonRelay5000` and `BenchmarkRelayCandidates256` give the
table above.
- `TestMarkNonRelayMatchesFilteringEachList`: every prefix of the
5,000-repeater map matches filtering its list on the spot.
- Red commit first, then green.
- `go test ./cmd/server` passes.

No config or UI changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:42:18 +02:00

137 lines
5.0 KiB
Go

package main
import (
"encoding/hex"
"math/rand"
"strings"
"testing"
)
// relayCandidates runs once per hop of every observation the cold loader
// resolves. With any listener-only observer known it built a filtered copy
// of the candidate nodeInfo structs on every call: about 1 GB allocated over
// one startup on a week of a busy network. The filtered lists are built once
// when the listeners are marked.
func TestRelayCandidatesDoesNotAllocate(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a4aa000000000000000000000000000000000000000000000000000000000001", Role: "repeater"},
{PublicKey: "a4bb000000000000000000000000000000000000000000000000000000000002", Role: "repeater"},
{PublicKey: "c1cc000000000000000000000000000000000000000000000000000000000003", Role: "repeater"},
})
pm.markNonRelay([]string{"A4BB000000000000000000000000000000000000000000000000000000000002"})
if got := pm.relayCandidates("A4"); len(got) != 1 || got[0].PublicKey[:4] != "a4aa" {
t.Fatalf("relayCandidates(A4) = %v, want only the a4aa relay", got)
}
if got := pm.relayCandidates("a4bb"); len(got) != 0 {
t.Fatalf("relayCandidates(a4bb) = %v, want none (the only match is a listener)", got)
}
if got := pm.relayCandidates("c1"); len(got) != 1 {
t.Fatalf("relayCandidates(c1) = %v, want one", got)
}
for _, hop := range []string{"a4", "a4bb", "c1", "ff"} {
if n := testing.AllocsPerRun(100, func() { _ = pm.relayCandidates(hop) }); n != 0 {
t.Errorf("relayCandidates(%q) allocates %.0f times per call, want 0", hop, n)
}
}
}
// Marking more listeners later still filters them.
func TestRelayCandidatesAfterSecondMark(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a4aa000000000000000000000000000000000000000000000000000000000001", Role: "repeater"},
{PublicKey: "a4bb000000000000000000000000000000000000000000000000000000000002", Role: "repeater"},
})
pm.markNonRelay([]string{"a4aa000000000000000000000000000000000000000000000000000000000001"})
pm.markNonRelay([]string{"a4bb000000000000000000000000000000000000000000000000000000000002"})
if got := pm.relayCandidates("a4"); len(got) != 0 {
t.Fatalf("relayCandidates(a4) = %v, want none after both are marked", got)
}
}
// markNonRelayFixture is 5,000 repeaters with random keys, three of which
// are marked as listeners, the size of a busy regional network.
func markNonRelayFixture() (*prefixMap, []string) {
rng := rand.New(rand.NewSource(1290))
nodes := make([]nodeInfo, 5000)
for i := range nodes {
b := make([]byte, 32)
rng.Read(b)
nodes[i] = nodeInfo{PublicKey: hex.EncodeToString(b), Role: "repeater"}
}
return buildPrefixMap(nodes), []string{nodes[7].PublicKey, nodes[1234].PublicKey, strings.ToUpper(nodes[4321].PublicKey)}
}
// markNonRelay runs on every node cache rebuild, as often as every 30 s. It
// used to copy every prefix's list to find the few that hold a listener;
// those can only be the listeners' own prefixes, so only they are visited.
func TestMarkNonRelayVisitsOnlyListenerPrefixes(t *testing.T) {
_, listeners := markNonRelayFixture()
n := testing.AllocsPerRun(5, func() {
pm, _ := markNonRelayFixture()
pm.markNonRelay(listeners)
}) - testing.AllocsPerRun(5, func() { markNonRelayFixture() })
// Three listeners have at most 8 prefixes each, plus the maps themselves.
if n > 80 {
t.Errorf("markNonRelay allocates %.0f times for 3 listeners among 5,000 repeaters, want at most 80", n)
}
}
// Every prefix gives what filtering its list on the spot would.
func TestMarkNonRelayMatchesFilteringEachList(t *testing.T) {
pm, listeners := markNonRelayFixture()
pm.markNonRelay(listeners)
isListener := map[string]bool{}
for _, pk := range listeners {
isListener[strings.ToLower(pk)] = true
}
for key, candidates := range pm.m {
var want []nodeInfo
for _, c := range candidates {
if !isListener[strings.ToLower(c.PublicKey)] {
want = append(want, c)
}
}
got := pm.relayCandidates(key)
if len(got) != len(want) {
t.Fatalf("relayCandidates(%q) has %d candidates, want %d", key, len(got), len(want))
}
for i := range want {
if got[i].PublicKey != want[i].PublicKey {
t.Fatalf("relayCandidates(%q)[%d] = %s, want %s", key, i, got[i].PublicKey, want[i].PublicKey)
}
}
}
}
func BenchmarkMarkNonRelay5000(b *testing.B) {
pm, listeners := markNonRelayFixture()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
pm.nonRelay, pm.relay = nil, nil // as on a fresh rebuild
pm.markNonRelay(listeners)
}
}
// relayCandidates is the other side of markNonRelay's work: one lookup per
// 2-character prefix, the hop width most paths use, so 256 lookups per op.
func BenchmarkRelayCandidates256(b *testing.B) {
pm, listeners := markNonRelayFixture()
pm.markNonRelay(listeners)
const digits = "0123456789abcdef"
hops := make([]string, 0, 256)
for _, x := range digits {
for _, y := range digits {
hops = append(hops, string(x)+string(y))
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, h := range hops {
_ = pm.relayCandidates(h)
}
}
}