Skip to content

Commit ead4fa9

Browse files
committed
fixup! test(@angular/build): add library builder scaling benchmark suite
Adds a `--builder` axis (library, ng-packagr) so fixtures can be generated in either builder's project shape for A/B benchmarking. Fixtures only; no results recorded.
1 parent f7e154a commit ead4fa9

6 files changed

Lines changed: 236 additions & 89 deletions

File tree

‎scripts/benchmark.mts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import fs from 'node:fs';
1010
import path from 'node:path';
1111
import { type BenchmarkCliOptions, runI18nBenchmarks } from './benchmarks/i18n/index.mts';
12-
import { STYLE_FLAVORS, type StyleFlavor } from './benchmarks/library-builder/fixtures.mts';
12+
import {
13+
LIBRARY_BUILDERS,
14+
STYLE_FLAVORS,
15+
type LibraryBuilder,
16+
type StyleFlavor,
17+
} from './benchmarks/library-builder/fixtures.mts';
1318
import {
1419
type LibraryBuilderBenchmarkOptions,
1520
runLibraryBuilderBenchmarks,
@@ -62,6 +67,7 @@ function parseStringList(raw: string | undefined): string[] | undefined {
6267
async function runLibraryBuilderSubsystem(
6368
options: {
6469
layout?: string;
70+
builder?: string;
6571
count?: string;
6672
depth?: string;
6773
'style-flavor'?: string;
@@ -119,6 +125,21 @@ async function runLibraryBuilderSubsystem(
119125
return 1;
120126
}
121127

128+
const builder = parseStringList(options.builder);
129+
const validBuilders: readonly string[] = LIBRARY_BUILDERS;
130+
if (builder !== undefined && builder.some((b) => !validBuilders.includes(b))) {
131+
// eslint-disable-next-line no-console
132+
console.error(
133+
'Error: --builder must be a comma-separated list of: ' +
134+
LIBRARY_BUILDERS.join(', ') +
135+
'. Got "' +
136+
options.builder +
137+
'".',
138+
);
139+
140+
return 1;
141+
}
142+
122143
const styleFlavor = parseStringList(options['style-flavor']);
123144
const validStyleFlavors: readonly string[] = STYLE_FLAVORS;
124145
if (styleFlavor !== undefined && styleFlavor.some((f) => !validStyleFlavors.includes(f))) {
@@ -136,6 +157,7 @@ async function runLibraryBuilderSubsystem(
136157

137158
const cliOptions: LibraryBuilderBenchmarkOptions = {
138159
layout: options.layout as LibraryBuilderBenchmarkOptions['layout'],
160+
builder: builder as LibraryBuilder[] | undefined,
139161
count,
140162
depth,
141163
styleFlavor: styleFlavor as StyleFlavor[] | undefined,
@@ -165,6 +187,7 @@ export default async function (
165187
compareBaseline?: string;
166188
'compare-baseline'?: string;
167189
layout?: string;
190+
builder?: string;
168191
count?: string;
169192
depth?: string;
170193
'style-flavor'?: string;
@@ -186,7 +209,7 @@ Usage:
186209
187210
Subsystems:
188211
i18n Run i18n inliner performance benchmarks (default)
189-
library-builder Run @angular/build:library scaling benchmarks
212+
library-builder Run @angular/build:library (and :ng-packagr) scaling benchmarks
190213
191214
Options (i18n):
192215
--scenario=<name> Run a specific scenario (e.g. standard-app, enterprise-multilingual)
@@ -201,6 +224,8 @@ Options (i18n):
201224
202225
Options (library-builder):
203226
--layout=<flat|deep> Fixture layout (default: flat)
227+
--builder=<b,b,...> Builders to benchmark, one or more of: library, ng-packagr.
228+
Pass both for an A/B run (default: library)
204229
--count=<n,n,...> Entry-point count targets, drives both layouts
205230
(default: 10,20,50,100,200,300,400,500,1000,2000)
206231
--depth=<n,n,...> Explicit tree depths, layout=deep only. Overrides the depths

‎scripts/benchmarks/library-builder/README.md‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ Each entry point consists of a `public-api.ts` and a component file with a bit o
6060
content derived from the entry's id (e.g. `<h2>Component no. 0042</h2>`), so entries aren't
6161
byte-identical copies of each other.
6262

63+
### Builder axis (A/B)
64+
65+
`--builder=<list>` selects which Architect builder each fixture targets. The same synthetic sources
66+
are emitted in either builder's project shape:
67+
68+
| Builder | Architect builder | Project shape |
69+
| :----------- | :-------------------------- | :----------------------------------------------------------------------------------------------------------------------- |
70+
| `library` | `@angular/build:library` | One root `package.json` with a flat `exports` map; no `ng-package.json`. |
71+
| `ng-packagr` | `@angular/build:ng-packagr` | Root `ng-package.json` (`dest`, `lib.entryFile`) plus one `ng-package.json` per secondary entry point; no `exports` map. |
72+
73+
Defaults to `library` only. Pass `--builder=library,ng-packagr` for an A/B run: the builder is the
74+
innermost loop, so the two builds of an identical fixture size run back to back. `inline-scss`
75+
sets `inlineStyleLanguage` on the builder for `library` and in `ng-package.json` for `ng-packagr`.
76+
6377
### Style-flavor matrix
6478

6579
`--style-flavor` is a second, independent sweep axis (crossed with `--layout` and the
@@ -113,6 +127,7 @@ pnpm admin benchmark library-builder [options]
113127
| Option | Description | Default |
114128
| :------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------- |
115129
| `--layout=<flat\|deep>` | Fixture layout | `flat` |
130+
| `--builder=<b,b,...>` | Builders to benchmark, one or more of `library`, `ng-packagr`. Pass both for an A/B run | `library` |
116131
| `--count=<n,n,...>` | Entry-point count targets, drives both layouts | `10,20,50,100,200,300,400,500,1000,2000` |
117132
| `--depth=<n,n,...>` | **`layout=deep` only.** Explicit tree depths, overriding the depths otherwise derived from `--count` | derived from `--count` |
118133
| `--style-flavor=<f,f,...>` | Style-flavor targets (see "Style-flavor matrix" above), one or more of `inline`, `inline-css`, `inline-scss`, `external`, `external-css`, `external-scss` | `inline` |
@@ -133,6 +148,12 @@ Sweep the flat layout at a single size:
133148
pnpm admin benchmark library-builder --layout=flat --count=500
134149
```
135150

151+
A/B run of both builders at a few sizes:
152+
153+
```sh
154+
pnpm admin benchmark library-builder --builder=library,ng-packagr --layout=flat --count=10,50,200
155+
```
156+
136157
Sweep the full style-flavor matrix at a single size, both layouts:
137158

138159
```sh
@@ -219,9 +240,30 @@ Verification performed:
219240
- Verified `--style-flavor` rejects an unrecognized flavor name with a clear error listing the
220241
valid set, consistent with existing `--layout`/`--count`/`--depth` validation.
221242

243+
### 2026-10-22: Fixtures and harness prepared for A/B runs (no results yet)
244+
245+
Added the `--builder` axis (see "Builder axis (A/B)" above): the fixture generator can now emit
246+
`ng-packagr`-shaped projects (per-entry-point `ng-package.json`) next to the native builder's
247+
`exports`-map projects, and `builder` is part of the JSON/CSV output.
248+
249+
This entry is preparation only -- **no benchmark results are recorded here**. Verification performed:
250+
251+
- `--count=5` with both layouts, all 6 style flavors, both builders, `--iterations=1`: all 24
252+
builds completed with `status: "ok"`.
253+
- Inspected `ng-packagr` output for a `deep`/`external-scss` fixture: every secondary entry point
254+
was built and bundled, and the per-entry SCSS variable compiled to a literal color, i.e. the
255+
fixtures exercise the same work on both builders.
256+
- `--builder` rejects an unrecognized name with a clear error listing the valid set.
257+
- A reduced, throwaway sweep (flat, counts 10/50/200, 2 iterations, once per builder) ran cleanly
258+
on both builders. Those numbers are deliberately not recorded: they are a smoke test, not a
259+
measurement.
260+
261+
We now have a basis for a first real A/B run. The full sweep takes hours and needs proper
262+
preparation (quiet machine, fixed environment, enough iterations) before it is worth running.
263+
222264
## Future Work
223265

224-
- A/B comparison against `ng-packagr` (both are already registered Architect builders — `@angular/build:ng-packagr` and `@angular/build:library` — so this is a same-builder-package comparison, not a separate tool invocation).
266+
- First real A/B run against `ng-packagr` (`--builder=library,ng-packagr`; the fixture generation and harness support are in place, see 2026-10-22). Needs a quiet machine and the full default sweep.
225267
- Full style-flavor matrix sweep (default `--count` sweep x 6 flavors x 2 layouts) to quantify the actual performance impact of stylesheet processing and external file reads, now that the matrix is implemented.
226268
- Dependencies between entry points.
227269
- Shared stylesheet tokens imported by multiple components.

‎scripts/benchmarks/library-builder/fixtures.mts‎

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,23 @@
77
*/
88

99
// Generates synthetic Angular Package Format (APF) library fixtures for benchmarking
10-
// `@angular/build:library`, and converts them into the builder's project shape (a flat
11-
// `exports` map in one root package.json, rather than the per-entry-point `ng-package.json`
12-
// convention used by third-party tools such as ng-packagr).
10+
// `@angular/build:library` and `@angular/build:ng-packagr`. The same synthetic sources are emitted
11+
// in either builder's project shape: a flat `exports` map in one root package.json for the native
12+
// builder, or the per-entry-point `ng-package.json` convention for ng-packagr.
1313

1414
import fs from 'node:fs';
1515
import path from 'node:path';
1616

1717
export type Layout = 'flat' | 'deep';
1818

19+
/** Which Architect builder the generated project targets. */
20+
export type LibraryBuilder = 'library' | 'ng-packagr';
21+
22+
/** All library builders, in A/B order (native builder first, legacy builder second). */
23+
export const LIBRARY_BUILDERS: readonly LibraryBuilder[] = ['library', 'ng-packagr'];
24+
25+
export const DEFAULT_LIBRARY_BUILDER: LibraryBuilder = 'library';
26+
1927
/**
2028
* Style-flavor matrix: crosses template location (inline vs. external `templateUrl`) with
2129
* stylesheet presence/language (none, CSS, SCSS). `external` has no stylesheet, matching
@@ -49,6 +57,9 @@ export interface FixtureOptions {
4957

5058
/** Template/stylesheet flavor for every entry point in the fixture. Defaults to `inline`. */
5159
styleFlavor?: StyleFlavor;
60+
61+
/** Builder the generated project targets. Defaults to `library`. */
62+
builder?: LibraryBuilder;
5263
}
5364

5465
/** Per-entry CSS/SCSS content, varied by `id` like the template so entries aren't byte-identical. */
@@ -106,9 +117,22 @@ export class ${className} {}
106117
}
107118

108119
/** Writes one secondary entry point's source files (component + public-api.ts) into `entryDir`. */
109-
function writeEntryPointSource(entryDir: string, id: string, styleFlavor: StyleFlavor): void {
120+
function writeEntryPointSource(
121+
entryDir: string,
122+
id: string,
123+
styleFlavor: StyleFlavor,
124+
builder: LibraryBuilder,
125+
): void {
110126
fs.mkdirSync(entryDir, { recursive: true });
111127

128+
if (builder === 'ng-packagr') {
129+
// ng-packagr discovers secondary entry points by the presence of a per-directory ng-package.json.
130+
fs.writeFileSync(
131+
path.join(entryDir, 'ng-package.json'),
132+
JSON.stringify({ lib: { entryFile: 'public-api.ts' } }, null, 2) + '\n',
133+
);
134+
}
135+
112136
const className = `RefComponent${id}`;
113137
const selector = `ref-comp-${id}`;
114138
const compBase = `comp-${id}`;
@@ -150,13 +174,18 @@ function writeEntryPointSource(entryDir: string, id: string, styleFlavor: StyleF
150174
}
151175

152176
/** Generates `count` flat secondary entry points as direct siblings, returning their relative dirs. */
153-
function generateFlatEntries(outDir: string, count: number, styleFlavor: StyleFlavor): string[] {
177+
function generateFlatEntries(
178+
outDir: string,
179+
count: number,
180+
styleFlavor: StyleFlavor,
181+
builder: LibraryBuilder,
182+
): string[] {
154183
const width = String(count).length;
155184
const dirs: string[] = [];
156185
for (let i = 1; i <= count; i++) {
157186
const id = String(i).padStart(width, '0');
158187
const relDir = `comp-${id}`;
159-
writeEntryPointSource(path.join(outDir, relDir), id, styleFlavor);
188+
writeEntryPointSource(path.join(outDir, relDir), id, styleFlavor, builder);
160189
dirs.push(relDir);
161190
}
162191

@@ -167,7 +196,12 @@ function generateFlatEntries(outDir: string, count: number, styleFlavor: StyleFl
167196
* Generates a binary tree `depth` levels deep (`sub-a`/`sub-b` at each level), placing one
168197
* secondary entry point at each of the 2^depth leaves. Returns their relative dirs.
169198
*/
170-
function generateDeepEntries(outDir: string, depth: number, styleFlavor: StyleFlavor): string[] {
199+
function generateDeepEntries(
200+
outDir: string,
201+
depth: number,
202+
styleFlavor: StyleFlavor,
203+
builder: LibraryBuilder,
204+
): string[] {
171205
const total = 2 ** depth;
172206
const width = String(total).length;
173207
const dirs: string[] = [];
@@ -177,7 +211,7 @@ function generateDeepEntries(outDir: string, depth: number, styleFlavor: StyleFl
177211
if (level === depth) {
178212
counter++;
179213
const id = String(counter).padStart(width, '0');
180-
writeEntryPointSource(path.join(outDir, currentRelDir), id, styleFlavor);
214+
writeEntryPointSource(path.join(outDir, currentRelDir), id, styleFlavor, builder);
181215
dirs.push(currentRelDir);
182216

183217
return;
@@ -207,6 +241,7 @@ export function generateFixture(outDir: string, options: FixtureOptions): number
207241
fs.mkdirSync(outDir, { recursive: true });
208242

209243
const styleFlavor = options.styleFlavor ?? DEFAULT_STYLE_FLAVOR;
244+
const builder = options.builder ?? DEFAULT_LIBRARY_BUILDER;
210245

211246
fs.writeFileSync(
212247
path.join(outDir, 'public-api.ts'),
@@ -215,12 +250,16 @@ export function generateFixture(outDir: string, options: FixtureOptions): number
215250

216251
const secondaryDirs =
217252
options.layout === 'flat'
218-
? generateFlatEntries(outDir, options.count ?? 10, styleFlavor)
219-
: generateDeepEntries(outDir, options.depth ?? 2, styleFlavor);
253+
? generateFlatEntries(outDir, options.count ?? 10, styleFlavor, builder)
254+
: generateDeepEntries(outDir, options.depth ?? 2, styleFlavor, builder);
220255

256+
// Only the native builder reads an `exports` map; ng-packagr derives (and writes) it itself from
257+
// the per-entry-point ng-package.json files.
221258
const exportsMap: Record<string, string> = { '.': './public-api.ts' };
222-
for (const relDir of secondaryDirs) {
223-
exportsMap[`./${entryKey(relDir)}`] = `./${relDir.split(path.sep).join('/')}/public-api.ts`;
259+
if (builder === 'library') {
260+
for (const relDir of secondaryDirs) {
261+
exportsMap[`./${entryKey(relDir)}`] = `./${relDir.split(path.sep).join('/')}/public-api.ts`;
262+
}
224263
}
225264

226265
// Fallback to a hardcoded version if the path lookup fails in the sandbox/environment.
@@ -247,7 +286,7 @@ export function generateFixture(outDir: string, options: FixtureOptions): number
247286
'@angular/core': peerRange,
248287
'@angular/common': peerRange,
249288
},
250-
exports: exportsMap,
289+
...(builder === 'library' ? { exports: exportsMap } : {}),
251290
},
252291
null,
253292
2,
@@ -298,15 +337,36 @@ export function generateFixture(outDir: string, options: FixtureOptions): number
298337
) + '\n',
299338
);
300339

301-
const buildOptions: Record<string, unknown> = {
302-
tsConfig: './tsconfig.lib.json',
303-
outputPath: 'dist',
304-
};
305-
if (styleFlavor === 'inline-scss') {
306-
// Inline style language is a project-wide builder option, not per-component -- this is the
307-
// only flavor that needs it set, since external .scss files are detected by extension and
308-
// 'css' (the default) already covers the other flavors.
309-
buildOptions['inlineStyleLanguage'] = 'scss';
340+
let buildTarget: { builder: string; options: Record<string, unknown> };
341+
if (builder === 'library') {
342+
const buildOptions: Record<string, unknown> = {
343+
tsConfig: './tsconfig.lib.json',
344+
outputPath: 'dist',
345+
};
346+
if (styleFlavor === 'inline-scss') {
347+
// Inline style language is a project-wide builder option, not per-component -- this is the
348+
// only flavor that needs it set, since external .scss files are detected by extension and
349+
// 'css' (the default) already covers the other flavors.
350+
buildOptions['inlineStyleLanguage'] = 'scss';
351+
}
352+
buildTarget = { builder: '@angular/build:library', options: buildOptions };
353+
} else {
354+
const ngPackage: Record<string, unknown> = {
355+
dest: 'dist',
356+
lib: { entryFile: 'public-api.ts' },
357+
};
358+
if (styleFlavor === 'inline-scss') {
359+
// For ng-packagr the inline style language is configured in ng-package.json, not the builder.
360+
ngPackage['inlineStyleLanguage'] = 'scss';
361+
}
362+
fs.writeFileSync(
363+
path.join(outDir, 'ng-package.json'),
364+
JSON.stringify(ngPackage, null, 2) + '\n',
365+
);
366+
buildTarget = {
367+
builder: '@angular/build:ng-packagr',
368+
options: { project: './ng-package.json', tsConfig: './tsconfig.lib.json' },
369+
};
310370
}
311371

312372
fs.writeFileSync(
@@ -320,12 +380,7 @@ export function generateFixture(outDir: string, options: FixtureOptions): number
320380
root: '.',
321381
projectType: 'library',
322382
sourceRoot: '.',
323-
targets: {
324-
build: {
325-
builder: '@angular/build:library',
326-
options: buildOptions,
327-
},
328-
},
383+
targets: { build: buildTarget },
329384
},
330385
},
331386
},

‎scripts/benchmarks/library-builder/harness.mts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
// Runs and measures a single `@angular/build:library` cold build via Architect's standalone CLI.
9+
// Runs and measures a single `@angular/build:library` or `@angular/build:ng-packagr` cold build via Architect's standalone CLI.
1010
// `fixtures.mts` owns generating the on-disk project; this module owns spawning the builder
1111
// against it and reporting what happened. Kept separate so each file has one job.
1212

1313
import { spawnSync } from 'node:child_process';
1414
import fs from 'node:fs';
1515
import path from 'node:path';
16-
import type { Layout, StyleFlavor } from './fixtures.mts';
16+
import type { Layout, LibraryBuilder, StyleFlavor } from './fixtures.mts';
1717

1818
export interface BuildMeasurement {
19+
builder: LibraryBuilder;
1920
layout: Layout;
2021
styleFlavor: StyleFlavor;
2122
entryPoints: number;

0 commit comments

Comments
 (0)