Skip to content

Commit 712971c

Browse files
mattlewis92alan-agius4
authored andcommitted
perf(@angular/build): share i18n translations with the inliner workers by reference
`inlineForLocale` passes the locale's translations to `workerPool.run()` once per file, so every request structure-clones the whole set of messages into a Worker. For an application with a few thousand localized chunks and a catalog of tens of thousands of messages, that is tens of gigabytes of short-lived allocation and minutes of serialization on the builder's main thread. Because each clone is several megabytes it lands in V8's large object space, where only a major collection reclaims it, so a build with a heap ceiling sized for the machine can exhaust the machine before the collector intervenes. The messages are now serialized once per locale and passed as a Blob. Cloning a Blob shares its data by reference, which is the same reason the application files are already passed that way. Each Worker deserializes the messages once per locale and retains only the active locale, so at most one set of messages is held per Worker. `node:v8` serialization is used rather than JSON so that the messages arrive in the Worker exactly as the structured clone delivered them today. `translate` only reads from the messages, so sharing one deserialized set across the files of a locale is safe. Measured on an application with 1,721 localized chunks across 9 locales, where the largest catalog serializes to 6.0MB: inlining 400 chunks across 4 locales went from 23,679ms and 14,504MB peak RSS to 915ms and 1,118MB, with byte-identical output. (cherry picked from commit 596847f)
1 parent 3d7e363 commit 712971c

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

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

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
1010
import { MagicString } from 'magic-string';
1111
import assert from 'node:assert';
12+
import { deserialize } from 'node:v8';
1213
import { workerData } from 'node:worker_threads';
1314
import { Visitor, parseSync } from 'oxc-parser';
1415

@@ -28,9 +29,11 @@ interface InlineFileRequest {
2829
locale: string;
2930

3031
/**
31-
* The translation messages for the locale that should be used during the inlining process of the file.
32+
* The serialized translation messages for the locale that should be used during the inlining
33+
* process of the file. A Blob is used so that the messages are shared with the Worker by
34+
* reference instead of being copied into it for every request.
3235
*/
33-
translation?: Record<string, unknown>;
36+
translation?: Blob;
3437
}
3538

3639
/**
@@ -53,19 +56,55 @@ interface InlineCodeRequest {
5356
locale: string;
5457

5558
/**
56-
* The translation messages for the locale that should be used during the inlining process of the file.
59+
* The serialized translation messages for the locale that should be used during the inlining
60+
* process of the file. A Blob is used so that the messages are shared with the Worker by
61+
* reference instead of being copied into it for every request.
5762
*/
58-
translation?: Record<string, unknown>;
63+
translation?: Blob;
5964
}
6065

6166
// Extract the application files and common options used for inline requests from the Worker context
62-
// TODO: Evaluate overall performance difference of passing translations here as well
6367
const { files, missingTranslation, shouldOptimize } = (workerData || {}) as {
6468
files: ReadonlyMap<string, Blob>;
6569
missingTranslation: 'error' | 'warning' | 'ignore';
6670
shouldOptimize: boolean;
6771
};
6872

73+
/**
74+
* The translation messages deserialized for the locale most recently requested of this Worker.
75+
* Locales are inlined one at a time, so retaining only the active locale is enough to avoid
76+
* deserializing the messages once per file while holding at most one set of messages in memory.
77+
*/
78+
let activeTranslation: { locale: string; messages: Promise<Record<string, unknown>> } | undefined;
79+
80+
/**
81+
* Deserializes the translation messages for an inline request, reusing the result for any
82+
* subsequent request that targets the same locale.
83+
* @param request An inline request containing the locale and its serialized messages.
84+
* @returns The translation messages, or undefined if the locale has no translations.
85+
*/
86+
function loadTranslation(
87+
request: InlineFileRequest | InlineCodeRequest,
88+
): Promise<Record<string, unknown>> | undefined {
89+
const { locale, translation } = request;
90+
if (!translation) {
91+
return undefined;
92+
}
93+
94+
if (activeTranslation?.locale !== locale) {
95+
activeTranslation = {
96+
locale,
97+
// Deserializing within the stored promise ensures that concurrent requests for a locale
98+
// share the one deserialization instead of each performing their own.
99+
messages: translation
100+
.arrayBuffer()
101+
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, unknown>),
102+
};
103+
}
104+
105+
return activeTranslation.messages;
106+
}
107+
69108
/**
70109
* Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage.
71110
* This function is the main entry for the Worker's action that is called by the worker pool.
@@ -80,7 +119,12 @@ export default async function inlineFile(request: InlineFileRequest) {
80119

81120
const code = await data.text();
82121
const map = await files.get(request.filename + '.map')?.text();
83-
const result = await transformWithOxc(code, map && (JSON.parse(map) as SourceMapInput), request);
122+
const result = await transformWithOxc(
123+
code,
124+
map && (JSON.parse(map) as SourceMapInput),
125+
request,
126+
await loadTranslation(request),
127+
);
84128

85129
return {
86130
file: request.filename,
@@ -98,7 +142,12 @@ export default async function inlineFile(request: InlineFileRequest) {
98142
* @returns An object containing the inlined code.
99143
*/
100144
export async function inlineCode(request: InlineCodeRequest) {
101-
const result = await transformWithOxc(request.code, undefined, request);
145+
const result = await transformWithOxc(
146+
request.code,
147+
undefined,
148+
request,
149+
await loadTranslation(request),
150+
);
102151

103152
return {
104153
output: result.code,
@@ -136,12 +185,14 @@ async function loadLocalizeTools(): Promise<LocalizeUtilityModule> {
136185
* @param code A string containing the JavaScript code to transform.
137186
* @param map A sourcemap object for the provided JavaScript code.
138187
* @param options The inline request options to use.
188+
* @param translation The translation messages to inline, or undefined for an untranslated locale.
139189
* @returns An object containing the code, map, and diagnostics from the transformation.
140190
*/
141191
async function transformWithOxc(
142192
code: string,
143193
map: SourceMapInput | undefined,
144194
options: InlineFileRequest,
195+
translation: Record<string, unknown> | undefined,
145196
) {
146197
const { program } = parseSync(options.filename, code, {
147198
sourceType: 'unambiguous',
@@ -169,10 +220,10 @@ async function transformWithOxc(
169220

170221
const [translatedParts, translatedSubstitutions] = translate(
171222
diagnostics,
172-
options.translation || {},
223+
translation || {},
173224
messageParts,
174225
node.quasi.expressions.map((_, index) => index),
175-
options.translation === undefined ? 'ignore' : missingTranslation,
226+
translation === undefined ? 'ignore' : missingTranslation,
176227
);
177228

178229
// Reconstruct the new template/string literal replacement

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import assert from 'node:assert';
1010
import { createHash } from 'node:crypto';
1111
import { extname, join } from 'node:path';
12+
import { serialize } from 'node:v8';
1213
import { WorkerPool } from '../../utils/worker-pool';
1314
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
1415
import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
@@ -19,6 +20,20 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
1920
*/
2021
const LOCALIZE_KEYWORD = '$localize';
2122

23+
/**
24+
* Serializes the translation messages for a locale for transfer to an inliner Worker.
25+
*
26+
* A Blob is used because cloning one shares its data by reference, whereas sending the messages
27+
* themselves copies them into a Worker for every request that carries them. A locale can contain
28+
* tens of thousands of messages, which makes that copy the dominant cost of inlining a locale.
29+
*
30+
* @param translation The translation messages for a locale, if the locale has any.
31+
* @returns A Blob containing the serialized messages, or undefined if the locale has none.
32+
*/
33+
function serializeTranslation(translation: Record<string, unknown> | undefined): Blob | undefined {
34+
return translation && new Blob([serialize(translation)]);
35+
}
36+
2237
/**
2338
* Inlining options that should apply to all transformed code.
2439
*/
@@ -125,6 +140,10 @@ export class I18nInliner {
125140
await this.initCache();
126141

127142
const { shouldOptimize, missingTranslation } = this.options;
143+
144+
// Serialized once here and then shared by the request for every file of this locale
145+
const translationBlob = serializeTranslation(translation);
146+
128147
// Request inlining for each file that contains localize calls
129148
const requests = [];
130149

@@ -160,7 +179,11 @@ export class I18nInliner {
160179
return cachedResult;
161180
}
162181

163-
const result = await this.#workerPool.run({ filename, locale, translation });
182+
const result = await this.#workerPool.run({
183+
filename,
184+
locale,
185+
translation: translationBlob,
186+
});
164187
if (this.#cache && cacheKey) {
165188
try {
166189
// Failure to set the value should not fail the transform
@@ -227,7 +250,12 @@ export class I18nInliner {
227250
}
228251

229252
const { output, messages } = await this.#workerPool.run(
230-
{ code: templateCode, filename: templateId, locale, translation },
253+
{
254+
code: templateCode,
255+
filename: templateId,
256+
locale,
257+
translation: serializeTranslation(translation),
258+
},
231259
{ name: 'inlineCode' },
232260
);
233261

0 commit comments

Comments
 (0)