Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
const existing = fileDataCache.get(filename);
if (existing) {
if (!cache) {
fileDataCache.delete(filename);
}

return existing;
}

Expand Down
70 changes: 54 additions & 16 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,23 @@ import { encodeTranslationToBuffer } from './i18n-translation-encoder';
const LOCALIZE_KEYWORD = '$localize';

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

/**
* Minimum byte size threshold for a file to be eligible for multi-batch sharding.
* Files below this threshold (< 100 KB) are processed in a single batch to minimize IPC overhead.
*/
const SMALL_FILE_FLOOR_BYTES = 100 * 1024;

/**
* Ratio of the maximum file size in a window to consider a file "dominant".
* Files within 70% of the largest file are sharded across all workers for maximum concurrency.
*/
const DOMINANT_FILE_RATIO = 0.7;

/**
* Serializes the translation messages for a locale for transfer to an inliner Worker.
*
Expand Down Expand Up @@ -271,11 +283,13 @@ export class I18nInliner {
(name) => !name.endsWith('.map'),
);

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

// Pre-calculate cache key bases and serialized Blobs for each locale in this window
const localeCacheBases = new Map<string, string>();
Expand Down Expand Up @@ -375,7 +389,6 @@ export class I18nInliner {
if (uncachedByFile.size > 0) {
await this.#processUncachedBatches(
uncachedByFile,
windowLocales.length,
fileResultsByLocale,
activeLocales,
isLastWindow,
Expand Down Expand Up @@ -441,25 +454,50 @@ export class I18nInliner {

async #processUncachedBatches(
uncachedByFile: Map<string, UncachedLocaleEntry[]>,
localeCount: number,
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
activeLocales?: string[],
isLastWindow = true,
): Promise<void> {
const workerCount = this.#workerPool.maxThreads || 1;
const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2);
const localesPerBatch = Math.max(
1,
Math.ceil(localeCount / (targetTaskCount / (uncachedByFile.size || 1))),
);

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

for (const [filename, entries] of uncachedByFile) {
// Extract file data and identify the heaviest file size in a single pass
let maxFileSize = 0;
const sortedFiles = Array.from(uncachedByFile, ([filename, entries]) => {
const codeFile = this.#localizeFiles.get(filename);
assert(codeFile !== undefined, 'Localize file must exist: ' + filename);
const fileSize = codeFile.contents.byteLength;
if (fileSize > maxFileSize) {
maxFileSize = fileSize;
}

return { filename, entries, codeFile, fileSize };
});

// Sort files descending by byte size (Longest Processing Time First / LPT).
// Heavy files (e.g. main.js) are queued first to saturate all worker threads immediately,
// while small files act as gap fillers near the window barrier to prevent tail stragglers.
sortedFiles.sort((a, b) => b.fileSize - a.fileSize);

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

for (const { filename, entries, codeFile, fileSize } of sortedFiles) {
const mapFile = this.#localizeFiles.get(filename + '.map');

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));
} 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));
} else {
// Intermediate files: moderate sharding
localesPerBatch = Math.max(1, Math.ceil(entries.length / 2));
}

const ephemeral = isLastWindow && entries.length <= localesPerBatch;
for (let i = 0; i < entries.length; i += localesPerBatch) {
const batchEntries = entries.slice(i, i + localesPerBatch);
Expand Down
53 changes: 53 additions & 0 deletions packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,4 +823,57 @@ describe('I18nInliner', () => {
]),
).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/);
});

it('correctly inlines when files have varying sizes across multiple workers', async () => {
// Create a large dominant bundle (> 120 KB) and a small chunk (< 10 KB)
const largePadding = '/* padding */ console.log(1);\n'.repeat(4000);
const largeSource = `${GREETING_SOURCE}\n${largePadding}`;
const smallSource = 'console.log($localize`:@@farewell:Goodbye`);';

const largeFile = browserFile('main.js', largeSource);
const smallFile = browserFile('chunk.js', smallSource);

inliner = new I18nInliner(
{ missingTranslation: 'error', outputFiles: [largeFile, smallFile] },
2,
);

const results = await inliner.inlineAll([
{
locale: 'fr',
translation: {
greeting: translationFor('Bonjour'),
farewell: translationFor('Au revoir'),
},
},
{
locale: 'de',
translation: {
greeting: translationFor('Guten Tag'),
farewell: translationFor('Auf Wiedersehen'),
},
},
{
locale: 'es',
translation: {
greeting: translationFor('Hola'),
farewell: translationFor('Adios'),
},
},
]);

expect(results.size).toBe(3);

const frFiles = results.get('fr')?.outputFiles ?? [];
expect(findFile(frFiles, 'main.js').text).toContain('"Bonjour"');
expect(findFile(frFiles, 'chunk.js').text).toContain('"Au revoir"');

const deFiles = results.get('de')?.outputFiles ?? [];
expect(findFile(deFiles, 'main.js').text).toContain('"Guten Tag"');
expect(findFile(deFiles, 'chunk.js').text).toContain('"Auf Wiedersehen"');

const esFiles = results.get('es')?.outputFiles ?? [];
expect(findFile(esFiles, 'main.js').text).toContain('"Hola"');
expect(findFile(esFiles, 'chunk.js').text).toContain('"Adios"');
});
});