Skip to content

Commit fbe6bcf

Browse files
committed
perf(@angular/build): consolidate build worker pools with shared router
Unify isolated per-subsystem worker pools (JavaScriptTransformer, SassService, I18nInliner) into a single singleton WorkerPool routed via dynamic task dispatching (shared-worker-router.ts). - Reduce active worker threads by 66.7% (24 -> 8 threads), eliminating thread thrashing and reducing kernel system CPU time by 42.3%. - Implement zero-copy transferable file and translation Blobs, avoiding IPC serialization overhead. - Add bounded Map caching (fileDataCache, deserializedTranslations) with fileKey and translationKey in the i18n inliner worker isolate, achieving 100% AST and 99.8% translation cache hit rates across locales without memory leaks. - Standardize discriminated union tasks (InlineI18nFileTask, InlineI18nCodeTask) with zero-allocation synchronous dispatching. | Metric | Baseline (`main`) | Consolidated (`perf-build-singleton-shared-worker-pool`) | Delta / Speedup | | :--- | :--- | :--- | :--- | | Active Worker Threads | 24 threads | 8 threads | -66.7% threads (-16 threads) | | Cold Build Duration (mean) | 1,182.0 ms | 1,180.5 ms | 1.00x faster (-0.1%) | | Cold Build Duration (min / max) | 1,151.7 ms / 1,228.2 ms | 1,156.8 ms / 1,224.2 ms | +5.1 ms / -4.0 ms | | P95 Build Latency | 1,228.2 ms | 1,224.2 ms | -0.3% (-4.0 ms) | | Throughput | 2,962.4 ops/sec | 2,966.1 ops/sec | +0.1% (+3.7 ops/sec) | | I18n Inlining Duration (Pure mean) | 696.0 ms | 217.9 ms | 3.19x faster (-68.7%) | | I18n Inlining (min / max) | 678.7 ms / 730.5 ms | 196.4 ms / 243.2 ms | 3.46x / 3.00x faster | | AST Cache Hit Rate (Subsequent Locales) | 0.0% (0 / 2,000) | 100.0% (2,000 / 2,000) | 100.0% cache hit rate | | Translation Cache Hit Rate | 0.0% (0 / 2,500) | 99.8% (2,495 / 2,500) | 99.8% cache hit rate | | Process RSS Delta | +2,786.0 MB | +1,519.7 MB | -45.5% (-1,266.3 MB saved) | | Final Process RSS | 2,842.1 MB | 1,576.4 MB | -44.5% (-1,265.7 MB saved) | | Kernel System CPU | 2,355.1 ms | 1,358.3 ms | -42.3% (1.73x less kernel CPU) | | Total CPU (User + Kernel) | 16,198.8 ms | 10,808.7 ms | -33.3% (1.50x CPU efficiency) | | Metric | Baseline (`main`) | Consolidated (`perf-build-singleton-shared-worker-pool`) | Delta / Speedup | | :--- | :--- | :--- | :--- | | Active Worker Threads | 24 threads | 8 threads | -66.7% threads (-16 threads) | | E2E Build Duration (mean) | 5,267.1 ms | 5,651.3 ms | +7.3% (+384.2 ms) | | E2E Build Duration (min / max) | 5,187.5 ms / 5,351.7 ms | 5,595.6 ms / 5,737.8 ms | +408.1 ms / +386.1 ms | | P95 Build Latency | 5,351.7 ms | 5,737.8 ms | +7.2% (+386.1 ms) | | Process RSS Delta | +2,215.3 MB | +2,124.3 MB | -4.1% (-91.0 MB saved) | | Final Process RSS | 2,296.2 MB | 2,204.7 MB | -4.0% (-91.5 MB saved) | | Kernel System CPU | 3,087.4 ms | 3,000.0 ms | -2.8% (1.03x less kernel CPU) | | Total CPU (User + Kernel) | 19,968.8 ms | 17,906.2 ms | -10.3% (-2,062.6 ms CPU saved) | > **Note on Concurrency Bounds**: On high-core machines, baseline's 24 unthrottled concurrent threads across 3 independent pools allow concurrent execution of different compiler phases, whereas the consolidated 8-thread singleton enforces strict concurrency bounds, trading ~380 ms wall-clock time for -2.06s lower total CPU work and lower peak memory. This is particularly important for lower-end machines and resource-constrained CI/container environments to avoid severe CPU starvation, thread thrashing, and out-of-memory crashes.
1 parent 11ab5eb commit fbe6bcf

9 files changed

Lines changed: 692 additions & 165 deletions

File tree

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

Lines changed: 199 additions & 66 deletions
Large diffs are not rendered by default.

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

Lines changed: 94 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import assert from 'node:assert';
1010
import { extname, join } from 'node:path';
1111
import { serialize } from 'node:v8';
1212
import { calculateHash, createContentHash, initializeHash } from '../../utils/hash';
13-
import { WorkerPool } from '../../utils/worker-pool';
13+
import { WorkerPool, getSharedBuildWorkerPool } from '../../utils/worker-pool';
1414
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
1515
import { type Cache, type PersistentCacheStore, createPersistentCacheStore } from './cache';
16+
import type { InlineCodeResult, InlineDiagnosticMessage } from './i18n-inliner-worker';
1617

1718
/**
1819
* A keyword used to indicate if a JavaScript file may require inlining of translations.
@@ -116,6 +117,33 @@ interface CacheCheckItem {
116117
cachedResult: Promise<TransformedFileResult | null>;
117118
}
118119

120+
export interface InlineTemplateUpdateResult {
121+
code: string;
122+
errors: string[];
123+
warnings: string[];
124+
}
125+
126+
/**
127+
* Partitions diagnostic messages into error and warning strings.
128+
*/
129+
function partitionDiagnostics(messages: readonly InlineDiagnosticMessage[]): {
130+
errors: string[];
131+
warnings: string[];
132+
} {
133+
const errors: string[] = [];
134+
const warnings: string[] = [];
135+
136+
for (const message of messages) {
137+
if (message.type === 'error') {
138+
errors.push(message.message);
139+
} else {
140+
warnings.push(message.message);
141+
}
142+
}
143+
144+
return { errors, warnings };
145+
}
146+
119147
/**
120148
* A class that performs i18n translation inlining of JavaScript code.
121149
* A worker pool is used to distribute the transformation actions and allow
@@ -128,27 +156,27 @@ export class I18nInliner {
128156
#cacheStore: PersistentCacheStore | undefined;
129157
#cache: Cache<TransformedFileResult> | undefined;
130158
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
159+
readonly #filesBlobs: Map<string, Blob>;
131160
readonly #unmodifiedFiles: Array<BuildOutputFile>;
132161

133162
constructor(
134163
private readonly options: I18nInlinerOptions,
135-
maxThreads?: number,
164+
_maxThreads?: number,
136165
) {
137166
this.#unmodifiedFiles = [];
138-
const { outputFiles, shouldOptimize, missingTranslation, translations } = options;
167+
const { outputFiles } = options;
139168
const files = new Map<string, BuildOutputFile>();
140169

141170
const pendingMaps = [];
142171
for (const file of outputFiles) {
143172
if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) {
144173
// Skip also the server entry-point.
145-
// Skip stats and similar files.
174+
this.#unmodifiedFiles.push(file);
146175
continue;
147176
}
148177

149178
const fileExtension = extname(file.path);
150179
if (fileExtension === '.js' || fileExtension === '.mjs') {
151-
// Check if localizations are present
152180
const contentBuffer = Buffer.isBuffer(file.contents)
153181
? file.contents
154182
: Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength);
@@ -179,24 +207,11 @@ export class I18nInliner {
179207
}
180208

181209
this.#localizeFiles = files;
210+
this.#filesBlobs = new Map<string, Blob>(
211+
Array.from(files, ([name, file]) => [name, new Blob([file.contents])]),
212+
);
182213

183-
this.#workerPool = new WorkerPool({
184-
filename: require.resolve('./i18n-inliner-worker'),
185-
maxThreads,
186-
// Extract options to ensure only the named options are serialized and sent to the worker
187-
workerData: {
188-
missingTranslation,
189-
shouldOptimize,
190-
translations,
191-
// A Blob is an immutable data structure that allows sharing the data between workers
192-
// without copying until the data is actually used within a Worker. This is useful here
193-
// since each file may not actually be processed in each Worker and the Blob avoids
194-
// unneeded repeat copying of potentially large JavaScript files.
195-
files: new Map<string, Blob>(
196-
Array.from(files, ([name, file]) => [name, new Blob([file.contents])]),
197-
),
198-
},
199-
});
214+
this.#workerPool = getSharedBuildWorkerPool();
200215
}
201216

202217
/**
@@ -238,9 +253,15 @@ export class I18nInliner {
238253
// Pre-calculate cache key bases and serialized Blobs for each locale in this window
239254
const localeCacheBases = new Map<string, string>();
240255
const localeBlobs = new Map<string, Blob | undefined>();
256+
const localeKeys = new Map<string, string | undefined>();
241257

242258
for (const { locale, translation, translationIntegrity } of windowLocales) {
243259
localeBlobs.set(locale, serializeTranslation(translation));
260+
localeKeys.set(
261+
locale,
262+
translationIntegrity ??
263+
(translation ? calculateHash(JSON.stringify(translation)) : undefined),
264+
);
244265

245266
if (this.#cacheStore) {
246267
localeCacheBases.set(
@@ -304,7 +325,7 @@ export class I18nInliner {
304325
// Group uncached items by filename for this window
305326
const uncachedByFile = new Map<
306327
string,
307-
Array<{ locale: string; cacheKey?: string; translation?: Blob }>
328+
Array<{ locale: string; cacheKey?: string; translation?: Blob; translationKey?: string }>
308329
>();
309330

310331
for (const item of resolvedChecks) {
@@ -322,6 +343,7 @@ export class I18nInliner {
322343
locale: item.locale,
323344
cacheKey: item.cacheKey,
324345
translation: localeBlobs.get(item.locale),
346+
translationKey: localeKeys.get(item.locale),
325347
});
326348
}
327349
}
@@ -362,13 +384,11 @@ export class I18nInliner {
362384
outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type));
363385
}
364386

365-
for (const message of fileResult.messages) {
366-
if (message.type === 'error') {
367-
errors.push(message.message);
368-
} else {
369-
warnings.push(message.message);
370-
}
371-
}
387+
const { errors: newErrors, warnings: newWarnings } = partitionDiagnostics(
388+
fileResult.messages,
389+
);
390+
errors.push(...newErrors);
391+
warnings.push(...newWarnings);
372392
}
373393
}
374394

@@ -386,7 +406,10 @@ export class I18nInliner {
386406
}
387407

388408
async #processUncachedBatches(
389-
uncachedByFile: Map<string, Array<{ locale: string; cacheKey?: string; translation?: Blob }>>,
409+
uncachedByFile: Map<
410+
string,
411+
Array<{ locale: string; cacheKey?: string; translation?: Blob; translationKey?: string }>
412+
>,
390413
localeCount: number,
391414
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
392415
activeLocales?: string[],
@@ -402,22 +425,32 @@ export class I18nInliner {
402425
const workerTasks: Promise<void>[] = [];
403426

404427
for (const [filename, entries] of uncachedByFile) {
428+
const file = this.#localizeFiles.get(filename);
429+
const fileBlob = this.#filesBlobs.get(filename);
430+
const mapBlob = this.#filesBlobs.get(filename + '.map');
431+
const fileKey = file ? `${filename}\0${file.hash}` : undefined;
405432
const ephemeral = isLastWindow && entries.length <= localesPerBatch;
433+
406434
for (let i = 0; i < entries.length; i += localesPerBatch) {
407435
const batchEntries = entries.slice(i, i + localesPerBatch);
408436
const task = (async () => {
409-
const batchResult = (await this.#workerPool.run(
410-
{
411-
filename,
412-
locales: batchEntries.map((e) => ({
413-
locale: e.locale,
414-
translation: e.translation,
415-
})),
416-
ephemeral,
417-
activeLocales,
418-
},
419-
{ name: 'inlineFileBatch' },
420-
)) as {
437+
const batchResult = (await this.#workerPool.run({
438+
tag: 'inline-i18n',
439+
action: 'inlineFileBatch',
440+
filename,
441+
fileBlob,
442+
fileKey,
443+
mapBlob,
444+
missingTranslation: this.options.missingTranslation,
445+
shouldOptimize: this.options.shouldOptimize,
446+
ephemeral,
447+
activeLocales,
448+
locales: batchEntries.map((e) => ({
449+
locale: e.locale,
450+
translation: e.translation,
451+
translationKey: e.translationKey,
452+
})),
453+
})) as {
421454
file: string;
422455
results: Array<TransformedFileResult & { locale: string }>;
423456
};
@@ -476,7 +509,7 @@ export class I18nInliner {
476509
translation: Record<string, unknown> | undefined,
477510
templateCode: string,
478511
templateId: string,
479-
): Promise<{ code: string; errors: string[]; warnings: string[] }> {
512+
): Promise<InlineTemplateUpdateResult> {
480513
const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD);
481514

482515
if (!hasLocalize) {
@@ -487,25 +520,19 @@ export class I18nInliner {
487520
};
488521
}
489522

490-
const { output, messages } = await this.#workerPool.run(
491-
{
492-
code: templateCode,
493-
filename: templateId,
494-
locale,
495-
translation: serializeTranslation(translation),
496-
},
497-
{ name: 'inlineCode' },
498-
);
523+
const { output, messages } = (await this.#workerPool.run({
524+
tag: 'inline-i18n',
525+
action: 'inlineCode',
526+
code: templateCode,
527+
filename: templateId,
528+
locale,
529+
translation: serializeTranslation(translation),
530+
translationKey: translation ? calculateHash(JSON.stringify(translation)) : undefined,
531+
missingTranslation: this.options.missingTranslation,
532+
shouldOptimize: this.options.shouldOptimize,
533+
})) as InlineCodeResult;
499534

500-
const errors: string[] = [];
501-
const warnings: string[] = [];
502-
for (const message of messages) {
503-
if (message.type === 'error') {
504-
errors.push(message.message);
505-
} else {
506-
warnings.push(message.message);
507-
}
508-
}
535+
const { errors, warnings } = partitionDiagnostics(messages);
509536

510537
return {
511538
code: output,
@@ -519,7 +546,11 @@ export class I18nInliner {
519546
* @returns A void promise that resolves when closing is complete.
520547
*/
521548
async close(): Promise<void> {
522-
await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]);
549+
if (this.#workerPool !== getSharedBuildWorkerPool()) {
550+
await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]);
551+
} else {
552+
await this.#cacheStore?.close();
553+
}
523554
}
524555

525556
/**

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,25 @@ import {
2222
import { transform as transformWithOxc } from '../oxc/oxc-transform.js';
2323
import type { JavaScriptTransformerOptions } from './javascript-transformer';
2424

25-
interface JavaScriptTransformRequest {
25+
export interface JavaScriptTransformRequest {
2626
filename: string;
2727
data: string | Uint8Array;
2828
skipLinker?: boolean;
2929
sideEffects?: boolean;
3030
instrumentForCoverage?: boolean;
31+
sourcemap?: boolean;
32+
thirdPartySourcemaps?: boolean;
33+
advancedOptimizations?: boolean;
34+
jit?: boolean;
3135
}
3236

3337
interface TransformOptions extends Omit<JavaScriptTransformRequest, 'filename' | 'data'> {
3438
inputSourceMap?: EncodedSourceMap;
3539
isAlreadyStripped?: boolean;
40+
sourcemap?: boolean;
41+
thirdPartySourcemaps?: boolean;
42+
advancedOptimizations?: boolean;
43+
jit?: boolean;
3644
}
3745

3846
const {
@@ -92,10 +100,18 @@ async function instrumentCoverage(
92100
export default async function transformJavaScript(
93101
request: JavaScriptTransformRequest,
94102
): Promise<unknown> {
95-
const { filename, data, ...options } = request;
103+
const {
104+
filename,
105+
data,
106+
sourcemap: reqSourcemap = sourcemap,
107+
thirdPartySourcemaps: reqThirdPartySourcemaps = thirdPartySourcemaps,
108+
advancedOptimizations: reqAdvancedOptimizations = advancedOptimizations,
109+
jit: reqJit = jit,
110+
...options
111+
} = request;
96112

97113
const useInputSourcemap =
98-
sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
114+
reqSourcemap && (!!reqThirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
99115

100116
let textData: string;
101117
let inputSourceMap: EncodedSourceMap | undefined;
@@ -137,6 +153,10 @@ export default async function transformJavaScript(
137153

138154
const transformedData = await transformJavaScriptImpl(filename, textData, {
139155
...options,
156+
sourcemap: reqSourcemap,
157+
thirdPartySourcemaps: reqThirdPartySourcemaps,
158+
advancedOptimizations: reqAdvancedOptimizations,
159+
jit: reqJit,
140160
inputSourceMap,
141161
isAlreadyStripped,
142162
});
@@ -156,8 +176,13 @@ async function transformJavaScriptImpl(
156176
options: TransformOptions,
157177
): Promise<string> {
158178
const shouldLink = !options.skipLinker;
179+
const optSourcemap = options.sourcemap ?? sourcemap;
180+
const optThirdPartySourcemaps = options.thirdPartySourcemaps ?? thirdPartySourcemaps;
181+
const optAdvancedOptimizations = options.advancedOptimizations ?? advancedOptimizations;
182+
const optJit = options.jit ?? jit;
183+
159184
const useInputSourcemap =
160-
sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
185+
optSourcemap && (!!optThirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
161186

162187
let code = data;
163188
const maps: (DecodedSourceMap | EncodedSourceMap)[] = [];
@@ -191,7 +216,7 @@ async function transformJavaScriptImpl(
191216
relative: (_from: string, to: string) => to,
192217
} as never,
193218
logger: new ConsoleLogger(LogLevel.info),
194-
linkerJitMode: jit,
219+
linkerJitMode: optJit,
195220
// This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
196221
sourceMapping: false,
197222
}) as PluginItem,
@@ -206,16 +231,16 @@ async function transformJavaScriptImpl(
206231

207232
// Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass
208233
const oxcLink = shouldLink && !useBabelLinker;
209-
if (oxcLink || advancedOptimizations) {
234+
if (oxcLink || optAdvancedOptimizations) {
210235
const sideEffectFree = options.sideEffects === false;
211236
const safeAngularPackage =
212237
sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
213238
const topLevelSafeMode = !safeAngularPackage;
214239

215240
const result = transformWithOxc(filename, code, {
216241
link: oxcLink,
217-
jit,
218-
advancedOptimizations,
242+
jit: optJit,
243+
advancedOptimizations: optAdvancedOptimizations,
219244
sourcemap: useInputSourcemap,
220245
sideEffects: options.sideEffects,
221246
topLevelSafeMode,

0 commit comments

Comments
 (0)