Skip to content

perf: closes #753 — Eager/Deferred init split for dynamic import()#768

Merged
TheHypnoo merged 2 commits into
mainfrom
feat/eager-deferred-import-split-753
May 14, 2026
Merged

perf: closes #753 — Eager/Deferred init split for dynamic import()#768
TheHypnoo merged 2 commits into
mainfrom
feat/eager-deferred-import-split-753

Conversation

@TheHypnoo
Copy link
Copy Markdown
Contributor

@TheHypnoo TheHypnoo commented May 14, 2026

Summary

  • Reachability classifies each module as Eager (reachable from entry through any static import / re-export chain) or Deferred (reached only via dynamic import()).
  • Entry main's eager init loop filters Deferred modules out; their <prefix>__init fires lazily from each Expr::DynamicImport dispatch site.
  • Each non-entry <prefix>__init becomes an idempotent guard wrapper around <prefix>__init_body, with transitive dep init so a Deferred → re-export → Deferred chain still populates its namespace correctly.

Closes #753.

Benchmark

bench_heavy.ts builds a 1M-entry int array at top level; bench_main.ts dynamically imports it only when process.argv[2] === 'use'. hyperfine -N -w 5 -r 30:

PRE-#753 (main) POST-#753 (this branch)
no-arg (deferred not loaded) 8.4 ms ± 1.6 (min 7.4) 4.8 ms ± 2.2 (min 3.1)
use (deferred loaded) 7.6 ms ± 0.4 (min 7.1) 8.4 ms ± 0.7 (min 7.5)

No-arg branch drops by ~43% on mean / ~58% on min — that 4 ms is the for-loop that no longer runs at startup. The use branch is statistically indistinguishable (heavy init still runs, just lazily with one extra call indirection).

Test plan

  • All 7 dynamic-import gap tests from Dynamic imports: compile-time resolution for statically analyzable import() calls #100 byte-equal node --experimental-strip-types: literal, ternary, template, reexport, tla, cycle, init_time.
  • New test_gap_dynamic_import_deferred.ts — marker module's top-level console.log fires only on the dispatch branch (argv[2]=1), suppressed on the no-arg path. Byte-equal to Node on both branches.
  • cargo test --release --workspace green (excluding cross-host UI crates).
  • Cycle compile: dynamic_import_cycle_a.ts now compiles standalone (entry no-op __init stub satisfies cycle_b's dispatch-site reference).
  • Cache key invalidation: program that gains/loses a dynamic-import reachability path invalidates the entry module's cached .o.

Version bump + CHANGELOG.md entry will land in a follow-up commit on this branch once CI is green.

@TheHypnoo TheHypnoo force-pushed the feat/eager-deferred-import-split-753 branch from 1accf75 to 34e2c80 Compare May 14, 2026 13:11
…mic import()

Follow-up to #100/#752. The dynamic-import resolver registered every
target as a regular Import with `is_dynamic: true` and put it in the
eager init chain at program start — functionally correct, but a heavy
locale bundle or optional feature module was paid upfront even when no
dispatch site ever fired.

Reachability classification (compile.rs): fixed-point pass starting from
the entry, propagating Eager across non-type-only static imports and
re-export sources. Everything unmarked is Deferred. Writes the result
to each Module::init_kind (HIR field from #100, no codegen consulted
it before).

Codegen (codegen.rs + expr.rs):
- Entry main filters `deferred_module_prefixes` out of the eager init
  call sequence — Deferred modules only fire from dispatch sites.
- Each non-entry module gains a 3-block wrapper `<prefix>__init` (load
  `@__perry_init_done_<prefix>`, icmp ne 0, cond_br to ret-or-do; do
  block stores 1, calls dep wrappers transitively, then calls
  `<prefix>__init_body`). Existing body code keeps every semantic;
  rename to `_body` is invisible to other code paths.
- `Expr::DynamicImport` (single + multi-path arms) calls
  `<target>__init` before loading `@__perry_ns_<target_prefix>`. For
  Eager targets the guard short-circuits; for Deferred targets it's
  the only invocation that builds the namespace.
- Entry emits a no-op `<entry_prefix>__init` stub so a non-entry
  module dispatching `await import("./entry.ts")` resolves at link.
  The entry's actual body still runs in main; the stub just satisfies
  the dispatch's unconditional init call.

Module init deps (per-module: static-import + re-export sources) are
plumbed to the wrapper's do block so a Deferred module that reaches
another Deferred only through its own re-export chain still initializes
the source before its namespace populator runs.

Cache key hashes deferred_module_prefixes (sorted) and module_init_deps
(ordered) so a program that gains or loses a dynamic-import reachability
path invalidates the entry's cached .o.

Acceptance:
- All 7 dynamic-import gap tests from #100 (literal, ternary, template,
  reexport, tla, cycle, init_time) byte-equal `node --experimental-strip-types`.
- New test_gap_dynamic_import_deferred.ts covers the Deferred-only case:
  marker module's top-level console.log fires only on the dispatch
  branch, never on the no-arg path.
- cargo test --workspace green (excluding cross-host UI crates).

Benchmark (heavy.ts builds a 1M-entry int array at top level,
main.ts dynamically imports it only when argv[2] === 'use'):

|              | PRE-#753              | POST-#753             |
|--------------|-----------------------|-----------------------|
| no-arg       | 8.4 ms ± 1.6 (min 7.4) | 4.8 ms ± 2.2 (min 3.1) |
| use          | 7.6 ms ± 0.4 (min 7.1) | 8.4 ms ± 0.7 (min 7.5) |

hyperfine -N -w 5 -r 30. No-arg branch drops by 43% on mean / 58% on
min — that 4 ms is the for-loop that no longer runs at startup. The
`use` branch is statistically indistinguishable; the heavy init still
runs, just lazily, with one extra call indirection.
@TheHypnoo TheHypnoo force-pushed the feat/eager-deferred-import-split-753 branch from dd0e81f to 603e507 Compare May 14, 2026 13:17
@TheHypnoo TheHypnoo merged commit 3bc62af into main May 14, 2026
9 checks passed
@TheHypnoo TheHypnoo deleted the feat/eager-deferred-import-split-753 branch May 14, 2026 15:50
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.

perf: skip eager init for modules reached only via dynamic import() (Eager/Deferred split, follow-up to #100)

1 participant