Skip to content

Commit f249e7e

Browse files
committed
perf(@angular/cli): enable Node.js compile code cache when available
The Angular CLI will now enable the Node.js compile cache when available for use. Node.js v22.8 and higher currently provide support for this feature. The compile cache stores the v8 intermediate forms of JavaScript code for the Angular CLI itself. This provides a speed up to initialization on subsequent uses the Angular CLI. The Node.js cache is stored in a temporary directory in a globally accessible location so that all Node.js instances of a compatible version can share the cache. The code cache can be disabled if preferred via `NODE_DISABLE_COMPILE_CACHE=1`. Based on initial profiling, this change provides an ~6% production build time improvement for a newly generated project once the cache is available. ``` Benchmark 1: NODE_DISABLE_COMPILE_CACHE=1 node ./node_modules/.bin/ng build Time (mean ± σ): 2.617 s ± 0.016 s [User: 3.795 s, System: 1.284 s] Range (min … max): 2.597 s … 2.640 s 10 runs Benchmark 2: node ./node_modules/.bin/ng build Time (mean ± σ): 2.475 s ± 0.017 s [User: 3.555 s, System: 1.354 s] Range (min … max): 2.454 s … 2.510 s 10 runs Summary node ./node_modules/.bin/ng build ran 1.06 ± 0.01 times faster than NODE_DISABLE_COMPILE_CACHE=1 node ./node_modules/.bin/ng build ```
1 parent ea4b99b commit f249e7e

File tree

8 files changed

+81
-37
lines changed

8 files changed

+81
-37
lines changed

packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts

+3-8
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { CompilerOptions } from '@angular/compiler-cli';
1010
import type { PartialMessage } from 'esbuild';
1111
import { createRequire } from 'node:module';
1212
import { MessageChannel } from 'node:worker_threads';
13-
import Piscina from 'piscina';
1413
import type { SourceFile } from 'typescript';
14+
import { WorkerPool } from '../../../utils/worker-pool';
1515
import type { AngularHostOptions } from '../angular-host';
1616
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
1717

@@ -24,23 +24,18 @@ import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-c
2424
* main Node.js CLI process memory settings with large application code sizes.
2525
*/
2626
export class ParallelCompilation extends AngularCompilation {
27-
readonly #worker: Piscina;
27+
readonly #worker: WorkerPool;
2828

2929
constructor(readonly jit: boolean) {
3030
super();
3131

3232
// TODO: Convert to import.meta usage during ESM transition
3333
const localRequire = createRequire(__filename);
3434

35-
this.#worker = new Piscina({
36-
minThreads: 1,
35+
this.#worker = new WorkerPool({
3736
maxThreads: 1,
3837
idleTimeout: Infinity,
39-
// Web containers do not support transferable objects with receiveOnMessagePort which
40-
// is used when the Atomics based wait loop is enable.
41-
useAtomics: !process.versions.webcontainer,
4238
filename: localRequire.resolve('./parallel-worker'),
43-
recordTiming: false,
4439
});
4540
}
4641

packages/angular/build/src/tools/esbuild/i18n-inliner.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import assert from 'node:assert';
10-
import Piscina from 'piscina';
10+
import { WorkerPool } from '../../utils/worker-pool';
1111
import { BuildOutputFile, BuildOutputFileType } from './bundler-context';
1212
import { createOutputFile } from './utils';
1313

@@ -33,7 +33,7 @@ export interface I18nInlinerOptions {
3333
* localize function (`$localize`).
3434
*/
3535
export class I18nInliner {
36-
#workerPool: Piscina;
36+
#workerPool: WorkerPool;
3737
readonly #localizeFiles: ReadonlyMap<string, Blob>;
3838
readonly #unmodifiedFiles: Array<BuildOutputFile>;
3939
readonly #fileToType = new Map<string, BuildOutputFileType>();
@@ -88,7 +88,7 @@ export class I18nInliner {
8888

8989
this.#localizeFiles = files;
9090

91-
this.#workerPool = new Piscina({
91+
this.#workerPool = new WorkerPool({
9292
filename: require.resolve('./i18n-inliner-worker'),
9393
maxThreads,
9494
// Extract options to ensure only the named options are serialized and sent to the worker
@@ -97,7 +97,6 @@ export class I18nInliner {
9797
shouldOptimize: options.shouldOptimize,
9898
files,
9999
},
100-
recordTiming: false,
101100
});
102101
}
103102

packages/angular/build/src/tools/esbuild/javascript-transformer.ts

+4-8
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { createHash } from 'node:crypto';
1010
import { readFile } from 'node:fs/promises';
11-
import Piscina from 'piscina';
11+
import { WorkerPool } from '../../utils/worker-pool';
1212
import { Cache } from './cache';
1313

1414
/**
@@ -29,7 +29,7 @@ export interface JavaScriptTransformerOptions {
2929
* and advanced optimizations.
3030
*/
3131
export class JavaScriptTransformer {
32-
#workerPool: Piscina | undefined;
32+
#workerPool: WorkerPool | undefined;
3333
#commonOptions: Required<JavaScriptTransformerOptions>;
3434
#fileCacheKeyBase: Uint8Array;
3535

@@ -54,14 +54,10 @@ export class JavaScriptTransformer {
5454
this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8');
5555
}
5656

57-
#ensureWorkerPool(): Piscina {
58-
this.#workerPool ??= new Piscina({
57+
#ensureWorkerPool(): WorkerPool {
58+
this.#workerPool ??= new WorkerPool({
5959
filename: require.resolve('./javascript-transformer-worker'),
60-
minThreads: 1,
6160
maxThreads: this.maxThreads,
62-
// Shutdown idle threads after 1 second of inactivity
63-
idleTimeout: 1000,
64-
recordTiming: false,
6561
});
6662

6763
return this.#workerPool;

packages/angular/build/src/tools/sass/sass-service.ts

+4-11
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import assert from 'node:assert';
1010
import { fileURLToPath, pathToFileURL } from 'node:url';
1111
import { MessageChannel } from 'node:worker_threads';
12-
import { Piscina } from 'piscina';
1312
import type {
1413
CanonicalizeContext,
1514
CompileResult,
@@ -22,6 +21,7 @@ import type {
2221
StringOptions,
2322
} from 'sass';
2423
import { maxWorkers } from '../../utils/environment-options';
24+
import { WorkerPool } from '../../utils/worker-pool';
2525

2626
// Polyfill Symbol.dispose if not present
2727
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -84,24 +84,17 @@ interface RenderResponseMessage {
8484
* the worker which can be up to two times faster than the asynchronous variant.
8585
*/
8686
export class SassWorkerImplementation {
87-
#workerPool: Piscina | undefined;
87+
#workerPool: WorkerPool | undefined;
8888

8989
constructor(
9090
private readonly rebase = false,
9191
readonly maxThreads = MAX_RENDER_WORKERS,
9292
) {}
9393

94-
#ensureWorkerPool(): Piscina {
95-
this.#workerPool ??= new Piscina({
94+
#ensureWorkerPool(): WorkerPool {
95+
this.#workerPool ??= new WorkerPool({
9696
filename: require.resolve('./worker'),
97-
minThreads: 1,
9897
maxThreads: this.maxThreads,
99-
// Web containers do not support transferable objects with receiveOnMessagePort which
100-
// is used when the Atomics based wait loop is enable.
101-
useAtomics: !process.versions.webcontainer,
102-
// Shutdown idle threads after 1 second of inactivity
103-
idleTimeout: 1000,
104-
recordTiming: false,
10598
});
10699

107100
return this.#workerPool;

packages/angular/build/src/typings.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@
1717
declare module 'esbuild' {
1818
export * from 'esbuild-wasm';
1919
}
20+
21+
/**
22+
* Augment the Node.js module builtin types to support the v22.8+ compile cache functions
23+
*/
24+
declare module 'node:module' {
25+
function getCompileCacheDir(): string | undefined;
26+
}

packages/angular/build/src/utils/server-rendering/prerender.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import { readFile } from 'node:fs/promises';
1010
import { extname, join, posix } from 'node:path';
1111
import { pathToFileURL } from 'node:url';
12-
import Piscina from 'piscina';
1312
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1413
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
1514
import { urlJoin } from '../url';
15+
import { WorkerPool } from '../worker-pool';
1616
import type { RenderWorkerData } from './render-worker';
1717
import type {
1818
RoutersExtractorWorkerResult,
@@ -188,7 +188,7 @@ async function renderPages(
188188
workerExecArgv.push('--enable-source-maps');
189189
}
190190

191-
const renderWorker = new Piscina({
191+
const renderWorker = new WorkerPool({
192192
filename: require.resolve('./render-worker'),
193193
maxThreads: Math.min(allRoutes.size, maxThreads),
194194
workerData: {
@@ -197,7 +197,6 @@ async function renderPages(
197197
assetFiles: assetFilesForWorker,
198198
} as RenderWorkerData,
199199
execArgv: workerExecArgv,
200-
recordTiming: false,
201200
});
202201

203202
try {
@@ -286,7 +285,7 @@ async function getAllRoutes(
286285
workerExecArgv.push('--enable-source-maps');
287286
}
288287

289-
const renderWorker = new Piscina({
288+
const renderWorker = new WorkerPool({
290289
filename: require.resolve('./routes-extractor-worker'),
291290
maxThreads: 1,
292291
workerData: {
@@ -295,7 +294,6 @@ async function getAllRoutes(
295294
assetFiles: assetFilesForWorker,
296295
} as RoutesExtractorWorkerData,
297296
execArgv: workerExecArgv,
298-
recordTiming: false,
299297
});
300298

301299
const errors: string[] = [];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
import { getCompileCacheDir } from 'node:module';
10+
import { Piscina } from 'piscina';
11+
12+
export type WorkerPoolOptions = ConstructorParameters<typeof Piscina>[0];
13+
14+
export class WorkerPool extends Piscina {
15+
constructor(options: WorkerPoolOptions) {
16+
const piscinaOptions: WorkerPoolOptions = {
17+
minThreads: 1,
18+
idleTimeout: 1000,
19+
// Web containers do not support transferable objects with receiveOnMessagePort which
20+
// is used when the Atomics based wait loop is enable.
21+
useAtomics: !process.versions.webcontainer,
22+
recordTiming: false,
23+
...options,
24+
};
25+
26+
// Enable compile code caching if enabled for the main process (only exists on Node.js v22.8+).
27+
// Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work
28+
// well with Bazel's hermeticity requirements.
29+
const compileCacheDirectory = process.env['RUNFILES'] ? undefined : getCompileCacheDir?.();
30+
if (compileCacheDirectory) {
31+
if (typeof piscinaOptions.env === 'object') {
32+
piscinaOptions.env['NODE_COMPILE_CACHE'] = compileCacheDirectory;
33+
} else {
34+
// Default behavior of `env` option is to copy current process values
35+
piscinaOptions.env = {
36+
...process.env,
37+
'NODE_COMPILE_CACHE': compileCacheDirectory,
38+
};
39+
}
40+
}
41+
42+
super(piscinaOptions);
43+
}
44+
}

packages/angular/cli/bin/bootstrap.js

+13-1
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,16 @@
1818
* range.
1919
*/
2020

21-
import('../lib/init.js');
21+
// Enable on-disk code caching if available (Node.js 22.8+)
22+
// Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work
23+
// well with Bazel's hermeticity requirements.
24+
if (!process.env['RUNFILES']) {
25+
try {
26+
const { enableCompileCache } = require('node:module');
27+
28+
enableCompileCache?.();
29+
} catch {}
30+
}
31+
32+
// Initialize the Angular CLI
33+
void import('../lib/init.js');

0 commit comments

Comments
 (0)