Skip to content

Commit 5fa5739

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 14.1%. - Pre-warm worker pool threads (minThreads: maxTransformWorkers, maxThreads: maxInlinerWorkers) with Piscina FixedQueue and 30s idle timeout, while lazily loading the i18n inliner worker handler on demand. - 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 1.19x faster i18n inlining and 26.0% lower memory RSS delta. - 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) | 569.6 ms | 547.3 ms | 1.04x faster (-3.9%) | | Cold Build Duration (min / max) | 537.3 ms / 593.8 ms | 522.5 ms / 575.5 ms | -14.8 ms / -18.3 ms | | P95 Build Latency | 593.8 ms | 575.5 ms | -3.1% (-18.3 ms faster) | | Throughput | 6,151.9 ops/sec | 6,402.4 ops/sec | +4.1% (+250.5 ops/sec) | | I18n Inlining Duration (Pure mean) | 265.4 ms | 223.0 ms | 1.19x faster (-16.0%) | | Process RSS Delta | +624.4 MB | +462.1 MB | -26.0% (-162.3 MB saved) | | Final Process RSS | 690.9 MB | 528.6 MB | -23.5% (-162.3 MB saved) | | Kernel System CPU | 709.4 ms | 609.2 ms | -14.1% (1.16x less kernel CPU) | | Total CPU (User + Kernel) | 3,869.7 ms | 3,429.1 ms | -11.4% (-440.6 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) | | E2E Build Duration (mean) | 5,527.1 ms | 5,526.6 ms | -0.0% (-0.5 ms faster) | | E2E Build Duration (min / max) | 5,470.0 ms / 5,578.7 ms | 5,402.3 ms / 5,696.3 ms | -67.7 ms / +117.6 ms | | P95 Build Latency | 5,578.7 ms | 5,696.3 ms | +2.1% (+117.6 ms) | | Process RSS Delta | +882.9 MB | +1,072.3 MB | +21.5% (+189.4 MB) | | Kernel System CPU | 1,477.7 ms | 1,372.8 ms | -7.1% (-104.9 ms less kernel CPU) | | Total CPU (User + Kernel) | 12,815.2 ms | 12,449.8 ms | -2.9% (-365.4 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,118.3 ms | 2,132.9 ms | +0.7% (+14.6 ms) | | Cold Build Duration (min / max) | 2,080.8 ms / 2,201.9 ms | 2,079.7 ms / 2,216.5 ms | -1.1 ms / +14.6 ms | | P95 Build Latency | 2,201.9 ms | 2,216.5 ms | +0.7% (+14.6 ms) | | Process RSS Delta | +617.1 MB | +732.2 MB | +18.6% (pool pre-warming) | | Kernel System CPU | 822.5 ms | 793.2 ms | -3.6% (-29.3 ms less kernel CPU) | | Total CPU (User + Kernel) | 6,011.7 ms | 6,169.9 ms | +2.6% (+158.2 ms) |
1 parent 07845f0 commit 5fa5739

12 files changed

Lines changed: 782 additions & 175 deletions

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

Lines changed: 157 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,17 @@ export interface InlineCodeRequest {
4747
*/
4848
translation?: Blob | SharedArrayBuffer;
4949

50+
translationKey?: string;
51+
5052
/**
5153
* How to handle missing translations.
5254
*/
5355
missingTranslation?: 'error' | 'warning' | 'ignore';
5456
}
5557

56-
/**
57-
* The response returned from a code request.
58-
*/
59-
export interface InlineCodeResult {
60-
output: string;
61-
messages: { type: 'error' | 'warning'; message: string }[];
58+
export interface InlineFileBatchLocaleEntry {
59+
translation?: Blob | SharedArrayBuffer;
60+
translationKey?: string;
6261
}
6362

6463
/**
@@ -83,7 +82,7 @@ export interface InlineFileBatchRequest {
8382
/**
8483
* The locale specifiers and optional translations to use during the inlining process of the file.
8584
*/
86-
locales: ReadonlyMap<string, Blob | SharedArrayBuffer | undefined>;
85+
locales: ReadonlyMap<string, InlineFileBatchLocaleEntry | Blob | SharedArrayBuffer | undefined>;
8786

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

112143
/**
@@ -116,7 +147,7 @@ export interface InlineLocaleResult {
116147
locale: string;
117148
code?: string;
118149
map?: string;
119-
messages: { type: 'error' | 'warning'; message: string }[];
150+
messages: InlineDiagnosticMessage[];
120151
}
121152

122153
/**
@@ -134,6 +165,17 @@ export type InlineFileBatchResult =
134165
results: InlineLocaleResult[];
135166
};
136167

168+
/**
169+
* Maximum number of AST metadata structures cached in memory per worker isolate.
170+
* Bounding capacity prevents unbounded memory growth across watch rebuilds.
171+
*/
172+
const MAX_CACHED_FILES = 256;
173+
174+
/**
175+
* Maximum number of deserialized translation dictionaries cached in memory per worker isolate.
176+
*/
177+
const MAX_CACHED_TRANSLATIONS = 32;
178+
137179
/**
138180
* Cached file data including code and extracted localization metadata.
139181
*/
@@ -143,12 +185,12 @@ interface CachedFileData {
143185
}
144186

145187
/**
146-
* Cache of file data promises keyed by filename.
188+
* Cache of file data promises keyed by `${filename}\0${hash}` or filename.
147189
*/
148190
const fileDataCache = new Map<string, Promise<CachedFileData>>();
149191

150192
/**
151-
* Cache of deserialized translation messages keyed by locale.
193+
* Deserialized translation message dictionary cache keyed by `${locale}\0${translationKey}` or locale.
152194
*/
153195
const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsedTranslation>>>();
154196

@@ -160,72 +202,106 @@ let currentGeneration: number | undefined;
160202
let remapping: typeof import('@ampproject/remapping').default | undefined;
161203

162204
/**
163-
* Retrieves the file data for a filename, loading and extracting localization metadata.
164-
* If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker.
165-
* If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it
166-
* to be garbage-collected once the batch request finishes.
205+
* Retrieves the code and extracted localization metadata for a file.
206+
* Caches the metadata promise in memory to avoid reparsing the AST across locales.
207+
* If `cache` is false (ephemeral), the result is not retained in `fileDataCache`,
208+
* allowing it to be garbage-collected once the batch request finishes.
167209
*
168-
* @param filename The name of the file to load.
210+
* @param filename The name of the file.
169211
* @param codeBlob The source code file as a Blob.
212+
* @param fileKey Optional cache key uniquely identifying the file content.
170213
* @param cache Whether to cache the loaded file data in the Worker's long-term cache.
171-
* @returns The cached or newly extracted code and localization metadata.
214+
* @returns The cached file data.
172215
*/
173-
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
174-
const existing = fileDataCache.get(filename);
175-
if (existing) {
176-
if (!cache) {
177-
fileDataCache.delete(filename);
178-
}
179-
180-
return existing;
181-
}
182-
183-
const fileDataPromise = (async () => {
184-
const code = await codeBlob.text();
185-
const metadata = extractLocalizeMetadata(filename, code);
216+
function getFileData(
217+
filename: string,
218+
codeBlob: Blob,
219+
fileKey?: string,
220+
cache = true,
221+
): Promise<CachedFileData> {
222+
const cacheKey = fileKey ?? filename;
223+
let dataPromise = fileDataCache.get(cacheKey);
224+
if (!dataPromise) {
225+
dataPromise = (async () => {
226+
const code = await codeBlob.text();
227+
228+
return {
229+
code,
230+
metadata: extractLocalizeMetadata(filename, code),
231+
};
232+
})().catch((error) => {
233+
if (fileDataCache.get(cacheKey) === dataPromise) {
234+
fileDataCache.delete(cacheKey);
235+
}
236+
throw error;
237+
});
186238

187-
return { code, metadata };
188-
})();
239+
if (cache) {
240+
if (fileDataCache.size >= MAX_CACHED_FILES) {
241+
const oldestKey = fileDataCache.keys().next().value;
242+
if (oldestKey !== undefined) {
243+
fileDataCache.delete(oldestKey);
244+
}
245+
}
189246

190-
if (cache) {
191-
fileDataPromise.catch(() => {
192-
fileDataCache.delete(filename);
193-
});
194-
fileDataCache.set(filename, fileDataPromise);
247+
fileDataCache.set(cacheKey, dataPromise);
248+
}
249+
} else if (cache) {
250+
fileDataCache.delete(cacheKey);
251+
fileDataCache.set(cacheKey, dataPromise);
252+
} else {
253+
fileDataCache.delete(cacheKey);
195254
}
196255

197-
return fileDataPromise;
256+
return dataPromise;
198257
}
199258

200259
/**
201260
* Deserializes or wraps the translation messages for a locale, reusing the result for any
202-
* subsequent request that targets the same locale.
203-
* @param locale The locale identifier.
204-
* @param translation Optional serialized translation messages (SharedArrayBuffer or Blob).
205-
* @returns The translation messages, or undefined if the locale has no translations.
261+
* subsequent request that targets the same locale and translation payload.
262+
*
263+
* @param request The translation request object containing locale, translation payload, and optional key.
264+
* @param explicitTranslation Optional fallback translation payload if request is a string.
206265
*/
207266
function loadTranslation(
208267
locale: string,
209268
translation?: Blob | SharedArrayBuffer,
269+
translationKey?: string,
210270
): Promise<Record<string, ɵParsedTranslation>> | undefined {
211271
if (!translation) {
212272
return undefined;
213273
}
214274

215-
let messagesPromise = deserializedTranslations.get(locale);
275+
const cacheKey = translationKey ? `${locale}\0${translationKey}` : undefined;
276+
let messagesPromise = cacheKey ? deserializedTranslations.get(cacheKey) : undefined;
216277
if (!messagesPromise) {
217278
if (translation instanceof Blob) {
218279
messagesPromise = translation
219280
.arrayBuffer()
220281
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, ɵParsedTranslation>)
221282
.catch((error) => {
222-
deserializedTranslations.delete(locale);
283+
if (cacheKey && deserializedTranslations.get(cacheKey) === messagesPromise) {
284+
deserializedTranslations.delete(cacheKey);
285+
}
223286
throw error;
224287
});
225288
} else {
226289
messagesPromise = Promise.resolve(createSharedTranslationProxy(translation));
227290
}
228-
deserializedTranslations.set(locale, messagesPromise);
291+
292+
if (cacheKey) {
293+
if (deserializedTranslations.size >= MAX_CACHED_TRANSLATIONS) {
294+
const oldestKey = deserializedTranslations.keys().next().value;
295+
if (oldestKey !== undefined) {
296+
deserializedTranslations.delete(oldestKey);
297+
}
298+
}
299+
300+
deserializedTranslations.set(cacheKey, messagesPromise);
301+
}
302+
} else if (cacheKey) {
303+
deserializedTranslations.delete(cacheKey);
304+
deserializedTranslations.set(cacheKey, messagesPromise);
229305
}
230306

231307
return messagesPromise;
@@ -248,14 +324,25 @@ export async function inlineFileBatch(
248324

249325
if (request.activeLocales) {
250326
const activeSet = new Set(request.activeLocales);
251-
for (const locale of deserializedTranslations.keys()) {
252-
if (!activeSet.has(locale)) {
253-
deserializedTranslations.delete(locale);
327+
for (const key of deserializedTranslations.keys()) {
328+
const keyLocale = key.includes('\0') ? key.split('\0', 1)[0] : key;
329+
if (!activeSet.has(keyLocale)) {
330+
deserializedTranslations.delete(key);
254331
}
255332
}
256333
}
257334

258-
const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral);
335+
const codeBlob = request.code ?? request.fileBlob;
336+
if (!codeBlob) {
337+
throw new Error(`File content not provided for: ${request.filename}`);
338+
}
339+
340+
const { code, metadata } = await getFileData(
341+
request.filename,
342+
codeBlob,
343+
request.fileKey,
344+
!request.ephemeral,
345+
);
259346

260347
// Fast path: file has no $localize call sites or locale insert sites
261348
if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) {
@@ -271,20 +358,31 @@ export async function inlineFileBatch(
271358

272359
// Parse the sourcemap once for the entire batch if provided.
273360
// It will naturally be garbage-collected after this batch action returns.
361+
const rawMapBlob = request.map ?? request.mapBlob;
274362
let map: SourceMapInput | undefined;
275-
if (request.map) {
276-
const rawMap = await request.map.text();
363+
let rawMap: string | undefined;
364+
if (rawMapBlob) {
365+
rawMap = await rawMapBlob.text();
277366
map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined;
278367
}
279368

280369
const results = [];
281-
for (const [locale, translation] of request.locales) {
370+
for (const [locale, entry] of request.locales) {
371+
const translation =
372+
entry && typeof entry === 'object' && 'translation' in entry
373+
? entry.translation
374+
: (entry as Blob | SharedArrayBuffer | undefined);
375+
const translationKey =
376+
entry && typeof entry === 'object' && 'translationKey' in entry
377+
? entry.translationKey
378+
: undefined;
379+
282380
const result = await inlineLocalize(
283381
code,
284382
map,
285383
metadata,
286384
locale,
287-
await loadTranslation(locale, translation),
385+
await loadTranslation(locale, translation, translationKey),
288386
request.filename,
289387
request.missingTranslation,
290388
);
@@ -307,7 +405,7 @@ export async function inlineFileBatch(
307405
* Inlines the provided locale and translation into JavaScript code that contains `$localize` usage.
308406
* This function is a secondary entry primarily for use with component HMR update modules.
309407
*
310-
* @param request An InlineRequest object representing the options for inlining
408+
* @param request An InlineCodeRequest object representing the options for inlining
311409
* @returns An object containing the inlined code.
312410
*/
313411
export async function inlineCode(request: InlineCodeRequest): Promise<InlineCodeResult> {
@@ -317,7 +415,7 @@ export async function inlineCode(request: InlineCodeRequest): Promise<InlineCode
317415
undefined,
318416
metadata,
319417
request.locale,
320-
await loadTranslation(request.locale, request.translation),
418+
await loadTranslation(request.locale, request.translation, request.translationKey),
321419
request.filename,
322420
request.missingTranslation,
323421
);
@@ -559,7 +657,7 @@ async function inlineLocalize(
559657
}
560658

561659
const outputCode = magicString.toString();
562-
let outputMap;
660+
let outputMap: string | undefined;
563661
if (map) {
564662
// A decoded map is generated here rather than an encoded one because remapping decodes its
565663
// inputs. Encoding the mappings only for remapping to immediately decode them again doubles
@@ -570,12 +668,14 @@ async function inlineLocalize(
570668
hires: 'boundary',
571669
});
572670
remapping ??= (await import('@ampproject/remapping')).default;
573-
outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null);
671+
outputMap = JSON.stringify(
672+
remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null),
673+
);
574674
}
575675

576676
return {
577677
code: outputCode,
578-
map: outputMap && JSON.stringify(outputMap),
678+
map: outputMap,
579679
diagnostics,
580680
};
581681
}

0 commit comments

Comments
 (0)