Skip to content
Draft
99 changes: 97 additions & 2 deletions scripts/benchmark.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,6 +40,76 @@ function checkBuildStatus(logger: Console): boolean {
return true;
}

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: {
layout?: string;
count?: string;
depth?: 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;
}

const count = parseIntList(options.count);
if (count !== undefined && count.some((n) => !Number.isInteger(n) || n < 1)) {
// eslint-disable-next-line no-console
console.error(
'Error: --count must be a comma-separated list of positive integers, got "' +
options.count +
'".',
);

return 1;
}

const depth = parseIntList(options.depth);
if (depth !== undefined && depth.some((n) => !Number.isInteger(n) || n < 1)) {
// eslint-disable-next-line no-console
console.error(
'Error: --depth must be a comma-separated list of positive integers, got "' +
options.depth +
'".',
);

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'],
count,
depth,
iterations,
json: Boolean(options.json),
};

const { exitCode } = await runLibraryBuilderBenchmarks(cliOptions);

return exitCode;
}
Comment thread
dherges marked this conversation as resolved.

export default async function (
options: {
_?: string[];
Expand All @@ -51,6 +125,9 @@ export default async function (
'save-baseline'?: string;
compareBaseline?: string;
'compare-baseline'?: string;
layout?: string;
count?: string;
depth?: string;
help?: boolean;
[key: string]: unknown;
},
Expand All @@ -69,8 +146,9 @@ Usage:

Subsystems:
i18n Run i18n inliner performance benchmarks (default)
library-builder Run @angular/build:library scaling benchmarks

Options:
Options (i18n):
--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)
Expand All @@ -80,15 +158,32 @@ Options:
--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

Options (library-builder):
--layout=<flat|deep> Fixture layout (default: flat)
--count=<n,n,...> Entry-point count targets, drives both layouts
(default: 10,20,50,100,200,300,400,500,1000,2000)
--depth=<n,n,...> Explicit tree depths, layout=deep only. Overrides the depths
that would otherwise be derived from --count (the smallest
depth with 2^depth >= each target count).
--iterations=<n> Measured iterations (default: 3)
--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;
}
Expand Down
171 changes: 171 additions & 0 deletions scripts/benchmarks/library-builder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Library Builds Scaling Benchmark

This benchmark suites measures build time metrics for Angular libraries.

## Context

Angular CLI is in the process of transitioning from `ng-packagr` to Angular CLI's native library builder.
Both library build systems are registered Architect builders:

- `@angular/build:ng-packagr`: runs library builds through the legacy `ng-packagr` build pipeline.
- `@angular/build:library`: runs library builds through Angular CLI's native library builder.

This benchmark suite is designed to quantify performance impacts and ensure scalability.

### Previous Benchmarks

For context, when running a comparable benchmark suite directly against `ng-packagr`, it hinted at a "U-curve" in build times,
where build time per entry point in `ng-packagr` drops until entry point N (with N varying in the range 200..500).
This shape indicates an initial overhead as a possible explanation for a small number of entry points.
However, we discovered that `ng-packagr` build time per entry point increases
as the number of entry points scale past a certain N (in the test runs N=200+).

[Landing Angular CLI's native `@angular/build:library` builder](https://github.com/angular/angular-cli/pull/34106#issuecomment-5744311681),
indicated very favourable performance metrics for the new library builder that can be explained by a number of
architectural changes in the design of the library build pipeline:

- Shared Stylesheet Bundling
- Public API Invalidation via Declaration Hashing
- Dedicated Worker Pool for Production Builds
- Single Pass Output Emission
- Direct Async Pipeline

The benchmark design of this suite is built to not only verify the previous results,
but also to confirm or contradict the architectural choices in the library builder pipeline.

### Goals

This benchmark suite runs explorative research on the performance of library builds in Angular CLI.
Interesting results from the previous benchmarks indicate 2 measurable goals:

- Measure the new library builder against itself: How does it scale with the number of entry points?
- Enable A/B benchmarks of the `@angular/build:ng-packagr` builder against `@angular/build:library` builder: How much do library builds benefit from the transition to the native library builder?

As the previous benchmark runs hint at significant performance improvements in the new library builder,
this benchmark suite aims to quantify the impact of that transition.

## Benchmark Design

This suite supports two primary layouts to test different filesystem and entry-point scaling scenarios:

- **Flat (`--layout=flat`)**: Generates $N$ secondary entry points as direct siblings, where $N$
comes straight from `--count`.
- Design: Each entry point is named `comp-i` (e.g., `comp-0001`, `comp-0002`).

- **Deep (`--layout=deep`)**: Generates $2^N$ secondary entry points in a binary tree structure.
- Design: Creates a nested directory structure (`sub-a`/`sub-b` at each level).
- Depth $N$ is not an independent sweep parameter -- see "Sweep rounds" below.

Each entry point consists of a `public-api.ts` and a component file with an inline template
containing a bit of per-entry content derived from the entry's id (e.g. `<h2>Component no.
0042</h2>`), so entries aren't byte-identical copies of each other.

### Sweep rounds

`--count` is the one sweep parameter: a comma-separated list of entry-point count targets. The
default sweep spans small to large entry-point counts in one round, chosen to resolve the
U-shaped scaling pattern observed with `ng-packagr` (see above):
`10, 20, 50, 100, 200, 300, 400, 500, 1000, 2000`.

For `--layout=deep`, there's no "number of entry points" parameter directly -- a binary tree of
depth $N$ always has exactly $2^N$ leaves/entry points. So `--count`'s targets are translated into
tree depths: for each target `n`, the smallest `depth` with `2^depth >= n`, de-duplicated (e.g.
`300`, `400`, and `500` all round up to the same `depth=9`, i.e. 512 entry points). This keeps
both layouts comparable at roughly the same entry-point scale without exposing depth as a second,
independently-tunable sweep axis. `--depth` still exists as an **explicit override** for
`--layout=deep` -- pass it directly to sweep exact tree depths instead of entry-point targets --
but it is not part of the default sweep design and is ignored for `--layout=flat`.

## Running the Benchmarks

```sh
# Ensure the builder is built first
pnpm build

# Run the benchmark
pnpm admin benchmark library-builder [options]
```

### Options

| Option | Description | Default |
| :---------------------- | :--------------------------------------------------------------------------------------------------- | :--------------------------------------- |
| `--layout=<flat\|deep>` | Fixture layout | `flat` |
| `--count=<n,n,...>` | Entry-point count targets, drives both layouts | `10,20,50,100,200,300,400,500,1000,2000` |
| `--depth=<n,n,...>` | **`layout=deep` only.** Explicit tree depths, overriding the depths otherwise derived from `--count` | derived from `--count` |
| `--iterations=<n>` | Measured iterations per size | `3` |
| `--json` | Output raw measurements as JSON | N/A |

> `--count` and `--depth` are not independent sweep axes for `layout=deep`: `--count` is translated
> into depths (smallest `depth` with `2^depth >= n` per target, de-duplicated); `--depth` only
> matters when you want to bypass that translation and name exact tree depths yourself. Passing
> both for `layout=deep` makes `--depth` win; `--depth` is ignored entirely for `layout=flat`.

### Example

Sweep the flat layout at a single size:

```sh
pnpm admin benchmark library-builder --layout=flat --count=500
```

## Benchmark Results

Each entry below is one benchmark run, with its own summary and conclusions.

### 2026-10-12: Iteration 1 baseline sweep (native library builder, flat & deep)

First full run of the default sweep against `@angular/build:library` only &mdash; no `ng-packagr` A/B comparison yet (still Future Work).
Fixtures use inline HTML templates only, no stylesheets.
Default `--count` sweep, `--iterations=3`, both layouts.
Environment: 4-core / 16 GiB sandbox, NVMe SSD.
Raw log: `tmp/benchmark-run-1791795379.log` (gitignored; not included in this commit).

#### Flat layout (inline templates only)

| Entries | Median | ms/entry |
| ------: | -----: | -------: |
| 10 | 3.25s | 324.63 |
| 20 | 3.28s | 164.14 |
| 50 | 3.33s | 66.61 |
| 100 | 3.75s | 37.53 |
| 200 | 4.31s | 21.56 |
| 300 | 4.90s | 16.35 |
| 400 | 5.51s | 13.78 |
| 500 | 6.18s | 12.36 |
| 1000 | 10.29s | 10.29 |
| 2000 | 19.47s | 9.74 |

#### Deep layout (inline templates only)

| Entries | Median | ms/entry |
| ------: | -----: | -------: |
| 16 | 3.27s | 204.47 |
| 32 | 3.30s | 103.16 |
| 64 | 3.57s | 55.74 |
| 128 | 4.20s | 32.84 |
| 256 | 4.70s | 18.36 |
| 512 | 6.31s | 12.32 |
| 1024 | 10.25s | 10.01 |
| 2048 | 21.67s | 10.58 |

**Conclusion:** per-entry build time decreases monotonically across both layouts and flattens out
around 9.6-10.6ms/entry at the largest sizes tested -- no U-shaped climb-back-up at any tested
size (up to 2000/2048 entries), unlike the pattern reported for `ng-packagr` past N≈200-500. Flat
and deep track each other closely at comparable entry-point scale, suggesting the directory
layout itself isn't a meaningful cost driver for the native builder, just raw entry-point count.
`deep`'s depth-11 run (2048 entries) showed slightly higher run-to-run variance (20.78s-22.46s)
than `flat`'s 2000-entry run (19.46s-19.63s) -- possibly directory-tree-walk overhead at depth,
but not a regression, and not yet investigated further.

All 60 builds (10 sizes x 3 iterations x 2 layouts) completed with `status: "ok"`; zero failures.
Comment on lines +124 to +161

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterated the Benchmark Design and ran the first full default sweep against @angular/build:library (flat & deep layouts, --iterations=3, inline-template fixtures only:

  • No U-shape: unlike ng-packagr's reported climb past ~200-500 entries, per-entry build time decreases monotonically here and flattens around 9.6-10.6ms/entry at the top end (2000+ entries).
  • Flat and deep layouts track closely at comparable entry-point scale — directory structure doesn't appear to be a meaningful cost driver on its own.
  • All 60 builds (10 sizes × 3 iterations × 2 layouts) succeeded, zero failures.


## Future Work

- Style-flavor matrix: inline / inline-css / inline-scss / external / external-css / external-scss, swept across both layouts.
- 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).
- Dependencies between entry points.
- Shared stylesheet tokens imported by multiple components.
- Watch mode.
- Peak memory (`ru_maxrss`) and CPU time (`ru_utime` + `ru_stime`) metrics per build, in addition to wall-clock build time.
- Library "taste" variants beyond plain flat/deep, e.g. flat/deep with inter-entry-point dependencies or shared SCSS tokens (currently out of scope, listed above).
Loading
Loading