Skip to content

Skip the deinline size search in module phase (it is a guaranteed no-op)#506

Open
darkverbito wants to merge 1 commit into
Chia-Network:mainfrom
darkverbito:fix/deinline-module-phase-blowup
Open

Skip the deinline size search in module phase (it is a guaranteed no-op)#506
darkverbito wants to merge 1 commit into
Chia-Network:mainfrom
darkverbito:fix/deinline-module-phase-blowup

Conversation

@darkverbito

Copy link
Copy Markdown

Summary

Compiling a multi-export module (the (export ...) form) whose exports share a
chain of helpers that each contain a deep nested let stack causes
compile_modern to spend an unbounded-looking amount of time at 100% CPU and
effectively never finish. The cost is superlinear in the number of synthetic
let-binding helpers, so adding one helper or one let level multiplies compile
time.

The work is performed by the deinline inline/deinline size search in
src/compiler/optimize/deinline.rs (deinline_opt). In module phase that search
is a guaranteed no-op — it can never keep a flip — yet it still runs a full
codegen of the whole program for every synthetic function in every root set, on
every improvement pass. On large multi-export modules this dominates compile time
to the point that the compiler appears to hang.

This PR skips the search in module phase (where it is provably a no-op) and adds a
regression test.

Affected versions

Observed on 0.4.5 and reproduces unchanged on current main
(deinline_opt has not been touched since the 0.4.5 tag).

The previous 0.4.1 compiles the same input in well under a second. The
regression is the 0.4.1 -> 0.4.5 change to deinline_opt, which (a) removed the
early return that skipped the search when there was nothing to deinline, and
(b) began building the dependency graph with with_constants: true in module
phase. The net effect is that the size search now always runs in module phase over
a larger function set.

This is dialect-independent: it reproduces under both *standard-cl-23* and
*standard-cl-25* (the multi-export compile_module path is shared).

Minimal reproduction

A fully synthetic, addition-only module (so the export results stay small and
exactly assertable). A chain of N helpers, each a depth-D let stack, with a
couple of exports:

(include *standard-cl-25*)

(defun sh0 (x) (let ((v0 (+ (sh1 (+ x 1)) 1)) (w0 (* (sh1 (+ x 1)) 2)))
  (let ((v1 (+ (+ v0 w0) 2)) (w1 (* (+ v0 w0) 3)))
  (let ((v2 (+ (+ v1 w1) 3)) (w2 (* (+ v1 w1) 4)))
  (let ((v3 (+ (+ v2 w2) 4)) (w3 (* (+ v2 w2) 5)))
  (let ((v4 (+ (+ v3 w3) 5)) (w4 (* (+ v3 w3) 6)))
  (let ((v5 (+ (+ v4 w4) 6)) (w5 (* (+ v4 w4) 7))) (+ v5 w5)))))))))
;; sh1..shN-1 identical, each calling the next; the last terminates the chain.
(defun ex_0 (x) (sh0 (+ x 1)))
(defun ex_1 (x) (sh0 (+ x 2)))
(export ex_0)
(export ex_1)

Compile time grows superlinearly with N and D — the signature of the search
blow-up. Measured on current main (run -i <dir> repro.clsp, release build):

helpers / let-depth / exports unfixed main with this PR
6 / 6 / 2 9 s < 1 s
8 / 8 / 2 47 s < 1 s
10 / 10 / 2 175 s 1 s
12 / 10 / 3 517 s 2 s

Real multi-export handler modules push this into the tens-of-minutes range.

Root cause

In deinline_opt:

  1. After desugaring, every let binding has become a synthetic helper with
    SyntheticType::NoInlinePreference.
  2. The search groups these into "root sets" and, for each root set, runs a loop
    that, for every function in the set, flips its inline status, runs a full
    codegen of the whole program, and keeps the flip only if it shrank the
    program — repeating until no improvement.
  3. flip_helper's guard is ... && (!is_module_compile || !*inline). In module
    phase this only ever flips a NoInlinePreference synthetic from
    non-inline to inline. The module convention keeps these synthetics
    non-inline because that is the smaller representation, so the inline direction
    is always larger and the flip is always rejected.

Therefore, in module phase, the search never updates best_compileform — it
is a guaranteed no-op. But it still performs O(functions) full codegen calls
per root set (and codegen is itself superlinear in program size), so the total
cost explodes on modules with many synthetic helpers.

The existing source comment already notes that "no program in my test set lost
weight by switching inline off after losing weight by switching it on, and the
cost of this search can be high."

Fix

Skip the search in module phase, where it is provably a no-op. The change adds an
early return Ok(compileform) when opts.module_phase().is_some(), before the
depgraph build and search loop. Because the search never updates
best_compileform in module phase, returning the unchanged program is
output-identical and removes the blow-up. The now-unused module-phase depgraph
branch (and its DepgraphOptions import) is removed.

Verification

  • The full existing test suite passes with the fix (706 passed; 0 failed; 1 ignored, the 1 ignored being pre-existing).
  • cargo fmt --check and cargo clippy are clean on the changed files.
  • The fix only short-circuits the module phase; non-module compilation is
    untouched, so the "recompilation matches" output-stability checks are
    unaffected.
  • The reproduction module compiles in ~2 s with the fix instead of hanging (table
    above).
  • A regression test is added (test_module_phase_deinline_does_not_blow_up) that
    compiles a multi-export let-stack module
    (resources/tests/module/deinline_module_blowup.clsp) and runs both exports,
    asserting their results.

In module phase, deinline_opt's inline/deinline size search can never keep a
flip: flip_helper's guard only permits the non-inline -> inline direction for
NoInlinePreference synthetics, and the module convention keeps those synthetics
non-inline because that is the smaller representation, so the inline direction
is always larger and always rejected.

Running the search anyway still performs O(functions) full code generations per
root set, each of which is itself superlinear in program size. On large
multi-export modules (many synthetic let-binding helpers) this dominates compile
time and makes compilation appear to hang.

Return the unchanged program in module phase. This is output-identical (the
search never updates best_compileform there) and removes the blow-up.

Adds a regression test that compiles a multi-export module built from a chain of
helpers with deep let stacks and runs both exports.
@prozacchiwawa

Copy link
Copy Markdown
Contributor

thanks for the analysis. our workflows require signed prs, so if you force push with signatures, i can fully review.

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.

2 participants