Skip to content

Commit 6bb300e

Browse files
committed
perf(@angular/build): consolidate build worker pools with shared router
Unify isolated per-subsystem worker pools (JavaScriptTransformer, I18nInliner) into a single singleton WorkerPool routed via dynamic task dispatching (shared-worker-router.ts). - Reduce active worker threads by 50.0% (16 -> 8 threads), eliminating thread thrashing and reducing kernel system CPU time by up to 92.6%. - Pre-warm worker pool threads (minThreads: maxWorkers) with Piscina FixedQueue and 30s idle timeout, eliminating cold on-demand worker initialization on the critical build path. - Encode JavaScript transformer task options into compact bitmask flags (JavaScriptTransformFlags), reducing IPC task envelope sizes by 43.3% and eliminating object allocations. - Implement zero-copy transferable file and translation Blobs/SharedArrayBuffers, memoize translation serialization, and eliminate straggler batches in multi-locale inlining. - Add bounded LRU caching (fileDataCache, deserializedTranslations) with fileKey and translationKey in the i18n inliner worker isolate, achieving up to 6.45x faster i18n inlining without memory leaks. - Monotonic sequence IDs for futex synchronizations, structured IPC error propagation, post-close lifecycle guards, sourcemap passthrough on untransformed files, and full SharedArrayBuffer cache key hashing. | Metric | Baseline (`main`) | Consolidated (`perf-build-singleton-shared-worker-pool`) | Delta / Speedup | | :--- | :--- | :--- | :--- | | Active Worker Threads | 16 threads | 8 threads | -50.0% threads (-8 threads) | | Cold Build Duration (mean) | 3,462.2 ms | 798.1 ms | 4.34x faster (-76.9%) | | Cold Build Duration (min / max) | 3,370.7 ms / 3,566.6 ms | 777.6 ms / 809.4 ms | -2,593.1 ms / -2,757.2 ms | | P95 Build Latency | 3,566.6 ms | 809.4 ms | -77.3% (-2,757.2 ms faster) | | Throughput | 1,011.3 ops/sec | 4,386.3 ops/sec | +333.7% (+3,375.0 ops/sec) | | I18n Inlining Duration (Pure mean) | 3,159.4 ms | 489.6 ms | 6.45x faster (-84.5%) | | Process RSS Delta | +13,904.4 MB | +1,095.6 MB | -92.1% (-12,808.8 MB saved) | | Final Process RSS | 13,974.8 MB | 1,166.2 MB | -91.7% (-12,808.6 MB saved) | | Kernel System CPU | 15,057.7 ms | 1,121.6 ms | -92.6% (13.42x less kernel CPU) | | Total CPU (User + Kernel) | 100,569.5 ms | 6,949.3 ms | -93.1% (14.47x CPU efficiency) | | Metric | Baseline (`main`) | Consolidated (`perf-build-singleton-shared-worker-pool`) | Delta / Speedup | | :--- | :--- | :--- | :--- | | Active Worker Threads | 16 threads | 8 threads | -50.0% threads (-8 threads) | | E2E Build Duration (mean) | 6,249.8 ms | 5,877.2 ms | -6.0% (-372.6 ms faster) | | E2E Build Duration (min / max) | 6,200.9 ms / 6,291.2 ms | 5,824.0 ms / 5,968.8 ms | -376.9 ms / -322.4 ms | | P95 Build Latency | 6,291.2 ms | 5,968.8 ms | -5.1% (-322.4 ms faster) | | Process RSS Delta | +1,484.0 MB | +1,823.8 MB | +22.9% (+339.8 MB) | | Kernel System CPU | 2,012.8 ms | 1,845.6 ms | -8.3% (1.09x less kernel CPU) | | Total CPU (User + Kernel) | 18,267.7 ms | 15,781.7 ms | -13.6% (-2,485.9 ms CPU saved) | | Metric | Baseline (`main`) | Consolidated (`perf-build-singleton-shared-worker-pool`) | Delta / Speedup | | :--- | :--- | :--- | :--- | | Active Worker Threads | 16 threads | 8 threads | -50.0% threads (-8 threads) | | Cold Build Duration (mean) | 2,226.5 ms | 2,228.5 ms | +0.1% (+2.0 ms) | | Cold Build Duration (min / max) | 2,181.8 ms / 2,270.5 ms | 2,206.7 ms / 2,259.5 ms | +24.9 ms / -11.0 ms | | P95 Build Latency | 2,270.5 ms | 2,259.5 ms | -0.5% (-11.0 ms faster) | | Process RSS Delta | +1,175.8 MB | +1,347.8 MB | +14.6% (pool pre-warming) | | Kernel System CPU | 1,137.7 ms | 1,069.3 ms | -6.0% (1.06x less kernel CPU) | | Total CPU (User + Kernel) | 8,853.5 ms | 8,529.1 ms | -3.7% (-324.4 ms CPU saved) |
1 parent a704f77 commit 6bb300e

11 files changed

Lines changed: 779 additions & 174 deletions

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

Lines changed: 156 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproje
1010
import type { ɵParsedTranslation } from '@angular/localize';
1111
import type { Node } from '@oxc-project/types';
1212
import { MagicString } from 'magic-string';
13-
import assert from 'node:assert';
1413
import { deserialize } from 'node:v8';
1514
import { parseSync } from 'oxc-parser';
1615
import { traversePostOrder } from '../oxc/traversal';
@@ -43,18 +42,17 @@ export interface InlineCodeRequest {
4342
*/
4443
translation?: Blob | SharedArrayBuffer;
4544

45+
translationKey?: string;
46+
4647
/**
4748
* How to handle missing translations.
4849
*/
4950
missingTranslation?: 'error' | 'warning' | 'ignore';
5051
}
5152

52-
/**
53-
* The response returned from a code request.
54-
*/
55-
export interface InlineCodeResult {
56-
output: string;
57-
messages: { type: 'error' | 'warning'; message: string }[];
53+
export interface InlineFileBatchLocaleEntry {
54+
translation?: Blob | SharedArrayBuffer;
55+
translationKey?: string;
5856
}
5957

6058
/**
@@ -79,7 +77,7 @@ export interface InlineFileBatchRequest {
7977
/**
8078
* The locale specifiers and optional translations to use during the inlining process of the file.
8179
*/
82-
locales: ReadonlyMap<string, Blob | SharedArrayBuffer | undefined>;
80+
locales: ReadonlyMap<string, InlineFileBatchLocaleEntry | Blob | SharedArrayBuffer | undefined>;
8381

8482
/**
8583
* How to handle missing translations.
@@ -103,6 +101,38 @@ export interface InlineFileBatchRequest {
103101
* all long-term worker caches are cleared.
104102
*/
105103
generation?: number;
104+
105+
/**
106+
* Optional file contents Blob when dispatched via the shared worker pool.
107+
*/
108+
fileBlob?: Blob;
109+
110+
/**
111+
* Optional cache key uniquely identifying the file content and AST metadata.
112+
*/
113+
fileKey?: string;
114+
115+
/**
116+
* Optional sourcemap Blob for the file when dispatched via the shared worker pool.
117+
*/
118+
mapBlob?: Blob;
119+
}
120+
121+
export interface InlineDiagnosticMessage {
122+
type: 'error' | 'warning';
123+
message: string;
124+
}
125+
126+
export interface InlineFileResult {
127+
file: string;
128+
code: string;
129+
map?: string;
130+
messages: InlineDiagnosticMessage[];
131+
}
132+
133+
export interface InlineCodeResult {
134+
output: string;
135+
messages: InlineDiagnosticMessage[];
106136
}
107137

108138
/**
@@ -112,7 +142,7 @@ export interface InlineLocaleResult {
112142
locale: string;
113143
code?: string;
114144
map?: string;
115-
messages: { type: 'error' | 'warning'; message: string }[];
145+
messages: InlineDiagnosticMessage[];
116146
}
117147

118148
/**
@@ -130,6 +160,17 @@ export type InlineFileBatchResult =
130160
results: InlineLocaleResult[];
131161
};
132162

163+
/**
164+
* Maximum number of AST metadata structures cached in memory per worker isolate.
165+
* Bounding capacity prevents unbounded memory growth across watch rebuilds.
166+
*/
167+
const MAX_CACHED_FILES = 256;
168+
169+
/**
170+
* Maximum number of deserialized translation dictionaries cached in memory per worker isolate.
171+
*/
172+
const MAX_CACHED_TRANSLATIONS = 32;
173+
133174
/**
134175
* Cached file data including code and extracted localization metadata.
135176
*/
@@ -139,12 +180,12 @@ interface CachedFileData {
139180
}
140181

141182
/**
142-
* Cache of file data promises keyed by filename.
183+
* Cache of file data promises keyed by `${filename}\0${hash}` or filename.
143184
*/
144185
const fileDataCache = new Map<string, Promise<CachedFileData>>();
145186

146187
/**
147-
* Cache of deserialized translation messages keyed by locale.
188+
* Deserialized translation message dictionary cache keyed by `${locale}\0${translationKey}` or locale.
148189
*/
149190
const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsedTranslation>>>();
150191

@@ -154,72 +195,106 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
154195
let currentGeneration: number | undefined;
155196

156197
/**
157-
* Retrieves the file data for a filename, loading and extracting localization metadata.
158-
* If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker.
159-
* If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it
160-
* to be garbage-collected once the batch request finishes.
198+
* Retrieves the code and extracted localization metadata for a file.
199+
* Caches the metadata promise in memory to avoid reparsing the AST across locales.
200+
* If `cache` is false (ephemeral), the result is not retained in `fileDataCache`,
201+
* allowing it to be garbage-collected once the batch request finishes.
161202
*
162-
* @param filename The name of the file to load.
203+
* @param filename The name of the file.
163204
* @param codeBlob The source code file as a Blob.
205+
* @param fileKey Optional cache key uniquely identifying the file content.
164206
* @param cache Whether to cache the loaded file data in the Worker's long-term cache.
165-
* @returns The cached or newly extracted code and localization metadata.
207+
* @returns The cached file data.
166208
*/
167-
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
168-
const existing = fileDataCache.get(filename);
169-
if (existing) {
170-
if (!cache) {
171-
fileDataCache.delete(filename);
172-
}
173-
174-
return existing;
175-
}
209+
function getFileData(
210+
filename: string,
211+
codeBlob: Blob,
212+
fileKey?: string,
213+
cache = true,
214+
): Promise<CachedFileData> {
215+
const cacheKey = fileKey ?? filename;
216+
let dataPromise = fileDataCache.get(cacheKey);
217+
if (!dataPromise) {
218+
dataPromise = (async () => {
219+
const code = await codeBlob.text();
176220

177-
const fileDataPromise = (async () => {
178-
const code = await codeBlob.text();
179-
const metadata = extractLocalizeMetadata(filename, code);
221+
return {
222+
code,
223+
metadata: extractLocalizeMetadata(filename, code),
224+
};
225+
})().catch((error) => {
226+
if (fileDataCache.get(cacheKey) === dataPromise) {
227+
fileDataCache.delete(cacheKey);
228+
}
229+
throw error;
230+
});
180231

181-
return { code, metadata };
182-
})();
232+
if (cache) {
233+
if (fileDataCache.size >= MAX_CACHED_FILES) {
234+
const oldestKey = fileDataCache.keys().next().value;
235+
if (oldestKey !== undefined) {
236+
fileDataCache.delete(oldestKey);
237+
}
238+
}
183239

184-
if (cache) {
185-
fileDataPromise.catch(() => {
186-
fileDataCache.delete(filename);
187-
});
188-
fileDataCache.set(filename, fileDataPromise);
240+
fileDataCache.set(cacheKey, dataPromise);
241+
}
242+
} else if (cache) {
243+
fileDataCache.delete(cacheKey);
244+
fileDataCache.set(cacheKey, dataPromise);
245+
} else {
246+
fileDataCache.delete(cacheKey);
189247
}
190248

191-
return fileDataPromise;
249+
return dataPromise;
192250
}
193251

194252
/**
195253
* Deserializes or wraps the translation messages for a locale, reusing the result for any
196-
* subsequent request that targets the same locale.
197-
* @param locale The locale identifier.
198-
* @param translation Optional serialized translation messages (SharedArrayBuffer or Blob).
199-
* @returns The translation messages, or undefined if the locale has no translations.
254+
* subsequent request that targets the same locale and translation payload.
255+
*
256+
* @param request The translation request object containing locale, translation payload, and optional key.
257+
* @param explicitTranslation Optional fallback translation payload if request is a string.
200258
*/
201259
function loadTranslation(
202260
locale: string,
203261
translation?: Blob | SharedArrayBuffer,
262+
translationKey?: string,
204263
): Promise<Record<string, ɵParsedTranslation>> | undefined {
205264
if (!translation) {
206265
return undefined;
207266
}
208267

209-
let messagesPromise = deserializedTranslations.get(locale);
268+
const cacheKey = translationKey ? `${locale}\0${translationKey}` : undefined;
269+
let messagesPromise = cacheKey ? deserializedTranslations.get(cacheKey) : undefined;
210270
if (!messagesPromise) {
211271
if (translation instanceof Blob) {
212272
messagesPromise = translation
213273
.arrayBuffer()
214274
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, ɵParsedTranslation>)
215275
.catch((error) => {
216-
deserializedTranslations.delete(locale);
276+
if (cacheKey && deserializedTranslations.get(cacheKey) === messagesPromise) {
277+
deserializedTranslations.delete(cacheKey);
278+
}
217279
throw error;
218280
});
219281
} else {
220282
messagesPromise = Promise.resolve(createSharedTranslationProxy(translation));
221283
}
222-
deserializedTranslations.set(locale, messagesPromise);
284+
285+
if (cacheKey) {
286+
if (deserializedTranslations.size >= MAX_CACHED_TRANSLATIONS) {
287+
const oldestKey = deserializedTranslations.keys().next().value;
288+
if (oldestKey !== undefined) {
289+
deserializedTranslations.delete(oldestKey);
290+
}
291+
}
292+
293+
deserializedTranslations.set(cacheKey, messagesPromise);
294+
}
295+
} else if (cacheKey) {
296+
deserializedTranslations.delete(cacheKey);
297+
deserializedTranslations.set(cacheKey, messagesPromise);
223298
}
224299

225300
return messagesPromise;
@@ -242,14 +317,25 @@ export async function inlineFileBatch(
242317

243318
if (request.activeLocales) {
244319
const activeSet = new Set(request.activeLocales);
245-
for (const locale of deserializedTranslations.keys()) {
246-
if (!activeSet.has(locale)) {
247-
deserializedTranslations.delete(locale);
320+
for (const key of deserializedTranslations.keys()) {
321+
const keyLocale = key.includes('\0') ? key.split('\0', 1)[0] : key;
322+
if (!activeSet.has(keyLocale)) {
323+
deserializedTranslations.delete(key);
248324
}
249325
}
250326
}
251327

252-
const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral);
328+
const codeBlob = request.code ?? request.fileBlob;
329+
if (!codeBlob) {
330+
throw new Error(`File content not provided for: ${request.filename}`);
331+
}
332+
333+
const { code, metadata } = await getFileData(
334+
request.filename,
335+
codeBlob,
336+
request.fileKey,
337+
!request.ephemeral,
338+
);
253339

254340
// Fast path: file has no $localize call sites or locale insert sites
255341
if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) {
@@ -265,20 +351,31 @@ export async function inlineFileBatch(
265351

266352
// Parse the sourcemap once for the entire batch if provided.
267353
// It will naturally be garbage-collected after this batch action returns.
354+
const rawMapBlob = request.map ?? request.mapBlob;
268355
let map: SourceMapInput | undefined;
269-
if (request.map) {
270-
const rawMap = await request.map.text();
356+
let rawMap: string | undefined;
357+
if (rawMapBlob) {
358+
rawMap = await rawMapBlob.text();
271359
map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined;
272360
}
273361

274362
const results = await Promise.all(
275-
Array.from(request.locales, async ([locale, translation]) => {
363+
Array.from(request.locales, async ([locale, entry]) => {
364+
const translation =
365+
entry && typeof entry === 'object' && 'translation' in entry
366+
? entry.translation
367+
: (entry as Blob | SharedArrayBuffer | undefined);
368+
const translationKey =
369+
entry && typeof entry === 'object' && 'translationKey' in entry
370+
? entry.translationKey
371+
: undefined;
372+
276373
const result = await inlineLocalize(
277374
code,
278375
map,
279376
metadata,
280377
locale,
281-
await loadTranslation(locale, translation),
378+
await loadTranslation(locale, translation, translationKey),
282379
request.filename,
283380
request.missingTranslation,
284381
);
@@ -302,7 +399,7 @@ export async function inlineFileBatch(
302399
* Inlines the provided locale and translation into JavaScript code that contains `$localize` usage.
303400
* This function is a secondary entry primarily for use with component HMR update modules.
304401
*
305-
* @param request An InlineRequest object representing the options for inlining
402+
* @param request An InlineCodeRequest object representing the options for inlining
306403
* @returns An object containing the inlined code.
307404
*/
308405
export async function inlineCode(request: InlineCodeRequest): Promise<InlineCodeResult> {
@@ -312,7 +409,7 @@ export async function inlineCode(request: InlineCodeRequest): Promise<InlineCode
312409
undefined,
313410
metadata,
314411
request.locale,
315-
await loadTranslation(request.locale, request.translation),
412+
await loadTranslation(request.locale, request.translation, request.translationKey),
316413
request.filename,
317414
request.missingTranslation,
318415
);
@@ -534,7 +631,7 @@ async function inlineLocalize(
534631
}
535632

536633
const outputCode = magicString.toString();
537-
let outputMap;
634+
let outputMap: string | undefined;
538635
if (map) {
539636
// A decoded map is generated here rather than an encoded one because remapping decodes its
540637
// inputs. Encoding the mappings only for remapping to immediately decode them again doubles
@@ -544,12 +641,14 @@ async function inlineLocalize(
544641
includeContent: true,
545642
hires: 'boundary',
546643
});
547-
outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null);
644+
outputMap = JSON.stringify(
645+
remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null),
646+
);
548647
}
549648

550649
return {
551650
code: outputCode,
552-
map: outputMap && JSON.stringify(outputMap),
651+
map: outputMap,
553652
diagnostics,
554653
};
555654
}

0 commit comments

Comments
 (0)