Skip to content

Commit 10bc395

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 10bc395

4 files changed

Lines changed: 86 additions & 8 deletions

File tree

scripts/benchmark.mts

Lines changed: 12 additions & 2 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,20 @@ Options:
99103
return 1;
100104
}
101105

106+
const rawSaveBaseline = options.saveBaseline ?? options['save-baseline'];
107+
const rawCompareBaseline = options.compareBaseline ?? options['compare-baseline'];
108+
102109
const cliOptions: BenchmarkCliOptions = {
103110
scenario: options.scenario,
104111
iterations: options.iterations !== undefined ? Number(options.iterations) : undefined,
105112
warmup: options.warmup !== undefined ? Number(options.warmup) : undefined,
106113
concurrency: options.concurrency !== undefined ? Number(options.concurrency) : undefined,
107114
json: Boolean(options.json),
108-
saveBaseline: options.saveBaseline ?? options['save-baseline'],
109-
compareBaseline: options.compareBaseline ?? options['compare-baseline'],
115+
inProcess: Boolean(options.inProcess ?? options['in-process']),
116+
saveBaseline: rawSaveBaseline ? path.resolve(_cwd, String(rawSaveBaseline)) : undefined,
117+
compareBaseline: rawCompareBaseline
118+
? path.resolve(_cwd, String(rawCompareBaseline))
119+
: undefined,
110120
};
111121

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

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/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: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ 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

1718
import type { BuildOutputFile } from '../../../dist/@angular/build/src/tools/esbuild/bundler-files.d.ts';
1819
import type { LocaleInlineOptions } from '../../../dist/@angular/build/src/tools/esbuild/i18n-inliner.d.ts';
@@ -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,22 @@ 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,
359370
['--no-warnings=ExperimentalWarning', '--experimental-transform-types', '-e', primerCode],
360371
{ stdio: 'inherit' },
361372
);
373+
374+
if (primerProc.status !== 0) {
375+
throw new Error(
376+
`Failed to prime cache for persistent-cache-warm scenario: ${primerProc.error?.message ?? primerProc.status}`,
377+
);
378+
}
362379
},
363380

364381
async run() {
@@ -384,11 +401,60 @@ export function createPersistentCacheWarmScenario(
384401
};
385402
}
386403

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

0 commit comments

Comments
 (0)