Skip to content

Commit 4f19146

Browse files
committed
perf(@angular/build): bound concurrent worker allocation for dominant files in i18n inliner
Dominant files (such as main.js or other large bundles >= 70% of max file size) previously sharded their locales across all available workers in the pool. On high-core machines, this dispatched heavy AST parsing and sourcemap remapping simultaneously across all worker threads, monopolizing the pool, causing memory bus/cache contention, and spiking peak V8 heap and RSS. A MAX_DOMINANT_WORKERS constant of 4 now caps the number of worker threads concurrently processing a single dominant file. Additionally, when multiple files exist in the window, workers are dynamically allocated to guarantee that at least one worker thread is always available to process smaller chunks concurrently without starvation. A new multi-dominant scenario has also been added to the benchmark suite to measure throughput and memory under multi-bundle workloads.
1 parent b097be0 commit 4f19146

2 files changed

Lines changed: 101 additions & 4 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ const SMALL_FILE_FLOOR_BYTES = 100 * 1024;
7070
*/
7171
const DOMINANT_FILE_RATIO = 0.7;
7272

73+
/**
74+
* Maximum number of worker threads to allocate concurrently for a single dominant file.
75+
* Capping concurrency on dominant files (which hold large AST and sourcemap trees)
76+
* prevents V8 heap and RSS explosion on machines with high CPU core counts.
77+
*/
78+
const MAX_DOMINANT_WORKERS = 4;
79+
7380
/**
7481
* Serializes the translation messages for a locale for transfer to an inliner Worker.
7582
*
@@ -522,14 +529,17 @@ export class I18nInliner {
522529

523530
let localesPerBatch: number;
524531
if (uncachedByFile.size === 1) {
525-
// Single file in window: shard across all workers to avoid idle threads
526-
localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount));
532+
// Single file in window: shard across bounded workers to balance throughput and peak memory
533+
const targetWorkers = Math.min(workerCount, MAX_DOMINANT_WORKERS);
534+
localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers));
527535
} else if (fileSize < SMALL_FILE_FLOOR_BYTES) {
528536
// Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead
529537
localesPerBatch = entries.length;
530538
} else if (fileSize >= maxFileSize * DOMINANT_FILE_RATIO) {
531-
// Dominant file(s): shard across all workers for maximum multi-core parallelism
532-
localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount));
539+
// Dominant file(s) with multiple files: shard across bounded workers while always reserving
540+
// at least one worker to process smaller chunks in parallel without starvation.
541+
const targetWorkers = Math.max(1, Math.min(workerCount - 1, MAX_DOMINANT_WORKERS));
542+
localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers));
533543
} else {
534544
// Intermediate files: moderate sharding
535545
localesPerBatch = Math.max(1, Math.ceil(entries.length / 2));

‎scripts/benchmarks/i18n/scenarios.mts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,92 @@ export function createMonolithicScenario(options: ScenarioFactoryOptions = {}):
311311
};
312312
}
313313

314+
/**
315+
* 5. Multi-Dominant Scenario:
316+
* 3 dominant bundles (3 MB each, e.g. main.js, polyfills.js, admin-portal.js) + 15 small chunks (40 KB),
317+
* 8 locales, sourcemaps ON.
318+
* Tests contention and worker distribution when multiple large files qualify as dominant in the same window.
319+
*/
320+
export function createMultiDominantScenario(
321+
options: ScenarioFactoryOptions = {},
322+
): BenchmarkScenario {
323+
let workload: GeneratedWorkload | undefined;
324+
325+
return {
326+
name: 'multi-dominant',
327+
description:
328+
'Multi-Dominant: 3 large bundles (3 MB each) + 15 chunks (40 KB), 8 locales, sourcemaps ON',
329+
get inputSizeBytes() {
330+
return workload?.totalInputSizeBytes ?? 0;
331+
},
332+
get localeCount() {
333+
return DEFAULT_LOCALES_8.length;
334+
},
335+
async setup() {
336+
await initializeFixtures();
337+
const files: BuildOutputFile[] = [];
338+
const totalMessages = 3000;
339+
const dominantCount = 3;
340+
const dominantSize = 3 * 1024 * 1024;
341+
const dominantMessages = 600; // 600 each x 3 = 1800 messages
342+
343+
for (let d = 0; d < dominantCount; d++) {
344+
const { codeFile, mapFile } = generateSyntheticBundle({
345+
filename: `dominant_${d}.js`,
346+
targetByteSize: dominantSize,
347+
messageCount: dominantMessages,
348+
withSourceMap: true,
349+
messageIdOffset: d * dominantMessages,
350+
});
351+
files.push(codeFile);
352+
if (mapFile) {
353+
files.push(mapFile);
354+
}
355+
}
356+
357+
const chunkCount = 15;
358+
const chunkSizeBytes = 40 * 1024;
359+
const remainingMessages = totalMessages - dominantCount * dominantMessages;
360+
const messagesPerChunk = Math.max(1, Math.floor(remainingMessages / chunkCount));
361+
362+
for (let i = 0; i < chunkCount; i++) {
363+
const { codeFile, mapFile } = generateSyntheticBundle({
364+
filename: `chunk_${i}.js`,
365+
targetByteSize: chunkSizeBytes,
366+
messageCount: messagesPerChunk,
367+
withSourceMap: true,
368+
messageIdOffset: dominantCount * dominantMessages + i * messagesPerChunk,
369+
});
370+
files.push(codeFile);
371+
if (mapFile) {
372+
files.push(mapFile);
373+
}
374+
}
375+
376+
const locales = generateTranslations(DEFAULT_LOCALES_8, totalMessages);
377+
workload = {
378+
files,
379+
locales,
380+
totalInputSizeBytes: calculateInputSizeBytes(files),
381+
};
382+
},
383+
async run() {
384+
if (!workload) {
385+
return;
386+
}
387+
const inliner = new I18nInliner({
388+
missingTranslation: 'warning',
389+
maxConcurrency: options.concurrency,
390+
});
391+
try {
392+
await inliner.inlineAll(workload.files, workload.locales);
393+
} finally {
394+
await inliner.close();
395+
}
396+
},
397+
};
398+
}
399+
314400
/**
315401
* Helper to prime the persistent cache in an isolated process.
316402
*/
@@ -462,6 +548,7 @@ export function getAllScenarios(options: ScenarioFactoryOptions = {}): Benchmark
462548
createEnterpriseScenario(options),
463549
createLargeEnterpriseScenario(options),
464550
createMonolithicScenario(options),
551+
createMultiDominantScenario(options),
465552
createPersistentCacheWarmScenario(options),
466553
];
467554
}

0 commit comments

Comments
 (0)