Skip to content

Commit 2de2480

Browse files
committed
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.
1 parent 88ebc67 commit 2de2480

4 files changed

Lines changed: 113 additions & 10 deletions

File tree

‎packages/angular/build/src/builders/application/chunk-optimizer.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from '../../tools/esbuild/bundler-files';
3030
import { useRolldownChunks } from '../../utils/environment-options';
3131
import { assertIsError } from '../../utils/error';
32+
import { loadRolldown } from '../../utils/load-rolldown';
3233
import { toPosixPath } from '../../utils/path';
3334

3435
/**
@@ -116,7 +117,7 @@ function bundleOutputToEsbuildMetafile(
116117
// Read once per module: in Rolldown, `renderedLength` is an uncached getter that
117118
// copies the entire module code across the NAPI bridge on each access.
118119
const { renderedLength } = renderedModule;
119-
120+
120121
for (const [originalInputPath, originalInputInfo] of Object.entries(
121122
originalOutputEntry.inputs,
122123
)) {
@@ -296,7 +297,7 @@ export async function optimizeChunks(
296297
];
297298

298299
if (useRolldownChunks) {
299-
const { rolldown } = await import('rolldown');
300+
const { rolldown } = await loadRolldown();
300301
bundle = await rolldown({
301302
input: mainFile,
302303
plugins,

‎packages/angular/build/src/builders/library/pipeline/bundler.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,8 @@
88

99
import assert from 'node:assert';
1010
import path from 'node:path';
11-
import {
12-
type OutputChunk,
13-
type Plugin,
14-
type RolldownOutput,
15-
type RolldownPluginOption,
16-
rolldown,
17-
} from 'rolldown';
18-
import { dts } from 'rolldown-plugin-dts';
11+
import type { OutputChunk, Plugin, RolldownOutput, RolldownPluginOption } from 'rolldown';
12+
import { loadRolldown } from '../../../utils/load-rolldown';
1913
import { toPosixPath } from '../../../utils/path';
2014
import type { BundleResult, NormalizedEntryPoint, NormalizedLibraryOptions } from '../types';
2115
import {
@@ -307,6 +301,7 @@ async function executeMultiBundle(
307301
): Promise<MultiBundleOutput> {
308302
const isDts = extension === 'd.ts';
309303
const dir = isDts ? TYPES_OUTPUT_DIR : FESM_OUTPUT_DIR;
304+
const { rolldown } = await loadRolldown();
310305
const bundle = await rolldown({
311306
context: 'this',
312307
input,
@@ -379,6 +374,11 @@ async function bundleAllDts(
379374
return { filesToEmit: [], moduleIdsByBundle: new Map() };
380375
}
381376

377+
// Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating
378+
// `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`.
379+
await loadRolldown();
380+
const { dts } = await import('rolldown-plugin-dts');
381+
382382
const dtsSourcemap = options.declarationMap;
383383
// Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted
384384
// in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY';
10+
11+
let rolldownPromise: Promise<typeof import('rolldown')> | undefined;
12+
13+
/**
14+
* Dynamically loads the `rolldown` module while configuring its embedded
15+
* mimalloc v3 allocator to immediately purge freed arena memory.
16+
*
17+
* Rolldown's native binding statically links mimalloc v3, which defaults to a
18+
* 4-second arena purge delay (`MIMALLOC_PURGE_DELAY=1000` * `MIMALLOC_ARENA_PURGE_MULT=4`)
19+
* and only triggers purges cooperatively during subsequent allocations. In single-run
20+
* CLI builds where Rolldown is not invoked again after bundling completes, non-zero
21+
* purge delays prevent freed arena pages from being decommitted back to the OS,
22+
* leading to high peak RSS during post-bundle stages.
23+
*
24+
* Because mimalloc reads and caches environment options synchronously when the native
25+
* `.node` addon is first loaded (`mi_process_init` -> `_mi_options_init`), temporarily
26+
* setting `MIMALLOC_PURGE_DELAY=0` during the initial `import('rolldown')` call
27+
* configures Rolldown's allocator without leaving the environment variable set for
28+
* subsequent code or child processes.
29+
*
30+
* @returns A promise that resolves to the `rolldown` module namespace.
31+
*/
32+
export function loadRolldown(): Promise<typeof import('rolldown')> {
33+
if (!rolldownPromise) {
34+
const hasCustomPurgeDelay = Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY);
35+
if (!hasCustomPurgeDelay) {
36+
process.env[MIMALLOC_PURGE_DELAY] = '0';
37+
}
38+
39+
rolldownPromise = import('rolldown').finally(() => {
40+
if (!hasCustomPurgeDelay) {
41+
delete process.env[MIMALLOC_PURGE_DELAY];
42+
}
43+
});
44+
}
45+
46+
return rolldownPromise;
47+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY';
10+
11+
describe('loadRolldown', () => {
12+
const originalEnvValue = process.env[MIMALLOC_PURGE_DELAY];
13+
14+
function getFreshLoadRolldown(): typeof import('./load-rolldown') {
15+
delete require.cache[require.resolve('./load-rolldown')];
16+
17+
return require('./load-rolldown');
18+
}
19+
20+
afterEach(() => {
21+
if (originalEnvValue !== undefined) {
22+
process.env[MIMALLOC_PURGE_DELAY] = originalEnvValue;
23+
} else {
24+
delete process.env[MIMALLOC_PURGE_DELAY];
25+
}
26+
delete require.cache[require.resolve('./load-rolldown')];
27+
});
28+
29+
it('loads rolldown and cleans up temporary MIMALLOC_PURGE_DELAY when unset', async () => {
30+
delete process.env[MIMALLOC_PURGE_DELAY];
31+
const { loadRolldown } = getFreshLoadRolldown();
32+
33+
const promise1 = loadRolldown();
34+
expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('0');
35+
36+
const promise2 = loadRolldown();
37+
expect(promise1).toBe(promise2);
38+
39+
const rolldownModule = await promise1;
40+
expect(typeof rolldownModule.rolldown).toBe('function');
41+
expect(Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY)).toBeFalse();
42+
});
43+
44+
it('preserves user-configured MIMALLOC_PURGE_DELAY when already set', async () => {
45+
process.env[MIMALLOC_PURGE_DELAY] = '1000';
46+
const { loadRolldown } = getFreshLoadRolldown();
47+
48+
const promise = loadRolldown();
49+
expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000');
50+
51+
const rolldownModule = await promise;
52+
expect(typeof rolldownModule.rolldown).toBe('function');
53+
expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000');
54+
});
55+
});

0 commit comments

Comments
 (0)