From df5dc1a067bfdd95300540a84e2bcbae677d3275 Mon Sep 17 00:00:00 2001 From: David Herges Date: Thu, 8 Oct 2026 21:58:27 +0200 Subject: [PATCH 1/4] test(@angular/build): add library builder scaling benchmark suite --- scripts/benchmark.mts | 72 ++++- scripts/benchmarks/library-builder/README.md | 77 ++++++ .../benchmarks/library-builder/fixtures.mts | 257 ++++++++++++++++++ scripts/benchmarks/library-builder/index.mts | 247 +++++++++++++++++ 4 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 scripts/benchmarks/library-builder/README.md create mode 100644 scripts/benchmarks/library-builder/fixtures.mts create mode 100644 scripts/benchmarks/library-builder/index.mts diff --git a/scripts/benchmark.mts b/scripts/benchmark.mts index 0237f5101bc4..a8318b84af11 100644 --- a/scripts/benchmark.mts +++ b/scripts/benchmark.mts @@ -9,6 +9,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { type BenchmarkCliOptions, runI18nBenchmarks } from './benchmarks/i18n/index.mts'; +import { + type LibraryBuilderBenchmarkOptions, + runLibraryBuilderBenchmarks, +} from './benchmarks/library-builder/index.mts'; function checkBuildStatus(logger: Console): boolean { const distFile = 'dist/@angular/build/src/tools/i18n/i18n-inliner.js'; @@ -36,6 +40,50 @@ function checkBuildStatus(logger: Console): boolean { return true; } +function parseIntList(raw: string | undefined): number[] | undefined { + return raw?.split(',').map((s) => Number(s.trim())); +} + +async function runLibraryBuilderSubsystem(options: { + layout?: string; + style?: string; + sizes?: string; + depths?: string; + iterations?: string | number; + json?: boolean; +}): Promise { + 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; +} + export default async function ( options: { _?: string[]; @@ -51,6 +99,10 @@ export default async function ( 'save-baseline'?: string; compareBaseline?: string; 'compare-baseline'?: string; + layout?: string; + style?: string; + sizes?: string; + depths?: string; help?: boolean; [key: string]: unknown; }, @@ -69,8 +121,9 @@ Usage: Subsystems: i18n Run i18n inliner performance benchmarks (default) + library-builder Run @angular/build:library scaling benchmarks -Options: +Options (i18n): --scenario= Run a specific scenario (e.g. standard-app, enterprise-multilingual) --iterations= Number of measured iterations (default: 5) --warmup= Number of warmup iterations (default: 2) @@ -80,15 +133,30 @@ Options: --json Output results in machine-readable JSON --save-baseline= Save run results to a baseline JSON file --compare-baseline= Compare run results against an existing baseline JSON file + +Options (library-builder): + --layout= Fixture layout (default: flat) + --style= Component style variant (default: inline) + --sizes= Entry-point counts to sweep, layout=flat only (default: 10,50,300,1000,2000) + --depths= Tree depths to sweep, layout=deep only (default: 2,5,8,11) + --iterations= Measured iterations per size (default: 2) + --json Output raw measurements as JSON + --help Show this help message `); return 0; } + if (targetSubsystem === 'library-builder') { + return runLibraryBuilderSubsystem(options); + } + if (targetSubsystem !== 'i18n') { // eslint-disable-next-line no-console - console.error(`Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n`); + console.error( + `Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n, library-builder`, + ); return 1; } diff --git a/scripts/benchmarks/library-builder/README.md b/scripts/benchmarks/library-builder/README.md new file mode 100644 index 000000000000..407ba6710bc2 --- /dev/null +++ b/scripts/benchmarks/library-builder/README.md @@ -0,0 +1,77 @@ +# `@angular/build:library` scaling benchmark + +This benchmark measures how `@angular/build:library`'s cold build time scales with the number +of secondary entry points in an Angular Package Format (APF) library, across two structurally +different layouts (flat siblings vs. a deeply nested tree) and three component style variants +(inline template, inline template + styles, external template/stylesheet files). + +## Why + +A comparison against [ng-packagr](https://github.com/ng-packagr/ng-packagr) (a third-party APF +build tool) found that its build time **per entry point** grows super-linearly once a library +has more than roughly 300-500 secondary entry points -- doubling the library size past that +point costs noticeably more than double the build time. This suite exists to check whether +`@angular/build:library`, a from-scratch build pipeline unrelated to ng-packagr's implementation, +shows the same growth on equivalent fixtures. + +**Finding:** it does not. Across the same sweep (flat layout: 10/50/300/1000/2000 entries; deep +layout: tree depths 2/5/8/11, i.e. 4/32/256/2048 entries), `@angular/build:library`'s per-entry +cost keeps falling as the library grows, with no climb at the largest sizes tested. It is also +1.8-13x faster than ng-packagr at matching sizes, with the gap widening as size increases. See +the PR description this suite was added alongside for the full writeup and caveats. + +## Running it + +```sh +pnpm build # or: pnpm admin build --local -- the benchmark measures the compiled builder, + # not TypeScript source, so it must exist at dist/@angular/build and + # dist/@angular-devkit/architect first +pnpm admin benchmark library-builder +``` + +Options: + +``` +--layout= Fixture layout (default: flat) +--style= Component style variant (default: inline) +--sizes= Entry-point counts to sweep, layout=flat only (default: 10,50,300,1000,2000) +--depths= Tree depths to sweep, layout=deep only (default: 2,5,8,11) +--iterations= Measured iterations per size (default: 2) +--json Output raw measurements as JSON instead of a human-readable log +``` + +Example: sweep the external-style variant at a few sizes with 3 iterations each: + +```sh +pnpm admin benchmark library-builder --style=external --sizes=100,500,1000 --iterations=3 +``` + +## How it works + +- `fixtures.mts` generates a synthetic library at a given size/layout/style: a primary entry + point, N secondary entry points (each a minimal standalone Angular component), and the + `angular.json`/`tsconfig.lib.json`/`package.json` scaffolding `@angular/build:library` needs. + Secondary entry points are declared as a single flat `exports` map in the root `package.json` + (the builder's actual convention), not as separate per-entry config files. +- `index.mts` times `architect lib:build` (the standalone `@angular-devkit/architect` CLI, + invoked directly -- no `ng` CLI needed) once per iteration via `child_process.spawnSync`, + discards the generated project afterwards, and reports per-size median build time. +- Because `@angular/build` and `@angular-devkit/architect` are Bazel-built workspace packages + rather than ordinary npm dependencies, a plain `pnpm install` never links them (or their own + dependencies) into the repo's root `node_modules` -- nothing in `dist/` is resolvable via a + bare `require()`/`import` otherwise. On first run, `index.mts` symlinks `@angular/build` and + `@angular-devkit/architect` from `dist/` into the root `node_modules`, then walks their + declared `dependencies` and links any of those still missing from the pnpm store. This only + touches `node_modules` (already gitignored) and is idempotent -- safe to run repeatedly or + alongside other work in the same checkout. + +## Methodology notes + +- Each reported number is the **median** of the measured iterations, not the mean -- process-spawn + wall-clock timings are noisy and right-skewed, and the median is more representative of steady + state than an outlier-sensitive mean. +- Results are specific to the machine they were collected on. Run on one otherwise-idle machine + for a self-consistent sweep; absolute numbers will differ across hardware, though the _shape_ + of the curve (does per-entry cost grow with size, or not) is the more portable finding. +- This benchmark only covers **cold, one-shot builds**. It does not exercise incremental rebuilds + or watch mode. diff --git a/scripts/benchmarks/library-builder/fixtures.mts b/scripts/benchmarks/library-builder/fixtures.mts new file mode 100644 index 000000000000..33ffc91762b7 --- /dev/null +++ b/scripts/benchmarks/library-builder/fixtures.mts @@ -0,0 +1,257 @@ +/** + * @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 + */ + +// Generates synthetic Angular Package Format (APF) library fixtures for benchmarking +// `@angular/build:library`, and converts them into the builder's project shape (a flat +// `exports` map in one root package.json, rather than the per-entry-point `ng-package.json` +// convention used by third-party tools such as ng-packagr). + +import fs from 'node:fs'; +import path from 'node:path'; + +export type Layout = 'flat' | 'deep'; +export type Style = 'inline' | 'inline-scss' | 'external'; + +export interface FixtureOptions { + layout: Layout; + style: Style; + + /** Number of secondary entry points for `layout: 'flat'`. */ + count?: number; + + /** Binary-tree depth for `layout: 'deep'` (2^depth leaf entry points). */ + depth?: number; +} + +function buildComponentSource( + className: string, + selector: string, + compBase: string, + id: string, + style: Style, +): string { + const metadataLines = [` selector: '${selector}',`]; + + if (style === 'external') { + metadataLines.push(` templateUrl: './${compBase}.component.html',`); + metadataLines.push(` styleUrls: ['./${compBase}.component.scss'],`); + } else { + metadataLines.push(` template: '
Reference component ${id}
',`); + if (style === 'inline-scss') { + metadataLines.push(` styles: ['.ref-comp { display: block; padding: 4px; }'],`); + } + } + + return `import { Component } from '@angular/core'; + +@Component({ +${metadataLines.join('\n')} +}) +export class ${className} {} +`; +} + +/** Writes one secondary entry point's source files (component + public-api.ts) into `entryDir`. */ +function writeEntryPointSource(entryDir: string, id: string, style: Style): void { + fs.mkdirSync(entryDir, { recursive: true }); + + const className = `RefComponent${id}`; + const selector = `ref-comp-${id}`; + const compBase = `comp-${id}`; + + fs.writeFileSync( + path.join(entryDir, 'public-api.ts'), + `export * from './${compBase}.component';\n`, + ); + + if (style === 'external') { + fs.writeFileSync( + path.join(entryDir, `${compBase}.component.html`), + `
Reference component ${id}
\n`, + ); + fs.writeFileSync( + path.join(entryDir, `${compBase}.component.scss`), + `.ref-comp {\n display: block;\n padding: 4px;\n}\n`, + ); + } + + fs.writeFileSync( + path.join(entryDir, `${compBase}.component.ts`), + buildComponentSource(className, selector, compBase, id, style), + ); +} + +/** Generates `count` flat secondary entry points as direct siblings, returning their relative dirs. */ +function generateFlatEntries(outDir: string, count: number, style: Style): string[] { + const width = String(count).length; + const dirs: string[] = []; + for (let i = 1; i <= count; i++) { + const id = String(i).padStart(width, '0'); + const relDir = `comp-${id}`; + writeEntryPointSource(path.join(outDir, relDir), id, style); + dirs.push(relDir); + } + + return dirs; +} + +/** + * Generates a binary tree `depth` levels deep (`sub-a`/`sub-b` at each level), placing one + * secondary entry point at each of the 2^depth leaves. Returns their relative dirs. + */ +function generateDeepEntries(outDir: string, depth: number, style: Style): string[] { + const total = 2 ** depth; + const width = String(total).length; + const dirs: string[] = []; + let counter = 0; + + function build(currentRelDir: string, level: number): void { + if (level === depth) { + counter++; + const id = String(counter).padStart(width, '0'); + writeEntryPointSource(path.join(outDir, currentRelDir), id, style); + dirs.push(currentRelDir); + + return; + } + for (const child of ['sub-a', 'sub-b']) { + build(path.join(currentRelDir, child), level + 1); + } + } + + build('.', 0); + + return dirs.map((d) => path.normalize(d)); +} + +/** "sub-a/sub-b" -> "sub-a-sub-b"; "comp-0001" -> "comp-0001". Used as the exports map subpath key. */ +function entryKey(relDir: string): string { + return relDir.split(path.sep).join('-'); +} + +/** + * Generates a complete `@angular/build:library`-buildable project at `outDir`: a primary entry + * point, N secondary entry points per `options`, and the angular.json/tsconfig/package.json + * scaffolding the builder needs. Returns the number of secondary entry points generated. + */ +export function generateFixture(outDir: string, options: FixtureOptions): number { + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + fs.writeFileSync( + path.join(outDir, 'public-api.ts'), + `export const REFERENCE_VERSION = '1.0.0';\n`, + ); + + const secondaryDirs = + options.layout === 'flat' + ? generateFlatEntries(outDir, options.count ?? 10, options.style) + : generateDeepEntries(outDir, options.depth ?? 2, options.style); + + const exportsMap: Record = { '.': './public-api.ts' }; + for (const relDir of secondaryDirs) { + exportsMap[`./${entryKey(relDir)}`] = `./${relDir.split(path.sep).join('/')}/public-api.ts`; + } + + const angularCoreVersion = JSON.parse( + fs.readFileSync( + path.resolve(import.meta.dirname, '../../../node_modules/@angular/core/package.json'), + 'utf8', + ), + ).version as string; + // e.g. "22.3.0-next.0" -> "22.3.0-next" (drop the trailing prerelease build number only). + const peerRange = `^${angularCoreVersion.replace(/\.\d+$/, '')}`; + + fs.writeFileSync( + path.join(outDir, 'package.json'), + JSON.stringify( + { + name: `ref-${options.layout}-${options.style}`, + version: '0.0.1', + peerDependencies: { + '@angular/core': peerRange, + '@angular/common': peerRange, + }, + exports: exportsMap, + }, + null, + 2, + ) + '\n', + ); + + fs.writeFileSync( + path.join(outDir, 'tsconfig.json'), + JSON.stringify( + { + compileOnSave: false, + compilerOptions: { + outDir: './dist/out-tsc', + sourceMap: true, + declaration: false, + moduleResolution: 'bundler', + experimentalDecorators: true, + target: 'es2022', + module: 'es2022', + useDefineForClassFields: false, + lib: ['es2022', 'dom'], + }, + angularCompilerOptions: { + enableIvy: true, + disableTypeScriptVersionCheck: true, + }, + }, + null, + 2, + ) + '\n', + ); + + fs.writeFileSync( + path.join(outDir, 'tsconfig.lib.json'), + JSON.stringify( + { + extends: './tsconfig.json', + compilerOptions: { + declaration: true, + declarationMap: true, + inlineSources: true, + types: [], + }, + exclude: ['**/*.spec.ts'], + }, + null, + 2, + ) + '\n', + ); + + fs.writeFileSync( + path.join(outDir, 'angular.json'), + JSON.stringify( + { + version: 1, + cli: { cache: { enabled: false } }, + projects: { + lib: { + root: '.', + projectType: 'library', + sourceRoot: '.', + targets: { + build: { + builder: '@angular/build:library', + options: { tsConfig: './tsconfig.lib.json', outputPath: 'dist' }, + }, + }, + }, + }, + }, + null, + 2, + ) + '\n', + ); + + return secondaryDirs.length; +} diff --git a/scripts/benchmarks/library-builder/index.mts b/scripts/benchmarks/library-builder/index.mts new file mode 100644 index 000000000000..6e3aaaba03bd --- /dev/null +++ b/scripts/benchmarks/library-builder/index.mts @@ -0,0 +1,247 @@ +/** + * @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 + */ + +// Benchmarks `@angular/build:library`'s cold build time across a sweep of library sizes. +// +// Context: a comparison against ng-packagr (a third-party APF build tool) found that its +// per-entry-point build cost grows super-linearly past roughly 300-500 secondary entry points. +// This suite exists to check whether `@angular/build:library` -- a from-scratch build pipeline, +// unrelated to ng-packagr's implementation -- shows the same growth on equivalent fixtures. +// See scripts/benchmarks/library-builder/README.md for the full methodology and findings. + +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'; + +export interface LibraryBuilderBenchmarkOptions { + layout?: Layout; + style?: Style; + + /** Comma-separated entry-point counts, for `layout: 'flat'`. */ + sizes?: number[]; + + /** Comma-separated tree depths, for `layout: 'deep'`. */ + depths?: number[]; + iterations?: number; + json?: boolean; +} + +export interface BuildMeasurement { + layout: Layout; + style: Style; + entryPoints: number; + iteration: number; + durationMs: number; + status: 'ok' | 'fail'; +} + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); +const scratchRoot = path.join(repoRoot, 'dist', '.library-builder-benchmark-tmp'); +const rootNodeModules = path.join(repoRoot, 'node_modules'); +const pnpmStore = path.join(rootNodeModules, '.pnpm'); + +function checkBuilderIsBuilt( + logger: Console, +): { buildPkg: string; architectPkg: string; architectCli: string } | undefined { + const buildPkg = path.join(repoRoot, 'dist', '@angular', 'build'); + const architectPkg = path.join(repoRoot, 'dist', '@angular-devkit', 'architect'); + const architectCli = path.join(architectPkg, 'bin', 'cli.js'); + + if (!fs.existsSync(path.join(buildPkg, 'package.json')) || !fs.existsSync(architectCli)) { + logger.error( + 'Error: @angular/build and/or @angular-devkit/architect have not been built yet.\n' + + 'Run "pnpm build" (or "pnpm admin build --local" for a faster local build) first.', + ); + + return undefined; + } + + return { buildPkg, architectPkg, architectCli }; +} + +/** + * `@angular/build` and `@angular-devkit/architect` are Bazel-built workspace packages, not + * ordinary npm dependencies -- a plain `pnpm install` never links them (or their own + * dependencies) into the root node_modules. Resolving a plain `require('@angular/build')` + * from anywhere therefore needs these links to exist at least once, at the repo root (Node's + * module resolution walks up from each required file's real, on-disk location, so per-fixture + * symlinks are not enough once a dependency itself does a bare `require`). + * + * This walks the dependency graph starting at `@angular/build` and `@angular-devkit/architect` + * and links any package missing from node_modules, resolving each from the pnpm store so it + * doesn't go stale if a dependency version changes. + */ +function ensureBuilderDepsLinkedAtRepoRoot( + logger: Console, + { buildPkg, architectPkg }: { buildPkg: string; architectPkg: string }, +): void { + fs.mkdirSync(path.join(rootNodeModules, '@angular'), { recursive: true }); + fs.mkdirSync(path.join(rootNodeModules, '@angular-devkit'), { recursive: true }); + linkIfMissing('@angular/build', buildPkg); + linkIfMissing('@angular-devkit/architect', architectPkg); + + const seen = new Set(['@angular/build', '@angular-devkit/architect']); + const queue: string[] = ['@angular/build', '@angular-devkit/architect']; + while (queue.length > 0) { + const pkgName = queue.shift(); + if (pkgName === undefined) { + break; + } + const pkgJsonPath = path.join(rootNodeModules, pkgName, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + const deps = Object.keys( + (JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).dependencies ?? {}) as Record< + string, + string + >, + ); + for (const dep of deps) { + if (seen.has(dep)) { + continue; + } + seen.add(dep); + queue.push(dep); + if (!fs.existsSync(path.join(rootNodeModules, dep))) { + linkFromPnpmStore(logger, dep); + } + } + } +} + +function linkIfMissing(name: string, target: string): void { + const linkPath = path.join(rootNodeModules, name); + if (!fs.existsSync(linkPath)) { + fs.symlinkSync(target, linkPath, 'dir'); + } +} + +/** Resolves `dep` (e.g. "rxjs" or "@angular-devkit/core") to its pnpm store dir and symlinks it. */ +function linkFromPnpmStore(logger: Console, dep: string): void { + const storeName = dep.startsWith('@') ? dep.replace('/', '+') : dep; + const candidates = fs.existsSync(pnpmStore) + ? fs.readdirSync(pnpmStore).filter((entry) => entry.startsWith(`${storeName}@`)) + : []; + if (candidates.length === 0) { + logger.warn(`Warning: could not resolve "${dep}" from the pnpm store; it may be missing.`); + + return; + } + // Prefer the lexically-last match (newest version) when more than one is installed. + candidates.sort(); + const storeDir = candidates[candidates.length - 1]; + const target = path.join(pnpmStore, storeDir, 'node_modules', dep); + if (dep.includes('/')) { + fs.mkdirSync(path.join(rootNodeModules, path.dirname(dep)), { recursive: true }); + } + linkIfMissing(dep, target); +} + +function buildOnce( + projectDir: string, + architectCli: string, +): { durationMs: number; status: 'ok' | 'fail' } { + const start = performance.now(); + const result = spawnSync(process.execPath, [architectCli, 'lib:build'], { + cwd: projectDir, + stdio: 'pipe', + maxBuffer: Infinity, + }); + const durationMs = performance.now() - start; + const status = result.status === 0 ? 'ok' : 'fail'; + 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()); + } + + return { durationMs, status }; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +export async function runLibraryBuilderBenchmarks( + options: LibraryBuilderBenchmarkOptions = {}, +): Promise<{ measurements: BuildMeasurement[]; exitCode: number }> { + const builder = checkBuilderIsBuilt(console); + if (!builder) { + return { measurements: [], exitCode: 1 }; + } + ensureBuilderDepsLinkedAtRepoRoot(console, builder); + + const layout = options.layout ?? 'flat'; + const style = options.style ?? 'inline'; + const iterations = options.iterations ?? 2; + const sizes = + layout === 'flat' + ? (options.sizes ?? [10, 50, 300, 1000, 2000]) + : (options.depths ?? [2, 5, 8, 11]); + + 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 }); + + const anyFailed = measurements.some((m) => m.status === 'fail'); + if (options.json) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(measurements, null, 2)); + } + + return { measurements, exitCode: anyFailed ? 1 : 0 }; +} From 19f942ce322522ee6d7b407ebb379988cc47cf8e Mon Sep 17 00:00:00 2001 From: David Herges Date: Sat, 10 Oct 2026 05:54:26 +0000 Subject: [PATCH 2/4] fixup! test(@angular/build): add library builder scaling benchmark suite --- .../benchmarks/library-builder/fixtures.mts | 6 +++--- scripts/benchmarks/library-builder/index.mts | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/benchmarks/library-builder/fixtures.mts b/scripts/benchmarks/library-builder/fixtures.mts index 33ffc91762b7..940d570e6984 100644 --- a/scripts/benchmarks/library-builder/fixtures.mts +++ b/scripts/benchmarks/library-builder/fixtures.mts @@ -160,9 +160,9 @@ export function generateFixture(outDir: string, options: FixtureOptions): number const angularCoreVersion = JSON.parse( fs.readFileSync( - path.resolve(import.meta.dirname, '../../../node_modules/@angular/core/package.json'), - 'utf8', - ), + new URL('../../../node_modules/@angular/core/package.json', import.meta.url), + 'utf8' + ) ).version as string; // e.g. "22.3.0-next.0" -> "22.3.0-next" (drop the trailing prerelease build number only). const peerRange = `^${angularCoreVersion.replace(/\.\d+$/, '')}`; diff --git a/scripts/benchmarks/library-builder/index.mts b/scripts/benchmarks/library-builder/index.mts index 6e3aaaba03bd..9be2070b965f 100644 --- a/scripts/benchmarks/library-builder/index.mts +++ b/scripts/benchmarks/library-builder/index.mts @@ -18,6 +18,7 @@ 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'; export interface LibraryBuilderBenchmarkOptions { @@ -42,7 +43,7 @@ export interface BuildMeasurement { status: 'ok' | 'fail'; } -const repoRoot = path.resolve(import.meta.dirname, '../../..'); +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)); const scratchRoot = path.join(repoRoot, 'dist', '.library-builder-benchmark-tmp'); const rootNodeModules = path.join(repoRoot, 'node_modules'); const pnpmStore = path.join(rootNodeModules, '.pnpm'); @@ -119,9 +120,21 @@ function ensureBuilderDepsLinkedAtRepoRoot( function linkIfMissing(name: string, target: string): void { const linkPath = path.join(rootNodeModules, name); - if (!fs.existsSync(linkPath)) { - fs.symlinkSync(target, linkPath, 'dir'); + 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'); } /** Resolves `dep` (e.g. "rxjs" or "@angular-devkit/core") to its pnpm store dir and symlinks it. */ From 136c57a796b535146e9c734f833530c17d94dea9 Mon Sep 17 00:00:00 2001 From: David Herges Date: Sat, 10 Oct 2026 06:08:50 +0000 Subject: [PATCH 3/4] fixup! test(@angular/build): add library builder scaling benchmark suite --- scripts/benchmark.mts | 36 ++++++++-- scripts/benchmarks/library-builder/index.mts | 72 +++++++++++--------- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/scripts/benchmark.mts b/scripts/benchmark.mts index a8318b84af11..9f3bb1e5353a 100644 --- a/scripts/benchmark.mts +++ b/scripts/benchmark.mts @@ -32,7 +32,7 @@ function checkBuildStatus(logger: Console): boolean { 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', + 'Run "pnpm build" to ensure your benchmark reflects your latest local edits.\n', ); } } @@ -54,7 +54,7 @@ async function runLibraryBuilderSubsystem(options: { }): Promise { 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}".`); + console.error("Error: --layout must be \"flat\" or \"deep\", got \"" + options.layout + "\"."); return 1; } @@ -64,18 +64,42 @@ async function runLibraryBuilderSubsystem(options: { ) { // eslint-disable-next-line no-console console.error( - `Error: --style must be one of "inline", "inline-scss", "external", got "${options.style}".`, + "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: parseIntList(options.sizes), - depths: parseIntList(options.depths), - iterations: options.iterations !== undefined ? Number(options.iterations) : undefined, + sizes, + depths, + iterations, json: Boolean(options.json), }; diff --git a/scripts/benchmarks/library-builder/index.mts b/scripts/benchmarks/library-builder/index.mts index 9be2070b965f..2824d6c3aaf6 100644 --- a/scripts/benchmarks/library-builder/index.mts +++ b/scripts/benchmarks/library-builder/index.mts @@ -58,7 +58,7 @@ function checkBuilderIsBuilt( if (!fs.existsSync(path.join(buildPkg, 'package.json')) || !fs.existsSync(architectCli)) { logger.error( 'Error: @angular/build and/or @angular-devkit/architect have not been built yet.\n' + - 'Run "pnpm build" (or "pnpm admin build --local" for a faster local build) first.', + 'Run "pnpm build" (or "pnpm admin build --local" for a faster local build) first.', ); return undefined; @@ -171,6 +171,10 @@ function buildOnce( const durationMs = performance.now() - start; const status = result.status === 0 ? 'ok' : 'fail'; 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 @@ -207,49 +211,51 @@ export async function runLibraryBuilderBenchmarks( 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); + try { + for (const sizeOrDepth of sizes) { + const label = layout + "-" + style + "-" + (layout === "deep" ? "depth" + sizeOrDepth : sizeOrDepth); + const projectDir = path.join(scratchRoot, label); - if (!options.json) { - // eslint-disable-next-line no-console - console.log(`\n[library-builder] ${label} (${entryPoints} entry points)`); - } + const fixtureOptions: FixtureOptions = + layout === 'flat' + ? { layout, style, count: sizeOrDepth } + : { layout, style, depth: sizeOrDepth }; + const entryPoints = generateFixture(projectDir, fixtureOptions); - 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("\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( - ` run ${i}/${iterations}: ${status === 'ok' ? 'done' : 'FAILED'} in ${(durationMs / 1000).toFixed(2)}s`, + " median: " + (medianMs / 1000).toFixed(2) + "s (" + (medianMs / entryPoints).toFixed(2) + "ms/entry)" ); } - 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(projectDir, { recursive: true, force: true }); + } finally { + fs.rmSync(scratchRoot, { recursive: true, force: true }); } - fs.rmSync(scratchRoot, { recursive: true, force: true }); - const anyFailed = measurements.some((m) => m.status === 'fail'); if (options.json) { // eslint-disable-next-line no-console From 01111495a40ec5eb677d2e5c2a2d86ce1939439f Mon Sep 17 00:00:00 2001 From: David Herges Date: Sat, 10 Oct 2026 06:35:59 +0000 Subject: [PATCH 4/4] fixup! test(@angular/build): add library builder scaling benchmark suite --- scripts/benchmark.mts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/benchmark.mts b/scripts/benchmark.mts index 9f3bb1e5353a..8a8764744091 100644 --- a/scripts/benchmark.mts +++ b/scripts/benchmark.mts @@ -40,8 +40,11 @@ function checkBuildStatus(logger: Console): boolean { return true; } -function parseIntList(raw: string | undefined): number[] | undefined { - return raw?.split(',').map((s) => Number(s.trim())); +function parseIntList(raw: string | number | undefined): number[] | undefined { + if (raw === undefined) { + return undefined; + } + return String(raw).split(',').map((s) => Number(s.trim())); } async function runLibraryBuilderSubsystem(options: {