Skip to content

perf: eliminate fixed runtime bloat from minimal native binaries #6468

Description

@TheHypnoo

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.ts
// 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:

  1. HTTP/2 constants
  2. Web storage
  3. Messaging
  4. TUI/Yoga layout
  5. Cluster scheduling
  6. 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

  • Empty and hello-world binaries no longer retain gated Node/module-specific subsystems.
  • Full/default builds preserve the existing runtime and API surface.
  • Each optional runtime slice is activated deterministically from imports or HIR usage.
  • Runtime feature choices are represented in the auto-optimized cache key and build stamp.
  • Feature-off and feature-on runtime builds compile on supported host platforms.
  • Behavioral tests prove every gated API still works when imported.
  • Link-map or symbol tests prove the gated subsystem is absent when unused.
  • No new parity regressions.
  • Binary-size measurements are recorded for each slice so improvements remain cumulative.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions