3.6 KiB
Single-pass graph building — design spec
Status: accepted (README-documented; backlog #1) Owner: fold optimization loop (round 4+), operator-confirmed option 2. Guard rails: docs/research/optimization-loop-2026-07-30.md (measure-first, gates never weakened), the finding-fingerprint + gold gates, and the wall/RSS baselines.
Goal
Eliminate the second full per-scan parse. Today a source scan parses every TU
twice — once to build the project call graph (call_graph_ms 18.8s on the fold
self-scan), once to run detectors — because the detector pass consumes the graph
(cross-TU candidate expansion + summary priming). The portability-safe fix is
one parse per TU; the graph comes from that same parse.
Current sequence (scan_runner.cpp runClangDetectorsOnShard)
primeCrossTUProperties— third ClangTool parse, only when div-by-zero/null-deref/int-overflow/oob are enabled (no-op on the fold manifest).assembleProjectCallGraph— full re-parse of all TUs →call_graph(graph_security_candidates= cross-TU-expanded candidate set).- Detector pass — second full parse, runs every detector; the security
walk consults
graph_security_candidates, and summary priming uses the graph for SCC-ordered passes.
Why it cannot be folded naively
The candidate-expansion closure needs the complete cross-TU graph, which exists only after every TU is parsed. Running detectors in the very parse that builds the graph therefore works only for graph-independent detectors. Graph- dependent detectors (security candidate expansion; summary priming) demand, in the deferred phase, either (A) kept ASTs (~210MB/AST — infeasible at 8 TUs), or (B) re-materializing the walk input from persisted IR.
(B) is the chosen shape: encode functions to fold IR once, keep the lightweight records, discard ASTs, run the closure, then walk candidates from the stored IR without a re-parse.
Seams
| Existing | Change |
|---|---|
collectCallGraphEdges(ctx, local_graph) (source_call_graph.cpp) |
already runs on a live clang::ASTContext; in-pass graph contribution is the same call + graphToContribution — no re-parse |
SecurityWalker / detectors encode via encodeExpr + visitor hooks on the AST |
the deferred walk must run on serialized FunctionIR (see fold/ir, serialize.cpp, FunctionSummary) instead of the live AST |
graph_security_candidates + securityCandidateFunctions |
unchanged consumer; its input (merged graph) is available only post-pass |
primeSymbolicSummaries SCC order |
unchanged consumer; needs the post-pass graph |
Slice plan (each lands behind the gates; fingerprints exact)
- S0 (measure, no behavior change) — confirm the 18.8s is ClangTool parse vs edge/merge, and measure the IR size for all fold-manifest functions (bounds the memory for kept-IR). If per-function IR is ~KB-scale, (B) fits in tens of MB.
- S1 (neutral plumbing) — a
--single-pass-graphflag, default off; no behavior change. - S2 — collect graph contributions in the detector pass (S1 gate on),
retire the
assembleProjectCallGraphre-parse for the no-index path when contributions cover the shard's files. - S3 — defer the graph-dependent security candidate expansion + summary priming to the post-pass closure; walk from serialized IR.
- S4 — fold
primeCrossTUPropertiesinto the same parse for the div/null-deref/overflow/oob corpora.
Each slice: focused tests → full suite → fold + yams eval (fingerprints 0fec78e5…
/ 247523c5… EXACT, gold precision/recall 1.0, wall/RSS baselines) → checkpoint.