From f249e7e856bf16e8c5f154fdb8aff36421649a1b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 4 Sep 2024 21:36:21 -0400 Subject: [PATCH] perf(@angular/cli): enable Node.js compile code cache when available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``` --- .../compilation/parallel-compilation.ts | 11 ++--- .../build/src/tools/esbuild/i18n-inliner.ts | 7 ++- .../tools/esbuild/javascript-transformer.ts | 12 ++--- .../build/src/tools/sass/sass-service.ts | 15 ++----- packages/angular/build/src/typings.d.ts | 7 +++ .../src/utils/server-rendering/prerender.ts | 8 ++-- .../angular/build/src/utils/worker-pool.ts | 44 +++++++++++++++++++ packages/angular/cli/bin/bootstrap.js | 14 +++++- 8 files changed, 81 insertions(+), 37 deletions(-) create mode 100644 packages/angular/build/src/utils/worker-pool.ts diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts index 225aa9eb1fa3..434ca958b4d1 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -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'; @@ -24,7 +24,7 @@ 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(); @@ -32,15 +32,10 @@ export class ParallelCompilation extends AngularCompilation { // 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, }); } diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index b1714efd459f..3075595aee87 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -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'; @@ -33,7 +33,7 @@ export interface I18nInlinerOptions { * localize function (`$localize`). */ export class I18nInliner { - #workerPool: Piscina; + #workerPool: WorkerPool; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; readonly #fileToType = new Map(); @@ -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 @@ -97,7 +97,6 @@ export class I18nInliner { shouldOptimize: options.shouldOptimize, files, }, - recordTiming: false, }); } diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index ce4b0aa91356..f8445ccbef03 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -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'; /** @@ -29,7 +29,7 @@ export interface JavaScriptTransformerOptions { * and advanced optimizations. */ export class JavaScriptTransformer { - #workerPool: Piscina | undefined; + #workerPool: WorkerPool | undefined; #commonOptions: Required; #fileCacheKeyBase: Uint8Array; @@ -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; diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts index 3e54e0ec1520..2df6f85f7a52 100644 --- a/packages/angular/build/src/tools/sass/sass-service.ts +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -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, @@ -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 @@ -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; diff --git a/packages/angular/build/src/typings.d.ts b/packages/angular/build/src/typings.d.ts index c784b3c4220a..7eaadcafc536 100644 --- a/packages/angular/build/src/typings.d.ts +++ b/packages/angular/build/src/typings.d.ts @@ -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; +} diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 1b53fd9ca6d9..55fec73b5210 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -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, @@ -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: { @@ -197,7 +197,6 @@ async function renderPages( assetFiles: assetFilesForWorker, } as RenderWorkerData, execArgv: workerExecArgv, - recordTiming: false, }); try { @@ -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: { @@ -295,7 +294,6 @@ async function getAllRoutes( assetFiles: assetFilesForWorker, } as RoutesExtractorWorkerData, execArgv: workerExecArgv, - recordTiming: false, }); const errors: string[] = []; diff --git a/packages/angular/build/src/utils/worker-pool.ts b/packages/angular/build/src/utils/worker-pool.ts new file mode 100644 index 000000000000..ca35f7edb46b --- /dev/null +++ b/packages/angular/build/src/utils/worker-pool.ts @@ -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[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); + } +} diff --git a/packages/angular/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js index 96b978296dcc..1282f906aef2 100644 --- a/packages/angular/cli/bin/bootstrap.js +++ b/packages/angular/cli/bin/bootstrap.js @@ -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');