Skip to content

Commit a7ec3ee

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 36.5%. - 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.51x faster i18n inlining and 46.9% 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) | 1,543.6 ms | 1,063.9 ms | 1.45x faster (-31.1%) | | Cold Build Duration (min / max) | 1,322.2 ms / 1,700.6 ms | 880.2 ms / 1,488.3 ms | -442.0 ms / -212.3 ms | | P95 Build Latency | 1,700.6 ms | 1,488.3 ms | -12.5% (-212.3 ms faster) | | Throughput | 2,287.4 ops/sec | 3,402.9 ops/sec | +48.8% (+1,115.5 ops/sec) | | I18n Inlining Duration (Pure mean) | 1,135.2 ms | 751.7 ms | 1.51x faster (-33.8%) | | Process RSS Delta | +2,020.4 MB | +1,073.7 MB | -46.9% (-946.7 MB saved) | | Final Process RSS | 2,084.5 MB | 1,138.4 MB | -45.4% (-946.1 MB saved) | | Kernel System CPU | 2,811.5 ms | 1,785.4 ms | -36.5% (1.57x less kernel CPU) | | Total CPU (User + Kernel) | 12,634.5 ms | 7,824.4 ms | -38.1% (-4,810.1 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) | 6,728.9 ms | 6,273.4 ms | -6.8% (-455.5 ms faster) | | E2E Build Duration (min / max) | 6,492.6 ms / 7,016.1 ms | 6,100.9 ms / 6,541.7 ms | -391.7 ms / -474.4 ms | | P95 Build Latency | 7,016.1 ms | 6,541.7 ms | -6.8% (-474.4 ms faster) | | Process RSS Delta | +1,492.7 MB | +1,705.7 MB | +14.3% (+213.0 MB) | | Kernel System CPU | 4,515.1 ms | 4,403.0 ms | -2.5% (-112.1 ms less kernel CPU) | | Total CPU (User + Kernel) | 21,485.0 ms | 18,821.6 ms | -12.4% (-2,663.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,496.7 ms | 2,473.7 ms | -0.9% (-23.0 ms faster) | | Cold Build Duration (min / max) | 2,227.3 ms / 2,869.8 ms | 2,278.7 ms / 2,748.2 ms | +51.4 ms / -121.6 ms | | P95 Build Latency | 2,869.8 ms | 2,748.2 ms | -4.2% (-121.6 ms faster) | | Process RSS Delta | +1,170.0 MB | +1,318.8 MB | +12.7% (pool pre-warming) | | Kernel System CPU | 2,294.7 ms | 2,402.0 ms | +4.7% (+107.3 ms) | | Total CPU (User + Kernel) | 9,805.8 ms | 9,908.2 ms | +1.0% (+102.4 ms) |
1 parent feb41cc commit a7ec3ee

12 files changed

Lines changed: 781 additions & 174 deletions

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

Lines changed: 156 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,17 @@ export interface InlineCodeRequest {
4141
*/
4242
translation?: Blob | SharedArrayBuffer;
4343

44+
translationKey?: string;
45+
4446
/**
4547
* How to handle missing translations.
4648
*/
4749
missingTranslation?: 'error' | 'warning' | 'ignore';
4850
}
4951

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

5857
/**
@@ -77,7 +76,7 @@ export interface InlineFileBatchRequest {
7776
/**
7877
* The locale specifiers and optional translations to use during the inlining process of the file.
7978
*/
80-
locales: ReadonlyMap<string, Blob | SharedArrayBuffer | undefined>;
79+
locales: ReadonlyMap<string, InlineFileBatchLocaleEntry | Blob | SharedArrayBuffer | undefined>;
8180

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

106137
/**
@@ -110,7 +141,7 @@ export interface InlineLocaleResult {
110141
locale: string;
111142
code?: string;
112143
map?: string;
113-
messages: { type: 'error' | 'warning'; message: string }[];
144+
messages: InlineDiagnosticMessage[];
114145
}
115146

116147
/**
@@ -128,6 +159,17 @@ export type InlineFileBatchResult =
128159
results: InlineLocaleResult[];
129160
};
130161

162+
/**
163+
* Maximum number of AST metadata structures cached in memory per worker isolate.
164+
* Bounding capacity prevents unbounded memory growth across watch rebuilds.
165+
*/
166+
const MAX_CACHED_FILES = 256;
167+
168+
/**
169+
* Maximum number of deserialized translation dictionaries cached in memory per worker isolate.
170+
*/
171+
const MAX_CACHED_TRANSLATIONS = 32;
172+
131173
/**
132174
* Cached file data including code and extracted localization metadata.
133175
*/
@@ -137,12 +179,12 @@ interface CachedFileData {
137179
}
138180

139181
/**
140-
* Cache of file data promises keyed by filename.
182+
* Cache of file data promises keyed by `${filename}\0${hash}` or filename.
141183
*/
142184
const fileDataCache = new Map<string, Promise<CachedFileData>>();
143185

144186
/**
145-
* Cache of deserialized translation messages keyed by locale.
187+
* Deserialized translation message dictionary cache keyed by `${locale}\0${translationKey}` or locale.
146188
*/
147189
const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsedTranslation>>>();
148190

@@ -152,72 +194,106 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
152194
let currentGeneration: number | undefined;
153195

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

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

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

182-
if (cache) {
183-
fileDataPromise.catch(() => {
184-
fileDataCache.delete(filename);
185-
});
186-
fileDataCache.set(filename, fileDataPromise);
239+
fileDataCache.set(cacheKey, dataPromise);
240+
}
241+
} else if (cache) {
242+
fileDataCache.delete(cacheKey);
243+
fileDataCache.set(cacheKey, dataPromise);
244+
} else {
245+
fileDataCache.delete(cacheKey);
187246
}
188247

189-
return fileDataPromise;
248+
return dataPromise;
190249
}
191250

192251
/**
193252
* Deserializes or wraps the translation messages for a locale, reusing the result for any
194-
* subsequent request that targets the same locale.
195-
* @param locale The locale identifier.
196-
* @param translation Optional serialized translation messages (SharedArrayBuffer or Blob).
197-
* @returns The translation messages, or undefined if the locale has no translations.
253+
* subsequent request that targets the same locale and translation payload.
254+
*
255+
* @param request The translation request object containing locale, translation payload, and optional key.
256+
* @param explicitTranslation Optional fallback translation payload if request is a string.
198257
*/
199258
function loadTranslation(
200259
locale: string,
201260
translation?: Blob | SharedArrayBuffer,
261+
translationKey?: string,
202262
): Promise<Record<string, ɵParsedTranslation>> | undefined {
203263
if (!translation) {
204264
return undefined;
205265
}
206266

207-
let messagesPromise = deserializedTranslations.get(locale);
267+
const cacheKey = translationKey ? `${locale}\0${translationKey}` : undefined;
268+
let messagesPromise = cacheKey ? deserializedTranslations.get(cacheKey) : undefined;
208269
if (!messagesPromise) {
209270
if (translation instanceof Blob) {
210271
messagesPromise = translation
211272
.arrayBuffer()
212273
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, ɵParsedTranslation>)
213274
.catch((error) => {
214-
deserializedTranslations.delete(locale);
275+
if (cacheKey && deserializedTranslations.get(cacheKey) === messagesPromise) {
276+
deserializedTranslations.delete(cacheKey);
277+
}
215278
throw error;
216279
});
217280
} else {
218281
messagesPromise = Promise.resolve(createSharedTranslationProxy(translation));
219282
}
220-
deserializedTranslations.set(locale, messagesPromise);
283+
284+
if (cacheKey) {
285+
if (deserializedTranslations.size >= MAX_CACHED_TRANSLATIONS) {
286+
const oldestKey = deserializedTranslations.keys().next().value;
287+
if (oldestKey !== undefined) {
288+
deserializedTranslations.delete(oldestKey);
289+
}
290+
}
291+
292+
deserializedTranslations.set(cacheKey, messagesPromise);
293+
}
294+
} else if (cacheKey) {
295+
deserializedTranslations.delete(cacheKey);
296+
deserializedTranslations.set(cacheKey, messagesPromise);
221297
}
222298

223299
return messagesPromise;
@@ -240,14 +316,25 @@ export async function inlineFileBatch(
240316

241317
if (request.activeLocales) {
242318
const activeSet = new Set(request.activeLocales);
243-
for (const locale of deserializedTranslations.keys()) {
244-
if (!activeSet.has(locale)) {
245-
deserializedTranslations.delete(locale);
319+
for (const key of deserializedTranslations.keys()) {
320+
const keyLocale = key.includes('\0') ? key.split('\0', 1)[0] : key;
321+
if (!activeSet.has(keyLocale)) {
322+
deserializedTranslations.delete(key);
246323
}
247324
}
248325
}
249326

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

252339
// Fast path: file has no $localize call sites or locale insert sites
253340
if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) {
@@ -263,20 +350,31 @@ export async function inlineFileBatch(
263350

264351
// Parse the sourcemap once for the entire batch if provided.
265352
// It will naturally be garbage-collected after this batch action returns.
353+
const rawMapBlob = request.map ?? request.mapBlob;
266354
let map: SourceMapInput | undefined;
267-
if (request.map) {
268-
const rawMap = await request.map.text();
355+
let rawMap: string | undefined;
356+
if (rawMapBlob) {
357+
rawMap = await rawMapBlob.text();
269358
map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined;
270359
}
271360

272361
const results = await Promise.all(
273-
Array.from(request.locales, async ([locale, translation]) => {
362+
Array.from(request.locales, async ([locale, entry]) => {
363+
const translation =
364+
entry && typeof entry === 'object' && 'translation' in entry
365+
? entry.translation
366+
: (entry as Blob | SharedArrayBuffer | undefined);
367+
const translationKey =
368+
entry && typeof entry === 'object' && 'translationKey' in entry
369+
? entry.translationKey
370+
: undefined;
371+
274372
const result = await inlineLocalize(
275373
code,
276374
map,
277375
metadata,
278376
locale,
279-
await loadTranslation(locale, translation),
377+
await loadTranslation(locale, translation, translationKey),
280378
request.filename,
281379
request.missingTranslation,
282380
);
@@ -300,7 +398,7 @@ export async function inlineFileBatch(
300398
* Inlines the provided locale and translation into JavaScript code that contains `$localize` usage.
301399
* This function is a secondary entry primarily for use with component HMR update modules.
302400
*
303-
* @param request An InlineRequest object representing the options for inlining
401+
* @param request An InlineCodeRequest object representing the options for inlining
304402
* @returns An object containing the inlined code.
305403
*/
306404
export async function inlineCode(request: InlineCodeRequest): Promise<InlineCodeResult> {
@@ -310,7 +408,7 @@ export async function inlineCode(request: InlineCodeRequest): Promise<InlineCode
310408
undefined,
311409
metadata,
312410
request.locale,
313-
await loadTranslation(request.locale, request.translation),
411+
await loadTranslation(request.locale, request.translation, request.translationKey),
314412
request.filename,
315413
request.missingTranslation,
316414
);
@@ -532,7 +630,7 @@ async function inlineLocalize(
532630
}
533631

534632
const outputCode = magicString.toString();
535-
let outputMap;
633+
let outputMap: string | undefined;
536634
if (map) {
537635
// A decoded map is generated here rather than an encoded one because remapping decodes its
538636
// inputs. Encoding the mappings only for remapping to immediately decode them again doubles
@@ -542,12 +640,14 @@ async function inlineLocalize(
542640
includeContent: true,
543641
hires: 'boundary',
544642
});
545-
outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null);
643+
outputMap = JSON.stringify(
644+
remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null),
645+
);
546646
}
547647

548648
return {
549649
code: outputCode,
550-
map: outputMap && JSON.stringify(outputMap),
650+
map: outputMap,
551651
diagnostics,
552652
};
553653
}

0 commit comments

Comments
 (0)