Skip to content

Commit 970c127

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. ### Benchmark 1: Subsystem Benchmark (500 Components, 500 SCSS Stylesheets, 5 Locales / 3,500 operations, 5 iterations) | 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) | ### Benchmark 2: End-to-End `ng build` Application Benchmark (500 Components, 500 SCSS Stylesheets, 5 Locales, 5 iterations) | 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 204eafc commit 970c127

9 files changed

Lines changed: 603 additions & 151 deletions

File tree

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

Lines changed: 158 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import { parseSync, visitorKeys } from 'oxc-parser';
1717
/**
1818
* The options passed to the inliner for each file request
1919
*/
20-
interface InlineFileRequest {
20+
export interface InlineFileRequest {
2121
/**
2222
* The filename that should be processed. The data for the file is provided to the Worker
23-
* during Worker initialization.
23+
* during Worker initialization or per request.
2424
*/
2525
filename: string;
2626

@@ -35,12 +35,24 @@ interface InlineFileRequest {
3535
* reference instead of being copied into it for every request.
3636
*/
3737
translation?: Blob;
38+
39+
/**
40+
* Optional cache key uniquely identifying the translation messages for the locale.
41+
*/
42+
translationKey?: string;
43+
44+
missingTranslation?: 'error' | 'warning' | 'ignore';
45+
shouldOptimize?: boolean;
46+
fileBlob?: Blob;
47+
fileKey?: string;
48+
mapBlob?: Blob;
49+
files?: ReadonlyMap<string, Blob>;
3850
}
3951

4052
/**
4153
* The options passed to the inliner for each code request
4254
*/
43-
interface InlineCodeRequest {
55+
export interface InlineCodeRequest {
4456
/**
4557
* The code that should be processed.
4658
*/
@@ -62,12 +74,37 @@ interface InlineCodeRequest {
6274
* reference instead of being copied into it for every request.
6375
*/
6476
translation?: Blob;
77+
78+
/**
79+
* Optional cache key uniquely identifying the translation messages for the locale.
80+
*/
81+
translationKey?: string;
82+
83+
missingTranslation?: 'error' | 'warning' | 'ignore';
84+
shouldOptimize?: boolean;
85+
}
86+
87+
export interface InlineDiagnosticMessage {
88+
type: 'error' | 'warning';
89+
message: string;
90+
}
91+
92+
export interface InlineFileResult {
93+
file: string;
94+
code: string;
95+
map?: string;
96+
messages: InlineDiagnosticMessage[];
97+
}
98+
99+
export interface InlineCodeResult {
100+
output: string;
101+
messages: InlineDiagnosticMessage[];
65102
}
66103

67104
// Extract the application files and common options used for inline requests from the Worker context
68-
const { files, missingTranslation } = (workerData || {}) as {
69-
files: ReadonlyMap<string, Blob>;
70-
missingTranslation: 'error' | 'warning' | 'ignore';
105+
const { files, missingTranslation = 'ignore' } = (workerData || {}) as {
106+
files?: ReadonlyMap<string, Blob>;
107+
missingTranslation?: 'error' | 'warning' | 'ignore';
71108
};
72109

73110
/**
@@ -79,34 +116,67 @@ interface CachedFileData {
79116
}
80117

81118
/**
82-
* Cache of file data promises keyed by filename.
119+
* Maximum number of file AST and localization metadata entries cached in memory per worker isolate.
120+
* Bounded size prevents unbounded memory growth across watch rebuilds.
121+
*/
122+
const MAX_CACHED_FILES = 256;
123+
124+
/**
125+
* Maximum number of deserialized translation message tables cached in memory per worker isolate.
126+
*/
127+
const MAX_CACHED_TRANSLATIONS = 32;
128+
129+
/**
130+
* Cache of file data promises keyed by cache key (filename or file hash identifier).
83131
*/
84132
const fileDataCache = new Map<string, Promise<CachedFileData>>();
85133

86134
/**
87-
* Cache of deserialized translation messages keyed by locale.
135+
* Cache of deserialized translation messages keyed by locale or translation cache key.
88136
*/
89137
const deserializedTranslations = new Map<string, Promise<Record<string, unknown>>>();
90138

91139
/**
92140
* Retrieves the cached file data for a filename, loading and extracting it on the first request.
93141
*
94142
* @param filename The name of the file to load.
143+
* @param fileBlob Optional Blob containing the file data.
144+
* @param fileKey Optional cache key uniquely identifying the file content.
95145
* @returns The cached code and localization metadata.
96146
*/
97-
function getFileData(filename: string): Promise<CachedFileData> {
98-
let fileDataPromise = fileDataCache.get(filename);
147+
async function getFileData(
148+
filename: string,
149+
fileBlob?: Blob,
150+
fileKey?: string,
151+
): Promise<CachedFileData> {
152+
const cacheKey = fileKey ?? filename;
153+
let fileDataPromise = fileDataCache.get(cacheKey);
99154
if (!fileDataPromise) {
100-
fileDataPromise = (async () => {
101-
const data = files.get(filename);
102-
assert(data !== undefined, `Invalid inline request for file '${filename}'.`);
155+
const data = fileBlob ?? files?.get(filename);
156+
assert(data !== undefined, `Invalid inline request for file '${filename}'.`);
157+
158+
fileDataPromise = data
159+
.text()
160+
.then((code) => {
161+
const metadata = extractLocalizeMetadata(filename, code);
162+
163+
return { code, metadata };
164+
})
165+
.catch((error) => {
166+
if (fileDataCache.get(cacheKey) === fileDataPromise) {
167+
fileDataCache.delete(cacheKey);
168+
}
169+
throw error;
170+
});
103171

104-
const code = await data.text();
105-
const metadata = extractLocalizeMetadata(filename, code);
172+
if (fileDataCache.size >= MAX_CACHED_FILES) {
173+
const oldestKey = fileDataCache.keys().next().value;
174+
if (oldestKey !== undefined) {
175+
fileDataCache.delete(oldestKey);
176+
}
177+
}
106178

107-
return { code, metadata };
108-
})();
109-
fileDataCache.set(filename, fileDataPromise);
179+
fileDataCache.set(cacheKey, fileDataPromise);
110180
}
111181

112182
return fileDataPromise;
@@ -121,17 +191,32 @@ function getFileData(filename: string): Promise<CachedFileData> {
121191
function loadTranslation(
122192
request: InlineFileRequest | InlineCodeRequest,
123193
): Promise<Record<string, unknown>> | undefined {
124-
const { locale, translation } = request;
194+
const { translation, locale, translationKey } = request;
125195
if (!translation) {
126196
return undefined;
127197
}
128198

129-
let messagesPromise = deserializedTranslations.get(locale);
199+
const cacheKey = translationKey ? `${locale}\0${translationKey}` : locale;
200+
let messagesPromise = deserializedTranslations.get(cacheKey);
130201
if (!messagesPromise) {
131202
messagesPromise = translation
132203
.arrayBuffer()
133-
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, unknown>);
134-
deserializedTranslations.set(locale, messagesPromise);
204+
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, unknown>)
205+
.catch((error) => {
206+
if (deserializedTranslations.get(cacheKey) === messagesPromise) {
207+
deserializedTranslations.delete(cacheKey);
208+
}
209+
throw error;
210+
});
211+
212+
if (deserializedTranslations.size >= MAX_CACHED_TRANSLATIONS) {
213+
const oldestKey = deserializedTranslations.keys().next().value;
214+
if (oldestKey !== undefined) {
215+
deserializedTranslations.delete(oldestKey);
216+
}
217+
}
218+
219+
deserializedTranslations.set(cacheKey, messagesPromise);
135220
}
136221

137222
return messagesPromise;
@@ -144,14 +229,16 @@ function loadTranslation(
144229
* @param request An InlineRequest object representing the options for inlining
145230
* @returns An object containing the inlined file and optional map content.
146231
*/
147-
export default async function inlineFile(request: InlineFileRequest) {
148-
const { code, metadata } = await getFileData(request.filename);
232+
export default async function inlineFile(request: InlineFileRequest): Promise<InlineFileResult> {
233+
const { code, metadata } = await getFileData(request.filename, request.fileBlob, request.fileKey);
149234

150235
// Sourcemaps are parsed on demand per request rather than cached long-term to prevent
151236
// monotonic memory growth as a worker processes multiple files across the build.
152237
// When multi-locale batching is implemented, the sourcemap can be parsed once per batch and released
153238
// upon batch completion.
154-
const rawMap = await files.get(request.filename + '.map')?.text();
239+
const rawMap = request.mapBlob
240+
? await request.mapBlob.text()
241+
: await files?.get(request.filename + '.map')?.text();
155242
const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined;
156243

157244
const result = await inlineLocalize(
@@ -161,6 +248,7 @@ export default async function inlineFile(request: InlineFileRequest) {
161248
request.locale,
162249
await loadTranslation(request),
163250
request.filename,
251+
request.missingTranslation ?? missingTranslation,
164252
);
165253

166254
return {
@@ -178,7 +266,7 @@ export default async function inlineFile(request: InlineFileRequest) {
178266
* @param request An InlineRequest object representing the options for inlining
179267
* @returns An object containing the inlined code.
180268
*/
181-
export async function inlineCode(request: InlineCodeRequest) {
269+
export async function inlineCode(request: InlineCodeRequest): Promise<InlineCodeResult> {
182270
const metadata = extractLocalizeMetadata(request.filename, request.code);
183271
const result = await inlineLocalize(
184272
request.code,
@@ -187,6 +275,7 @@ export async function inlineCode(request: InlineCodeRequest) {
187275
request.locale,
188276
await loadTranslation(request),
189277
request.filename,
278+
request.missingTranslation ?? missingTranslation,
190279
);
191280

192281
return {
@@ -352,6 +441,41 @@ function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMe
352441
return { callSites, localeInsertSites, diagnostics };
353442
}
354443

444+
/**
445+
* Formats translated template parts and expressions into a JavaScript string
446+
* or template literal replacement.
447+
*/
448+
function formatReplacement(
449+
translatedParts: readonly string[],
450+
translatedSubstitutions: readonly number[],
451+
expressions: readonly { start: number; end: number }[],
452+
magicString: MagicString,
453+
): string {
454+
if (translatedSubstitutions.length === 0) {
455+
return JSON.stringify(translatedParts[0]);
456+
}
457+
458+
let replacement = '`';
459+
for (let i = 0; i < translatedParts.length; i++) {
460+
const escapedPart = JSON.stringify(translatedParts[i])
461+
.slice(1, -1)
462+
.replace(/\\"/g, '"')
463+
.replace(/`/g, '\\`')
464+
.replace(/\$\{/g, '\\${');
465+
replacement += escapedPart;
466+
467+
if (i < translatedSubstitutions.length) {
468+
const originalIndex = translatedSubstitutions[i];
469+
const expr = expressions[originalIndex];
470+
const exprCode = magicString.slice(expr.start, expr.end);
471+
replacement += '${' + exprCode + '}';
472+
}
473+
}
474+
replacement += '`';
475+
476+
return replacement;
477+
}
478+
355479
/**
356480
* Inlines translations into code using previously extracted localization metadata.
357481
*
@@ -370,6 +494,7 @@ async function inlineLocalize(
370494
locale: string,
371495
translation: Record<string, unknown> | undefined,
372496
filename: string,
497+
missingTranslationOption: 'error' | 'warning' | 'ignore' = missingTranslation ?? 'ignore',
373498
) {
374499
const magicString = new MagicString(code);
375500
const { Diagnostics, translate } = await loadLocalizeTools();
@@ -391,32 +516,15 @@ async function inlineLocalize(
391516
translation || {},
392517
callSite.messageParts,
393518
callSite.expressions.map((_, index) => index),
394-
translation === undefined ? 'ignore' : missingTranslation,
519+
translation === undefined ? 'ignore' : missingTranslationOption,
395520
);
396521

397-
// Reconstruct the new template/string literal replacement
398-
let replacement: string;
399-
if (translatedSubstitutions.length === 0) {
400-
replacement = JSON.stringify(translatedParts[0]);
401-
} else {
402-
replacement = '`';
403-
for (let i = 0; i < translatedParts.length; i++) {
404-
const escapedPart = JSON.stringify(translatedParts[i])
405-
.slice(1, -1)
406-
.replace(/\\"/g, '"')
407-
.replace(/`/g, '\\`')
408-
.replace(/\$\{/g, '\\${');
409-
replacement += escapedPart;
410-
411-
if (i < translatedSubstitutions.length) {
412-
const originalIndex = translatedSubstitutions[i];
413-
const expr = callSite.expressions[originalIndex];
414-
const exprCode = magicString.slice(expr.start, expr.end);
415-
replacement += '${' + exprCode + '}';
416-
}
417-
}
418-
replacement += '`';
419-
}
522+
const replacement = formatReplacement(
523+
translatedParts,
524+
translatedSubstitutions,
525+
callSite.expressions,
526+
magicString,
527+
);
420528

421529
magicString.overwrite(callSite.start, callSite.end, replacement);
422530
}

0 commit comments

Comments
 (0)