Skip to content

Commit 3d08dcd

Browse files
committed
perf(@angular/build): use size-weighted task heuristics in i18n inliner
Incorporate Longest Processing Time First (LPT) scheduling, hybrid relative sizing, and hardware-adaptive sliding windows for task dispatch in I18nInliner. Previously, files were dispatched in arbitrary insertion order, partitioned using a uniform locale batch size across all files regardless of byte size, and processed in fixed 8-locale sliding windows. This could result in large dominant files like main.js starting late in a window and causing single-worker straggler latency at the window barrier, while small chunks were unnecessarily fragmented into multiple IPC tasks and high-core machines (>8 cores) were throttled by the fixed 8-locale limit.
1 parent 2b85529 commit 3d08dcd

3 files changed

Lines changed: 113 additions & 14 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
144144
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
145145
const existing = fileDataCache.get(filename);
146146
if (existing) {
147+
if (!cache) {
148+
fileDataCache.delete(filename);
149+
}
150+
147151
return existing;
148152
}
149153

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

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,23 @@ import { encodeTranslationToBuffer } from './i18n-translation-encoder';
2323
const LOCALIZE_KEYWORD = '$localize';
2424

2525
/**
26-
* The maximum number of locales to process concurrently in a single sliding window.
27-
* This caps peak worker memory while maintaining multi-locale batching throughput.
26+
* The baseline number of locales to process concurrently in a single sliding window.
27+
* This caps peak worker memory on low-core machines while maintaining multi-locale batching throughput.
2828
*/
2929
const DEFAULT_LOCALE_WINDOW_SIZE = 8;
3030

31+
/**
32+
* Minimum byte size threshold for a file to be eligible for multi-batch sharding.
33+
* Files below this threshold (< 100 KB) are processed in a single batch to minimize IPC overhead.
34+
*/
35+
const SMALL_FILE_FLOOR_BYTES = 100 * 1024;
36+
37+
/**
38+
* Ratio of the maximum file size in a window to consider a file "dominant".
39+
* Files within 70% of the largest file are sharded across all workers for maximum concurrency.
40+
*/
41+
const DOMINANT_FILE_RATIO = 0.7;
42+
3143
/**
3244
* Serializes the translation messages for a locale for transfer to an inliner Worker.
3345
*
@@ -271,11 +283,13 @@ export class I18nInliner {
271283
(name) => !name.endsWith('.map'),
272284
);
273285

274-
// Process locales in sliding windows to cap peak worker memory
275-
for (let i = 0; i < localeList.length; i += DEFAULT_LOCALE_WINDOW_SIZE) {
276-
const windowLocales = localeList.slice(i, i + DEFAULT_LOCALE_WINDOW_SIZE);
286+
// Process locales in sliding windows to cap peak worker memory.
287+
// Ensure the window has at least enough locales to saturate all available workers on high-core machines.
288+
const windowSize = Math.max(DEFAULT_LOCALE_WINDOW_SIZE, this.#workerPool.maxThreads || 1);
289+
for (let i = 0; i < localeList.length; i += windowSize) {
290+
const windowLocales = localeList.slice(i, i + windowSize);
277291
const activeLocales = windowLocales.map((item) => item.locale);
278-
const isLastWindow = i + DEFAULT_LOCALE_WINDOW_SIZE >= localeList.length;
292+
const isLastWindow = i + windowSize >= localeList.length;
279293

280294
// Pre-calculate cache key bases and serialized Blobs for each locale in this window
281295
const localeCacheBases = new Map<string, string>();
@@ -375,7 +389,6 @@ export class I18nInliner {
375389
if (uncachedByFile.size > 0) {
376390
await this.#processUncachedBatches(
377391
uncachedByFile,
378-
windowLocales.length,
379392
fileResultsByLocale,
380393
activeLocales,
381394
isLastWindow,
@@ -441,24 +454,53 @@ export class I18nInliner {
441454

442455
async #processUncachedBatches(
443456
uncachedByFile: Map<string, UncachedLocaleEntry[]>,
444-
localeCount: number,
445457
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
446458
activeLocales?: string[],
447459
isLastWindow = true,
448460
): Promise<void> {
449461
const workerCount = this.#workerPool.maxThreads || 1;
450-
const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2);
451-
const localesPerBatch = Math.max(
452-
1,
453-
Math.ceil(localeCount / (targetTaskCount / (uncachedByFile.size || 1))),
454-
);
462+
463+
// Identify the heaviest file size in the current window to enable relative sizing heuristics
464+
let maxFileSize = 0;
465+
for (const filename of uncachedByFile.keys()) {
466+
const size = this.#localizeFiles.get(filename)?.contents.byteLength ?? 0;
467+
if (size > maxFileSize) {
468+
maxFileSize = size;
469+
}
470+
}
471+
472+
// Sort files descending by byte size (Longest Processing Time First / LPT).
473+
// Heavy files (e.g. main.js) are queued first to saturate all worker threads immediately,
474+
// while small files act as gap fillers near the window barrier to prevent tail stragglers.
475+
const sortedFiles = Array.from(uncachedByFile.entries()).sort(([fileA], [fileB]) => {
476+
const sizeA = this.#localizeFiles.get(fileA)?.contents.byteLength ?? 0;
477+
const sizeB = this.#localizeFiles.get(fileB)?.contents.byteLength ?? 0;
478+
479+
return sizeB - sizeA;
480+
});
455481

456482
const workerTasks: Promise<void>[] = [];
457483

458-
for (const [filename, entries] of uncachedByFile) {
484+
for (const [filename, entries] of sortedFiles) {
459485
const codeFile = this.#localizeFiles.get(filename);
460486
assert(codeFile !== undefined, 'Localize file must exist: ' + filename);
461487
const mapFile = this.#localizeFiles.get(filename + '.map');
488+
const fileSize = codeFile.contents.byteLength;
489+
490+
let localesPerBatch: number;
491+
if (uncachedByFile.size === 1) {
492+
// Single file in window: shard across all workers to avoid idle threads
493+
localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount));
494+
} else if (fileSize < SMALL_FILE_FLOOR_BYTES) {
495+
// Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead
496+
localesPerBatch = entries.length;
497+
} else if (fileSize >= maxFileSize * DOMINANT_FILE_RATIO) {
498+
// Dominant file(s): shard across all workers for maximum multi-core parallelism
499+
localesPerBatch = Math.max(1, Math.ceil(entries.length / workerCount));
500+
} else {
501+
// Intermediate files: moderate sharding
502+
localesPerBatch = Math.max(1, Math.ceil(entries.length / 2));
503+
}
462504

463505
const ephemeral = isLastWindow && entries.length <= localesPerBatch;
464506
for (let i = 0; i < entries.length; i += localesPerBatch) {

packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,4 +823,57 @@ describe('I18nInliner', () => {
823823
]),
824824
).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/);
825825
});
826+
827+
it('correctly inlines when files have varying sizes across multiple workers', async () => {
828+
// Create a large dominant bundle (> 120 KB) and a small chunk (< 10 KB)
829+
const largePadding = '/* padding */ console.log(1);\n'.repeat(4000);
830+
const largeSource = `${GREETING_SOURCE}\n${largePadding}`;
831+
const smallSource = 'console.log($localize`:@@farewell:Goodbye`);';
832+
833+
const largeFile = browserFile('main.js', largeSource);
834+
const smallFile = browserFile('chunk.js', smallSource);
835+
836+
inliner = new I18nInliner(
837+
{ missingTranslation: 'error', outputFiles: [largeFile, smallFile] },
838+
2,
839+
);
840+
841+
const results = await inliner.inlineAll([
842+
{
843+
locale: 'fr',
844+
translation: {
845+
greeting: translationFor('Bonjour'),
846+
farewell: translationFor('Au revoir'),
847+
},
848+
},
849+
{
850+
locale: 'de',
851+
translation: {
852+
greeting: translationFor('Guten Tag'),
853+
farewell: translationFor('Auf Wiedersehen'),
854+
},
855+
},
856+
{
857+
locale: 'es',
858+
translation: {
859+
greeting: translationFor('Hola'),
860+
farewell: translationFor('Adios'),
861+
},
862+
},
863+
]);
864+
865+
expect(results.size).toBe(3);
866+
867+
const frFiles = results.get('fr')?.outputFiles ?? [];
868+
expect(findFile(frFiles, 'main.js').text).toContain('"Bonjour"');
869+
expect(findFile(frFiles, 'chunk.js').text).toContain('"Au revoir"');
870+
871+
const deFiles = results.get('de')?.outputFiles ?? [];
872+
expect(findFile(deFiles, 'main.js').text).toContain('"Guten Tag"');
873+
expect(findFile(deFiles, 'chunk.js').text).toContain('"Auf Wiedersehen"');
874+
875+
const esFiles = results.get('es')?.outputFiles ?? [];
876+
expect(findFile(esFiles, 'main.js').text).toContain('"Hola"');
877+
expect(findFile(esFiles, 'chunk.js').text).toContain('"Adios"');
878+
});
826879
});

0 commit comments

Comments
 (0)