Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new scaling benchmark for @angular/build:library under scripts/benchmarks/library-builder/, including fixture generation, benchmark execution, and integration into the main benchmark CLI. The review feedback highlights several important improvements: resolving Node.js 18 compatibility issues caused by import.meta.dirname, robustly handling broken symlinks to avoid EEXIST errors, validating CLI inputs (sizes, depths, iterations), logging process spawn errors, and wrapping the benchmark execution in a try...finally block to ensure temporary directories are always cleaned up.
| const angularCoreVersion = JSON.parse( | ||
| fs.readFileSync( | ||
| path.resolve(import.meta.dirname, '../../../node_modules/@angular/core/package.json'), | ||
| 'utf8', | ||
| ), | ||
| ).version as string; |
There was a problem hiding this comment.
Using import.meta.dirname is not compatible with Node.js 18 (which is still supported by the Angular CLI workspace). It will be undefined and cause a runtime crash. We can use a URL object directly with fs.readFileSync to remain compatible with Node.js 18+.
const angularCoreVersion = JSON.parse(
fs.readFileSync(
new URL('../../../node_modules/@angular/core/package.json', import.meta.url),
'utf8'
)
).version as string;
| import { spawnSync } from 'node:child_process'; | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { performance } from 'node:perf_hooks'; | ||
| import { type FixtureOptions, type Layout, type Style, generateFixture } from './fixtures.mts'; |
There was a problem hiding this comment.
Import fileURLToPath from node:url to resolve the repository root in a Node.js 18 compatible way.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { type FixtureOptions, type Layout, type Style, generateFixture } from './fixtures.mts';
| status: 'ok' | 'fail'; | ||
| } | ||
|
|
||
| const repoRoot = path.resolve(import.meta.dirname, '../../..'); |
| function linkIfMissing(name: string, target: string): void { | ||
| const linkPath = path.join(rootNodeModules, name); | ||
| if (!fs.existsSync(linkPath)) { | ||
| fs.symlinkSync(target, linkPath, 'dir'); | ||
| } | ||
| } |
There was a problem hiding this comment.
Using fs.existsSync on a broken symlink returns false, which causes fs.symlinkSync to throw an EEXIST error because the symlink file itself still exists. We should use fs.lstatSync to check for existing symlinks and clean up broken ones to prevent EEXIST errors.
function linkIfMissing(name: string, target: string): void {
const linkPath = path.join(rootNodeModules, name);
try {
const stat = fs.lstatSync(linkPath);
if (stat.isSymbolicLink() && !fs.existsSync(linkPath)) {
// Remove broken symlink to avoid EEXIST error on recreation
fs.unlinkSync(linkPath);
} else {
// Symlink already exists and is valid
return;
}
} catch (e: any) {
if (e.code !== 'ENOENT') {
throw e;
}
}
fs.symlinkSync(target, linkPath, 'dir');
}
| async function runLibraryBuilderSubsystem(options: { | ||
| layout?: string; | ||
| style?: string; | ||
| sizes?: string; | ||
| depths?: string; | ||
| iterations?: string | number; | ||
| json?: boolean; | ||
| }): Promise<number> { | ||
| if (options.layout !== undefined && options.layout !== 'flat' && options.layout !== 'deep') { | ||
| // eslint-disable-next-line no-console | ||
| console.error(`Error: --layout must be "flat" or "deep", got "${options.layout}".`); | ||
|
|
||
| return 1; | ||
| } | ||
| if ( | ||
| options.style !== undefined && | ||
| !['inline', 'inline-scss', 'external'].includes(options.style) | ||
| ) { | ||
| // eslint-disable-next-line no-console | ||
| console.error( | ||
| `Error: --style must be one of "inline", "inline-scss", "external", got "${options.style}".`, | ||
| ); | ||
|
|
||
| return 1; | ||
| } | ||
|
|
||
| const cliOptions: LibraryBuilderBenchmarkOptions = { | ||
| layout: options.layout as LibraryBuilderBenchmarkOptions['layout'], | ||
| style: options.style as LibraryBuilderBenchmarkOptions['style'], | ||
| sizes: parseIntList(options.sizes), | ||
| depths: parseIntList(options.depths), | ||
| iterations: options.iterations !== undefined ? Number(options.iterations) : undefined, | ||
| json: Boolean(options.json), | ||
| }; | ||
|
|
||
| const { exitCode } = await runLibraryBuilderBenchmarks(cliOptions); | ||
|
|
||
| return exitCode; | ||
| } |
There was a problem hiding this comment.
The sizes, depths, and iterations options are not validated for the library-builder subsystem. Invalid inputs (such as non-integers, negative numbers, or strings that parse to NaN) can cause silent failures or unexpected behavior in the benchmark runner. We should validate these options to ensure they are positive integers before running the benchmarks.
async function runLibraryBuilderSubsystem(options: {
layout?: string;
style?: string;
sizes?: string;
depths?: string;
iterations?: string | number;
json?: boolean;
}): Promise<number> {
if (options.layout !== undefined && options.layout !== 'flat' && options.layout !== 'deep') {
// eslint-disable-next-line no-console
console.error("Error: --layout must be \"flat\" or \"deep\", got \"" + options.layout + "\".");
return 1;
}
if (
options.style !== undefined &&
!['inline', 'inline-scss', 'external'].includes(options.style)
) {
// eslint-disable-next-line no-console
console.error(
"Error: --style must be one of \"inline\", \"inline-scss\", \"external\", got \"" + options.style + "\"."
);
return 1;
}
const sizes = parseIntList(options.sizes);
if (sizes !== undefined && sizes.some((n) => !Number.isInteger(n) || n < 1)) {
// eslint-disable-next-line no-console
console.error("Error: --sizes must be a comma-separated list of positive integers, got \"" + options.sizes + "\".");
return 1;
}
const depths = parseIntList(options.depths);
if (depths !== undefined && depths.some((n) => !Number.isInteger(n) || n < 1)) {
// eslint-disable-next-line no-console
console.error("Error: --depths must be a comma-separated list of positive integers, got \"" + options.depths + "\".");
return 1;
}
const iterations = options.iterations !== undefined ? Number(options.iterations) : undefined;
if (iterations !== undefined && (!Number.isInteger(iterations) || iterations < 1)) {
// eslint-disable-next-line no-console
console.error("Error: --iterations must be a positive integer, got \"" + options.iterations + "\".");
return 1;
}
const cliOptions: LibraryBuilderBenchmarkOptions = {
layout: options.layout as LibraryBuilderBenchmarkOptions['layout'],
style: options.style as LibraryBuilderBenchmarkOptions['style'],
sizes,
depths,
iterations,
json: Boolean(options.json),
};
const { exitCode } = await runLibraryBuilderBenchmarks(cliOptions);
return exitCode;
}
| if (status === 'fail') { | ||
| // eslint-disable-next-line no-console | ||
| console.error(result.stdout?.toString()); | ||
| // eslint-disable-next-line no-console | ||
| console.error(result.stderr?.toString()); | ||
| } |
There was a problem hiding this comment.
If spawnSync fails to spawn the process (e.g., due to an invalid path or permissions), result.error will contain the error object, but result.stdout and result.stderr might be empty or null. We should log result.error to make debugging easier.
if (status === 'fail') {
if (result.error) {
// eslint-disable-next-line no-console
console.error(result.error);
}
// eslint-disable-next-line no-console
console.error(result.stdout?.toString());
// eslint-disable-next-line no-console
console.error(result.stderr?.toString());
}
| fs.mkdirSync(scratchRoot, { recursive: true }); | ||
| const measurements: BuildMeasurement[] = []; | ||
|
|
||
| for (const sizeOrDepth of sizes) { | ||
| const label = `${layout}-${style}-${layout === 'deep' ? `depth${sizeOrDepth}` : sizeOrDepth}`; | ||
| const projectDir = path.join(scratchRoot, label); | ||
|
|
||
| const fixtureOptions: FixtureOptions = | ||
| layout === 'flat' | ||
| ? { layout, style, count: sizeOrDepth } | ||
| : { layout, style, depth: sizeOrDepth }; | ||
| const entryPoints = generateFixture(projectDir, fixtureOptions); | ||
|
|
||
| if (!options.json) { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`\n[library-builder] ${label} (${entryPoints} entry points)`); | ||
| } | ||
|
|
||
| const durations: number[] = []; | ||
| for (let i = 1; i <= iterations; i++) { | ||
| const { durationMs, status } = buildOnce(projectDir, builder.architectCli); | ||
| if (!options.json) { | ||
| // eslint-disable-next-line no-console | ||
| console.log( | ||
| ` run ${i}/${iterations}: ${status === 'ok' ? 'done' : 'FAILED'} in ${(durationMs / 1000).toFixed(2)}s`, | ||
| ); | ||
| } | ||
| if (status === 'ok') { | ||
| durations.push(durationMs); | ||
| } | ||
| measurements.push({ layout, style, entryPoints, iteration: i, durationMs, status }); | ||
| } | ||
|
|
||
| if (!options.json && durations.length > 0) { | ||
| const medianMs = median(durations); | ||
| // eslint-disable-next-line no-console | ||
| console.log( | ||
| ` median: ${(medianMs / 1000).toFixed(2)}s (${(medianMs / entryPoints).toFixed(2)}ms/entry)`, | ||
| ); | ||
| } | ||
|
|
||
| fs.rmSync(projectDir, { recursive: true, force: true }); | ||
| } | ||
|
|
||
| fs.rmSync(scratchRoot, { recursive: true, force: true }); |
There was a problem hiding this comment.
If the benchmark run is interrupted or fails with an exception, the temporary scratchRoot directory will be left behind on disk. Wrapping the loop in a try...finally block ensures that the temporary directory is always cleaned up.
fs.mkdirSync(scratchRoot, { recursive: true });
const measurements: BuildMeasurement[] = [];
try {
for (const sizeOrDepth of sizes) {
const label = layout + "-" + style + "-" + (layout === "deep" ? "depth" + sizeOrDepth : sizeOrDepth);
const projectDir = path.join(scratchRoot, label);
const fixtureOptions: FixtureOptions =
layout === 'flat'
? { layout, style, count: sizeOrDepth }
: { layout, style, depth: sizeOrDepth };
const entryPoints = generateFixture(projectDir, fixtureOptions);
if (!options.json) {
// eslint-disable-next-line no-console
console.log("\n[library-builder] " + label + " (" + entryPoints + " entry points)");
}
const durations: number[] = [];
for (let i = 1; i <= iterations; i++) {
const { durationMs, status } = buildOnce(projectDir, builder.architectCli);
if (!options.json) {
// eslint-disable-next-line no-console
console.log(
" run " + i + "/" + iterations + ": " + (status === 'ok' ? 'done' : 'FAILED') + " in " + (durationMs / 1000).toFixed(2) + "s"
);
}
if (status === 'ok') {
durations.push(durationMs);
}
measurements.push({ layout, style, entryPoints, iteration: i, durationMs, status });
}
if (!options.json && durations.length > 0) {
const medianMs = median(durations);
// eslint-disable-next-line no-console
console.log(
" median: " + (medianMs / 1000).toFixed(2) + "s (" + (medianMs / entryPoints).toFixed(2) + "ms/entry)"
);
}
fs.rmSync(projectDir, { recursive: true, force: true });
}
} finally {
fs.rmSync(scratchRoot, { recursive: true, force: true });
}
Measuring that Angular's native library builder scales with grace — far better than ng-packagr: ng-packagr/ng-packagr#3461
PR Checklist
test(@angular/build): ...)behavior change needing its own tests
scripts/benchmarks/library-builder/README.mdPR Type
packages/source changes)What is the current behavior?
Issue Number: N/A
There is currently no way to measure how
@angular/build:library's build time scales with library size (entry-point count, nesting depth, or component style variant).What is the new behavior?
Adds
scripts/benchmarks/library-builder/, a benchmark suite (following the existingscripts/benchmarks/i18n/suite's structure) that generates synthetic APF library fixtures at various sizes and times@angular/build:librarycold builds against them. Run via:Why this is useful
This was built while investigating a performance comparison against ng-packagr (a third-party APF build tool used by most existing Angular libraries today). That investigation found ng-packagr's build time per entry point grows super-linearly once a library exceeds roughly 300-500 secondary entry points -- doubling the library size costs noticeably more than double the build time.
Running the same fixture shapes through
@angular/build:libraryfound no such growth: its per-entry cost keeps falling as the library grows, with no climb even at the largest sizes tested (2000 flat entries / 2048 deeply-nested entries). It was also 1.8-13x faster than ng-packagr at matching sizes, with the gap widening as size increases.@angular/build:library(Deep/nested layout shows the same pattern: 13.0x slower for ng-packagr at the largest size, 2048 entries at tree depth 11.)
This doesn't necessarily reflect anything specific to
@angular/build:library's design beyond "it doesn't have this particular scaling problem" -- the two tools' pipelines are entirely different (different bundler, different incremental-compilation strategy), so this isn't a controlled "same work, different scheduler" comparison. But it is a useful data point: whatever makes ng-packagr's build time climb at scale is apparently not an inherent property of compiling many Angular library entry points, since a different pipeline handles the same fixtures without it.This benchmark suite exists so that:
@angular/build:library's entry-point handling have a regression checkfor this specific scaling characteristic.
Caveats
iterations each) -- enough to establish the shape of the curve, not a high-confidence
statistical comparison.
per-entry-cost curve (grows with size, or doesn't) is the more portable finding across
hardware.
Does this PR introduce a breaking change?
Other information
No changes to any
packages/source in this PR -- only new benchmark tooling and documentation underscripts/benchmarks/library-builder/, plus wiring it into the existingscripts/benchmark.mtsdispatcher as a newlibrary-buildersubsystem alongsidei18n.