Skip to content

perf(graph): store the declined calls' candidate lists once per distinct list - #208

Open
joyful-ii-V-I wants to merge 2 commits into
mainfrom
perf/declined-candidate-lists-shared
Open

perf(graph): store the declined calls' candidate lists once per distinct list#208
joyful-ii-V-I wants to merge 2 commits into
mainfrom
perf/declined-candidate-lists-shared

Conversation

@joyful-ii-V-I

Copy link
Copy Markdown
Collaborator

The call-graph build keeps, for every call the resolver declines to bind, the list of candidates it declined between, so --callers and friends can say how many calls were declined for a symbol. The lists were stored once per call, so the structure grew with calls × candidates: 27.9 M entries (114 MB) on an llvm-sized corpus, where most declined calls repeat a handful of identical lists.

Change

  • One stored copy per distinct list. internDeclinedList keys each declined call's list by its exact candidate sequence: an FNV-1a hash picks a bucket, and a hit is confirmed by length and memcmp. The storage becomes a CSR of distinct lists plus a call count per list; the per-call array is gone.
  • Every count stays the same. declinedCallsNaming walks the distinct lists and adds each matching list's call count.
  • Degrade and verify. A uint32 offset overflow takes DEGRADED_PATH_ALERT, and a new verifyOffsetCsr in src/infra/csrverify.h checks the list CSR next to verifyCsr.

Evidence

  • Output is byte-identical to main: 14 of 14 commands on this repo and 14 of 14 on llvm; mcpclidiffcheck 21 of 21.
  • llvm: 9,879 distinct lists and 62,359 entries, 368 KB instead of 114 MB. Peak footprint fell by about 145 MiB (median of 3 cold runs). The machine was loaded, so treat the timing and RSS figures as indicative only.
  • New gate test/declinedlistcheck.sh (30 rows): a mutation that shares lists by name instead of by content fails 12 of them; this change and main both pass all 30. declinecheck passes 161 of 161, and both gates run clean under ASan.

🤖 Generated with Claude Code

joyful-ii-V-I and others added 2 commits September 12, 2026 14:52
…nct list

The resolver recorded every tier-3 declined call's candidate definitions in a per-call CSR
(declinedCandOff/declinedCand), appending the whole same-name candidate list for each call. A common
name repeats its full definition list on every declined call to it. On a sparse llvm-project tree,
715,735 declined calls held 27.9 M entries (114 MB, 167 MB of capacity); `get` alone was 7,270 calls
over one 424-candidate list. Entries per node grew with the tree: 9.4, 41.8 and 69.2 on three growing
subtrees, 73.1 on the whole. The offsets were uint32 and would have wrapped silently past 4.29 B
entries, and every declined_calls= answer scanned all of them. The same tree holds only 9,879 distinct
lists, 62,359 entries in all.

The only reader, declinedCallsNaming, asks of each declined call whether its candidates include a
target and counts each call once, so list membership plus a call count answers it exactly. The new
internDeclinedList keys a declined call's list by its exact NodeId sequence. FNV-1a
(hashutil::fnv1aAbsorb) over the candidate bytes picks a bucket in a
HashMap<uint64_t, SmallVec<uint32_t, 1>> reserved to 4096. A hit is confirmed by length and memcmp and
adds one to that list's call count; a miss appends the list to a distinct-list CSR
(declinedListOff/declinedListCand) with a count of 1. No per-call array is kept. The reader scans each
distinct list until its first target, then adds that list's call count.

Equal sets meet without a sort. A declined call is never canonical, narrowed or SCIP-pinned, so its
candidates come only from the byName fill, in symbol-id order with each id once, and the decl/def
collapse and the namespace, JVM, Rust and std:: filters each keep an ordered subset. A temporary check
over the stored lists found none that was not strictly ascending, on the llvm tree and on this
repository, and no hash bucket held more than one list. Determinism holds: the resolve loop is
sequential, list numbers are first-seen, the map is only probed and inserted into, and no list number
reaches output.

A new list that would carry the uint32 offsets past UINT32_MAX is not recorded and raises
DEGRADED_PATH_ALERT. The call stays counted in declinedOut, and declined_calls= stays within the
counts_floor="1" its answers already carry. verifyOffsetCsr in src/infra/csrverify.h VERIFYs the new
CSR beside verifyCsr. The interner is defined beside its reader at the end of src/graph.h, behind a
forward declaration, so no line above that point moves: showcasecapturecheck arm (H) resolves the
published seed src/graph.h:3362 to rankGraphTeleport, and a first version that defined the interner
above buildGraph turned that arm red.

On the llvm tree the structure is now 9,879 lists and 62,359 entries: 368 KB, 512 KB of capacity.
The saving applies to each graph held at once. McpIndex keeps one for a whole MCP session, and
quality-delta builds a second for its HEAD snapshot.

Byte identity against an origin/main (558a2e0) build, both binaries on the same tree with --no-cache:
14 of 14 outputs identical on this repository and 14 of 14 on the llvm tree. They are the default map;
--callers=get, --callers=size, --callers=FILE:size (whose next= widens to --uses=size) and
--impact=size, each in XML and --json; and over MCP, find_referencing_symbols for get, size and
FILE:size, impact, and find_symbol. test/mcpclidiffcheck.sh passes 21 of 21 with each binary, with
identical rows.

Cold --no-cache map of the llvm tree, /usr/bin/time -l, three interleaved runs per binary, medians:
peak memory footprint 1913 → 1768 MiB (−146 MiB), wall 6.65 → 6.49 s, and max RSS 1983 → 2124 MiB
(+142 MiB). The machine was shared, with load averages of 16-19 on 18 cores during these runs, and max
RSS was dominated by noise: an earlier round on the same logic read max RSS 2235 → 2122 MiB (−113 MiB),
footprint 1808 → 1673 MiB (−134 MiB) and wall 7.58 → 6.99 s. Peak footprint fell in both rounds, by 134
and 146 MiB, against the ~160 MiB the per-call CSR had reserved. None of this is gated.

test/declinedlistcheck.sh (30 rows) pins that lists are shared by content, never by name. `size` is
defined in C++ under a/ and b/ and in Python under c/ and d/, with one declining caller each, so
--impact=a/size.cpp:size reads declined_calls="1" where a by-name share reads 2, while bare
--callers=size reads 2. A list named by two calls keeps its call count. Two roots each defining `foo`
twice give --callers=r1/x.cpp:foo 1. main was already exact (30 of 30 on the origin/main binary), so
red was shown by mutation: keying the interner by the called name turns 12 rows red, the (A) definition
rows including --json and MCP and both (C) definition rows, while the bare-name controls stay green.
Reverted, 30 of 30 pass. The gate is listed in test/regression.sh, and docs/gatecount_build.py wrote
608 to its eight sites.

test/declinecheck.sh passes 161 of 161, arms A-H. On an ASan build, declinedlistcheck passes 30 of 30
and declinecheck 161 of 161, and a map of this repository and --callers=size on the llvm tree print no
sanitizer report. pargates over declinedlist, decline, callers, packcallershare, impactimport,
impactpartition, mcpclidiff, mcpattrparity, relink (the gate that names csrverify.h), det-gate,
xmlwellformed, gatecount and manifest: gates=13 pass=13 skip=0 fail=0. docs/limits_build.py --check,
docs/gatecount_build.py --check and scripts/formatcheck.sh pass.

--quality-delta against git HEAD exits 0 with gating=0 and new-symbol=0. Its three rows are
sev="minor" short-horizon-churn with churn="self", on Graph, buildGraph and declinedCallsNaming; that
kind is preexisting by construction and gates only on two or more committed rewrites inside its window.
api-new-surface=2 counts internDeclinedList and verifyOffsetCsr. stale=75 are acknowledgement-ledger
rows that are already stale on main, where the same binary reports 76.

--test-gate exits 4 with changed=6, impacted=868, tests=39 and untested=764. buildGraph feeds nearly
every verb, so the untested radius is the tree's dispatchers (dispatchMcpLine, dispatchMain, runLint
and on), whose outputs the identity runs above show unchanged. Of the 37 gate scripts it names besides
test/regression.sh itself, 4 are in the set above and the other 33 ran in a second pargates pass:
gates=33 pass=33 skip=0 fail=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate list unioned, count regenerated

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	test/regression.sh
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

This review ran on the open-source allowance, not this organization's plan, because the pull request author doesn't have an assigned seat. Waiting won't change this — ask an organization admin to assign them a seat, or add seats in Billing if every seat is already assigned, then retry.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 5031392c-858e-4242-8101-277703baf63c

📥 Commits

Reviewing files that changed from the base of the PR and between 48222d6 and 040cf4e.

📒 Files selected for processing (7)
  • README.md
  • docs/EVALS.md
  • present/deck5_ripwire_build.js
  • src/graph.h
  • src/infra/csrverify.h
  • test/declinedlistcheck.sh
  • test/regression.sh

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant