Skip to content

Commit 1accf75

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 1accf75

10 files changed

Lines changed: 433 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
Detailed changelog for Perry. See CLAUDE.md for concise summaries.
44

5+
## v0.5.910 — perf: closes #753 — skip eager init for modules reached only via dynamic `import()`. Follow-up to #100/#752. **The problem.** Pre-PR, every module the resolver registered as a dynamic-`import()` target was added to the entry main's eager init chain just like a static import — functionally correct (the namespace was already populated when the dispatch site fired) but a wasted startup cost. A program that lazy-loads a heavy locale bundle, an optional feature module, or a route split still paid the full init at program start. Issue text spelled out the architecture; this PR is that architecture wired through the codegen + driver. **Eager/Deferred classification.** New reachability pass in `crates/perry/src/commands/compile.rs` (right after `entry_path` is computed, before the topo sort): start with `eager = {entry}`, loop to a fixed point propagating Eager across (a) every non-type-only static-import edge `import.is_dynamic == false`, (b) every `Export::ExportAll`/`ReExport`/`NamespaceReExport` re-export source. Anything still unmarked at the end is Deferred. Writes the result back to each `Module::init_kind` (HIR field already existed from #100, no codegen consulted it). Re-export propagation is load-bearing: a Deferred module's namespace populator at the tail of `__init_body` reads `perry_fn_<src>__<local>()` getters for re-exported bindings, so an Eager module re-exporting from B must keep B Eager. **Codegen — skip Deferred in main.** New `CrossModuleCtx.deferred_module_prefixes: HashSet<String>` plumbed from `CompileOptions` (driver fills it once per program — same set for every module's codegen — from the `Deferred`-classified modules). The entry main's eager init loop in `compile_module_entry` filters this set out of `non_entry_module_prefixes` and skips the `<prefix>__init` call. Extern declarations still emit for every non-entry prefix at the top of the entry's compilation, so dispatch-site calls into a Deferred module still link. **Codegen — dispatch calls init.** Both arms of `Expr::DynamicImport` in `crates/perry-codegen/src/expr.rs` (single-path fast-path + multi-path string-compare chain) call `<target>__init` immediately before loading `@__perry_ns_<target_prefix>`. For Eager targets the call is a no-op the guard short-circuits; for Deferred targets it's the only invocation that builds their namespace. Foreign init symbols get an `add_external_global`+`declare_function` pair at line ~2715 (alongside the existing `@__perry_ns_<prefix>` declare) so the link resolves. **Codegen — idempotent init guard via wrapper.** Each non-entry module now emits TWO functions instead of one. (1) `@__perry_init_done_<prefix>: i8 = 0` internal global. (2) `<prefix>__init` (external linkage) — a 3-block wrapper: entry loads `done`, `icmp ne 0`, `cond_br` to either `guard.ret` (just `ret void`) or `guard.do` (stores `1`, calls each static-dep `<dep>__init` to transitively pull Deferred chains, then calls `<prefix>__init_body`, then `ret void`). (3) `<prefix>__init_body` (internal linkage) — the existing body code (strings init, GC root registration, top-level statements, namespace populator). Renaming the body to `_body` is invisible to every other code path: the wrapper is the externally-visible symbol that every caller (entry main + dispatch sites) talks to. The 2-state guard matches ESM's partial-cycle semantics — re-entry during init returns without re-running the body, leaving the namespace populator's work partially observable. Setting `done = 1` BEFORE the body runs (not after) is what makes the cycle case work: A static-imports B, B static-imports A, B's wrapper-body chain enters first → sets `B.done=1` → calls A's wrapper → A's wrapper sets `A.done=1` → calls B's wrapper from A's dep loop → guard hits `done=1` → returns immediately. **Codegen — transitive Deferred init.** `CrossModuleCtx.module_init_deps: Vec<String>` is the per-module list of static-import + re-export source prefixes (driver computes from `hir.imports` + `hir.exports`, skips the entry's prefix since the entry has no `__init` to call). The wrapper's `guard.do` block iterates this list and calls each dep's `<dep>__init` before invoking `__init_body`. Necessary because a Deferred module that re-exports from another module reached only through its own chain (entry → dynamic → barrel → re-export → inner) would otherwise have inner's globals zero-initialized when barrel's namespace populator runs. With this list, barrel's wrapper calls inner's wrapper → inner's body runs → inner's globals populate → barrel's namespace populator reads them correctly. For Eager modules the calls short-circuit on the guard so the cost is one load + cmp + cond_br per dep — negligible vs the real init work. **Cache key.** `compute_object_cache_key` now hashes `deferred_module_prefixes` (sorted) and `module_init_deps` (preserves order — meaningful) so a program that gains or loses a dynamic-`import()` reachability path invalidates the entry module's cached `.o` correctly. Without this the entry main's eager init call list would change (some `<prefix>__init` calls removed/added) but the cached bytes would be reused. **Acceptance — parity.** All 7 existing dynamic-import gap tests from #100 (`test_gap_dynamic_import_literal.ts`, `_ternary`, `_template`, `_reexport`, `_tla`, `_cycle`, `_init_time`) byte-equal `node --experimental-strip-types`. `_init_time.ts` is the case the issue called out specifically — entry-level top-level `await import(...)` — which used to work because the dynamic edge participated in topo sort (target init'd before consumer); after this PR the dispatch must call the target's `__init` itself, and it does. **Acceptance — new test.** `test_gap_dynamic_import_deferred.ts` covers the Deferred-only case: a marker module logs `"deferred-init-ran"` at the top level; running the entry with no argument prints `before / skipped` (marker suppressed); running with arg `1` prints `before / deferred-init-ran / after:42` (marker fires lazily). Byte-equal to Node. **Benchmark — the actual point.** With the issue's heavy-import shape (`bench_heavy.ts` builds a 1 M-entry int array at top level, `bench_main.ts` dynamically imports it only when `process.argv[2] === 'use'`), startup time on the no-arg branch:
6+
7+
| | PRE-#753 | POST-#753 |
8+
|--------------|----------------|----------------|
9+
| no-arg (deferred not loaded) | **8.4 ms ± 1.6** (min 7.4) | **4.8 ms ± 2.2** (min 3.1) |
10+
| `use` (deferred loaded) | 7.6 ms ± 0.4 (min 7.1) | 8.4 ms ± 0.7 (min 7.5) |
11+
12+
Numbers from `hyperfine -N -w 5 -r 30` against release binaries built on the same machine. The no-arg branch drops by ~3.6 ms (43%) on the mean, ~4.3 ms on the min — and that 4 ms is dominated by the `for (let i = 0; i < 1_000_000; i++) big.push(i*i)` that no longer runs at startup. The `use` branch is statistically indistinguishable: the heavy init still has to run, just lazily, with one extra `call <prefix>__init` indirection (load + cmp + cond_br after the first invocation amortizes). **Out of scope.** Topo sort still includes dynamic edges (harmless — they no longer constrain order under the eager/deferred split but keep init order deterministic for any program that re-acquires a static dep via reflection). TLA chaining at the dispatch site stays synchronous via the target's `__init_body`'s blocking await (verified by `_tla.ts`). `--allow-quickjs-eval` runtime fallback for unresolvable paths is tracked separately. **Contributor.** Implementation by @TheHypnoo.
13+
514
## v0.5.909 — feat: add `libDirs` to native library manifest (#762). New optional `targets.<target>.libDirs` field in `package.json`'s `perry.nativeLibrary` block — array of linker search paths emitted ahead of the `libs` list. **Surface.** Documented in `docs/src/native-libraries/manifest-v1.md` alongside the existing `libs` / `frameworks` / `pkgConfig` entries. Use it when a wrapper ships a vendored `.a`/`.dylib` outside the cargo crate (the standard `lib_name` lookup probes `target/<triple>/release/`); previously the only escape hatch was an absolute path glued into `libs` via a workaround. **Plumbing.** Three edits — (1) `TargetNativeConfig.lib_dirs: Vec<PathBuf>` field added in `crates/perry/src/commands/compile.rs`; (2) `parse_native_library_manifest` parses `libDirs` and anchors relative entries to `package_dir` via `package_dir.join(p)` — mirrors the existing `swift_sources` / `metal_sources` pattern so a `"./vendor/lib"` in the manifest resolves against the wrapper's directory, not the user's cwd (absolute entries pass through unchanged since `PathBuf::join` ignores the base on an absolute right-hand side); (3) `build_and_run_link` in `crates/perry/src/commands/compile/link.rs` emits each entry between the `frameworks` loop and the `libs` loop — `-L<dir>` on every non-Windows target, `/LIBPATH:<dir>` when `is_windows` (mirrors the `libs` loop's existing `{lib}.lib` vs `-l{lib}` MSVC/Unix split, so a `targets.windows.libDirs` entry actually resolves the `{lib}.lib` lookups instead of being a silent no-op against link.exe). **Validation.** Two new unit tests in `crates/perry/src/commands/compile/resolve.rs::manifest_parse_tests` — `lib_dirs_relative_paths_anchored_to_package_dir` round-trips a manifest with both `"vendor/lib"` and `"/abs/path"` and asserts the package_dir-anchored / pass-through behavior; `lib_dirs_defaults_to_empty_when_absent` confirms the field is optional. `cargo build --release` clean; `cargo test -p perry --lib` green. **Version-bump note.** Renumbered v0.5.908 → v0.5.909 because #760 (test: argon2 + ethers parity) landed on main mid-review and took the v0.5.908 slot. **Contributor.** Implementation by @Lebei2046; MSVC `/LIBPATH:` branch + `package_dir` anchoring + tests + manifest doc entry + this changelog folded in at merge time.
615

716
## v0.5.908 — test: #698 — behavioral parity tests for argon2 + ethers integrations (#760). Follow-up to #694, addressing #698. Adds two new parity fixtures and a `@covers` block on `test_parity_crypto.ts`, moving 18 FFI entries out of `test_ffi_surface_stdlib_integrations.ts` (156 → 138 unique names) into behavioral coverage. **`test_parity_argon2.ts`** — round-trips `argon2.hash` / `argon2.verify` against the `perry-ext-argon2` wrapper; non-deterministic salt is handled by shape-checking the `$argon2id$` prefix and asserting verify round-trips against a freshly produced hash. Only the async path is in `NativeModSig`, so this covers the two FFI entries (`js_argon2_hash`, `js_argon2_verify`) reachable end-to-end. **`test_parity_ethers.ts`** — pure deterministic helpers from `perry-ext-ethers`: `getAddress` (EIP-55 checksum on lower- and upper-case input), `parseEther`/`formatEther` round-trip on 1.5 ETH, `parseUnits`/`formatUnits` at 6- (USDC) and 9-decimal (gwei) positions. Covers the five `js_ethers_*` helpers. Both new fixtures use the `test-parity/expected/` mechanism — Perry routes the npm-style imports to the bundled `perry-ext-*` wrappers, but Node can't resolve the same names without `node_modules`, so they fall through to stored expected-output comparison. **`test_parity_crypto.ts`** — adds a `@covers` block declaring the crypto/webcrypto/crypto_e2e FFI surface it already exercises via `node:crypto` and `crypto.subtle` (digest, hash, pbkdf2/hkdf, subtle.sign/verify); no behavioral change. **Out of scope (each gets its own follow-up).** `jsonwebtoken.sign` — `NativeModSig` passes `payload`/`secret` as `NA_F64` (NaN-boxed) but `js_jwt_sign` reads them as `*const StringHeader`, producing garbage tokens (calling-convention mismatch, same shape as the #591 argon2 fix). `bcrypt.hash` — return value reports `typeof === "object"` in user code; `compare` round-trips correctly but `.startsWith(...)` on the hash fails. `cheerio` — `$.select(...)` returns a bare-number handle, so `.length()` / `.first()` etc. fail with `(number).method is not a function` at the prototype check. Service-backed integrations (MongoDB, MySQL, PG, ioredis, Nodemailer, Sharp) also remain in the inventory pending the local-container / opt-in test setup described in #698. **Audit.** `./test-coverage/audit.sh --markdown` keeps TS-side and combined FFI coverage at **1791/1791 (100.0%)**. `test_ffi_surface_stdlib_integrations.ts` re-regenerated via `regen_ts_surface_inventory.py` to fold in the new `@covers` blocks. Comment-only changes; no behavioral or runtime impact. Contributor: @TheHypnoo.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
1010

11-
**Current Version:** 0.5.909
11+
**Current Version:** 0.5.910
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)