Skip to content

Commit ae6291b

Browse files
committed
test(@angular/build): add large-enterprise-10k benchmark scenario
Adds a large-scale stress test scenario to the i18n inliner benchmark suite with 10,000 translation messages across 32 locales. The scenario generates a 3 MB main bundle and 100 route chunks with source maps, evaluating binary translation catalog encoding, small file batching, and memory scaling under maximum enterprise workloads.
1 parent c86373d commit ae6291b

6 files changed

Lines changed: 131 additions & 19 deletions

File tree

scripts/benchmark.mts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import fs from 'node:fs';
10+
import path from 'node:path';
1011
import { type BenchmarkCliOptions, runI18nBenchmarks } from './benchmarks/i18n/index.mts';
1112

1213
function checkBuildStatus(logger: Console): boolean {
@@ -44,6 +45,8 @@ export default async function (
4445
concurrency?: string | number;
4546
build?: boolean;
4647
json?: boolean;
48+
inProcess?: boolean;
49+
'in-process'?: boolean;
4750
saveBaseline?: string;
4851
'save-baseline'?: string;
4952
compareBaseline?: string;
@@ -72,6 +75,7 @@ Options:
7275
--iterations=<n> Number of measured iterations (default: 5)
7376
--warmup=<n> Number of warmup iterations (default: 2)
7477
--concurrency=<n> Override worker thread pool concurrency
78+
--in-process Run all scenarios in a single process (useful for debugging)
7579
--build Automatically build packages before benchmarking
7680
--json Output results in machine-readable JSON
7781
--save-baseline=<file> Save run results to a baseline JSON file
@@ -99,14 +103,45 @@ Options:
99103
return 1;
100104
}
101105

106+
const rawSaveBaseline = options.saveBaseline ?? options['save-baseline'];
107+
const rawCompareBaseline = options.compareBaseline ?? options['compare-baseline'];
108+
109+
const iterations = options.iterations !== undefined ? Number(options.iterations) : undefined;
110+
const warmup = options.warmup !== undefined ? Number(options.warmup) : undefined;
111+
const concurrency = options.concurrency !== undefined ? Number(options.concurrency) : undefined;
112+
113+
if (iterations !== undefined && (!Number.isInteger(iterations) || iterations < 1)) {
114+
// eslint-disable-next-line no-console
115+
console.error('Error: --iterations must be a positive integer.');
116+
117+
return 1;
118+
}
119+
120+
if (warmup !== undefined && (!Number.isInteger(warmup) || warmup < 0)) {
121+
// eslint-disable-next-line no-console
122+
console.error('Error: --warmup must be a non-negative integer.');
123+
124+
return 1;
125+
}
126+
127+
if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) {
128+
// eslint-disable-next-line no-console
129+
console.error('Error: --concurrency must be a positive integer.');
130+
131+
return 1;
132+
}
133+
102134
const cliOptions: BenchmarkCliOptions = {
103135
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,
136+
iterations,
137+
warmup,
138+
concurrency,
107139
json: Boolean(options.json),
108-
saveBaseline: options.saveBaseline ?? options['save-baseline'],
109-
compareBaseline: options.compareBaseline ?? options['compare-baseline'],
140+
inProcess: Boolean(options.inProcess ?? options['in-process']),
141+
saveBaseline: rawSaveBaseline ? path.resolve(_cwd, String(rawSaveBaseline)) : undefined,
142+
compareBaseline: rawCompareBaseline
143+
? path.resolve(_cwd, String(rawCompareBaseline))
144+
: undefined,
110145
};
111146

112147
const { exitCode } = await runI18nBenchmarks(cliOptions);

scripts/benchmarks/i18n/fixtures.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { createRequire } from 'node:module';
1313

1414
import path from 'node:path';
1515

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';
16+
import type { BuildOutputFile } from '../../../packages/angular/build/src/tools/esbuild/bundler-files.js';
17+
import type { LocaleInlineOptions } from '../../../packages/angular/build/src/tools/esbuild/i18n-inliner.js';
1818

1919
// Setup module paths to resolve dependencies from packages/angular/build
2020
const requireFromBuild = createRequire(

scripts/benchmarks/i18n/harness.mts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export async function runScenario(
6363
const warmup = options.warmup ?? 2;
6464
const iterations = options.iterations ?? 5;
6565

66-
await scenario.setup?.();
67-
6866
try {
67+
await scenario.setup?.();
68+
6969
// Warmup phase
7070
for (let w = 0; w < warmup; w++) {
7171
// Force GC if available between warmups
@@ -106,10 +106,10 @@ export async function runScenario(
106106
if (rssDelta > maxRssDeltaBytes) {
107107
maxRssDeltaBytes = rssDelta;
108108
}
109-
finalHeap = memAfter.heapUsed;
110109

111110
// Force GC immediately after iteration to clean main thread isolate
112111
global.gc?.();
112+
finalHeap = process.memoryUsage().heapUsed;
113113
}
114114

115115
// Sort ascending for percentile computation

scripts/benchmarks/i18n/index.mts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,12 @@ export async function runI18nBenchmarks(
8282
}
8383

8484
const proc = spawnSync(process.execPath, args, { encoding: 'utf-8' });
85-
if (proc.status !== 0) {
85+
if (proc.status !== 0 || proc.error) {
8686
// eslint-disable-next-line no-console
87-
console.error(`Error running scenario ${scenario.name}:\n${proc.stderr || proc.stdout}`);
87+
console.error(
88+
`Error running scenario ${scenario.name}:\n` +
89+
(proc.error?.message ?? (proc.stderr || proc.stdout)),
90+
);
8891

8992
return { results, exitCode: 1 };
9093
}

scripts/benchmarks/i18n/init-env.mts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ const buildNodeModules = path.resolve(
1717

1818
const currentPath = process.env.NODE_PATH ?? '';
1919
if (!currentPath.includes(buildNodeModules)) {
20-
process.env.NODE_PATH = currentPath ? `${buildNodeModules}:${currentPath}` : buildNodeModules;
20+
process.env.NODE_PATH = currentPath
21+
? `${buildNodeModules}${path.delimiter}${currentPath}`
22+
: buildNodeModules;
2123
// Initialize internal search paths for Node CommonJS loader
2224
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2325
(Module as any)._initPaths?.();

scripts/benchmarks/i18n/scenarios.mts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import fs from 'node:fs/promises';
1313
import { createRequire } from 'node:module';
1414
import os from 'node:os';
1515
import path from 'node:path';
16+
import { pathToFileURL } from 'node:url';
1617

17-
import type { BuildOutputFile } from '../../../dist/@angular/build/src/tools/esbuild/bundler-files.d.ts';
18-
import type { LocaleInlineOptions } from '../../../dist/@angular/build/src/tools/esbuild/i18n-inliner.d.ts';
18+
import type { BuildOutputFile } from '../../../packages/angular/build/src/tools/esbuild/bundler-files.js';
19+
import type { LocaleInlineOptions } from '../../../packages/angular/build/src/tools/esbuild/i18n-inliner.js';
1920
import { generateSyntheticBundle, generateTranslations, initializeFixtures } from './fixtures.mts';
2021
import type { BenchmarkScenario } from './harness.mts';
2122

@@ -25,7 +26,7 @@ const requireFromBuild = createRequire(
2526

2627
const { I18nInliner } = requireFromBuild(
2728
'../../../dist/@angular/build/src/tools/esbuild/i18n-inliner.js',
28-
) as typeof import('../../../dist/@angular/build/src/tools/esbuild/i18n-inliner.d.ts');
29+
) as typeof import('../../../packages/angular/build/src/tools/esbuild/i18n-inliner.js');
2930

3031
export interface ScenarioFactoryOptions {
3132
concurrency?: number;
@@ -124,6 +125,15 @@ function calculateInputSizeBytes(files: BuildOutputFile[]): number {
124125
}, 0);
125126
}
126127

128+
/**
129+
* Note on Worker Pool Lifecycle:
130+
* Each scenario's run() method instantiates and closes an I18nInliner per iteration.
131+
* This is designed as a macro benchmark to reflect the cold-start behavior of single-shot
132+
* CLI build invocations (including worker thread pool initialization, task dispatch,
133+
* inlining transformations, and thread pool shutdown). Warmup iterations warm up the
134+
* main-thread V8 isolate and runtime paths, while worker threads are initialized per iteration.
135+
*/
136+
127137
/**
128138
* 1. Standard App Scenario:
129139
* 1 main bundle (1 MB) + 20 route chunks (50 KB) = ~2 MB input JS, 8 locales, maps enabled.
@@ -350,15 +360,28 @@ export function createPersistentCacheWarmScenario(
350360

351361
// Prime the persistent cache out-of-process so cold worker thread allocations
352362
// do not inflate this process's RSS metrics.
363+
const scenariosUrl = pathToFileURL(path.resolve(import.meta.dirname, './scenarios.mts')).href;
353364
const primerCode =
354-
`import { primeCache } from ${JSON.stringify(path.resolve(import.meta.dirname, './scenarios.mts'))};\n` +
365+
`import { primeCache } from ${JSON.stringify(scenariosUrl)};\n` +
355366
`await primeCache(${JSON.stringify(cacheDir)}, ${options.concurrency ?? 'undefined'});\n`;
356367

357-
spawnSync(
368+
const primerProc = spawnSync(
358369
process.execPath,
359-
['--no-warnings=ExperimentalWarning', '--experimental-transform-types', '-e', primerCode],
370+
[
371+
'--no-warnings=ExperimentalWarning',
372+
'--experimental-transform-types',
373+
'--input-type=module',
374+
'-e',
375+
primerCode,
376+
],
360377
{ stdio: 'inherit' },
361378
);
379+
380+
if (primerProc.status !== 0 || primerProc.error) {
381+
throw new Error(
382+
`Failed to prime cache for persistent-cache-warm scenario: ${primerProc.error?.message ?? primerProc.status}`,
383+
);
384+
}
362385
},
363386

364387
async run() {
@@ -384,11 +407,60 @@ export function createPersistentCacheWarmScenario(
384407
};
385408
}
386409

410+
/**
411+
* 6. Large Enterprise (10k translations) Scenario:
412+
* 1 main bundle (3 MB) + 100 chunks (50 KB), 32 locales, 10,000 translations, sourcemaps ON.
413+
* Maximum scale stress test for binary translation tables, memory retention, and multi-locale windows.
414+
*/
415+
export function createLargeEnterpriseScenario(
416+
options: ScenarioFactoryOptions = {},
417+
): BenchmarkScenario {
418+
let workload: GeneratedWorkload | undefined;
419+
420+
return {
421+
name: 'large-enterprise-10k',
422+
description:
423+
'Large Enterprise (10k msgs): 1 main (3 MB) + 100 chunks (50 KB), 32 locales, 10,000 translations',
424+
get inputSizeBytes() {
425+
return workload?.totalInputSizeBytes ?? 0;
426+
},
427+
get localeCount() {
428+
return DEFAULT_LOCALES_32.length;
429+
},
430+
async setup() {
431+
await initializeFixtures();
432+
const files = createBundleSet(3 * 1024 * 1024, 100, 50 * 1024, 10000, true);
433+
const locales = generateTranslations(DEFAULT_LOCALES_32, 10000);
434+
435+
workload = {
436+
files,
437+
locales,
438+
totalInputSizeBytes: calculateInputSizeBytes(files),
439+
};
440+
},
441+
async run() {
442+
if (!workload) {
443+
return;
444+
}
445+
const inliner = new I18nInliner({
446+
missingTranslation: 'warning',
447+
maxConcurrency: options.concurrency,
448+
});
449+
try {
450+
await inliner.inlineAll(workload.files, workload.locales);
451+
} finally {
452+
await inliner.close();
453+
}
454+
},
455+
};
456+
}
457+
387458
export function getAllScenarios(options: ScenarioFactoryOptions = {}): BenchmarkScenario[] {
388459
return [
389460
createStandardAppScenario(options),
390461
createStandardAppNoMapsScenario(options),
391462
createEnterpriseScenario(options),
463+
createLargeEnterpriseScenario(options),
392464
createMonolithicScenario(options),
393465
createPersistentCacheWarmScenario(options),
394466
];

0 commit comments

Comments
 (0)