Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/perf-tap-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"tapable": patch
---

Perf: reduce allocations and work on the tap registration and compile paths.

- `Hook#_tap` builds the final tap descriptor in a single allocation for the
common `hook.tap("name", fn)` string-options case instead of creating an
intermediate `{ name }` object that was then merged via `Object.assign`.
- `Hook#_insert` takes an O(1) fast path for the common append case (no
`before`, and stage consistent with the last tap) - the previous
implementation always ran the shift loop once.
- `Hook#_runRegisterInterceptors` early-returns when there are no
interceptors and uses an indexed loop instead of `for…of`.
- `HookMap#for` inlines the `_map.get` lookup instead of routing through
`this.get(key)`, saving a method dispatch on a path hit once per hook
access in consumers like webpack.
- `HookCodeFactory#setup` builds `_x` with a preallocated array + explicit
loop instead of `Array.prototype.map`.
- `HookCodeFactory#init` uses `Array.prototype.slice` instead of spread to
skip the iterator protocol.
- `HookCodeFactory#args` memoizes the common no-before/no-after result so
arguments are joined once per compile rather than once per tap.
- `HookCodeFactory#needContext`, `callTapsSeries`, `callTapsParallel` and
`MultiHook`'s iteration use indexed loops with cached length, and the
series/parallel code hoists the per-tap `done`/`doneBreak` closures
out of the compile-time loop. Replaces `Array.prototype.findIndex`
with a local loop to avoid callback allocation.

Registering 10 taps on a `SyncHook` is roughly 2× faster,
`SyncHook: tap 5 + first call (compile)` is ~15% faster, and
`HookMap#for (existing key)` is ~6% faster in the micro-benchmarks.
The `.call()` path is unchanged.
40 changes: 40 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Benchmarks

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
id-token: write # Required for OIDC authentication with CodSpeed

jobs:
benchmark:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: lts/*
cache: npm

- run: npm ci

- name: Run benchmarks
uses: CodSpeedHQ/action@fa0c9b1770f933c1bc025c83a9b42946b102f4e6 # v4.10.4
with:
run: npm run benchmark
mode: "simulation"
95 changes: 95 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Benchmarks

Performance benchmarks for `tapable`, tracked over time via
[CodSpeed](https://codspeed.io/).

Runner stack: [tinybench](https://github.com/tinylibs/tinybench) +
[`@codspeed/core`](https://www.npmjs.com/package/@codspeed/core) with a local
`withCodSpeed()` wrapper ported from webpack's
`test/BenchmarkTestCases.benchmark.mjs` (via enhanced-resolve). Locally it
falls back to plain tinybench wall-clock measurements, and under
`CodSpeedHQ/action` in CI it automatically switches to CodSpeed's
instrumentation mode.

## Running locally

```sh
npm run benchmark
```

Optional substring filter to run only matching cases:

```sh
npm run benchmark -- sync
BENCH_FILTER=async-parallel npm run benchmark
```

Locally the runner uses tinybench's wall-clock measurements and prints a
table of ops/s, mean, p99, and relative margin of error per task. Under CI,
the bridge detects the CodSpeed runner environment and switches to
instruction-counting mode automatically.

The V8 flags in `package.json` (`--no-opt --predictable --hash-seed=1` etc.)
are required by CodSpeed's instrumentation mode for deterministic results —
do not drop them.

### Optional: running real instruction counts locally

If you want to reproduce CI's exact instrument-count numbers on your own
machine (Linux only — the underlying Valgrind tooling has no macOS backend),
install the standalone CodSpeed CLI and wrap `npm run benchmark` with it:

```sh
curl -fsSL https://codspeed.io/install.sh | bash
codspeed run npm run benchmark
```

This is only useful if you want to debug an instruction-count regression
outside CI. Day-to-day benchmark iteration should use `npm run benchmark`
directly (wall-clock mode).

## Layout

```
benchmark/
├── run.mjs # entry point: discovers cases, runs bench
├── with-codspeed.mjs # local @codspeed/core <-> tinybench bridge
└── cases/
└── <case-name>/
└── index.bench.mjs # default export: register(bench, ctx)
```

Each case directory must contain `index.bench.mjs` exporting a default
function with the signature:

```js
export default function register(bench, { caseName, caseDir }) {
bench.add("my case: descriptive name", () => {
// ... hook calls ...
});
}
```

## Existing cases

| Case | What it measures |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| `sync-hook` | Steady-state `SyncHook#call` at tap counts 0/1/5/10/20/50 and arg counts 0..5 |
| `sync-bail-hook` | `SyncBailHook#call`, full walk vs. bail at start / middle / end |
| `sync-waterfall-hook` | Value-threading through taps that all return / all skip / mixed |
| `sync-loop-hook` | Single-pass and multi-pass loops |
| `async-series-hook` | `callAsync` and `promise`, sync / async / promise tap flavors |
| `async-series-bail-hook` | Full walk vs. bail, for sync and callback-async taps |
| `async-series-waterfall-hook` | Waterfall for sync / async / promise taps |
| `async-series-loop-hook` | Single-pass and multi-pass async loops |
| `async-parallel-hook` | Fan-out across sync / async / promise taps |
| `async-parallel-bail-hook` | Parallel race with and without a bailing tap |
| `hook-map` | `HookMap#for` hot / cold / missing lookups plus interceptor factories |
| `multi-hook` | Fan-out registration and `isUsed` / `intercept` across a 3-hook `MultiHook` |
| `interceptors-sync` | Baseline vs. `call`, `tap`, combined, multiple, and register interceptors on SyncHook |
| `interceptors-async` | Same matrix on `AsyncSeriesHook` and `AsyncParallelHook` |
| `tap-registration` | `tap` / `tapAsync` / `tapPromise` with string, object, stage, and `before` options |
| `hook-compile` | First-call code-gen cost for every hook type (5 taps + first call per iteration) |

Add new cases by creating a new directory under `cases/` — `run.mjs` will
pick it up automatically on the next run.
70 changes: 70 additions & 0 deletions benchmark/cases/async-parallel-bail-hook/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* async-parallel-bail-hook
*
* AsyncParallelBailHook races taps in parallel but bails (with the
* lowest-index result) as soon as any tap produces a non-undefined value.
*/

import tapable from "../../../lib/index.js";

const { AsyncParallelBailHook } = tapable;

function makeHook(numTaps, kind, bailAt) {
const hook = new AsyncParallelBailHook(["a"]);
for (let i = 0; i < numTaps; i++) {
const idx = i;
const name = `plugin-${idx}`;
if (kind === "sync") {
hook.tap(name, (v) => (idx === bailAt ? v : undefined));
} else {
hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined));
}
}
hook.callAsync(1, () => {});
return hook;
}

const INNER_ITERATIONS = 100;

function runBatch(hook) {
return new Promise((resolve, reject) => {
let remaining = INNER_ITERATIONS;
const done = (err) => {
if (err) return reject(err);
if (--remaining === 0) return resolve();
};
for (let i = 0; i < INNER_ITERATIONS; i++) {
hook.callAsync(1, done);
}
});
}

/**
* @param {import('tinybench').Bench} bench
*/
export default function register(bench) {
{
const hook = makeHook(10, "sync", -1);
bench.add("async-parallel-bail-hook: 10 sync taps, no bail", () =>
runBatch(hook)
);
}
{
const hook = makeHook(10, "sync", 4);
bench.add("async-parallel-bail-hook: 10 sync taps, bail mid", () =>
runBatch(hook)
);
}
{
const hook = makeHook(5, "async", -1);
bench.add("async-parallel-bail-hook: 5 async taps, no bail", () =>
runBatch(hook)
);
}
{
const hook = makeHook(5, "async", 2);
bench.add("async-parallel-bail-hook: 5 async taps, bail mid", () =>
runBatch(hook)
);
}
}
61 changes: 61 additions & 0 deletions benchmark/cases/async-parallel-hook/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* async-parallel-hook
*
* AsyncParallelHook fires every tap at once and waits for all of them
* to finish. Touches the generated parallel loop / counter structure.
*/

import tapable from "../../../lib/index.js";

const { AsyncParallelHook } = tapable;

function makeHook(numTaps, kind) {
const hook = new AsyncParallelHook(["a"]);
for (let i = 0; i < numTaps; i++) {
const name = `plugin-${i}`;
if (kind === "sync") {
hook.tap(name, () => {});
} else if (kind === "async") {
hook.tapAsync(name, (_a, cb) => cb());
} else if (kind === "promise") {
hook.tapPromise(name, () => Promise.resolve());
}
}
hook.callAsync(1, () => {});
return hook;
}

const INNER_ITERATIONS = 200;

function runBatch(hook) {
return new Promise((resolve, reject) => {
let remaining = INNER_ITERATIONS;
const done = (err) => {
if (err) return reject(err);
if (--remaining === 0) return resolve();
};
for (let i = 0; i < INNER_ITERATIONS; i++) {
hook.callAsync(1, done);
}
});
}

/**
* @param {import('tinybench').Bench} bench
*/
export default function register(bench) {
for (const n of [1, 5, 20]) {
const hook = makeHook(n, "sync");
bench.add(`async-parallel-hook: ${n} sync taps`, () => runBatch(hook));
}

for (const n of [5, 20]) {
const hook = makeHook(n, "async");
bench.add(`async-parallel-hook: ${n} async taps`, () => runBatch(hook));
}

{
const hook = makeHook(5, "promise");
bench.add("async-parallel-hook: 5 promise taps", () => runBatch(hook));
}
}
71 changes: 71 additions & 0 deletions benchmark/cases/async-series-bail-hook/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* async-series-bail-hook
*
* AsyncSeriesBailHook walks taps in order until one returns / callbacks
* with a non-undefined value. Exercises both the full-chain "nothing bails"
* branch and the early-exit branch.
*/

import tapable from "../../../lib/index.js";

const { AsyncSeriesBailHook } = tapable;

function makeHook(numTaps, kind, bailAt) {
const hook = new AsyncSeriesBailHook(["a"]);
for (let i = 0; i < numTaps; i++) {
const idx = i;
const name = `plugin-${idx}`;
if (kind === "sync") {
hook.tap(name, (v) => (idx === bailAt ? v : undefined));
} else {
hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined));
}
}
hook.callAsync(1, () => {});
return hook;
}

const INNER_ITERATIONS = 200;

function runBatch(hook) {
return new Promise((resolve, reject) => {
let remaining = INNER_ITERATIONS;
const done = (err) => {
if (err) return reject(err);
if (--remaining === 0) return resolve();
};
for (let i = 0; i < INNER_ITERATIONS; i++) {
hook.callAsync(1, done);
}
});
}

/**
* @param {import('tinybench').Bench} bench
*/
export default function register(bench) {
{
const hook = makeHook(10, "sync", -1);
bench.add("async-series-bail-hook: 10 sync taps, no bail", () =>
runBatch(hook)
);
}
{
const hook = makeHook(10, "sync", 4);
bench.add("async-series-bail-hook: 10 sync taps, bail mid", () =>
runBatch(hook)
);
}
{
const hook = makeHook(5, "async", -1);
bench.add("async-series-bail-hook: 5 async taps, no bail", () =>
runBatch(hook)
);
}
{
const hook = makeHook(5, "async", 2);
bench.add("async-series-bail-hook: 5 async taps, bail mid", () =>
runBatch(hook)
);
}
}
Loading
Loading