From 13441a3faf1b1a6c72545d4871123820e2936a1c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:27:31 -0400 Subject: [PATCH 1/2] perf(@angular/build): process batch locales sequentially in i18n inliner worker Executing batch locales concurrently via Promise.all within a single worker thread provided no concurrency benefit because inlining operations (MagicString transformations and source map decoding/remapping) are predominantly synchronous and CPU-bound on Node's single-threaded event loop. Holding concurrent MagicString instances, intermediate AST slices, and decoded source map mappings simultaneously inflated peak worker heap and RSS (especially for smaller chunks where up to 8 locales are processed in a single batch). Switching to sequential iteration allows intermediate transformation and mapping structures to be garbage-collected between locales, reducing peak RSS and slightly improving throughput due to reduced GC pressure. --- .../src/tools/i18n/i18n-inliner-worker.ts | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts index 66eaf23ca974..4286f33a1b41 100644 --- a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -276,26 +276,25 @@ export async function inlineFileBatch( map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; } - const results = await Promise.all( - Array.from(request.locales, async ([locale, translation]) => { - const result = await inlineLocalize( - code, - map, - metadata, - locale, - await loadTranslation(locale, translation), - request.filename, - request.missingTranslation, - ); + const results = []; + for (const [locale, translation] of request.locales) { + const result = await inlineLocalize( + code, + map, + metadata, + locale, + await loadTranslation(locale, translation), + request.filename, + request.missingTranslation, + ); - return { - locale, - code: result.code, - map: result.map, - messages: result.diagnostics, - }; - }), - ); + results.push({ + locale, + code: result.code, + map: result.map, + messages: result.diagnostics, + }); + } return { file: request.filename, From 566bb8655662578c09907ba1b3ee48e72b93e11b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:23:41 -0400 Subject: [PATCH 2/2] 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. --- .../build/src/tools/i18n/i18n-inliner.ts | 18 +++- scripts/benchmarks/i18n/scenarios.mts | 87 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner.ts b/packages/angular/build/src/tools/i18n/i18n-inliner.ts index 581a2477adc5..881440f2ece1 100644 --- a/packages/angular/build/src/tools/i18n/i18n-inliner.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner.ts @@ -70,6 +70,13 @@ const SMALL_FILE_FLOOR_BYTES = 100 * 1024; */ const DOMINANT_FILE_RATIO = 0.7; +/** + * Maximum number of worker threads to allocate concurrently for a single dominant file. + * Capping concurrency on dominant files (which hold large AST and sourcemap trees) + * prevents V8 heap and RSS explosion on machines with high CPU core counts. + */ +const MAX_DOMINANT_WORKERS = 4; + /** * Serializes the translation messages for a locale for transfer to an inliner Worker. * @@ -522,14 +529,17 @@ export class I18nInliner { let localesPerBatch: number; if (uncachedByFile.size === 1) { - // Single file in window: shard across all workers to avoid idle threads - localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount)); + // Single file in window: shard across bounded workers to balance throughput and peak memory + const targetWorkers = Math.min(workerCount, MAX_DOMINANT_WORKERS); + localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers)); } else if (fileSize < SMALL_FILE_FLOOR_BYTES) { // Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead localesPerBatch = entries.length; } else if (fileSize >= maxFileSize * DOMINANT_FILE_RATIO) { - // Dominant file(s): shard across all workers for maximum multi-core parallelism - localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount)); + // Dominant file(s) with multiple files: shard across bounded workers while always reserving + // at least one worker to process smaller chunks in parallel without starvation. + const targetWorkers = Math.max(1, Math.min(workerCount - 1, MAX_DOMINANT_WORKERS)); + localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers)); } else { // Intermediate files: moderate sharding localesPerBatch = Math.max(1, Math.ceil(entries.length / 2)); diff --git a/scripts/benchmarks/i18n/scenarios.mts b/scripts/benchmarks/i18n/scenarios.mts index d1bdcb8cfa40..aaebdfd47cf7 100644 --- a/scripts/benchmarks/i18n/scenarios.mts +++ b/scripts/benchmarks/i18n/scenarios.mts @@ -311,6 +311,92 @@ export function createMonolithicScenario(options: ScenarioFactoryOptions = {}): }; } +/** + * 5. Multi-Dominant Scenario: + * 3 dominant bundles (3 MB each, e.g. main.js, polyfills.js, admin-portal.js) + 15 small chunks (40 KB), + * 8 locales, sourcemaps ON. + * Tests contention and worker distribution when multiple large files qualify as dominant in the same window. + */ +export function createMultiDominantScenario( + options: ScenarioFactoryOptions = {}, +): BenchmarkScenario { + let workload: GeneratedWorkload | undefined; + + return { + name: 'multi-dominant', + description: + 'Multi-Dominant: 3 large bundles (3 MB each) + 15 chunks (40 KB), 8 locales, sourcemaps ON', + get inputSizeBytes() { + return workload?.totalInputSizeBytes ?? 0; + }, + get localeCount() { + return DEFAULT_LOCALES_8.length; + }, + async setup() { + await initializeFixtures(); + const files: BuildOutputFile[] = []; + const totalMessages = 3000; + const dominantCount = 3; + const dominantSize = 3 * 1024 * 1024; + const dominantMessages = 600; // 600 each x 3 = 1800 messages + + for (let d = 0; d < dominantCount; d++) { + const { codeFile, mapFile } = generateSyntheticBundle({ + filename: `dominant_${d}.js`, + targetByteSize: dominantSize, + messageCount: dominantMessages, + withSourceMap: true, + messageIdOffset: d * dominantMessages, + }); + files.push(codeFile); + if (mapFile) { + files.push(mapFile); + } + } + + const chunkCount = 15; + const chunkSizeBytes = 40 * 1024; + const remainingMessages = totalMessages - dominantCount * dominantMessages; + const messagesPerChunk = Math.max(1, Math.floor(remainingMessages / chunkCount)); + + for (let i = 0; i < chunkCount; i++) { + const { codeFile, mapFile } = generateSyntheticBundle({ + filename: `chunk_${i}.js`, + targetByteSize: chunkSizeBytes, + messageCount: messagesPerChunk, + withSourceMap: true, + messageIdOffset: dominantCount * dominantMessages + i * messagesPerChunk, + }); + files.push(codeFile); + if (mapFile) { + files.push(mapFile); + } + } + + const locales = generateTranslations(DEFAULT_LOCALES_8, totalMessages); + workload = { + files, + locales, + totalInputSizeBytes: calculateInputSizeBytes(files), + }; + }, + async run() { + if (!workload) { + return; + } + const inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: options.concurrency, + }); + try { + await inliner.inlineAll(workload.files, workload.locales); + } finally { + await inliner.close(); + } + }, + }; +} + /** * Helper to prime the persistent cache in an isolated process. */ @@ -462,6 +548,7 @@ export function getAllScenarios(options: ScenarioFactoryOptions = {}): Benchmark createEnterpriseScenario(options), createLargeEnterpriseScenario(options), createMonolithicScenario(options), + createMultiDominantScenario(options), createPersistentCacheWarmScenario(options), ]; }