Skip to content

Commit c86373d

Browse files
committed
test(@angular/build): add performance benchmark suite for i18n inliner
Introduces an automated macro performance benchmark suite for the i18n inlining subsystem in @angular/build, executable via the benchmark command. The suite generates synthetic in-memory bundles, source maps, and translation catalogs to evaluate realistic workloads without checking large test fixtures into the repository. Benchmark scenarios cover standard applications with and without source maps, enterprise multilingual applications across 32 locales, monolithic bundles to verify 2D task sharding, and warm persistent cache throughput. Each scenario is executed in an isolated child process, and persistent cache priming is performed out-of-process to avoid cross-scenario memory contamination from the OS memory allocator. The harness collects high-precision timing, peak heap, peak RSS, and memory deltas with explicit garbage collection support. The runner is integrated into devkit-admin with support for baseline comparison, machine-readable JSON output, stale build detection, and automatic rebuilds.
1 parent 5ec6dac commit c86373d

8 files changed

Lines changed: 1187 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"bazel": "bazelisk",
1616
"test": "bazel test //packages/...",
1717
"build": "pnpm --silent admin build",
18+
"benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark",
1819
"build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/",
1920
"lint": "eslint --cache --max-warnings=0",
2021
"templates": "pnpm --silent admin templates",

scripts/benchmark.mts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import fs from 'node:fs';
10+
import { type BenchmarkCliOptions, runI18nBenchmarks } from './benchmarks/i18n/index.mts';
11+
12+
function checkBuildStatus(logger: Console): boolean {
13+
const distFile = 'dist/@angular/build/src/tools/esbuild/i18n-inliner.js';
14+
const srcFile = 'packages/angular/build/src/tools/esbuild/i18n-inliner.ts';
15+
16+
if (!fs.existsSync(distFile)) {
17+
logger.error(
18+
'Error: @angular/build has not been built yet.\nPlease run "pnpm build" before benchmarking.',
19+
);
20+
21+
return false;
22+
}
23+
24+
if (fs.existsSync(srcFile)) {
25+
const srcMtime = fs.statSync(srcFile).mtimeMs;
26+
const distMtime = fs.statSync(distFile).mtimeMs;
27+
if (srcMtime > distMtime) {
28+
logger.warn(
29+
'Warning: Source files in packages/angular/build are newer than dist/.\n' +
30+
'Run "pnpm build" to ensure your benchmark reflects your latest local edits.\n',
31+
);
32+
}
33+
}
34+
35+
return true;
36+
}
37+
38+
export default async function (
39+
options: {
40+
_?: string[];
41+
scenario?: string;
42+
iterations?: string | number;
43+
warmup?: string | number;
44+
concurrency?: string | number;
45+
build?: boolean;
46+
json?: boolean;
47+
saveBaseline?: string;
48+
'save-baseline'?: string;
49+
compareBaseline?: string;
50+
'compare-baseline'?: string;
51+
help?: boolean;
52+
[key: string]: unknown;
53+
},
54+
_cwd: string,
55+
): Promise<number> {
56+
const positionals = options._ ?? [];
57+
const targetSubsystem = positionals[0] ?? 'i18n';
58+
59+
if (options.help || targetSubsystem === 'help') {
60+
// eslint-disable-next-line no-console
61+
console.log(`
62+
Angular CLI Performance Benchmark Runner
63+
64+
Usage:
65+
pnpm admin benchmark [subsystem] [options]
66+
67+
Subsystems:
68+
i18n Run i18n inliner performance benchmarks (default)
69+
70+
Options:
71+
--scenario=<name> Run a specific scenario (e.g. standard-app, enterprise-multilingual)
72+
--iterations=<n> Number of measured iterations (default: 5)
73+
--warmup=<n> Number of warmup iterations (default: 2)
74+
--concurrency=<n> Override worker thread pool concurrency
75+
--build Automatically build packages before benchmarking
76+
--json Output results in machine-readable JSON
77+
--save-baseline=<file> Save run results to a baseline JSON file
78+
--compare-baseline=<file> Compare run results against an existing baseline JSON file
79+
--help Show this help message
80+
`);
81+
82+
return 0;
83+
}
84+
85+
if (targetSubsystem !== 'i18n') {
86+
// eslint-disable-next-line no-console
87+
console.error(`Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n`);
88+
89+
return 1;
90+
}
91+
92+
if (options.build) {
93+
const buildModule = await import('./build.mts');
94+
await buildModule.default({ local: true });
95+
}
96+
97+
// eslint-disable-next-line no-console
98+
if (!checkBuildStatus(console)) {
99+
return 1;
100+
}
101+
102+
const cliOptions: BenchmarkCliOptions = {
103+
scenario: options.scenario,
104+
iterations: options.iterations !== undefined ? Number(options.iterations) : undefined,
105+
warmup: options.warmup !== undefined ? Number(options.warmup) : undefined,
106+
concurrency: options.concurrency !== undefined ? Number(options.concurrency) : undefined,
107+
json: Boolean(options.json),
108+
saveBaseline: options.saveBaseline ?? options['save-baseline'],
109+
compareBaseline: options.compareBaseline ?? options['compare-baseline'],
110+
};
111+
112+
const { exitCode } = await runI18nBenchmarks(cliOptions);
113+
114+
return exitCode;
115+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import './init-env.mts';
10+
import type { ɵParsedTranslation } from '@angular/localize';
11+
import { transformSync } from 'esbuild';
12+
import { createRequire } from 'node:module';
13+
14+
import path from 'node:path';
15+
16+
import type { BuildOutputFile } from '../../../dist/@angular/build/src/tools/esbuild/bundler-files.d.ts';
17+
import type { LocaleInlineOptions } from '../../../dist/@angular/build/src/tools/esbuild/i18n-inliner.d.ts';
18+
19+
// Setup module paths to resolve dependencies from packages/angular/build
20+
const requireFromBuild = createRequire(
21+
path.resolve(import.meta.dirname, '../../../packages/angular/build/package.json'),
22+
);
23+
24+
const { BuildOutputFileType, createOutputFile } = requireFromBuild(
25+
'../../../dist/@angular/build/src/tools/esbuild/bundler-files.js',
26+
) as typeof import('../../../packages/angular/build/src/tools/esbuild/bundler-files.js');
27+
28+
const { calculateHash, initializeHash } = requireFromBuild(
29+
'../../../dist/@angular/build/src/utils/hash.js',
30+
) as typeof import('../../../packages/angular/build/src/utils/hash.js');
31+
32+
let isHashInitialized = false;
33+
34+
export async function initializeFixtures(): Promise<void> {
35+
if (!isHashInitialized) {
36+
await initializeHash();
37+
isHashInitialized = true;
38+
}
39+
}
40+
41+
export function parsedTranslation(
42+
parts: string[],
43+
placeholderNames: string[] = [],
44+
text?: string,
45+
): ɵParsedTranslation {
46+
return {
47+
messageParts: Object.assign([...parts], { raw: [...parts] }),
48+
placeholderNames,
49+
text: text ?? parts.join(''),
50+
};
51+
}
52+
53+
export function generateTranslations(
54+
locales: string[],
55+
messageCount: number,
56+
): LocaleInlineOptions[] {
57+
return locales.map((locale) => {
58+
const translation: Record<string, ɵParsedTranslation> = {};
59+
60+
for (let i = 0; i < messageCount; i++) {
61+
const msgId = `msg_${i}`;
62+
translation[msgId] = parsedTranslation(
63+
[`[${locale}] Order #`, ` was confirmed for customer `, `. Thank you!`],
64+
['orderId', 'customerName'],
65+
`[${locale}] Order #${i} was confirmed for customer Doe. Thank you!`,
66+
);
67+
}
68+
69+
const translationIntegrity = calculateHash(JSON.stringify(translation));
70+
71+
return {
72+
locale,
73+
translation,
74+
translationIntegrity,
75+
};
76+
});
77+
}
78+
79+
export interface SyntheticBundleOptions {
80+
filename: string;
81+
targetByteSize: number;
82+
messageCount: number;
83+
withSourceMap?: boolean;
84+
messageIdOffset?: number;
85+
}
86+
87+
export function generateSyntheticBundle(options: SyntheticBundleOptions): {
88+
codeFile: BuildOutputFile;
89+
mapFile?: BuildOutputFile;
90+
} {
91+
const {
92+
filename,
93+
targetByteSize,
94+
messageCount,
95+
withSourceMap = true,
96+
messageIdOffset = 0,
97+
} = options;
98+
99+
const parts: string[] = [
100+
'// Synthetic test bundle generated for i18n-inliner benchmark\n',
101+
'export const BUNDLE_META = { generated: true, timestamp: Date.now() };\n',
102+
];
103+
104+
// Generate functions with $localize call sites
105+
for (let i = 0; i < messageCount; i++) {
106+
const msgId = `msg_${messageIdOffset + i}`;
107+
parts.push(
108+
`export function renderMessage_${i}(orderId, customerName) {\n`,
109+
` return $localize\`:@@${msgId}:Order #\${orderId}:orderId: ` +
110+
`was confirmed for customer \${customerName}:customerName:. Thank you!\`;\n`,
111+
`}\n`,
112+
);
113+
}
114+
115+
// Calculate current approximate size and pad with realistic JS functions if needed
116+
let currentSize = parts.reduce((acc, str) => acc + str.length, 0);
117+
let classIndex = 0;
118+
119+
while (currentSize < targetByteSize) {
120+
const filler =
121+
`export class DataProcessor_${classIndex} {\n` +
122+
` constructor(id, options = {}) {\n` +
123+
` this.id = id;\n` +
124+
` this.options = Object.assign({ enabled: true, retries: 3 }, options);\n` +
125+
` this.history = [];\n` +
126+
` }\n` +
127+
` process(batch) {\n` +
128+
` if (!Array.isArray(batch)) return [];\n` +
129+
` const result = batch.map((item, idx) => ({\n` +
130+
` id: this.id + '_' + idx,\n` +
131+
` source: item,\n` +
132+
` timestamp: Date.now(),\n` +
133+
` active: true\n` +
134+
` }));\n` +
135+
` this.history.push(...result);\n` +
136+
` return result;\n` +
137+
` }\n` +
138+
`}\n`;
139+
140+
parts.push(filler);
141+
currentSize += filler.length;
142+
classIndex++;
143+
}
144+
145+
const rawCode = parts.join('');
146+
147+
let finalCode = rawCode;
148+
let finalMap: string | undefined;
149+
150+
if (withSourceMap) {
151+
const result = transformSync(rawCode, {
152+
sourcemap: true,
153+
sourcefile: filename.replace(/\.js$/, '.ts'),
154+
});
155+
finalCode = result.code;
156+
finalMap = result.map;
157+
}
158+
159+
const codeFile = createOutputFile(filename, finalCode, BuildOutputFileType.Browser);
160+
const mapFile = finalMap
161+
? createOutputFile(filename + '.map', finalMap, BuildOutputFileType.Browser)
162+
: undefined;
163+
164+
return { codeFile, mapFile };
165+
}

0 commit comments

Comments
 (0)