Skip to content

Commit 2a304ae

Browse files
test: add tinybench benchmarks and CodSpeed CI (#217)
1 parent 0ec6d24 commit 2a304ae

File tree

24 files changed

+1829
-39
lines changed

24 files changed

+1829
-39
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"tapable": patch
3+
---
4+
5+
Perf: reduce allocations and work on the tap registration and compile paths.
6+
7+
- `Hook#_tap` builds the final tap descriptor in a single allocation for the
8+
common `hook.tap("name", fn)` string-options case instead of creating an
9+
intermediate `{ name }` object that was then merged via `Object.assign`.
10+
- `Hook#_insert` takes an O(1) fast path for the common append case (no
11+
`before`, and stage consistent with the last tap) - the previous
12+
implementation always ran the shift loop once.
13+
- `Hook#_runRegisterInterceptors` early-returns when there are no
14+
interceptors and uses an indexed loop instead of `for…of`.
15+
- `HookMap#for` inlines the `_map.get` lookup instead of routing through
16+
`this.get(key)`, saving a method dispatch on a path hit once per hook
17+
access in consumers like webpack.
18+
- `HookCodeFactory#setup` builds `_x` with a preallocated array + explicit
19+
loop instead of `Array.prototype.map`.
20+
- `HookCodeFactory#init` uses `Array.prototype.slice` instead of spread to
21+
skip the iterator protocol.
22+
- `HookCodeFactory#args` memoizes the common no-before/no-after result so
23+
arguments are joined once per compile rather than once per tap.
24+
- `HookCodeFactory#needContext`, `callTapsSeries`, `callTapsParallel` and
25+
`MultiHook`'s iteration use indexed loops with cached length, and the
26+
series/parallel code hoists the per-tap `done`/`doneBreak` closures
27+
out of the compile-time loop. Replaces `Array.prototype.findIndex`
28+
with a local loop to avoid callback allocation.
29+
30+
Registering 10 taps on a `SyncHook` is roughly 2× faster,
31+
`SyncHook: tap 5 + first call (compile)` is ~15% faster, and
32+
`HookMap#for (existing key)` is ~6% faster in the micro-benchmarks.
33+
The `.call()` path is unchanged.

.github/workflows/benchmarks.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Benchmarks
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
permissions:
15+
contents: read
16+
id-token: write # Required for OIDC authentication with CodSpeed
17+
18+
jobs:
19+
benchmark:
20+
runs-on: ubuntu-latest
21+
permissions:
22+
pull-requests: write
23+
steps:
24+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
with:
26+
fetch-depth: 0
27+
28+
- name: Use Node.js
29+
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
30+
with:
31+
node-version: lts/*
32+
cache: npm
33+
34+
- run: npm ci
35+
36+
- name: Run benchmarks
37+
uses: CodSpeedHQ/action@fa0c9b1770f933c1bc025c83a9b42946b102f4e6 # v4.10.4
38+
with:
39+
run: npm run benchmark
40+
mode: "simulation"

benchmark/README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Benchmarks
2+
3+
Performance benchmarks for `tapable`, tracked over time via
4+
[CodSpeed](https://codspeed.io/).
5+
6+
Runner stack: [tinybench](https://github.com/tinylibs/tinybench) +
7+
[`@codspeed/core`](https://www.npmjs.com/package/@codspeed/core) with a local
8+
`withCodSpeed()` wrapper ported from webpack's
9+
`test/BenchmarkTestCases.benchmark.mjs` (via enhanced-resolve). Locally it
10+
falls back to plain tinybench wall-clock measurements, and under
11+
`CodSpeedHQ/action` in CI it automatically switches to CodSpeed's
12+
instrumentation mode.
13+
14+
## Running locally
15+
16+
```sh
17+
npm run benchmark
18+
```
19+
20+
Optional substring filter to run only matching cases:
21+
22+
```sh
23+
npm run benchmark -- sync
24+
BENCH_FILTER=async-parallel npm run benchmark
25+
```
26+
27+
Locally the runner uses tinybench's wall-clock measurements and prints a
28+
table of ops/s, mean, p99, and relative margin of error per task. Under CI,
29+
the bridge detects the CodSpeed runner environment and switches to
30+
instruction-counting mode automatically.
31+
32+
The V8 flags in `package.json` (`--no-opt --predictable --hash-seed=1` etc.)
33+
are required by CodSpeed's instrumentation mode for deterministic results —
34+
do not drop them.
35+
36+
### Optional: running real instruction counts locally
37+
38+
If you want to reproduce CI's exact instrument-count numbers on your own
39+
machine (Linux only — the underlying Valgrind tooling has no macOS backend),
40+
install the standalone CodSpeed CLI and wrap `npm run benchmark` with it:
41+
42+
```sh
43+
curl -fsSL https://codspeed.io/install.sh | bash
44+
codspeed run npm run benchmark
45+
```
46+
47+
This is only useful if you want to debug an instruction-count regression
48+
outside CI. Day-to-day benchmark iteration should use `npm run benchmark`
49+
directly (wall-clock mode).
50+
51+
## Layout
52+
53+
```
54+
benchmark/
55+
├── run.mjs # entry point: discovers cases, runs bench
56+
├── with-codspeed.mjs # local @codspeed/core <-> tinybench bridge
57+
└── cases/
58+
└── <case-name>/
59+
└── index.bench.mjs # default export: register(bench, ctx)
60+
```
61+
62+
Each case directory must contain `index.bench.mjs` exporting a default
63+
function with the signature:
64+
65+
```js
66+
export default function register(bench, { caseName, caseDir }) {
67+
bench.add("my case: descriptive name", () => {
68+
// ... hook calls ...
69+
});
70+
}
71+
```
72+
73+
## Existing cases
74+
75+
| Case | What it measures |
76+
| ----------------------------- | ------------------------------------------------------------------------------------- |
77+
| `sync-hook` | Steady-state `SyncHook#call` at tap counts 0/1/5/10/20/50 and arg counts 0..5 |
78+
| `sync-bail-hook` | `SyncBailHook#call`, full walk vs. bail at start / middle / end |
79+
| `sync-waterfall-hook` | Value-threading through taps that all return / all skip / mixed |
80+
| `sync-loop-hook` | Single-pass and multi-pass loops |
81+
| `async-series-hook` | `callAsync` and `promise`, sync / async / promise tap flavors |
82+
| `async-series-bail-hook` | Full walk vs. bail, for sync and callback-async taps |
83+
| `async-series-waterfall-hook` | Waterfall for sync / async / promise taps |
84+
| `async-series-loop-hook` | Single-pass and multi-pass async loops |
85+
| `async-parallel-hook` | Fan-out across sync / async / promise taps |
86+
| `async-parallel-bail-hook` | Parallel race with and without a bailing tap |
87+
| `hook-map` | `HookMap#for` hot / cold / missing lookups plus interceptor factories |
88+
| `multi-hook` | Fan-out registration and `isUsed` / `intercept` across a 3-hook `MultiHook` |
89+
| `interceptors-sync` | Baseline vs. `call`, `tap`, combined, multiple, and register interceptors on SyncHook |
90+
| `interceptors-async` | Same matrix on `AsyncSeriesHook` and `AsyncParallelHook` |
91+
| `tap-registration` | `tap` / `tapAsync` / `tapPromise` with string, object, stage, and `before` options |
92+
| `hook-compile` | First-call code-gen cost for every hook type (5 taps + first call per iteration) |
93+
94+
Add new cases by creating a new directory under `cases/``run.mjs` will
95+
pick it up automatically on the next run.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* async-parallel-bail-hook
3+
*
4+
* AsyncParallelBailHook races taps in parallel but bails (with the
5+
* lowest-index result) as soon as any tap produces a non-undefined value.
6+
*/
7+
8+
import tapable from "../../../lib/index.js";
9+
10+
const { AsyncParallelBailHook } = tapable;
11+
12+
function makeHook(numTaps, kind, bailAt) {
13+
const hook = new AsyncParallelBailHook(["a"]);
14+
for (let i = 0; i < numTaps; i++) {
15+
const idx = i;
16+
const name = `plugin-${idx}`;
17+
if (kind === "sync") {
18+
hook.tap(name, (v) => (idx === bailAt ? v : undefined));
19+
} else {
20+
hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined));
21+
}
22+
}
23+
hook.callAsync(1, () => {});
24+
return hook;
25+
}
26+
27+
const INNER_ITERATIONS = 100;
28+
29+
function runBatch(hook) {
30+
return new Promise((resolve, reject) => {
31+
let remaining = INNER_ITERATIONS;
32+
const done = (err) => {
33+
if (err) return reject(err);
34+
if (--remaining === 0) return resolve();
35+
};
36+
for (let i = 0; i < INNER_ITERATIONS; i++) {
37+
hook.callAsync(1, done);
38+
}
39+
});
40+
}
41+
42+
/**
43+
* @param {import('tinybench').Bench} bench
44+
*/
45+
export default function register(bench) {
46+
{
47+
const hook = makeHook(10, "sync", -1);
48+
bench.add("async-parallel-bail-hook: 10 sync taps, no bail", () =>
49+
runBatch(hook)
50+
);
51+
}
52+
{
53+
const hook = makeHook(10, "sync", 4);
54+
bench.add("async-parallel-bail-hook: 10 sync taps, bail mid", () =>
55+
runBatch(hook)
56+
);
57+
}
58+
{
59+
const hook = makeHook(5, "async", -1);
60+
bench.add("async-parallel-bail-hook: 5 async taps, no bail", () =>
61+
runBatch(hook)
62+
);
63+
}
64+
{
65+
const hook = makeHook(5, "async", 2);
66+
bench.add("async-parallel-bail-hook: 5 async taps, bail mid", () =>
67+
runBatch(hook)
68+
);
69+
}
70+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* async-parallel-hook
3+
*
4+
* AsyncParallelHook fires every tap at once and waits for all of them
5+
* to finish. Touches the generated parallel loop / counter structure.
6+
*/
7+
8+
import tapable from "../../../lib/index.js";
9+
10+
const { AsyncParallelHook } = tapable;
11+
12+
function makeHook(numTaps, kind) {
13+
const hook = new AsyncParallelHook(["a"]);
14+
for (let i = 0; i < numTaps; i++) {
15+
const name = `plugin-${i}`;
16+
if (kind === "sync") {
17+
hook.tap(name, () => {});
18+
} else if (kind === "async") {
19+
hook.tapAsync(name, (_a, cb) => cb());
20+
} else if (kind === "promise") {
21+
hook.tapPromise(name, () => Promise.resolve());
22+
}
23+
}
24+
hook.callAsync(1, () => {});
25+
return hook;
26+
}
27+
28+
const INNER_ITERATIONS = 200;
29+
30+
function runBatch(hook) {
31+
return new Promise((resolve, reject) => {
32+
let remaining = INNER_ITERATIONS;
33+
const done = (err) => {
34+
if (err) return reject(err);
35+
if (--remaining === 0) return resolve();
36+
};
37+
for (let i = 0; i < INNER_ITERATIONS; i++) {
38+
hook.callAsync(1, done);
39+
}
40+
});
41+
}
42+
43+
/**
44+
* @param {import('tinybench').Bench} bench
45+
*/
46+
export default function register(bench) {
47+
for (const n of [1, 5, 20]) {
48+
const hook = makeHook(n, "sync");
49+
bench.add(`async-parallel-hook: ${n} sync taps`, () => runBatch(hook));
50+
}
51+
52+
for (const n of [5, 20]) {
53+
const hook = makeHook(n, "async");
54+
bench.add(`async-parallel-hook: ${n} async taps`, () => runBatch(hook));
55+
}
56+
57+
{
58+
const hook = makeHook(5, "promise");
59+
bench.add("async-parallel-hook: 5 promise taps", () => runBatch(hook));
60+
}
61+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* async-series-bail-hook
3+
*
4+
* AsyncSeriesBailHook walks taps in order until one returns / callbacks
5+
* with a non-undefined value. Exercises both the full-chain "nothing bails"
6+
* branch and the early-exit branch.
7+
*/
8+
9+
import tapable from "../../../lib/index.js";
10+
11+
const { AsyncSeriesBailHook } = tapable;
12+
13+
function makeHook(numTaps, kind, bailAt) {
14+
const hook = new AsyncSeriesBailHook(["a"]);
15+
for (let i = 0; i < numTaps; i++) {
16+
const idx = i;
17+
const name = `plugin-${idx}`;
18+
if (kind === "sync") {
19+
hook.tap(name, (v) => (idx === bailAt ? v : undefined));
20+
} else {
21+
hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined));
22+
}
23+
}
24+
hook.callAsync(1, () => {});
25+
return hook;
26+
}
27+
28+
const INNER_ITERATIONS = 200;
29+
30+
function runBatch(hook) {
31+
return new Promise((resolve, reject) => {
32+
let remaining = INNER_ITERATIONS;
33+
const done = (err) => {
34+
if (err) return reject(err);
35+
if (--remaining === 0) return resolve();
36+
};
37+
for (let i = 0; i < INNER_ITERATIONS; i++) {
38+
hook.callAsync(1, done);
39+
}
40+
});
41+
}
42+
43+
/**
44+
* @param {import('tinybench').Bench} bench
45+
*/
46+
export default function register(bench) {
47+
{
48+
const hook = makeHook(10, "sync", -1);
49+
bench.add("async-series-bail-hook: 10 sync taps, no bail", () =>
50+
runBatch(hook)
51+
);
52+
}
53+
{
54+
const hook = makeHook(10, "sync", 4);
55+
bench.add("async-series-bail-hook: 10 sync taps, bail mid", () =>
56+
runBatch(hook)
57+
);
58+
}
59+
{
60+
const hook = makeHook(5, "async", -1);
61+
bench.add("async-series-bail-hook: 5 async taps, no bail", () =>
62+
runBatch(hook)
63+
);
64+
}
65+
{
66+
const hook = makeHook(5, "async", 2);
67+
bench.add("async-series-bail-hook: 5 async taps, bail mid", () =>
68+
runBatch(hook)
69+
);
70+
}
71+
}

0 commit comments

Comments
 (0)