From 9738df9739eb389111245060cb1ab4ad87bf5b7b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:06:22 -0400 Subject: [PATCH] perf(@angular/build): immediately purge freed rolldown arena memory Rolldown's native binding statically links mimalloc v3, which defaults to a 4-second arena purge delay (MIMALLOC_PURGE_DELAY=1000 * MIMALLOC_ARENA_PURGE_MULT=4) and only executes scheduled purges cooperatively during subsequent allocations. In single-run CLI builds where Rolldown is not invoked again after bundling completes, non-zero purge delays prevent freed arena pages from being returned to the OS, increasing peak RSS when subsequent post-bundle stages run. Introduce a shared loadRolldown() helper used by both the application chunk optimizer and the library builder that temporarily sets MIMALLOC_PURGE_DELAY=0 (when not already configured in the environment) while loading Rolldown's native binding so mimalloc caches a 0ms purge delay without leaving the variable set in process.env. --- .../builders/application/chunk-optimizer.ts | 5 +- .../src/builders/library/pipeline/bundler.ts | 16 +++--- .../angular/build/src/utils/load-rolldown.ts | 47 ++++++++++++++++ .../build/src/utils/load-rolldown_spec.ts | 55 +++++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 packages/angular/build/src/utils/load-rolldown.ts create mode 100644 packages/angular/build/src/utils/load-rolldown_spec.ts diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index db3e1d2f180d..cd5e4e1814ec 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -29,6 +29,7 @@ import { } from '../../tools/esbuild/bundler-files'; import { useRolldownChunks } from '../../utils/environment-options'; import { assertIsError } from '../../utils/error'; +import { loadRolldown } from '../../utils/load-rolldown'; import { toPosixPath } from '../../utils/path'; /** @@ -116,7 +117,7 @@ function bundleOutputToEsbuildMetafile( // Read once per module: in Rolldown, `renderedLength` is an uncached getter that // copies the entire module code across the NAPI bridge on each access. const { renderedLength } = renderedModule; - + for (const [originalInputPath, originalInputInfo] of Object.entries( originalOutputEntry.inputs, )) { @@ -296,7 +297,7 @@ export async function optimizeChunks( ]; if (useRolldownChunks) { - const { rolldown } = await import('rolldown'); + const { rolldown } = await loadRolldown(); bundle = await rolldown({ input: mainFile, plugins, diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index 3f47b45548d6..54b54f32d87e 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -8,14 +8,8 @@ import assert from 'node:assert'; import path from 'node:path'; -import { - type OutputChunk, - type Plugin, - type RolldownOutput, - type RolldownPluginOption, - rolldown, -} from 'rolldown'; -import { dts } from 'rolldown-plugin-dts'; +import type { OutputChunk, Plugin, RolldownOutput, RolldownPluginOption } from 'rolldown'; +import { loadRolldown } from '../../../utils/load-rolldown'; import { toPosixPath } from '../../../utils/path'; import type { BundleResult, NormalizedEntryPoint, NormalizedLibraryOptions } from '../types'; import { @@ -317,6 +311,7 @@ async function executeMultiBundle( sourcemap = true; } + const { rolldown } = await loadRolldown(); const bundle = await rolldown({ context: 'this', input, @@ -389,6 +384,11 @@ async function bundleAllDts( return { filesToEmit: [], moduleIdsByBundle: new Map() }; } + // Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating + // `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`. + await loadRolldown(); + const { dts } = await import('rolldown-plugin-dts'); + const dtsSourcemap = options.declarationMap; // Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted // in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`. diff --git a/packages/angular/build/src/utils/load-rolldown.ts b/packages/angular/build/src/utils/load-rolldown.ts new file mode 100644 index 000000000000..78af6aeb3c9d --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +let rolldownPromise: Promise | undefined; + +/** + * Dynamically loads the `rolldown` module while configuring its embedded + * mimalloc v3 allocator to immediately purge freed arena memory. + * + * Rolldown's native binding statically links mimalloc v3, which defaults to a + * 4-second arena purge delay (`MIMALLOC_PURGE_DELAY=1000` * `MIMALLOC_ARENA_PURGE_MULT=4`) + * and only triggers purges cooperatively during subsequent allocations. In single-run + * CLI builds where Rolldown is not invoked again after bundling completes, non-zero + * purge delays prevent freed arena pages from being decommitted back to the OS, + * leading to high peak RSS during post-bundle stages. + * + * Because mimalloc reads and caches environment options synchronously when the native + * `.node` addon is first loaded (`mi_process_init` -> `_mi_options_init`), temporarily + * setting `MIMALLOC_PURGE_DELAY=0` during the initial `import('rolldown')` call + * configures Rolldown's allocator without leaving the environment variable set for + * subsequent code or child processes. + * + * @returns A promise that resolves to the `rolldown` module namespace. + */ +export function loadRolldown(): Promise { + if (!rolldownPromise) { + const hasCustomPurgeDelay = Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY); + if (!hasCustomPurgeDelay) { + process.env[MIMALLOC_PURGE_DELAY] = '0'; + } + + rolldownPromise = import('rolldown').finally(() => { + if (!hasCustomPurgeDelay) { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + }); + } + + return rolldownPromise; +} diff --git a/packages/angular/build/src/utils/load-rolldown_spec.ts b/packages/angular/build/src/utils/load-rolldown_spec.ts new file mode 100644 index 000000000000..0f7ea7cb4a80 --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +describe('loadRolldown', () => { + const originalEnvValue = process.env[MIMALLOC_PURGE_DELAY]; + + function getFreshLoadRolldown(): typeof import('./load-rolldown') { + delete require.cache[require.resolve('./load-rolldown')]; + + return require('./load-rolldown'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env[MIMALLOC_PURGE_DELAY] = originalEnvValue; + } else { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + delete require.cache[require.resolve('./load-rolldown')]; + }); + + it('loads rolldown and cleans up temporary MIMALLOC_PURGE_DELAY when unset', async () => { + delete process.env[MIMALLOC_PURGE_DELAY]; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise1 = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('0'); + + const promise2 = loadRolldown(); + expect(promise1).toBe(promise2); + + const rolldownModule = await promise1; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY)).toBeFalse(); + }); + + it('preserves user-configured MIMALLOC_PURGE_DELAY when already set', async () => { + process.env[MIMALLOC_PURGE_DELAY] = '1000'; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + + const rolldownModule = await promise; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + }); +});