Summary
Perry's auto-optimizer already reduces binary size substantially, but even an empty TypeScript program or a one-line hello-world binary retains several megabytes of runtime code that the program cannot reach.
The current minimum is approximately 5.1 MB on Apple Silicon macOS. The generated application object is only about 25 KB; nearly all remaining size comes from a fixed runtime floor.
This issue tracks converting more of the runtime from an always-linked monolith into import-/usage-driven slices while preserving the full/default compatibility surface.
Reproduction
// hello.ts
console.log("hello world!");
Using Perry 0.5.1255:
| Input |
Auto-optimized |
--no-auto-optimize |
| Empty |
5,142,120 bytes |
9,587,048 bytes |
| Hello world |
5,091,544 bytes |
9,519,816 bytes |
Auto-optimization already saves roughly 46%, primarily through the reduced Cargo feature set and panic=abort, but empty and hello-world remain almost identical in size. This indicates a large fixed runtime floor rather than application-driven retention.
Link-map evidence
For the empty auto-optimized binary:
- Application object: approximately 25,334 bytes
- One
perry_runtime codegen unit: 4,356,698 bytes
- Symbols retained from that unit: 14,592
Representative retained runtime families:
| Runtime family |
Approximate retained code |
| Object/runtime dispatch |
726 KB |
| GC |
276 KB |
| Intl |
214 KB |
node_stream |
184 KB |
| Filesystem |
150 KB |
| Arrays |
91 KB |
| JSON |
76 KB |
| Closures |
74 KB |
| URL |
68 KB |
| Promises |
67 KB |
| Node submodules |
67 KB |
| Process |
58 KB |
| Builtins |
57 KB |
| HTTP/2 constants |
20 KB |
The empty binary also retains code for child processes, timers, async hooks, messaging, web storage, TUI layout and other unused surfaces.
Root cause
Perry's current auto-optimizer successfully gates a small number of expensive engines and modules:
- regex engine
- Temporal
- URL/IDNA engine
- string normalization
- Intl Segmenter/Locale
- diagnostics
node:dgram
- WebAssembly host
However, most modules in perry-runtime/src/lib.rs remain compiled unconditionally. Cross-module registrations and dispatch paths keep large parts of the runtime reachable, and macOS -dead_strip cannot recover module-level granularity from the resulting runtime graph.
Simply increasing release codegen units is not sufficient. An isolated experiment changing codegen units from 1 to 16 increased the empty binary from 5,142,120 to 5,441,240 bytes (+5.8%).
Evidence that explicit runtime gates work
The existing mod-dgram gate behaves predictably:
- Empty program: zero retained dgram symbols
- Program importing
node:dgram: 82 symbols / approximately 47,112 bytes
A separate isolated prototype gated the HTTP/2 constant namespace behind perry-runtime/mod-http2-constants:
- Empty binary before: 5,142,120 bytes
- Empty binary after: 5,109,088 bytes
- Reduction: 33,032 bytes (-0.64%)
- Retained HTTP/2 constant symbols when unused: 20,700 -> 0 bytes
- Importing
node:http2 re-enabled the feature and preserved constant lookup behavior.
The prototype also added the usage bit to the auto-build cache key so a runtime built without HTTP/2 constants cannot be reused for an HTTP/2 program.
Proposed direction
Phase 1: isolated tracer-bullet gates
Start with runtime families that have few external references and a clear activation signal:
- HTTP/2 constants
- Web storage
- Messaging
- TUI/Yoga layout
- Cluster scheduling
- Other isolated Node namespace tables and helpers
Each slice should:
- remain enabled in full/default builds;
- be absent from auto-optimized builds when unreachable;
- be enabled from imports or HIR usage;
- participate in the auto-build cache key;
- include feature-off and feature-on compilation tests;
- include a behavioral fixture proving the enabled surface still works.
Phase 2: dependency bundles
Large subsystems such as node_stream cannot be gated safely as a single module today.
A direct node_stream cfg probe produced 79 compile errors and found direct coupling across 22 non-test runtime/stdlib files, including EventEmitter, readline, filesystem promises, V8, JSON stringify, class registries and instanceof.
These subsystems need explicit dependency bundles, for example:
stream-core
├── event-emitter integration
├── stream namespace
├── stream/promises + consumers
└── adapters required by fs/readline/http
The compiler should enable the smallest closed feature set required by the program rather than independently toggling modules with unresolved internal dependencies.
Phase 3: generated runtime registration
Longer term, replace broad global registration/dispatch tables with generated registration driven by the compiler's reachability result:
source/HIR usage
-> runtime capability set
-> closed dependency graph
-> Cargo/runtime feature set
-> generated registration roots
-> native linker dead stripping
Acceptance criteria
Performance expectations
The first slices primarily reduce disk/package size and instruction footprint. Small cold-table removals are not expected to produce measurable CPU or RSS improvements individually.
Startup and memory improvements should become measurable only after removing larger initialized subsystems or reducing the fixed bootstrap/runtime graph.
Relevant files
crates/perry-runtime/Cargo.toml
crates/perry-runtime/src/lib.rs
crates/perry-runtime/src/object/native_module.rs
crates/perry-runtime/src/object/native_module/
crates/perry/src/commands/compile/collect_modules/feature_detect.rs
crates/perry/src/commands/compile/optimized_libs/driver.rs
crates/perry/src/commands/compile/optimized_libs/freshness.rs
crates/perry/src/commands/compile/optimized_libs/tests.rs
crates/perry/src/commands/compile/link/build_and_run.rs
Risk
Medium overall.
Small isolated gates are low risk when full/default builds remain unchanged. Larger dependency bundles are higher risk because runtime dispatch, GC root scanning, EventEmitter integration and Node API adapters currently cross module boundaries.
Summary
Perry's auto-optimizer already reduces binary size substantially, but even an empty TypeScript program or a one-line hello-world binary retains several megabytes of runtime code that the program cannot reach.
The current minimum is approximately 5.1 MB on Apple Silicon macOS. The generated application object is only about 25 KB; nearly all remaining size comes from a fixed runtime floor.
This issue tracks converting more of the runtime from an always-linked monolith into import-/usage-driven slices while preserving the full/default compatibility surface.
Reproduction
// empty.tsUsing Perry 0.5.1255:
--no-auto-optimizeAuto-optimization already saves roughly 46%, primarily through the reduced Cargo feature set and
panic=abort, but empty and hello-world remain almost identical in size. This indicates a large fixed runtime floor rather than application-driven retention.Link-map evidence
For the empty auto-optimized binary:
perry_runtimecodegen unit: 4,356,698 bytesRepresentative retained runtime families:
node_streamThe empty binary also retains code for child processes, timers, async hooks, messaging, web storage, TUI layout and other unused surfaces.
Root cause
Perry's current auto-optimizer successfully gates a small number of expensive engines and modules:
node:dgramHowever, most modules in
perry-runtime/src/lib.rsremain compiled unconditionally. Cross-module registrations and dispatch paths keep large parts of the runtime reachable, and macOS-dead_stripcannot recover module-level granularity from the resulting runtime graph.Simply increasing release codegen units is not sufficient. An isolated experiment changing codegen units from 1 to 16 increased the empty binary from 5,142,120 to 5,441,240 bytes (+5.8%).
Evidence that explicit runtime gates work
The existing
mod-dgramgate behaves predictably:node:dgram: 82 symbols / approximately 47,112 bytesA separate isolated prototype gated the HTTP/2 constant namespace behind
perry-runtime/mod-http2-constants:node:http2re-enabled the feature and preserved constant lookup behavior.The prototype also added the usage bit to the auto-build cache key so a runtime built without HTTP/2 constants cannot be reused for an HTTP/2 program.
Proposed direction
Phase 1: isolated tracer-bullet gates
Start with runtime families that have few external references and a clear activation signal:
Each slice should:
Phase 2: dependency bundles
Large subsystems such as
node_streamcannot be gated safely as a single module today.A direct
node_streamcfg probe produced 79 compile errors and found direct coupling across 22 non-test runtime/stdlib files, including EventEmitter, readline, filesystem promises, V8, JSON stringify, class registries andinstanceof.These subsystems need explicit dependency bundles, for example:
The compiler should enable the smallest closed feature set required by the program rather than independently toggling modules with unresolved internal dependencies.
Phase 3: generated runtime registration
Longer term, replace broad global registration/dispatch tables with generated registration driven by the compiler's reachability result:
Acceptance criteria
Performance expectations
The first slices primarily reduce disk/package size and instruction footprint. Small cold-table removals are not expected to produce measurable CPU or RSS improvements individually.
Startup and memory improvements should become measurable only after removing larger initialized subsystems or reducing the fixed bootstrap/runtime graph.
Relevant files
crates/perry-runtime/Cargo.tomlcrates/perry-runtime/src/lib.rscrates/perry-runtime/src/object/native_module.rscrates/perry-runtime/src/object/native_module/crates/perry/src/commands/compile/collect_modules/feature_detect.rscrates/perry/src/commands/compile/optimized_libs/driver.rscrates/perry/src/commands/compile/optimized_libs/freshness.rscrates/perry/src/commands/compile/optimized_libs/tests.rscrates/perry/src/commands/compile/link/build_and_run.rsRisk
Medium overall.
Small isolated gates are low risk when full/default builds remain unchanged. Larger dependency bundles are higher risk because runtime dispatch, GC root scanning, EventEmitter integration and Node API adapters currently cross module boundaries.