Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"bazel": "bazelisk",
"test": "bazel test //packages/...",
"build": "pnpm --silent admin build",
"benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark",
"build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/",
"lint": "eslint --cache --max-warnings=0",
"templates": "pnpm --silent admin templates",
Expand Down
150 changes: 150 additions & 0 deletions scripts/benchmark.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

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

function checkBuildStatus(logger: Console): boolean {
const distFile = 'dist/@angular/build/src/tools/esbuild/i18n-inliner.js';
const srcFile = 'packages/angular/build/src/tools/esbuild/i18n-inliner.ts';

if (!fs.existsSync(distFile)) {
logger.error(
'Error: @angular/build has not been built yet.\nPlease run "pnpm build" before benchmarking.',
);

return false;
}

if (fs.existsSync(srcFile)) {
const srcMtime = fs.statSync(srcFile).mtimeMs;
const distMtime = fs.statSync(distFile).mtimeMs;
if (srcMtime > distMtime) {
logger.warn(
'Warning: Source files in packages/angular/build are newer than dist/.\n' +
'Run "pnpm build" to ensure your benchmark reflects your latest local edits.\n',
);
}
}

return true;
}

export default async function (
options: {
_?: string[];
scenario?: string;
iterations?: string | number;
warmup?: string | number;
concurrency?: string | number;
build?: boolean;
json?: boolean;
inProcess?: boolean;
'in-process'?: boolean;
saveBaseline?: string;
'save-baseline'?: string;
compareBaseline?: string;
'compare-baseline'?: string;
help?: boolean;
[key: string]: unknown;
},
_cwd: string,
): Promise<number> {
const positionals = options._ ?? [];
const targetSubsystem = positionals[0] ?? 'i18n';

if (options.help || targetSubsystem === 'help') {
// eslint-disable-next-line no-console
console.log(`
Angular CLI Performance Benchmark Runner

Usage:
pnpm admin benchmark [subsystem] [options]

Subsystems:
i18n Run i18n inliner performance benchmarks (default)

Options:
--scenario=<name> Run a specific scenario (e.g. standard-app, enterprise-multilingual)
--iterations=<n> Number of measured iterations (default: 5)
--warmup=<n> Number of warmup iterations (default: 2)
--concurrency=<n> Override worker thread pool concurrency
--in-process Run all scenarios in a single process (useful for debugging)
--build Automatically build packages before benchmarking
--json Output results in machine-readable JSON
--save-baseline=<file> Save run results to a baseline JSON file
--compare-baseline=<file> Compare run results against an existing baseline JSON file
--help Show this help message
`);

return 0;
}

if (targetSubsystem !== 'i18n') {
// eslint-disable-next-line no-console
console.error(`Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n`);

return 1;
}

if (options.build) {
const buildModule = await import('./build.mts');
await buildModule.default({ local: true });
}

// eslint-disable-next-line no-console
if (!checkBuildStatus(console)) {
return 1;
}

const rawSaveBaseline = options.saveBaseline ?? options['save-baseline'];
const rawCompareBaseline = options.compareBaseline ?? options['compare-baseline'];

const iterations = options.iterations !== undefined ? Number(options.iterations) : undefined;
const warmup = options.warmup !== undefined ? Number(options.warmup) : undefined;
const concurrency = options.concurrency !== undefined ? Number(options.concurrency) : undefined;

if (iterations !== undefined && (!Number.isInteger(iterations) || iterations < 1)) {
// eslint-disable-next-line no-console
console.error('Error: --iterations must be a positive integer.');

return 1;
}

if (warmup !== undefined && (!Number.isInteger(warmup) || warmup < 0)) {
// eslint-disable-next-line no-console
console.error('Error: --warmup must be a non-negative integer.');

return 1;
}

if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) {
// eslint-disable-next-line no-console
console.error('Error: --concurrency must be a positive integer.');

return 1;
}

const cliOptions: BenchmarkCliOptions = {
scenario: options.scenario,
iterations,
warmup,
concurrency,
json: Boolean(options.json),
inProcess: Boolean(options.inProcess ?? options['in-process']),
saveBaseline: rawSaveBaseline ? path.resolve(_cwd, String(rawSaveBaseline)) : undefined,
compareBaseline: rawCompareBaseline
? path.resolve(_cwd, String(rawCompareBaseline))
: undefined,
};
Comment thread
clydin marked this conversation as resolved.

const { exitCode } = await runI18nBenchmarks(cliOptions);

return exitCode;
}
165 changes: 165 additions & 0 deletions scripts/benchmarks/i18n/fixtures.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import './init-env.mts';
import type { ɵParsedTranslation } from '@angular/localize';
import { transformSync } from 'esbuild';
import { createRequire } from 'node:module';

import path from 'node:path';

import type { BuildOutputFile } from '../../../packages/angular/build/src/tools/esbuild/bundler-files.js';
import type { LocaleInlineOptions } from '../../../packages/angular/build/src/tools/esbuild/i18n-inliner.js';

// Setup module paths to resolve dependencies from packages/angular/build
const requireFromBuild = createRequire(
path.resolve(import.meta.dirname, '../../../packages/angular/build/package.json'),
);

const { BuildOutputFileType, createOutputFile } = requireFromBuild(
'../../../dist/@angular/build/src/tools/esbuild/bundler-files.js',
) as typeof import('../../../packages/angular/build/src/tools/esbuild/bundler-files.js');

const { calculateHash, initializeHash } = requireFromBuild(
'../../../dist/@angular/build/src/utils/hash.js',
) as typeof import('../../../packages/angular/build/src/utils/hash.js');

let isHashInitialized = false;

export async function initializeFixtures(): Promise<void> {
if (!isHashInitialized) {
await initializeHash();
isHashInitialized = true;
}
}

export function parsedTranslation(
parts: string[],
placeholderNames: string[] = [],
text?: string,
): ɵParsedTranslation {
return {
messageParts: Object.assign([...parts], { raw: [...parts] }),
placeholderNames,
text: text ?? parts.join(''),
};
}

export function generateTranslations(
locales: string[],
messageCount: number,
): LocaleInlineOptions[] {
return locales.map((locale) => {
const translation: Record<string, ɵParsedTranslation> = {};

for (let i = 0; i < messageCount; i++) {
const msgId = `msg_${i}`;
translation[msgId] = parsedTranslation(
[`[${locale}] Order #`, ` was confirmed for customer `, `. Thank you!`],
['orderId', 'customerName'],
`[${locale}] Order #${i} was confirmed for customer Doe. Thank you!`,
);
}

const translationIntegrity = calculateHash(JSON.stringify(translation));

return {
locale,
translation,
translationIntegrity,
};
});
}

export interface SyntheticBundleOptions {
filename: string;
targetByteSize: number;
messageCount: number;
withSourceMap?: boolean;
messageIdOffset?: number;
}

export function generateSyntheticBundle(options: SyntheticBundleOptions): {
codeFile: BuildOutputFile;
mapFile?: BuildOutputFile;
} {
const {
filename,
targetByteSize,
messageCount,
withSourceMap = true,
messageIdOffset = 0,
} = options;

const parts: string[] = [
'// Synthetic test bundle generated for i18n-inliner benchmark\n',
'export const BUNDLE_META = { generated: true, timestamp: Date.now() };\n',
];

// Generate functions with $localize call sites
for (let i = 0; i < messageCount; i++) {
const msgId = `msg_${messageIdOffset + i}`;
parts.push(
`export function renderMessage_${i}(orderId, customerName) {\n`,
` return $localize\`:@@${msgId}:Order #\${orderId}:orderId: ` +
`was confirmed for customer \${customerName}:customerName:. Thank you!\`;\n`,
`}\n`,
);
}

// Calculate current approximate size and pad with realistic JS functions if needed
let currentSize = parts.reduce((acc, str) => acc + str.length, 0);
let classIndex = 0;

while (currentSize < targetByteSize) {
const filler =
`export class DataProcessor_${classIndex} {\n` +
` constructor(id, options = {}) {\n` +
` this.id = id;\n` +
` this.options = Object.assign({ enabled: true, retries: 3 }, options);\n` +
` this.history = [];\n` +
` }\n` +
` process(batch) {\n` +
` if (!Array.isArray(batch)) return [];\n` +
` const result = batch.map((item, idx) => ({\n` +
` id: this.id + '_' + idx,\n` +
` source: item,\n` +
` timestamp: Date.now(),\n` +
` active: true\n` +
` }));\n` +
` this.history.push(...result);\n` +
` return result;\n` +
` }\n` +
`}\n`;

parts.push(filler);
currentSize += filler.length;
classIndex++;
}

const rawCode = parts.join('');

let finalCode = rawCode;
let finalMap: string | undefined;

if (withSourceMap) {
const result = transformSync(rawCode, {
sourcemap: true,
sourcefile: filename.replace(/\.js$/, '.ts'),
});
finalCode = result.code;
finalMap = result.map;
}

const codeFile = createOutputFile(filename, finalCode, BuildOutputFileType.Browser);
const mapFile = finalMap
? createOutputFile(filename + '.map', finalMap, BuildOutputFileType.Browser)
: undefined;

return { codeFile, mapFile };
}
Loading