Repository navigation
perf(analytics): reduce graph-score warmup allocations - #587
Conversation
…561) Adds an independent Floyd-Warshall / pair-dependency oracle for the Bridge (weighted Brandes betweenness) and Coverage (harmonic reach) scores, a golden before/after file captured from the current map-based implementation, determinism / stale-state / concurrency tests, an O(V+E) allocation budget, a readiness pin for both Start* recomputers, and seeded benchmarks at 600/2000 and 2000/10000. Red on master: the allocation budget (1.46M / 1.11M allocs at 600/2000), bit-identical results across runs and edge permutations (map iteration order perturbs float summation), and the no-state-across-calls check. Relates to #561 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ace (#561) Bridge (Brandes) and Coverage (harmonic reach) now index the weighted neighbor graph once per computation (sorted int32 vertex ids, CSR rows sorted by neighbor, cheapest duplicate kept) and run every source over one workspace of flat vertex-indexed slices with a typed min-heap and CSR-row predecessor storage, instead of allocating string-keyed maps and boxing heap entries per source. Workspaces are per call: no global cache, no sharing between goroutines, O(V+E) memory per in-flight computation. Semantics are unchanged (same input filtering, 1/weight distances, 1e-12 Brandes tie tolerance, halving and max normalization; Coverage still skips the source and unreachable vertices). Vertex numbering no longer depends on map iteration, so results are now bit-identical across runs and edge permutations. The synchronous initial snapshot in both Start* recomputers is untouched. weightedDistanceAdjacency, dijkstraFrom and bridgePQ had no other callers and are removed. Paired benchmarks (2000 vertices / 10000 edges, n=6 alternating): Bridge 4.88s -> 0.87s, 1.93GiB -> 1.3MiB, 17.1M -> 91 allocs; Coverage 3.79s -> 0.80s, 1.43GiB -> 1.2MiB, 13.1M -> 75 allocs; startup warmup (bridge + coverage + redundancy) 8.45s -> 1.69s. Tests: workspace reuse across sources and graph sizes with poisoning, indexed-graph structure, tie-weighted permutation determinism, and an allocation budget below one allocation per source. Relates to #561 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent2 PR#587 #561 — head a62a2aaStatus: Draft ready for review. All requirements are covered by tests and mutants, and CI is green on every job with no reruns. Not merged and not marked ready. Evidence legend: [T] verified by a local test or benchmark run · [A] analysis or reasoning, not executed · [K] checked by command or CI (grep, diff, CI job result). Requirements
Benchmarks (2000 vertices / 10000 edges, median of 6)
[T] B/op is allocated bytes, not retained heap. The 600/2000 rows are in the PR description. Standing constraints
Local test runs (head
|
| Job | Result |
|---|---|
| Go Build & Test | pass |
| Go Ingestor Tests | pass |
| Go Libraries & CLI | pass |
| Go Server Tests (1/3) | pass |
| Go Server Tests (2/3) | pass |
| Go Server Tests (3/3) | pass |
| JavaScript Unit Tests & Lint | pass |
| Script & Container Unit Checks | pass |
| Playwright E2E Tests (1/3) | pass |
| Playwright E2E Tests (2/3) | pass |
| Playwright E2E Tests (3/3) | pass |
| Verify E2E Shards & Coverage | pass |
| Verify Go Test Assignments | pass |
| Validate Docker Image | pass |
| Build & Publish Docker Image | pass |
| Publish Badges & Summary / Release Artifacts / Deploy Staging | skipped (fork guards / not a push) |
[K] No reruns were needed; the known flaky test #271 did not fail.
Remaining items
- [A] About 8.5k warmup allocations remain at 2000/10000. They come from
bridgeEdgesFromGraphand the map-based Redundancy axis, which are outside the scope of perf(analytics): measure and reduce graph-score warmup allocations #561 and unchanged. A follow-up could reuse the indexed graph for Redundancy too. - [A] Wall time is still dominated by the O(V·(E + V log V)) all-sources shortest paths (~1.7 s warmup at 2000/10000 on this host). Moving the warmup off the startup path, or parallelizing it across sources, would each need a separate readiness or CPU-budget decision. Neither is done here.
- [A] Behavior note: scores are now bit-deterministic. Previously the low float bits varied between runs because vertex order came from map iteration.
- [T] Equivalent mutant (
predLenreset) documented above; the reset is kept as defense in depth for the "fully re-initialized workspace" contract. - [K]
scripts/check-xss-sinks.shneedsbash;shfails with a syntax error. This is pre-existing and not changed here.
Review — CS-MacBook PR#587 — head a62a2aaDom: APPROVE with nits Evidence legend: [T] reproduced by a local test, benchmark or mutant run · [A] analysis or code reading, not executed · [K] checked by command or CI (grep, diff, CI job result). Trees used: PR head Findings
No blocking issues found. Answers to the review pointsScores identical before/after on a fixed graph
Readiness not weakened
Benchmark reproduction
The relative improvements match the PR (−74…−82%). The "after" alloc counts are identical to the PR's. B/op is allocated bytes, not retained heap. -race
Acceptance criteria → red before / green after
No behavior change beyond the goal
Standing constraints
Tests run (merged tree
|
| # | Mutant | Result |
|---|---|---|
| M1 | ssspWorkspace.reset no longer clears settled |
killed: 4 legacy Coverage tests, BridgeMatchesOracle, CoverageMatchesOracle, GoldenBeforeAfter, NoStateAcrossCallsOrSizes; then the finding 1 hang |
| M2 | CSR build keeps duplicate arcs (dedup disabled) | killed: BridgeMatchesOracle, IndexedGraphStructure |
| M3 | back-propagation drops the 1.0 + term |
killed: HandleNodesSurface, legacy Bridge tests, BridgeMatchesOracle, GoldenBeforeAfter, NoStateAcrossCallsOrSizes; then the finding 1 hang |
| M4 | tie test abs(alt-dist) <= 1e-12 → exact == |
survived (finding 2) |
| M5 | axes initial compute made asynchronous | killed: StartInstallsScoresBeforeReturn |
| M6 | distHeap.pop picks the larger right child |
killed: BridgeMatchesOracle/random-ties-b, CoverageMatchesOracle/grid-4x4, GoldenBeforeAfter; then the finding 1 hang |
Not verified
- I did not rerun the author's own mutant list; I ran my six above instead.
- No measurement on linux/amd64. My numbers are darwin/arm64 with Go 1.26.3, not the CI toolchain.
- The real-fixture comparison covers only the small E2E fixture (195 scored nodes, mostly zero redundancy); I did not compare on a production-sized graph. No staging or production access was used.
- I did not run the E2E suites unrelated to node scores.
- I did not check retained heap; all memory figures are allocated bytes.
- I did not browser-validate visually. There is no UI change; the E2E runs exercise the nodes and analytics pages.
Relates to #561
Summary
Measures and reduces the warmup cost of the Bridge (weighted Brandes betweenness) and Coverage (harmonic reach) graph scores without changing score semantics. Both axes now index the weighted neighbor graph once per computation (sorted
int32vertex ids, CSR adjacency) and run every source over one reusable workspace of flat, vertex-indexed slices. Previously each source allocated fresh string-keyed maps and boxed heap entries.The synchronous initial snapshot in
StartBridgeScoreRecomputer/StartUsefulnessAxesRecomputeris unchanged: the warmup remains on the startup path, so zero scores are never served as finished analytics. It is simply ~5× cheaper now. Moving the warmup off the HTTP startup path would need a separate readiness/API design (as #561 says), so this PR does not do it.Plan (two commits)
test(analytics)— pin semantics + baseline. Adds an independent oracle, a golden before/after file captured from the current implementation, determinism/stale-state/concurrency tests, an allocation budget, a readiness pin, and seeded benchmarks. Red on master: allocation budget, bit-identical determinism, no-state-across-calls.perf(analytics)— indexed graph + reusable workspace. Implementation, plus workspace-internal tests (poisoned reuse across sources and graph sizes, CSR structure).What changed
graph_weighted.go:indexedDistanceGraph(CSR) replacesweightedDistanceAdjacency. Same filtering: trim and lowercase, drop empty endpoints, self-loops, NaN/±Inf andw < 1e-9; distance1/w; keep the cheapest duplicate. Vertex ids are in sorted-key order and rows are sorted by neighbor id. Also addsssspWorkspace(dist / settled / typed min-heap with lazy deletion) andharmonicReach.bridge_score.go:brandesWorkspaceembeds the SSSP workspace and adds sigma / delta / stack, with predecessors stored in each vertex's own CSR row (predBuf, length 2E). The relaxation logic is a line-for-line port, including the1e-12tie tolerance, halving and max normalization.coverage_score.go: one SSSP workspace across all sources; Σ 1/d is accumulated at settle time (source and unreachable vertices excluded, as before).weightedDistanceAdjacency,dijkstraFromandbridgePQhad no other callers and are removed.map[string]interface{}.Bounded work / memory. Each
Compute*call owns its workspace: no global cache, nosync.Pool, no sharing across goroutines. The workspace is O(V + E): five vertex-length slices,predBufof 2E, and a heap of ≤ 2E+1 entries. It is reset in O(V) per source, the same as the old per-source map initialization. No new work runs underPacketStorelocks; the immutableatomic.Pointersnapshot publication is untouched.Determinism (behavioral note). Before, vertex order came from Go map iteration, so the floating-point summation order — and the low bits of the scores — varied between runs. Now results are bit-identical across runs and edge-order permutations. Values agree with the old implementation within float rounding; see below.
Benchmarks
go test -run '^$' -bench GraphScore561 -benchmem. Test binaries for the master implementation and this branch were built from the same benchmark file and run alternately, 6 rounds each. Results are compared withbenchstat: median ± 95% CI, Mann-Whitney U test. Noise policy: only differences with p < 0.05 are claimed. This host is noisy (time CIs up to ±70%), but every row has p = 0.002 with non-overlapping ranges. B/op is allocated bytes, not retained heap.Environment: linux/amd64, 12th Gen Intel i7-1260P (4 vCPU), Go 1.27.1, single-threaded compute. Graphs: deterministic seeded synthetic, connected (spanning tree + random extra edges), weights in (0, 1]. The Warmup benchmark runs exactly what the two
Start*recomputers do beforeListenAndServe(recomputeBridgeScoresSafe+recomputeUsefulnessAxes, i.e. bridge + coverage + redundancy, including thebridgeEdgesFromGraphsnapshot) over a realNeighborGraph.The remaining ~8.5k warmup allocations at 2 000 / 10 000 come from
bridgeEdgesFromGraphand the map-based Redundancy axis. Both are outside the scope of #561 and unchanged; they are reported here so no allocation is hidden.Equivalence (before/after)
graph_score_561_test.go): Floyd–Warshall all-pairs distances, path counts by a distance-ordered DP, and betweenness from the pair-dependency definition Σ σ_st(v)/σ_st. Production is never its own oracle. 17 fixtures: empty, single edge, line, triangle, star, diamond with an equal-path tie, weighted diamond, disconnected components, duplicates (either direction and case), invalid and boundary weights (0, negative, NaN, ±Inf, sub-epsilon including a sub-epsilon edge to a new vertex, exactly epsilon, w > 1), case/whitespace keys, a 4×4 grid, tie-weighted random graphs (power-of-two weights so ties are exact) and continuous random graphs up to 150 / 600. Tolerance:|Δ| ≤ 1e-9on scores in [0, 1]. Hand-derived values pin the oracle itself.2e6fd9b0) on a fixed 400 / 2 000 graph (testdata/graph_scores_561_golden.json; do not regenerate). Measured: Bridge bit-identical on 400/400 vertices; Coverage max |Δ| = 2.2e-15 (summation order only). The test enforces 1e-9.e2e-fixture.db,/api/nodescompared per node.bridge_scoreandredundancy_scoreare identical;coverage_scoremax |Δ| = 3.3e-16;usefulness_scoremax |Δ| = 5.6e-17 (195 nodes).Tests
TestGraphScore561_AllocationBudgetTestGraphScore561_AllocationBudgetBridgeMatchesOracle,OracleSanity,GoldenBeforeAfterCoverageMatchesOracle,IndexedGraphStructureDeterministicAcrossRunsAndPermutations(continuous + tie-weighted)WorkspaceReuseIsStateless(poisoned after every source, shrink + grow, anchored to the oracle),NoStateAcrossCallsOrSizesNoState…yes (via nondeterminism)predLennot reset is an equivalent mutant:predLen[w]is only read after the strict-improvement branch has set it to 1.ConcurrentComputationsunder-raceStartInstallsScoresBeforeReturnCommands run locally (all green):
cd cmd/server && go vet . && go test -count=1 ./...go test -race -run 'GraphScore561|Bridge|Coverage|Usefulness|Redundancy' .cd cmd/ingestor && go test -count=1 ./...sh test-all.sh(270/270 files)node test-frontend-helpers.js(725 passed)bash scripts/check-xss-sinks.sh --diff origin/master(no frontend changes)test-e2e-playwright.js, 154 passed / 3 skipped by design) against a local Go server on a scratch copy ofe2e-fixture.db, prepared as in CI (freshen, inline seed, migrate, seed files).Coordination with #492 / PR #554
PR #554 (merged) pauses unread snapshot recomputers and explicitly leaves the bridge and usefulness-axes loops unchanged. This PR does not touch any recomputer lifecycle, ticker or snapshot publication. It only changes the pure
Compute*functions they call.🤖 Generated with Claude Code