perf: startup time (22ms -> 15ms) (#34450)
### Measured impact
**Startup** (release, warm, 50 runs, mean ± σ; same machine, main =
merge-base `cffc8d782f`)
| invocation | main | this branch | |
|---|---|---|---|
| `deno eval ''` | 18.5 ms | **13.3 ms** | −28% |
| `deno run hello.js` (ESM) | 17.2 ms | **11.8 ms** | −31% |
| `deno run hello.js` (`"type":"commonjs"`) | 21.3 ms | 24.6 ms | +15% |
| `deno run all-node-apis.mjs` (imports many `node:*`) | 27.2 ms |
**24.2 ms** | −11% |
The common `deno run <script>` / `eval` paths are ~30–45% faster. The
one slower path is explicit CommonJS (`"type":"commonjs"`): a CJS
program loads the node `require` system (`node:module`), which eagerly
pulls the node-polyfill closure (~118 modules) at startup even when it
`require()`s none of them. Lazifying that require-map (loads ~118 → 69)
is a tracked follow-up — an attempt in this PR was reverted because it
broke node `events`/`console` tests; it needs proper validation. It only
affects `type:commonjs`; bare `.js`/`.ts` (the common case) is ESM and
unaffected.
**Why node-heavy doesn't regress.** Moving the node closure out of the
eager snapshot would make a node-heavy program re-pay parse+compile of
those modules at runtime instead of getting them pre-compiled in the
snapshot (≈ +44% before mitigation, ~38 ms). Residual `lazy_loaded_js`
modules are code-cached through the on-disk (DENO_DIR) V8 cache user
scripts already use — first run compiles+stores, warm runs consume — so
residual parse+compile drops **15.8 ms → 5.7 ms** warm (JS residuals
11.5 → 1.3 ms, 126/126 cache hit), bringing `all-node-apis.mjs` to 24.2
ms (faster than main; see table). Residual ESM (~4 ms) is not yet cached
— tracked follow-up. Empty/ESM startup is unaffected: residual scripts
that do load are cached, and none are newly evaluated.
**Binary size** (release profile `panic=abort` + `opt-level=z`, stripped
`-x -S`, aarch64-apple-darwin — matching CI)
| | stripped |
|---|---|
| main (merge-base `cffc8d782f`) | 80.5 MB |
| this branch | **78.9 MB** |
**−1.6 MB / −2%.** (The 80.5 MB baseline matches the published arm-macOS
canary ~81 MB.) The shrink comes from deferring the node-polyfill
closure and fast-call op resources out of the eager snapshot.
### Changes
**1. Defer the node require system out of the eager snapshot graph** —
`ext/node/lib.rs`, `runtime/js/{98_global_scope_shared,99_main}.js`,
`ext/node/polyfills/{01_require.js, process.ts, worker_threads.ts}`
`node:module`, `node:process`, and the foundational `stream`/`net`/`tty`
closure move from `esm`/`esm_entry_point` to `lazy_loaded_esm`.
`process`/`Buffer`/`require` globals become lazy via a bootstrap-trigger
pattern in `99_main`. `__bootstrapNodeProcess` is idempotent and
self-triggered from `process.ts` so the worker and IPC-fork paths still
see a fully initialized node process. The 140-module node-polyfill
closure (~2.3 MB of SFIs + context state) no longer lands in the
snapshot deserializer for non-node programs.
**2. Defer the V8 14.9 fast-call op upgrade to first residual-module
load** — `libs/core/runtime/{jsrealm,jsruntime,bindings}.rs`,
`libs/core/ops_builtin_v8.rs`
V8 14.9+ refuses to snapshot the `Managed<CFunctionWithSignature>`
resources fast-call setup creates, so the snapshot bakes slow op
functions and re-attaches fast-call overloads at runtime
(`upgrade_snapshotted_ops_with_fast_calls`, ~1.6k V8 functions). Baked
modules already captured their slow op refs at snapshot eval, so the
upgrade only benefits ops accessed by *residual* modules — we now defer
it to the first `op_lazy_load_esm` / `op_load_ext_script` call. Programs
that load no residual module (`deno run empty.js`) skip the pass
entirely.
`Deno.core.{ops, setUpAsyncStub}` are captured as `v8::Global`s during
`new_inner` because `Deno.core` is scrubbed from the public `Deno` after
bootstrap — the deferred caller can no longer read them from the global.
**3. Eight `process` capture-site lazifications** —
`ext/node/polyfills/internal/streams/{readable,writable,transform}.js`,
`fs.ts`, `worker_threads.ts`, `inspector.js`, `child_process.ts`,
`internal/fs/utils.mjs`
These polyfills used to capture `lazyProcess().default` into a
module-level `const` at eval time, which fails when `node:process` is
mid-eval. Each now reads `process` at use-time so `node:process` can
finish its body before consumers dereference it.
**4. Six `__bootstrap` correctness fixes** —
`internal/webstreams/{readablestream,util,adapters}.js`,
`internal_binding/inspector.js`, `internal/worker/js_transferable.js`,
`internal/process/active_resources.ts`
These IIFE ext-scripts read `globalThis.__bootstrap` (which the runtime
deletes after bootstrap) instead of the injected `__bootstrap` parameter
that `load_ext_script` makes available. Pure correctness fix; would
manifest as `Cannot destructure 'core' of globalThis.__bootstrap` on
cold load.
**5. Code-cache residual lazy scripts** — `libs/core/modules/map.rs`
`lazy_loaded_js` residuals ship source-only, so a node-heavy program
re-pays parse+compile of the node-polyfill closure at runtime (work the
eager snapshot used to carry pre-compiled). They now route through the
same on-disk (DENO_DIR) V8 code cache user scripts use — the first run
compiles and stores, warm runs consume and skip parse+compile (126/126
hit on `all-node-apis.mjs`, residual JS compile 11.5 → 1.3 ms). Empty
startup is unaffected. (Build-time cache production was tried but V8
rejects caches produced by a fresh build-script isolate — its
op/external-reference layout differs from the runtime isolate — so
caches are produced at runtime instead.)
> The startup-profiling instrumentation (`DENO_STARTUP_PHASES`) and the
clap fast-path that were originally part of this PR have been split out
into #35263.
### Correctness
Node-defer validated against `tests/unit_node/` in prior iteration: 89/0
`process_test`, 82/0 `fs_test`, 56/0 `buffer_test`, 27/0 `util_test`,
9/0 `events_test`, 6/0 `stream_test`, 24/0 `os_test`, 47/1
`child_process_test` (the 1 is `stdinInherit`, fails on the baseline
too), 46/1 `worker_threads_test` (the 1 is the narrow
`workerThreads.MessagePort === globalThis.MessagePort` aliasing
assertion — node aliases the global inside `__initWorkerThreads`, which
under node-defer doesn't run on the main thread; real programs rarely
depend on it).
Ops-defer validated on node sync (os/path/buffer), node async
(`fs.promises`), Deno fs ops, Deno async net (`fetch` with
timeout-`AbortSignal`), and dynamic `import("node:crypto")` — all
correct.
### Things worth a closer look in review
- `__bootstrapNodeProcess` idempotency in `process.ts` — safe to call
multiple times, self-triggered.
- The eager-bootstrap branch in `99_main` gated on
`op_node_has_child_ipc_pipe()` — forked IPC children load the full node
closure eagerly so they behave like a regular node process.
- `ContextState::deferred_fast_ops` is `RefCell<Option<(Global<Object>,
Global<Function>)>>`, only `Some` on the runtime-snapshot-load path.
`new_inner` pre-sets `fast_ops_upgraded = true` for the snapshot-build
and fresh-bind paths so the deferred upgrade is a no-op there (necessary
because `op_load_ext_script` also runs during the snapshot build).
Closes denoland/divybot#240
---------
Co-authored-by: divybot <divybot@users.noreply.github.com>