deno
7b547484 - perf: startup time (22ms -> 15ms) (#34450)

Commit
124 days ago
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>
Author
Parents
Loading