Skip to content

perf(dataflow): solve each function once instead of three times - #156

Open
rahlk wants to merge 1 commit into
mainfrom
fix/issue-155-serial-tail-redundant-solves
Open

perf(dataflow): solve each function once instead of three times#156
rahlk wants to merge 1 commit into
mainfrom
fix/issue-155-serial-tail-redundant-solves

Conversation

@rahlk

@rahlk rahlk commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #155.

What

The L4 serial tail — compute_summaries + assemble_sdg, ~48% of the dataflow layer — was re-deriving the same per-function solution three times. This makes it once.

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:

  1. 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 already has final callee summaries and cannot change on a second pass. Recursive SCCs (several members, or one calling itself) still iterate to fixpoint.
  2. assemble_sdg re-solved from scratch. compute_summaries discarded its own intermediates (new, _, _ = solve_function(...)), and the assembler called solve_function again per signature to recover the facts and DDG. It now receives the converged (facts, ddg) instead. Sound because a converged pass is by definition one in which no member's summary changed, so those by-products already reflect the final summaries. The recompute path stays the default for any caller whose summaries did not come from an immediately preceding run over the same infos.

Measured

Solve count: 3.0× → 1.0× per function (386 for 386, zero in the assembler).

Interleaved A/B/A/B on erpnext L4, --ray, load recorded per run:

run wall load
FIXED 231s 10.99
BASELINE 263s 16.78
FIXED 257s 20.99
BASELINE 282s 17.20

Means 244s vs 272s → 10.5% faster. The result survives the load confound in its strongest form: the worst FIXED run (257s at load 21) still beats the best BASELINE run (263s at load 17), and FIXED wins both adjacent pairings.

Why 10.5% and not the ~3× the solve count implies: solve_function is 95% of the serial tail, but the tail is only ~27% of an L4 run — the symbol-table phase still dominates. That puts the ceiling near 18%, and 10.5% sits in that range once emission and the remaining assembler work are counted.

Output unchanged

flask L4 matches the pre-change baseline exactly: callables 386, cfg 4,372, cdg 2,443, ddg 24,138, summary 3,449, param_in 1,608, param_out 1,201, and an identical ddg provenance histogram (ssa 6,693 / reaching-defs 10,129 / points-to 7,316). requests L4 verified sane.

Full suite: 291 passed, 6 skipped.

Context

This is the follow-up to #154, where parallelising the other half of the dataflow layer with Ray measured 3.1× slower and was reverted. The serial tail turned out to be the right target, and the win was algorithmic rather than parallel.

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.
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.

L4 serial tail solves every function 3x: singleton SCCs iterate twice and assemble_sdg re-solves

1 participant