Skip to content

Commit

Permalink
perf(@angular/cli): enable Node.js compile code cache when available
Browse files Browse the repository at this point in the history
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
```
  • Loading branch information
clydin committed Sep 18, 2024
1 parent ea4b99b commit f249e7e
Show file tree
Hide file tree
Showing 8 changed files with 81 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { CompilerOptions } from '@angular/compiler-cli';
import type { PartialMessage } from 'esbuild';
import { createRequire } from 'node:module';
import { MessageChannel } from 'node:worker_threads';
import Piscina from 'piscina';
import type { SourceFile } from 'typescript';
import { WorkerPool } from '../../../utils/worker-pool';
import type { AngularHostOptions } from '../angular-host';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';

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

constructor(readonly jit: boolean) {
super();

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

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

Expand Down
7 changes: 3 additions & 4 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

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

Expand All @@ -33,7 +33,7 @@ export interface I18nInlinerOptions {
* localize function (`$localize`).
*/
export class I18nInliner {
#workerPool: Piscina;
#workerPool: WorkerPool;
readonly #localizeFiles: ReadonlyMap<string, Blob>;
readonly #unmodifiedFiles: Array<BuildOutputFile>;
readonly #fileToType = new Map<string, BuildOutputFileType>();
Expand Down Expand Up @@ -88,7 +88,7 @@ export class I18nInliner {

this.#localizeFiles = files;

this.#workerPool = new Piscina({
this.#workerPool = new WorkerPool({
filename: require.resolve('./i18n-inliner-worker'),
maxThreads,
// Extract options to ensure only the named options are serialized and sent to the worker
Expand All @@ -97,7 +97,6 @@ export class I18nInliner {
shouldOptimize: options.shouldOptimize,
files,
},
recordTiming: false,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

/**
Expand All @@ -29,7 +29,7 @@ export interface JavaScriptTransformerOptions {
* and advanced optimizations.
*/
export class JavaScriptTransformer {
#workerPool: Piscina | undefined;
#workerPool: WorkerPool | undefined;
#commonOptions: Required<JavaScriptTransformerOptions>;
#fileCacheKeyBase: Uint8Array;

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

#ensureWorkerPool(): Piscina {
this.#workerPool ??= new Piscina({
#ensureWorkerPool(): WorkerPool {
this.#workerPool ??= new WorkerPool({
filename: require.resolve('./javascript-transformer-worker'),
minThreads: 1,
maxThreads: this.maxThreads,
// Shutdown idle threads after 1 second of inactivity
idleTimeout: 1000,
recordTiming: false,
});

return this.#workerPool;
Expand Down
15 changes: 4 additions & 11 deletions packages/angular/build/src/tools/sass/sass-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import assert from 'node:assert';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { MessageChannel } from 'node:worker_threads';
import { Piscina } from 'piscina';
import type {
CanonicalizeContext,
CompileResult,
Expand All @@ -22,6 +21,7 @@ import type {
StringOptions,
} from 'sass';
import { maxWorkers } from '../../utils/environment-options';
import { WorkerPool } from '../../utils/worker-pool';

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

constructor(
private readonly rebase = false,
readonly maxThreads = MAX_RENDER_WORKERS,
) {}

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

return this.#workerPool;
Expand Down
7 changes: 7 additions & 0 deletions packages/angular/build/src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@
declare module 'esbuild' {
export * from 'esbuild-wasm';
}

/**
* Augment the Node.js module builtin types to support the v22.8+ compile cache functions
*/
declare module 'node:module' {
function getCompileCacheDir(): string | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import { readFile } from 'node:fs/promises';
import { extname, join, posix } from 'node:path';
import { pathToFileURL } from 'node:url';
import Piscina from 'piscina';
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
import { urlJoin } from '../url';
import { WorkerPool } from '../worker-pool';
import type { RenderWorkerData } from './render-worker';
import type {
RoutersExtractorWorkerResult,
Expand Down Expand Up @@ -188,7 +188,7 @@ async function renderPages(
workerExecArgv.push('--enable-source-maps');
}

const renderWorker = new Piscina({
const renderWorker = new WorkerPool({
filename: require.resolve('./render-worker'),
maxThreads: Math.min(allRoutes.size, maxThreads),
workerData: {
Expand All @@ -197,7 +197,6 @@ async function renderPages(
assetFiles: assetFilesForWorker,
} as RenderWorkerData,
execArgv: workerExecArgv,
recordTiming: false,
});

try {
Expand Down Expand Up @@ -286,7 +285,7 @@ async function getAllRoutes(
workerExecArgv.push('--enable-source-maps');
}

const renderWorker = new Piscina({
const renderWorker = new WorkerPool({
filename: require.resolve('./routes-extractor-worker'),
maxThreads: 1,
workerData: {
Expand All @@ -295,7 +294,6 @@ async function getAllRoutes(
assetFiles: assetFilesForWorker,
} as RoutesExtractorWorkerData,
execArgv: workerExecArgv,
recordTiming: false,
});

const errors: string[] = [];
Expand Down
44 changes: 44 additions & 0 deletions packages/angular/build/src/utils/worker-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* @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
*/

import { getCompileCacheDir } from 'node:module';
import { Piscina } from 'piscina';

export type WorkerPoolOptions = ConstructorParameters<typeof Piscina>[0];

export class WorkerPool extends Piscina {
constructor(options: WorkerPoolOptions) {
const piscinaOptions: WorkerPoolOptions = {
minThreads: 1,
idleTimeout: 1000,
// Web containers do not support transferable objects with receiveOnMessagePort which
// is used when the Atomics based wait loop is enable.
useAtomics: !process.versions.webcontainer,
recordTiming: false,
...options,
};

// Enable compile code caching if enabled for the main process (only exists on Node.js v22.8+).
// Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work
// well with Bazel's hermeticity requirements.
const compileCacheDirectory = process.env['RUNFILES'] ? undefined : getCompileCacheDir?.();
if (compileCacheDirectory) {
if (typeof piscinaOptions.env === 'object') {
piscinaOptions.env['NODE_COMPILE_CACHE'] = compileCacheDirectory;
} else {
// Default behavior of `env` option is to copy current process values
piscinaOptions.env = {
...process.env,
'NODE_COMPILE_CACHE': compileCacheDirectory,
};
}
}

super(piscinaOptions);
}
}
14 changes: 13 additions & 1 deletion packages/angular/cli/bin/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@
* range.
*/

import('../lib/init.js');
// Enable on-disk code caching if available (Node.js 22.8+)
// Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work
// well with Bazel's hermeticity requirements.
if (!process.env['RUNFILES']) {
try {
const { enableCompileCache } = require('node:module');

enableCompileCache?.();
} catch {}
}

// Initialize the Angular CLI
void import('../lib/init.js');

1 comment on commit f249e7e

@Zionsammy
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Please sign in to comment.