From f40827f391e75e816582754d5ea2ebac77222470 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 26 Aug 2026 22:26:47 -0400 Subject: [PATCH] perf(dataflow): solve each function once instead of three times The L4 serial tail (compute_summaries + assemble_sdg, ~48% of the dataflow layer) was re-deriving the same per-function solution three times over. Instrumenting solve_function on the flask fixture: 1,158 calls for 386 functions, and solve_function is 95% of the tail's wall time. Two independent causes, both fixed: - Singleton SCCs iterated twice. compute_summaries ran `while changed`, so a non-recursive function computed its summary, set changed=True, then recomputed an identical summary purely to observe convergence. The condensation DAG is walked bottom-up, so a one-member SCC with no self-edge has all callee summaries final already and cannot change on a second pass. Genuinely recursive SCCs (several members, or one calling itself) still iterate to fixpoint. - assemble_sdg re-solved from scratch. compute_summaries discarded its own intermediates (`new, _, _ = solve_function(...)`) and the assembler then called solve_function again per signature to recover the facts and DDG. compute_summaries now optionally hands back the converged (facts, ddg) and the assembler consumes them. Sound because a converged pass is by definition one in which no member changed, so those by-products already reflect the final summaries. The recompute path remains the default for callers whose summaries did not come from an immediately preceding run over the same infos. Measured: solve calls 3.0x -> 1.0x per function (386 for 386, zero in the assembler). Interleaved A/B/A/B on erpnext L4 with --ray, load recorded per run: FIXED 231s/257s vs BASELINE 263s/282s, means 244s vs 272s = 10.5% faster; the worst FIXED run still beats the best BASELINE run, so the result survives this machine's load swings. Output is unchanged: flask L4 matches the pre-change baseline exactly on callables (386), cfg (4,372), cdg (2,443), ddg (24,138), summary (3,449), param_in (1,608), param_out (1,201) and the full ddg provenance histogram. Full suite: 291 passed, 6 skipped. Closes #155. --- codeanalyzer/dataflow/builder.py | 9 +++++++-- codeanalyzer/dataflow/sdg.py | 19 ++++++++++++++++-- codeanalyzer/dataflow/summaries.py | 31 ++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/codeanalyzer/dataflow/builder.py b/codeanalyzer/dataflow/builder.py index 8520848..497cd73 100644 --- a/codeanalyzer/dataflow/builder.py +++ b/codeanalyzer/dataflow/builder.py @@ -428,8 +428,13 @@ def build_program_graphs( for t in cs.targets: call_edges.append((sig, t)) - summaries = compute_summaries(infos, sorted(set(call_edges))) - return assemble_sdg(infos, summaries, k) + # The converged (facts, ddg) per function are threaded straight into the + # assembler rather than re-derived there (#155). + solutions: Dict[str, object] = {} + summaries = compute_summaries( + infos, sorted(set(call_edges)), solutions=solutions + ) + return assemble_sdg(infos, summaries, k, solutions=solutions) def emit_l4( diff --git a/codeanalyzer/dataflow/sdg.py b/codeanalyzer/dataflow/sdg.py index e2e9d5e..276943f 100644 --- a/codeanalyzer/dataflow/sdg.py +++ b/codeanalyzer/dataflow/sdg.py @@ -386,8 +386,18 @@ def assemble_sdg( infos: Dict[str, FunctionInfo], summaries: Dict[str, FunctionSummary], k: int, + *, + solutions: Optional[Dict[str, Tuple[Dict[int, object], List[object]]]] = None, ) -> ProgramGraphsIR: - """Stitch every function's PDG into the whole-program SDG.""" + """Stitch every function's PDG into the whole-program SDG. + + *solutions* optionally carries the converged ``(facts, ddg)`` that + :func:`~codeanalyzer.dataflow.summaries.compute_summaries` already + derived, sparing a second identical solve per function (#155). Omit it and + every function is re-solved, which is the historical behaviour and the + right posture whenever *summaries* did not come from an immediately + preceding run over these same *infos*. + """ ir = ProgramGraphsIR(k_limit=k) # Pass 1: solve each function against the final summaries and lay out its @@ -396,7 +406,12 @@ def assemble_sdg( formal_ids: Dict[str, Dict[str, int]] = {} for sig in sorted(infos): info = infos[sig] - summary, facts, ddg = solve_function(info, summaries) + cached = solutions.get(sig) if solutions is not None else None + if cached is None: + summary, facts, ddg = solve_function(info, summaries) + else: + facts, ddg = cached + summary = summaries[sig] asm = _FunctionAssembler(info, summary, facts, ddg) asm.build_formals() assemblers[sig] = asm diff --git a/codeanalyzer/dataflow/summaries.py b/codeanalyzer/dataflow/summaries.py index f7d0d2c..8d25d2c 100644 --- a/codeanalyzer/dataflow/summaries.py +++ b/codeanalyzer/dataflow/summaries.py @@ -199,19 +199,42 @@ def reach(start: Set[int]) -> Set[int]: def compute_summaries( infos: Dict[str, FunctionInfo], call_edges: List[Tuple[str, str]], + *, + solutions: Optional[Dict[str, Tuple[Dict[int, object], List[DDGEdge]]]] = None, ) -> Dict[str, FunctionSummary]: """Bottom-up composition over the SCC condensation DAG, monotone fixpoint - within each SCC.""" + within each SCC. + + A **singleton SCC with no self-edge** is solved exactly once: the + condensation is processed bottom-up, so every callee summary it reads is + already final and a second pass could only recompute the same answer to + observe that nothing changed. Genuinely recursive SCCs (several members, + or one member calling itself) still iterate to fixpoint. + + When *solutions* is supplied it receives each signature's converged + ``(facts, ddg)`` — the by-products of the final solve, which + :func:`~codeanalyzer.dataflow.sdg.assemble_sdg` would otherwise recompute + from scratch. They are the same values that a fresh solve against the + final summaries produces, because a converged pass is by definition one + in which no member's summary changed (#155). + """ order = strongly_connected_components(sorted(infos), call_edges) + self_calls = {src for src, dst in call_edges if src == dst} summaries: Dict[str, FunctionSummary] = {} for scc in order: members = [s for s in scc if s in infos] - changed = True - while changed: + if not members: + continue + recursive = len(members) > 1 or members[0] in self_calls + while True: changed = False for sig in members: - new, _, _ = solve_function(infos[sig], summaries) + new, facts, ddg = solve_function(infos[sig], summaries) + if solutions is not None: + solutions[sig] = (facts, ddg) if summaries.get(sig) != new: summaries[sig] = new changed = True + if not (recursive and changed): + break return summaries