Skip to content

Commit 34e2c80

Browse files
committed
perf: closes #753 — skip eager init for modules reached only via dynamic 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.
1 parent 8a7ea99 commit 34e2c80

6 files changed

Lines changed: 354 additions & 3 deletions

File tree

crates/perry-codegen/src/codegen.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,27 @@ pub struct CompileOptions {
226226
/// value (single-path) or to chain string-compare dispatches
227227
/// (multi-path). Empty if this module performs no dynamic imports.
228228
pub dynamic_import_path_to_prefix: std::collections::HashMap<String, String>,
229+
230+
/// Issue #753: sanitized prefixes of modules whose init must NOT
231+
/// run as part of the entry module's eager init chain. Reachable
232+
/// from the entry only through dynamic `import()` edges, so their
233+
/// `<prefix>__init` fires lazily from the dispatch site. The entry
234+
/// module's `main` filters this set out of `non_entry_module_prefixes`
235+
/// when emitting the eager init call sequence. Empty when no module
236+
/// in the program is deferred.
237+
pub deferred_module_prefixes: std::collections::HashSet<String>,
238+
239+
/// Issue #753: sanitized prefixes of THIS module's static-import +
240+
/// re-export source modules (non-entry only — the entry has no
241+
/// `__init` to call). The wrapper `<prefix>__init` calls each
242+
/// dep's `<dep>__init` (idempotently) before invoking the body.
243+
/// Required so that a Deferred module firing lazily transitively
244+
/// initializes any Deferred deps reached only through its own
245+
/// re-export chain — otherwise the namespace populator at the
246+
/// tail of `<prefix>__init_body` reads zero-initialized cross-
247+
/// module globals. For Eager modules the redundant calls
248+
/// short-circuit on the guard's first-write check.
249+
pub module_init_deps: Vec<String>,
229250
}
230251

231252
/// Issue #100: one entry in a module's namespace-population list.
@@ -483,6 +504,17 @@ pub(crate) struct CrossModuleCtx {
483504
/// dispatch site in `expr.rs::Expr::DynamicImport` to find the
484505
/// `@__perry_ns_<target_prefix>` global to load.
485506
pub dynamic_import_path_to_prefix: std::collections::HashMap<String, String>,
507+
/// Issue #753: sanitized prefixes of modules reached only through
508+
/// dynamic `import()` edges. Their `<prefix>__init` is excluded
509+
/// from the entry-main eager init call sequence and fires lazily
510+
/// from each `Expr::DynamicImport` dispatch site.
511+
pub deferred_module_prefixes: std::collections::HashSet<String>,
512+
/// Issue #753: this module's static-import + re-export source
513+
/// prefixes (non-entry only). Consumed by `compile_module_entry`
514+
/// when emitting the wrapper for `<prefix>__init` so dep init
515+
/// fires before the body — transitively pulls in any Deferred dep
516+
/// chain reached only through this module's re-exports.
517+
pub module_init_deps: Vec<String>,
486518
}
487519

488520
/// Compile a Perry HIR module to an object file via LLVM IR.
@@ -1325,6 +1357,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
13251357
.collect(),
13261358
namespace_entries: opts.namespace_entries.clone(),
13271359
dynamic_import_path_to_prefix: opts.dynamic_import_path_to_prefix.clone(),
1360+
deferred_module_prefixes: opts.deferred_module_prefixes.clone(),
1361+
module_init_deps: opts.module_init_deps.clone(),
13281362
};
13291363

13301364
// Module-level globals registry. Pre-walk:
@@ -2715,6 +2749,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
27152749
for prefix in foreign_prefixes {
27162750
let ns_name = format!("__perry_ns_{}", prefix);
27172751
llmod.add_external_global(&ns_name, DOUBLE);
2752+
// Issue #753: declare each dynamic-import target's `__init`
2753+
// so the dispatch site in `Expr::DynamicImport` can call it
2754+
// before loading the namespace. The wrapper-side init is
2755+
// idempotent — calling it for an already-initialized
2756+
// target costs a load + cmp + cond_br. For Deferred
2757+
// targets it's the only thing that triggers their init.
2758+
llmod.declare_function(&format!("{}__init", prefix), VOID, &[]);
27182759
}
27192760
}
27202761

@@ -3947,6 +3988,21 @@ fn compile_module_entry(
39473988
for prefix in non_entry_module_prefixes {
39483989
llmod.declare_function(&format!("{}__init", prefix), VOID, &[]);
39493990
}
3991+
// Issue #753: emit a no-op `<entry_prefix>__init` stub so the
3992+
// dispatch site in some other module that does `await
3993+
// import("./entry.ts")` resolves at link time. The entry
3994+
// module's actual body runs in `main`, not in a separate
3995+
// `__init` — the stub exists purely to satisfy the dispatch's
3996+
// unconditional init call. The namespace populator at the
3997+
// tail of `main` (when `cross_module.namespace_entries` is
3998+
// non-empty) is what makes the entry observable through the
3999+
// dynamic-import namespace; the stub does no work.
4000+
{
4001+
let stub_name = format!("{}__init", module_prefix);
4002+
let stub = llmod.define_function(&stub_name, VOID, vec![]);
4003+
let _ = stub.create_block("entry");
4004+
stub.block_mut(0).unwrap().ret_void();
4005+
}
39504006

39514007
// For dylib output, emit `void perry_module_init()` instead of
39524008
// `int main()`. The host process calls this once after dlopen to
@@ -4018,7 +4074,19 @@ fn compile_module_entry(
40184074
// Then every non-entry module's init in order. Each
40194075
// non-entry module's `<prefix>__init` runs its own string
40204076
// pool init internally before its top-level statements.
4077+
//
4078+
// Issue #753: skip Deferred modules — those reached only
4079+
// through dynamic `import()` edges. Their `<prefix>__init`
4080+
// fires lazily from each `Expr::DynamicImport` dispatch
4081+
// site, idempotently guarded by `@__perry_init_done_<prefix>`
4082+
// so a program that never reaches the dispatch never pays
4083+
// the startup cost. The extern declaration at line ~3947
4084+
// still emits for every non-entry prefix so the dispatch
4085+
// site can resolve the symbol at link time.
40214086
for prefix in non_entry_module_prefixes {
4087+
if cross_module.deferred_module_prefixes.contains(prefix) {
4088+
continue;
4089+
}
40224090
blk.call_void(&format!("{}__init", prefix), &[]);
40234091
}
40244092
}
@@ -4282,7 +4350,79 @@ fn compile_module_entry(
42824350
llmod.add_raw_global(raw.clone());
42834351
}
42844352
} else {
4353+
// Issue #753: idempotent init guard. Every non-entry module gets
4354+
// a one-byte `@__perry_init_done_<prefix>` flag and a thin
4355+
// wrapper `<prefix>__init` that returns immediately when the
4356+
// flag is set or stores 1 + dispatches to `<prefix>__init_body`
4357+
// when it isn't. The wrapper is what the entry main calls
4358+
// eagerly (for Eager modules) and what every
4359+
// `Expr::DynamicImport` dispatch site calls (for any module
4360+
// that's a dynamic-import target — possibly multiple sites in
4361+
// the same program). The 2-state guard matches ESM's
4362+
// partial-cycle semantics: re-entry during init returns without
4363+
// re-running the body, leaving the namespace populator's work
4364+
// partially observable. The wrapper sets `done = 1` BEFORE
4365+
// calling the body so the re-entry path returns immediately.
4366+
let done_global = format!("__perry_init_done_{}", module_prefix);
4367+
llmod.add_internal_global(&done_global, I8, "0");
42854368
let init_name = format!("{}__init", module_prefix);
4369+
let init_body_name = format!("{}__init_body", module_prefix);
4370+
{
4371+
let wrap_fn = llmod.define_function(&init_name, VOID, vec![]);
4372+
let _ = wrap_fn.create_block("entry");
4373+
let _ = wrap_fn.create_block("guard.ret");
4374+
let _ = wrap_fn.create_block("guard.do");
4375+
let ret_label = wrap_fn.block_mut(1).unwrap().label.clone();
4376+
let do_label = wrap_fn.block_mut(2).unwrap().label.clone();
4377+
{
4378+
let blk = wrap_fn.block_mut(0).unwrap();
4379+
let done = blk.load(I8, &format!("@{}", done_global));
4380+
let already = blk.icmp_ne(I8, &done, "0");
4381+
blk.cond_br(&already, &ret_label, &do_label);
4382+
}
4383+
{
4384+
let blk = wrap_fn.block_mut(1).unwrap();
4385+
blk.ret_void();
4386+
}
4387+
{
4388+
let blk = wrap_fn.block_mut(2).unwrap();
4389+
blk.store(I8, "1", &format!("@{}", done_global));
4390+
// Trigger init of static-dep + re-export source modules
4391+
// before the body runs. Each `<dep>__init` is itself
4392+
// wrapped by the same guard pattern, so this short-
4393+
// circuits when the dep was already initialized
4394+
// (Eager-via-main path) and fires the body when the
4395+
// dep is Deferred and this is the first reach. The
4396+
// entry module has no `__init` so the driver excludes
4397+
// it from `module_init_deps`.
4398+
for dep_prefix in &cross_module.module_init_deps {
4399+
if dep_prefix == module_prefix {
4400+
continue;
4401+
}
4402+
blk.call_void(&format!("{}__init", dep_prefix), &[]);
4403+
}
4404+
blk.call_void(&init_body_name, &[]);
4405+
blk.ret_void();
4406+
}
4407+
}
4408+
// Declare every dep's `__init` symbol so the wrapper's calls
4409+
// resolve at link time. Most overlap with `non_entry_module_prefixes`
4410+
// (whose declarations live in the entry module's compilation),
4411+
// but a non-entry module compiled standalone has no entry-side
4412+
// declaration list — emit them here too. `declare_function`
4413+
// dedupes by name.
4414+
for dep_prefix in &cross_module.module_init_deps {
4415+
if dep_prefix == module_prefix {
4416+
continue;
4417+
}
4418+
llmod.declare_function(&format!("{}__init", dep_prefix), VOID, &[]);
4419+
}
4420+
// The body retains every existing semantic of `<prefix>__init`
4421+
// (strings init, globals/GC registration, top-level statements,
4422+
// namespace populator at the tail). It's `internal` linkage:
4423+
// only the wrapper above ever calls it, both within this module
4424+
// and across modules via the wrapper's external symbol.
4425+
let init_name = init_body_name;
42864426
// Debug: emit puts("INIT: <prefix>") at the top of each module init
42874427
let debug_init_const = if std::env::var("PERRY_DEBUG_INIT").is_ok() {
42884428
let debug_msg = format!("INIT: {}\0", module_prefix);
@@ -4295,6 +4435,7 @@ fn compile_module_entry(
42954435
let ic_base = llmod.ic_counter;
42964436
let buffer_alias_base = llmod.buffer_alias_counter;
42974437
let init_fn = llmod.define_function(&init_name, VOID, vec![]);
4438+
init_fn.linkage = "internal".to_string();
42984439
let _ = init_fn.create_block("entry");
42994440
{
43004441
let blk = init_fn.block_mut(0).unwrap();

crates/perry-codegen/src/expr.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10819,7 +10819,15 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
1081910819
let target_prefix = ctx.dynamic_import_path_to_prefix.get(path).cloned();
1082010820
let blk = ctx.block();
1082110821
let ns_val = match target_prefix {
10822-
Some(prefix) => blk.load(DOUBLE, &format!("@__perry_ns_{}", prefix)),
10822+
Some(prefix) => {
10823+
// Issue #753: trigger the target's init before
10824+
// loading its namespace. For Eager targets the
10825+
// guard short-circuits; for Deferred targets
10826+
// this is the only invocation that populates
10827+
// `@__perry_ns_<prefix>`.
10828+
blk.call_void(&format!("{}__init", prefix), &[]);
10829+
blk.load(DOUBLE, &format!("@__perry_ns_{}", prefix))
10830+
}
1082310831
None => {
1082410832
// Driver didn't resolve this path to a target
1082510833
// module — surface a rejected promise.
@@ -10890,11 +10898,16 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
1089010898
let next_label_str = ctx.block_label(next_label);
1089110899
ctx.block().cond_br(&cond, &match_label, &next_label_str);
1089210900

10893-
// Match arm — load namespace, wrap in promise, store
10894-
// into result_slot, branch to join.
10901+
// Match arm — call target's __init (idempotent), load
10902+
// namespace, wrap in promise, store into result_slot,
10903+
// branch to join. Issue #753: the init call is the
10904+
// only thing that triggers a Deferred target's body
10905+
// and namespace populator; for Eager targets the
10906+
// guard short-circuits.
1089510907
ctx.current_block = match_block_idx;
1089610908
let join_label = ctx.block_label(join_block_idx);
1089710909
let blk = ctx.block();
10910+
blk.call_void(&format!("{}__init", target_prefix), &[]);
1089810911
let ns_val = blk.load(DOUBLE, &format!("@__perry_ns_{}", target_prefix));
1089910912
let promise = blk.call(I64, "js_promise_resolved", &[(DOUBLE, &ns_val)]);
1090010913
let boxed = nanbox_pointer_inline(blk, &promise);

0 commit comments

Comments
 (0)