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, 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), ]; }