Skip to content

Commit df5dc1a

Browse files
authored
test(@angular/build): add library builder scaling benchmark suite
1 parent dcf906a commit df5dc1a

4 files changed

Lines changed: 651 additions & 2 deletions

File tree

‎scripts/benchmark.mts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import fs from 'node:fs';
1010
import path from 'node:path';
1111
import { type BenchmarkCliOptions, runI18nBenchmarks } from './benchmarks/i18n/index.mts';
12+
import {
13+
type LibraryBuilderBenchmarkOptions,
14+
runLibraryBuilderBenchmarks,
15+
} from './benchmarks/library-builder/index.mts';
1216

1317
function checkBuildStatus(logger: Console): boolean {
1418
const distFile = 'dist/@angular/build/src/tools/i18n/i18n-inliner.js';
@@ -36,6 +40,50 @@ function checkBuildStatus(logger: Console): boolean {
3640
return true;
3741
}
3842

43+
function parseIntList(raw: string | undefined): number[] | undefined {
44+
return raw?.split(',').map((s) => Number(s.trim()));
45+
}
46+
47+
async function runLibraryBuilderSubsystem(options: {
48+
layout?: string;
49+
style?: string;
50+
sizes?: string;
51+
depths?: string;
52+
iterations?: string | number;
53+
json?: boolean;
54+
}): Promise<number> {
55+
if (options.layout !== undefined && options.layout !== 'flat' && options.layout !== 'deep') {
56+
// eslint-disable-next-line no-console
57+
console.error(`Error: --layout must be "flat" or "deep", got "${options.layout}".`);
58+
59+
return 1;
60+
}
61+
if (
62+
options.style !== undefined &&
63+
!['inline', 'inline-scss', 'external'].includes(options.style)
64+
) {
65+
// eslint-disable-next-line no-console
66+
console.error(
67+
`Error: --style must be one of "inline", "inline-scss", "external", got "${options.style}".`,
68+
);
69+
70+
return 1;
71+
}
72+
73+
const cliOptions: LibraryBuilderBenchmarkOptions = {
74+
layout: options.layout as LibraryBuilderBenchmarkOptions['layout'],
75+
style: options.style as LibraryBuilderBenchmarkOptions['style'],
76+
sizes: parseIntList(options.sizes),
77+
depths: parseIntList(options.depths),
78+
iterations: options.iterations !== undefined ? Number(options.iterations) : undefined,
79+
json: Boolean(options.json),
80+
};
81+
82+
const { exitCode } = await runLibraryBuilderBenchmarks(cliOptions);
83+
84+
return exitCode;
85+
}
86+
3987
export default async function (
4088
options: {
4189
_?: string[];
@@ -51,6 +99,10 @@ export default async function (
5199
'save-baseline'?: string;
52100
compareBaseline?: string;
53101
'compare-baseline'?: string;
102+
layout?: string;
103+
style?: string;
104+
sizes?: string;
105+
depths?: string;
54106
help?: boolean;
55107
[key: string]: unknown;
56108
},
@@ -69,8 +121,9 @@ Usage:
69121
70122
Subsystems:
71123
i18n Run i18n inliner performance benchmarks (default)
124+
library-builder Run @angular/build:library scaling benchmarks
72125
73-
Options:
126+
Options (i18n):
74127
--scenario=<name> Run a specific scenario (e.g. standard-app, enterprise-multilingual)
75128
--iterations=<n> Number of measured iterations (default: 5)
76129
--warmup=<n> Number of warmup iterations (default: 2)
@@ -80,15 +133,30 @@ Options:
80133
--json Output results in machine-readable JSON
81134
--save-baseline=<file> Save run results to a baseline JSON file
82135
--compare-baseline=<file> Compare run results against an existing baseline JSON file
136+
137+
Options (library-builder):
138+
--layout=<flat|deep> Fixture layout (default: flat)
139+
--style=<inline|inline-scss|external> Component style variant (default: inline)
140+
--sizes=<n,n,...> Entry-point counts to sweep, layout=flat only (default: 10,50,300,1000,2000)
141+
--depths=<n,n,...> Tree depths to sweep, layout=deep only (default: 2,5,8,11)
142+
--iterations=<n> Measured iterations per size (default: 2)
143+
--json Output raw measurements as JSON
144+
83145
--help Show this help message
84146
`);
85147

86148
return 0;
87149
}
88150

151+
if (targetSubsystem === 'library-builder') {
152+
return runLibraryBuilderSubsystem(options);
153+
}
154+
89155
if (targetSubsystem !== 'i18n') {
90156
// eslint-disable-next-line no-console
91-
console.error(`Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n`);
157+
console.error(
158+
`Unknown benchmark subsystem: "${targetSubsystem}". Supported subsystems: i18n, library-builder`,
159+
);
92160

93161
return 1;
94162
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# `@angular/build:library` scaling benchmark
2+
3+
This benchmark measures how `@angular/build:library`'s cold build time scales with the number
4+
of secondary entry points in an Angular Package Format (APF) library, across two structurally
5+
different layouts (flat siblings vs. a deeply nested tree) and three component style variants
6+
(inline template, inline template + styles, external template/stylesheet files).
7+
8+
## Why
9+
10+
A comparison against [ng-packagr](https://github.com/ng-packagr/ng-packagr) (a third-party APF
11+
build tool) found that its build time **per entry point** grows super-linearly once a library
12+
has more than roughly 300-500 secondary entry points -- doubling the library size past that
13+
point costs noticeably more than double the build time. This suite exists to check whether
14+
`@angular/build:library`, a from-scratch build pipeline unrelated to ng-packagr's implementation,
15+
shows the same growth on equivalent fixtures.
16+
17+
**Finding:** it does not. Across the same sweep (flat layout: 10/50/300/1000/2000 entries; deep
18+
layout: tree depths 2/5/8/11, i.e. 4/32/256/2048 entries), `@angular/build:library`'s per-entry
19+
cost keeps falling as the library grows, with no climb at the largest sizes tested. It is also
20+
1.8-13x faster than ng-packagr at matching sizes, with the gap widening as size increases. See
21+
the PR description this suite was added alongside for the full writeup and caveats.
22+
23+
## Running it
24+
25+
```sh
26+
pnpm build # or: pnpm admin build --local -- the benchmark measures the compiled builder,
27+
# not TypeScript source, so it must exist at dist/@angular/build and
28+
# dist/@angular-devkit/architect first
29+
pnpm admin benchmark library-builder
30+
```
31+
32+
Options:
33+
34+
```
35+
--layout=<flat|deep> Fixture layout (default: flat)
36+
--style=<inline|inline-scss|external> Component style variant (default: inline)
37+
--sizes=<n,n,...> Entry-point counts to sweep, layout=flat only (default: 10,50,300,1000,2000)
38+
--depths=<n,n,...> Tree depths to sweep, layout=deep only (default: 2,5,8,11)
39+
--iterations=<n> Measured iterations per size (default: 2)
40+
--json Output raw measurements as JSON instead of a human-readable log
41+
```
42+
43+
Example: sweep the external-style variant at a few sizes with 3 iterations each:
44+
45+
```sh
46+
pnpm admin benchmark library-builder --style=external --sizes=100,500,1000 --iterations=3
47+
```
48+
49+
## How it works
50+
51+
- `fixtures.mts` generates a synthetic library at a given size/layout/style: a primary entry
52+
point, N secondary entry points (each a minimal standalone Angular component), and the
53+
`angular.json`/`tsconfig.lib.json`/`package.json` scaffolding `@angular/build:library` needs.
54+
Secondary entry points are declared as a single flat `exports` map in the root `package.json`
55+
(the builder's actual convention), not as separate per-entry config files.
56+
- `index.mts` times `architect lib:build` (the standalone `@angular-devkit/architect` CLI,
57+
invoked directly -- no `ng` CLI needed) once per iteration via `child_process.spawnSync`,
58+
discards the generated project afterwards, and reports per-size median build time.
59+
- Because `@angular/build` and `@angular-devkit/architect` are Bazel-built workspace packages
60+
rather than ordinary npm dependencies, a plain `pnpm install` never links them (or their own
61+
dependencies) into the repo's root `node_modules` -- nothing in `dist/` is resolvable via a
62+
bare `require()`/`import` otherwise. On first run, `index.mts` symlinks `@angular/build` and
63+
`@angular-devkit/architect` from `dist/` into the root `node_modules`, then walks their
64+
declared `dependencies` and links any of those still missing from the pnpm store. This only
65+
touches `node_modules` (already gitignored) and is idempotent -- safe to run repeatedly or
66+
alongside other work in the same checkout.
67+
68+
## Methodology notes
69+
70+
- Each reported number is the **median** of the measured iterations, not the mean -- process-spawn
71+
wall-clock timings are noisy and right-skewed, and the median is more representative of steady
72+
state than an outlier-sensitive mean.
73+
- Results are specific to the machine they were collected on. Run on one otherwise-idle machine
74+
for a self-consistent sweep; absolute numbers will differ across hardware, though the _shape_
75+
of the curve (does per-entry cost grow with size, or not) is the more portable finding.
76+
- This benchmark only covers **cold, one-shot builds**. It does not exercise incremental rebuilds
77+
or watch mode.

0 commit comments

Comments
 (0)