21 KiB
fold performance audit (2026-05-23)
Purpose and scope
fold's --profile output rolls per-detector wall-clock into a flat
list. That answered "which detector is slow?" but not "where does
the unaccounted time go?" or "what overhead is self-inflicted?".
This audit captures a baseline on a real codebase (curl), surfaces the previously-hidden phases, ranks the self-inflicted overhead by observed cost rather than gut feel, and queues concrete fixes against the data.
In scope:
- Phase-level breakdown of
analyze_ms. - Existing cache + shared-state surfaces.
- Multi-walk redundancy (does the same TU/function get visited multiple times when one pass would do?).
- Fold-on-fold static findings — does our own code have duplication worth refactoring?
Out of scope:
- Z3 algorithmic complexity (not ours to optimise).
- Clang's
ASTUnit::createcost per TU (architectural; mitigations listed in the backlog). - Worker fanout + IPC mechanics beyond timing them.
- Detector precision/recall (separate concern; tracked in
docs/research/frontier.mdandbench/results/).
Methodology
- Target: curl (
../curl-source/curl/build/compile_commands.json), 306 TUs, 2339 functions. Picked because it's the documented bench baseline (bench/run_curl_ab.py) and fits a single-machine session. - Detector set:
clone,dead,equiv,null-deref— covers the clone hashing phase + the most expensive security detector (null-deref). - Mode:
--profileforces single-process serial execution (src/cli/scan_router.cpp:244). All wall-clock numbers below are serial; multi-worker reduces wall-clock proportionally for the detector phases but not for the parts that already serialise (call- graph build, cross-TU priming). - Tooling:
/usr/bin/time -pfor wall-clock;--profile-outputfor JSON breakdown;python3+jq-equivalents for parsing. - Baseline artifact:
docs/internal/perf-audit/baseline-instrumented-2026-05-23.json. - Fold-on-fold artifact:
docs/internal/perf-audit/fold-on-fold-2026-05-23.json(639 findings); triage indocs/internal/perf-audit/fold-on-fold.md.
Existing caches inventory
Six caches were mapped (Phase-1 exploration). Summary:
| Cache | Scope | Key | Invalidation | Notes |
|---|---|---|---|---|
CrossTUCache |
per-scan, process-global | qualified function name → CalleeProperties |
cleared at shard boundary | Primed by a dedicated walk; rebuilt every scan |
SummaryStore |
per-scan, process-global | qualified name → SymbolicSummary |
transitive on dependent change | In-memory only; no disk persistence |
ExprCache |
per-TU, process-global | z3 expr hash → check_result | cleared at TU boundary | Aggressive invalidation; solver results don't carry across functions |
IncrementalCache |
per-scan, disk-persisted | function (src hash, callee hash) | source/callee hash change | Opt-in via --incremental; forces --processes=1 |
ProjectCallGraph |
per-scan, in-memory | def → callees + SCCs | rebuilt every scan | Not persisted |
BufferSizeTracker, HeapModel |
per-function | per-SymbolicEnv | end of function | Correct lifetime; not a cache problem |
Note: subprocess workers don't share any of these — each cold- starts. That's a clang-binding limitation we can mitigate but not eliminate.
Empirical baseline
From docs/internal/perf-audit/baseline-instrumented-2026-05-23.json:
total_wall_ms: 455,756 (7.6 min)
compile_db_load_ms: 2
fanout_orchestration_ms: 0 (profile mode → serial)
serial_dispatch_ms: 455,682
parse_ms: 0
analyze_ms: 455,685
report_ms: 71
detector wall_ms breakdown:
null-deref 297,955 (65.4% of total)
dead 8,750 ( 1.9%)
equiv 2,240 ( 0.5%)
clone 216 ( 0.05%)
-------------------------------------
sum 309,161 (67.8%)
serial_dispatch_ms - sum 146,521 (32.2%)
Two findings jump out:
- null-deref dominates: 65% of total wall. With ~3s of actual
Z3 query time (from
[solver-profile]z3_time_ms aggregate), the other ~297s is in the encoder / state-merging / walker. - ~32% (146s) was previously unaccounted. New instrumentation
localizes it to
serial_dispatch_msminus detector wall — i.e., insiderunDetectorsOnShardbut outside any detector handler. That bucket contains: clang ASTUnit construction per TU (~1.0s × 306 ≈ 306s of CPU, but clang parses are roughly 0.5s wall each on this hardware → ~150s wall, matches), cross-TU priming (~1 full extra AST walk per TU), call-graph collection, function fingerprinting.
Self-inflicted-overhead findings (ranked by observed cost)
1. clang ASTUnit constructed once per TU, never shared
Observed cost: ~150s of the 455s baseline (33%). Dominant
contributor to serial_dispatch_ms - sum(detector.wall_ms).
Where: src/frontend/clang_backend.cpp:144. Each TU spawns a
fresh clang::tooling::ClangTool::run(). Worker subprocesses
amplify this: each worker re-parses every TU it owns from cold.
Remediation sketch:
- Short-term: emit precompiled headers (PCH) for project headers
used by every TU; clang accepts
-include-pchper invocation. - Medium-term: persistent worker that holds an LRU of recently- parsed ASTUnits keyed by TU + clang flags hash; new commands reach it over a Unix socket. Requires a daemon model that fold doesn't have today.
- Long-term: explore clang's
ASTReadersnapshot format if it becomes usable for our access patterns.
Estimated payoff: 30–50% wall-clock reduction on warm runs. The exact number depends on PCH coverage of fold's clang include graph.
2. null-deref encoder + walker traversal: 5 min CPU per curl scan
Observed cost: ~287s of detector wall, ~99% outside Z3.
Where: src/detect/null_deref.cpp + src/symbolic/encoder.cpp
- the security walker's path enumeration.
Phase breakdown (final — baseline-hooks-2026-05-23.json):
null-deref wall_ms: 344,644
walker_ms: 340,329 (98.8% of wall — almost all in walk/walkCFG)
encode_ms: 24,156 (7.1% of walker — NOT the target)
hook_ms: 11,860 (3.5% of walker — NOT the target)
state_merge_ms: 75 (0.02% of walker — NOT the target)
walker - the above: 304,238 (89.4% of walker — the actual cost)
Aggregate z3_time_ms across the entire scan: ~33ms (<0.01% of total).
Four wrong hypotheses, four pivots:
- Hash-walk fusion (original plan): 0.04% of total.
- State-merge optimization: 0.02% of null-deref walker.
- Encoder optimization: 7% of null-deref walker.
- Hook body optimization: 3.5% of null-deref walker.
The 304s that remains after subtracting all four was originally
hypothesised as "per-stmt bookkeeping that fires for every
statement." A second-round decomposition (Backlog #2) added four
timers — cfg_build_ms, nonnull_precompute_ms, control_walk_ms,
stmt_overhead_ms — and revised that hypothesis with measurement.
Final decomposition (curl null-deref baseline):
| Phase | ms | % of walker_ms |
|---|---|---|
control_walk_ms (walkIf + walkSwitch + walkFor + walkWhile + walkDo) |
254,102 | 85.3% |
encode_ms |
4,614 | 1.5% |
hook_ms |
2,808 | 0.9% |
stmt_overhead_ms (snapshot-delta remainder) |
287 | 0.1% |
state_merge_ms / cfg_build_ms / nonnull_precompute_ms |
0 | 0% |
walker_ms total |
297,749 | 100% |
The 89% bucket is recursive control-flow body walks, not
per-stmt bookkeeping. Each walkIf clones envs, recursively walks
each arm, and merges; walkFor/walkWhile unroll; the recursion
re-enters the per-block loop. Most source code lives inside
control structures, so most analysis time naturally falls there.
Caveat on double-counting: control_walk_ms covers the full wall
of the recursive walks, including the nested encode_ms/hook_ms
slices fired inside them. The 254s double-counts ≈7s of inner
encode+hook; net ≈247s is the recursive walking machinery itself
(env clone, refine ranges, body walk, merge).
A separate hypothesis — "env clones in walkIf dominate
control_walk_ms" — was added and refuted by direct measurement:
env_clone_ms totals 0 across 770,748 clones (under 1 µs each,
since z3::expr refcount bumps and 11 unordered_map shallow
copies amortize to nothing).
Remediation candidates for the 89%:
- Bound
walkForStmt/walkWhileStmtunroll cost — large loops multiply the recursion factor. - Cache
refineRangesForCondition/refineOctagonForConditionresults across re-entries of the same branch.
Neither will eliminate the bucket. The cost is symbolic execution proportional to (statements × control-nesting depth). Meaningful wall reduction requires changing the semantic model (lower precision, deeper bounded unroll cap, branch pruning, AST+env memoization) — design tradeoffs around precision vs speed, not optimizations of a single hot loop. Those belong in a separate proposal, not a perf-audit follow-up.
Older notes (kept for context): the pre-decomposition guesses walked per statement to find DeclRef writes; the result is used to invalidate octagon entries.
getParentMapContext()/ parent-map queries — clang's parent lookup, allocated lazily but consulted frequently.buildNonNullAtBlockEntry— pre-pass that walks the entire CFG to compute per-block non-null facts; runs at the start of every walkCFG.BranchLivenessBuilder+WriteCollector— ancillary per-function RecursiveASTVisitor passes (~4× per function).liveAtJoin(is)— live-set computation per if-statement.
Remediation targets (ranked by likely payoff):
- Cache
collectWrittenVarsper statement — landed. Memoizes a deterministic per-stmt result byStmt*pointer; 5 call sites previously re-walked the same subtree (octagon invalidation in walk() / walkCFG() main loops, BFS inbuildNonNullAtBlockEntry'swrittenInlambda, loop-inc inunrollLoop). Architecturally sound; perf delta within run-to-run variance on a single curl scan (see "On measurement discipline"). - Consolidate the 6 non-null-marking maps into one. Each stmt currently consults/updates several; merging them into a single accumulator removes 5× the hash-lookup overhead.
- Skip
refineRanges/refineOctagonwhen no IfStmt is in the immediate continuation. Today they fire unconditionally. - Lazy
buildNonNullAtBlockEntry— only run when a detector actually queries non-null facts. Currently runs for every function.
Beyond these four, the next round needs profiling-grade flame-graph data (perf, samply, Instruments) — phase-level instrumentation isolated the 304s to the walker but can't subdivide further without adding O(stmts) timers that would themselves perturb the measurement.
On measurement discipline
Cross-baseline data from this session shows ~15% run-to-run variance on the same curl invocation:
baseline (no timers): 465s
baseline (+compile-db timers): 455s
baseline (+walker/merge): 454s
baseline (+encode timer): 442s
baseline (+hook timer): 533s ← anomalously slow run
after writtenvars cache: 451s
The baseline-hooks run is an outlier (likely system load or the ~millions of hook RAII timers compounding). After-cache lands at 451s, statistically indistinguishable from the 442–465s spread of the earlier baselines. Single-run measurements at this resolution don't reliably attribute small wins.
Use bench/perf_audit_run.py for any future perf claim
≤15%. It runs fold N times against a fixed compile DB and
reports median + IQR + min/max for every --profile metric:
bench/perf_audit_run.py --label baseline --runs 5
# ... apply change ...
bench/perf_audit_run.py --label after-fix --runs 5 \
--diff docs/internal/perf-audit/runs/baseline.json
The --diff output flags moves outside 1.5×IQR as "significant".
For high-confidence claims (sub-5%), bump --runs 10+ and
quiesce the machine (close browsers etc.) during the window.
Other tactics worth knowing about:
- Pin CPU frequency where the OS allows (macOS: hard; Linux:
cpupower frequency-set). - Use
samplyorInstrumentsfor absolute line-level attribution; reserve--profileJSON diff for monitoring large regressions only.
The instrumentation that landed in this audit survives this variance — it's about relative attribution within one run, not absolute wall-time deltas across runs.
3. Cross-TU priming = one extra full AST walk per TU
Observed cost: baked into the 146s unaccounted bucket; not isolated yet.
Where: src/cli/scan_runner.cpp:1109-1115. Before detector
dispatch, every TU is walked once to populate CrossTUCache with
"this function dereferences param 2", "this function returns null
sometimes", etc. The cache lives one scan and dies.
Remediation sketch: persist CrossTUCache to disk like
IncrementalCache. Cache key: (qualified name, body hash). Reuse
across runs unless the body changed.
Estimated payoff: the priming pass takes roughly 1× the detector AST-walk cost, so eliminating it on warm scans saves ~30–60s on curl. Needs measurement.
4. --incremental is mutually exclusive with --processes >1
Observed cost: users pick speed (parallel) OR freshness (incremental); never both.
Where: src/cli/scan_router.cpp:210-212. The incremental
implementation runs serially because the parent-side fingerprint
map can't be sharded today.
Remediation sketch: sharded fingerprint files per worker; parent merges at completion. Already partially modelled by SubprocessFanout's NDJSON merge.
Estimated payoff: for users on big repos who re-scan frequently (CI runs, IDE), the speedup multiplies — incremental already skips most functions; pairing with fanout gives near-zero warm-scan times.
5. ExprCache lifetime is per-TU instead of per-scan
Observed cost: unmeasured but bounded by Z3 query time (~3s
across the whole scan, per [solver-profile] z3_time_ms summed).
Small absolute payoff today but rises with detector heaviness.
Where: src/cli/scan_runner.cpp:1511. ExprCache is cleared at
each TU boundary. Same arithmetic expression queried in TU A and
TU B re-solves from scratch.
Remediation sketch: extend lifetime to per-scan, with the canonical expression hash as the key. Risk: if two different expressions canonicalise to the same hash (collision), we'd return the wrong cached result. Audit the hash function for non-collision properties before changing lifetime.
Estimated payoff: small on curl (Z3 is already cheap here); larger on z3-heavy targets (loops, complex bit manipulation).
6. Security-detector path fans out to 7 walks when ≤1 is enabled
Observed cost: zero today (the audit baseline runs >1, so the composite path activates).
Where: src/cli/scan_runner.cpp:1890-1995 —
use_composite_security_walk only flips on when
enabledSecurityDetectorCount() > 1. If a user runs --detectors null-deref alone, we fall back to 7 independent walks.
Remediation sketch: drop the fallback; always use the composite walker even for single-detector runs. The composite walker handles the single-detector case correctly already.
Estimated payoff: negligible for default runs; clean-up for single-detector debugging paths.
Lower-priority items
| Item | Where | Estimated payoff |
|---|---|---|
| Dup CFG-build elimination (dead-statement + security walker build same CFG) | src/detect/dead_statement.cpp:66 + src/ir/cfg_walker.cpp:21 |
small (CFG build ≪ encoder) |
| 5 hash/tokenize walks per function in clone phase | src/frontend/ast_visitor.cpp:174-180 |
~170 ms on curl (clone is 0.05% of total) |
| Suppressions/baseline reparsed every command | src/report/triage.cpp:73-90 |
trivial |
The hash-walk fusion was the plan's highest-leverage target before the baseline. Empirically it's ~0.04% of total — keep it on the backlog for cleanliness, not perf.
Fold-on-fold findings (summary)
Full triage in docs/internal/perf-audit/fold-on-fold.md. Top patterns
worth refactoring:
mixHash5+ copies across security-detector anonymous namespaces. Lift to a shared helper.supportsParam/collectConjunctsrepeated across detectors. Move to asrc/symbolic/z3_helpers.{hpp,cpp}sibling of the existingz3_model_support.onFunctionEndboilerplate across the 5 security observers. Default implementation in a base class.ir::*::valid/operator bool/operator==boilerplate forCall,MemberRef,Symbol,Expr. CRTP or macro.
The fold-on-fold pass also surfaced 152 dead-statement findings; most are cache-layer short-circuits where a guard later in the function makes an early-return provably unreachable. Some are encoder bails that are intentionally defensive. Triage requires file-by-file review; the JSON is the full record.
Notable absence: equiv detector emitted zero findings. That's honest progress — the prior scout-rule extractions (encoder_arms, clang_type_queries, callee names) moved colliding helpers out of the per-detector files. The remaining duplication is in small helpers that don't reach equiv's signature-match threshold.
Phase-1 result: instrumentation, not the planned hash fusion
The original plan committed to fusing the 5 hash/tokenize walks
into one pass as the "highest-leverage" fix. Empirically that
target is 0.04% of total. Plan pivot: ship the
instrumentation (3 new ScanProfile fields:
compile_db_load_ms, fanout_orchestration_ms,
serial_dispatch_ms) so subsequent rounds have phase-level data
instead of a flat per-detector list. Commit: 503799a.
Result: the 146s previously-unaccounted bucket is now named
(serial_dispatch_ms - sum(detector.wall_ms)) and the next
optimisation round can target it specifically.
Phase-2 backlog (ranked by estimated payoff, after measurement)
For Z3 and Clang library-expansion opportunities (separate axis
from this backlog), see
library-expansion-matrix.md —
20 scored rows with file-cited insertion sites and a
recommended top-3 sequence.
The first two items below shipped in the same round as the audit. They are kept on the list because each surfaced a measurement worth recording.
- Persist CrossTUCache across scans — SHIPPED, ~8s wall (~2%).
v2 reaches 100% hit rate and exact finding-count parity
(7112==7112 on curl null-deref). The audit's 30–60s estimate
was inflated 3–6×; actual priming cost on curl is ~9s and v2
saves essentially all of it. Treat the cache as a correctness
invariant for incremental scans + small wall win + infrastructure
for future caching. Design + measurement:
docs/internal/perf-audit/cross-tu-cache-persistence.md. - Per-phase null-deref decomposition — SHIPPED, identified 89%
bucket. Four new timers (
cfg_build_ms,nonnull_precompute_ms,control_walk_ms,stmt_overhead_ms) resolve the previously-unattributed null-deref walker time. The 89% turned out to becontrol_walk_ms(recursive control- flow body walks), not per-stmt bookkeeping. See the null-deref section above for the full breakdown. --incremental+--processes >1compatibility. Requires sharded fingerprint files.- ExprCache lifetime extension to per-scan (with collision audit).
- Always-on composite security walker (drop the ≤1 fallback).
Attempted as a ~115-line cleanup (delete the 7
if (!use_composite && detectorEnabled(...))blocks, force the composite path). Build + 47/47 tests stayed green, but the curl A/B at the standard--budget-seconds 5showed up to ~1.5% finding drift across runs against the same code, indistinguishable from the ~0.8% run-to-run noise observed between back-to-back identical reruns. Reverted pending a longer-budget parity check (60s/TU, ~30 min per leg) that can separate semantic drift from budget-truncation noise. - Dup CFG-build elimination (small).
- Hash-walk fusion (cleanliness, ~170ms).
- Fold-on-fold refactor:
mixHash,supportsParam,onFunctionEnd,ir::*boilerplate. Cleanliness. - PCH for project headers — measured ceiling ~0.3% wall. Keep on the list for documentation but do not invest design effort until the 89% bookkeeping bucket is decomposed and proven not to dominate.
Regression prevention
Recommended addition to bench/run_curl_ab.py: capture the new
phase timings (compile_db_load_ms, fanout_orchestration_ms,
serial_dispatch_ms) alongside per-detector wall_ms. Diff the
JSON against docs/internal/perf-audit/baseline-instrumented-2026-05-23.json
on each release; alert on >10% regression in any phase.
The bench harness already parses --profile JSON; only the field
list needs extending.
Pointers
- Baseline JSON:
docs/internal/perf-audit/baseline-instrumented-2026-05-23.json - Fold-on-fold JSON:
docs/internal/perf-audit/fold-on-fold-2026-05-23.json - Fold-on-fold triage:
docs/internal/perf-audit/fold-on-fold.md - Instrumentation commit:
503799a - Fold-on-fold commit: (this series)
- Audit commit (this doc): (HEAD)