Skip to content

Commit f40827f

Browse files
committed
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.
1 parent cda4d24 commit f40827f

3 files changed

Lines changed: 51 additions & 8 deletions

File tree

codeanalyzer/dataflow/builder.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,8 +428,13 @@ def build_program_graphs(
428428
for t in cs.targets:
429429
call_edges.append((sig, t))
430430

431-
summaries = compute_summaries(infos, sorted(set(call_edges)))
432-
return assemble_sdg(infos, summaries, k)
431+
# The converged (facts, ddg) per function are threaded straight into the
432+
# assembler rather than re-derived there (#155).
433+
solutions: Dict[str, object] = {}
434+
summaries = compute_summaries(
435+
infos, sorted(set(call_edges)), solutions=solutions
436+
)
437+
return assemble_sdg(infos, summaries, k, solutions=solutions)
433438

434439

435440
def emit_l4(

codeanalyzer/dataflow/sdg.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,18 @@ def assemble_sdg(
386386
infos: Dict[str, FunctionInfo],
387387
summaries: Dict[str, FunctionSummary],
388388
k: int,
389+
*,
390+
solutions: Optional[Dict[str, Tuple[Dict[int, object], List[object]]]] = None,
389391
) -> ProgramGraphsIR:
390-
"""Stitch every function's PDG into the whole-program SDG."""
392+
"""Stitch every function's PDG into the whole-program SDG.
393+
394+
*solutions* optionally carries the converged ``(facts, ddg)`` that
395+
:func:`~codeanalyzer.dataflow.summaries.compute_summaries` already
396+
derived, sparing a second identical solve per function (#155). Omit it and
397+
every function is re-solved, which is the historical behaviour and the
398+
right posture whenever *summaries* did not come from an immediately
399+
preceding run over these same *infos*.
400+
"""
391401
ir = ProgramGraphsIR(k_limit=k)
392402

393403
# Pass 1: solve each function against the final summaries and lay out its
@@ -396,7 +406,12 @@ def assemble_sdg(
396406
formal_ids: Dict[str, Dict[str, int]] = {}
397407
for sig in sorted(infos):
398408
info = infos[sig]
399-
summary, facts, ddg = solve_function(info, summaries)
409+
cached = solutions.get(sig) if solutions is not None else None
410+
if cached is None:
411+
summary, facts, ddg = solve_function(info, summaries)
412+
else:
413+
facts, ddg = cached
414+
summary = summaries[sig]
400415
asm = _FunctionAssembler(info, summary, facts, ddg)
401416
asm.build_formals()
402417
assemblers[sig] = asm

codeanalyzer/dataflow/summaries.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,19 +199,42 @@ def reach(start: Set[int]) -> Set[int]:
199199
def compute_summaries(
200200
infos: Dict[str, FunctionInfo],
201201
call_edges: List[Tuple[str, str]],
202+
*,
203+
solutions: Optional[Dict[str, Tuple[Dict[int, object], List[DDGEdge]]]] = None,
202204
) -> Dict[str, FunctionSummary]:
203205
"""Bottom-up composition over the SCC condensation DAG, monotone fixpoint
204-
within each SCC."""
206+
within each SCC.
207+
208+
A **singleton SCC with no self-edge** is solved exactly once: the
209+
condensation is processed bottom-up, so every callee summary it reads is
210+
already final and a second pass could only recompute the same answer to
211+
observe that nothing changed. Genuinely recursive SCCs (several members,
212+
or one member calling itself) still iterate to fixpoint.
213+
214+
When *solutions* is supplied it receives each signature's converged
215+
``(facts, ddg)`` — the by-products of the final solve, which
216+
:func:`~codeanalyzer.dataflow.sdg.assemble_sdg` would otherwise recompute
217+
from scratch. They are the same values that a fresh solve against the
218+
final summaries produces, because a converged pass is by definition one
219+
in which no member's summary changed (#155).
220+
"""
205221
order = strongly_connected_components(sorted(infos), call_edges)
222+
self_calls = {src for src, dst in call_edges if src == dst}
206223
summaries: Dict[str, FunctionSummary] = {}
207224
for scc in order:
208225
members = [s for s in scc if s in infos]
209-
changed = True
210-
while changed:
226+
if not members:
227+
continue
228+
recursive = len(members) > 1 or members[0] in self_calls
229+
while True:
211230
changed = False
212231
for sig in members:
213-
new, _, _ = solve_function(infos[sig], summaries)
232+
new, facts, ddg = solve_function(infos[sig], summaries)
233+
if solutions is not None:
234+
solutions[sig] = (facts, ddg)
214235
if summaries.get(sig) != new:
215236
summaries[sig] = new
216237
changed = True
238+
if not (recursive and changed):
239+
break
217240
return summaries

0 commit comments

Comments
 (0)