From 88e7f38da0ca01ae8f0c87432f83c479d11b0c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 14:54:30 +0000 Subject: [PATCH 1/6] perf: add tinybench benchmarks, CodSpeed CI, and reduce tap-registration allocations - Add tinybench-based micro-benchmarks covering SyncHook, SyncBailHook, SyncWaterfallHook, SyncLoopHook, AsyncSeries*, AsyncParallel*, HookMap and the interceptor paths, plus tap registration and first-call compile. - Wire up @codspeed/tinybench-plugin so the same files double as CodSpeed benchmarks in CI and print a tinybench table locally. - Add a CodSpeed GitHub Actions workflow (.github/workflows/codspeed.yml) that runs on push to main and on pull requests. - Perf: Hook#_tap builds the final tap descriptor in a single allocation for the common string-options case (hook.tap("name", fn)), making tap registration ~2x faster in micro-benchmarks. - Perf: HookCodeFactory#setup uses a preallocated array + explicit loop instead of Array.prototype.map. --- .changeset/perf-tap-registration.md | 13 ++ .github/workflows/codspeed.yml | 38 ++++ benchmarks/README.md | 42 +++++ benchmarks/async.bench.js | 144 +++++++++++++++ benchmarks/helpers.js | 45 +++++ benchmarks/hookmap.bench.js | 55 ++++++ benchmarks/interceptors.bench.js | 69 ++++++++ benchmarks/sync.bench.js | 151 ++++++++++++++++ eslint.config.mjs | 2 +- lib/Hook.js | 39 ++-- lib/HookCodeFactory.js | 8 +- package-lock.json | 264 +++++++++++++++++++++++++++- package.json | 13 +- 13 files changed, 862 insertions(+), 21 deletions(-) create mode 100644 .changeset/perf-tap-registration.md create mode 100644 .github/workflows/codspeed.yml create mode 100644 benchmarks/README.md create mode 100644 benchmarks/async.bench.js create mode 100644 benchmarks/helpers.js create mode 100644 benchmarks/hookmap.bench.js create mode 100644 benchmarks/interceptors.bench.js create mode 100644 benchmarks/sync.bench.js diff --git a/.changeset/perf-tap-registration.md b/.changeset/perf-tap-registration.md new file mode 100644 index 0000000..1231d8a --- /dev/null +++ b/.changeset/perf-tap-registration.md @@ -0,0 +1,13 @@ +--- +"tapable": patch +--- + +Perf: reduce tap-registration allocations. + +`Hook#_tap` now 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`. In +micro-benchmarks, registering 10 taps on a `SyncHook` is roughly 2× faster. +`HookCodeFactory#setup` also builds `_x` with a preallocated array + explicit +loop instead of `Array.prototype.map`, trimming a small amount of work on +every hook compile. diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..8c516fc --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,38 @@ +name: CodSpeed + +on: + push: + branches: [main] + pull_request: + branches: [main] + # Allow manual runs from the Actions tab. + workflow_dispatch: + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: lts/* + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run benchmarks and upload to CodSpeed + uses: CodSpeedHQ/action@v3 + with: + # Run all benchmark files. CodSpeed instruments them automatically + # via @codspeed/tinybench-plugin, which is wired up in benchmarks/helpers.js. + run: npm run bench + token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..580ea6d --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,42 @@ +# tapable benchmarks + +Micro-benchmarks for tapable's hook primitives, built with +[tinybench](https://github.com/tinylibs/tinybench) and instrumented for +[CodSpeed](https://codspeed.io) via +[`@codspeed/tinybench-plugin`](https://github.com/CodSpeedHQ/codspeed-node). + +## Running locally + +```bash +# All benchmarks +npm run bench + +# A single suite +npm run bench:sync +npm run bench:async +npm run bench:hookmap +npm run bench:interceptors +``` + +Local runs print a tinybench result table. The CodSpeed plugin is a no-op +outside of the CodSpeed runner, so nothing is uploaded from local runs. + +## What is covered + +- `benchmarks/sync.bench.js` — `SyncHook`, `SyncBailHook`, + `SyncWaterfallHook`, `SyncLoopHook`, plus tap registration and + first-call compile cost. +- `benchmarks/async.bench.js` — `AsyncSeriesHook`, + `AsyncSeriesBailHook`, `AsyncSeriesWaterfallHook`, `AsyncParallelHook`, + `AsyncParallelBailHook` (callback + promise forms). +- `benchmarks/hookmap.bench.js` — `HookMap#for` (hot/cold paths) + and `HookMap#get`. +- `benchmarks/interceptors.bench.js` — the interceptor slow paths + (`call`, `tap`, `register`) layered on top of `SyncHook` and + `AsyncSeriesHook`. + +## CI + +`.github/workflows/codspeed.yml` runs `npm run bench` on push to `main` +and on pull requests, uploading results to CodSpeed. Set the +`CODSPEED_TOKEN` repository secret to enable uploads. diff --git a/benchmarks/async.bench.js b/benchmarks/async.bench.js new file mode 100644 index 0000000..1a6fd79 --- /dev/null +++ b/benchmarks/async.bench.js @@ -0,0 +1,144 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { + AsyncSeriesHook, + AsyncSeriesBailHook, + AsyncSeriesWaterfallHook, + AsyncParallelHook, + AsyncParallelBailHook +} = require("../lib"); +const { createBench, runBench } = require("./helpers"); + +function syncTap() {} +function syncWaterfallTap(v) { + return v + 1; +} + +function makeAsyncSeriesHook(numTaps) { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, syncTap); + } + // Warm up (compile) once. + hook.callAsync(1, () => {}); + return hook; +} + +function makeAsyncSeriesWaterfallHook(numTaps) { + const hook = new AsyncSeriesWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, syncWaterfallTap); + } + hook.callAsync(0, () => {}); + return hook; +} + +function makeAsyncSeriesBailHook(numTaps) { + const hook = new AsyncSeriesBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, syncTap); + } + hook.callAsync(1, () => {}); + return hook; +} + +function makeAsyncParallelHook(numTaps) { + const hook = new AsyncParallelHook(["a"]); + for (let i = 0; i < numTaps; i++) { + // Use async taps so parallel behavior is exercised. + hook.tapAsync(`plugin-${i}`, (_a, cb) => cb()); + } + hook.callAsync(1, () => {}); + return hook; +} + +function makeAsyncParallelBailHook(numTaps) { + const hook = new AsyncParallelBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + hook.tapAsync(`plugin-${i}`, (_a, cb) => cb()); + } + hook.callAsync(1, () => {}); + return hook; +} + +async function main() { + const bench = createBench(); + + // --- AsyncSeriesHook.callAsync (sync taps) --- + const seriesHook5 = makeAsyncSeriesHook(5); + bench.add("AsyncSeriesHook#callAsync (5 sync taps)", () => { + return new Promise((resolve) => { + seriesHook5.callAsync(1, resolve); + }); + }); + + const seriesHook20 = makeAsyncSeriesHook(20); + bench.add("AsyncSeriesHook#callAsync (20 sync taps)", () => { + return new Promise((resolve) => { + seriesHook20.callAsync(1, resolve); + }); + }); + + // --- AsyncSeriesHook.promise --- + const seriesPromise5 = makeAsyncSeriesHook(5); + bench.add("AsyncSeriesHook#promise (5 sync taps)", () => { + return seriesPromise5.promise(1); + }); + + // --- AsyncSeriesBailHook.callAsync --- + const seriesBail10 = makeAsyncSeriesBailHook(10); + bench.add("AsyncSeriesBailHook#callAsync (10 sync taps)", () => { + return new Promise((resolve) => { + seriesBail10.callAsync(1, resolve); + }); + }); + + // --- AsyncSeriesWaterfallHook.callAsync --- + const waterfall5 = makeAsyncSeriesWaterfallHook(5); + bench.add("AsyncSeriesWaterfallHook#callAsync (5 sync taps)", () => { + return new Promise((resolve) => { + waterfall5.callAsync(0, resolve); + }); + }); + + const waterfall20 = makeAsyncSeriesWaterfallHook(20); + bench.add("AsyncSeriesWaterfallHook#callAsync (20 sync taps)", () => { + return new Promise((resolve) => { + waterfall20.callAsync(0, resolve); + }); + }); + + // --- AsyncParallelHook.callAsync --- + const parallel5 = makeAsyncParallelHook(5); + bench.add("AsyncParallelHook#callAsync (5 async taps)", () => { + return new Promise((resolve) => { + parallel5.callAsync(1, resolve); + }); + }); + + const parallel20 = makeAsyncParallelHook(20); + bench.add("AsyncParallelHook#callAsync (20 async taps)", () => { + return new Promise((resolve) => { + parallel20.callAsync(1, resolve); + }); + }); + + // --- AsyncParallelBailHook.callAsync --- + const parallelBail10 = makeAsyncParallelBailHook(10); + bench.add("AsyncParallelBailHook#callAsync (10 async taps)", () => { + return new Promise((resolve) => { + parallelBail10.callAsync(1, resolve); + }); + }); + + await runBench(bench); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/benchmarks/helpers.js b/benchmarks/helpers.js new file mode 100644 index 0000000..d776cee --- /dev/null +++ b/benchmarks/helpers.js @@ -0,0 +1,45 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { Bench } = require("tinybench"); +const { withCodSpeed } = require("@codspeed/tinybench-plugin"); + +/** + * Create a new tinybench Bench, wrapped with CodSpeed instrumentation + * when running under the CodSpeed runner. When running locally the + * plugin is a no-op and regular tinybench output is produced. + * + * @param {import("tinybench").BenchOptions} [options] + * @returns {Bench} + */ +function createBench(options) { + return withCodSpeed( + new Bench({ + // Keep runs short by default - CodSpeed uses its own measurement + // strategy, and locally we only need enough iterations for a + // stable signal. + time: 500, + warmupTime: 100, + ...options + }) + ); +} + +/** + * Run a bench and print a result table when executed locally. + * + * @param {Bench} bench + */ +async function runBench(bench) { + await bench.run(); + // Only print a table for local runs. Under CodSpeed the output is + // collected by the runner and the table would be noisy. + if (!process.env.CODSPEED) { + // eslint-disable-next-line no-console + console.table(bench.table()); + } +} + +module.exports = { createBench, runBench }; diff --git a/benchmarks/hookmap.bench.js b/benchmarks/hookmap.bench.js new file mode 100644 index 0000000..a66b27e --- /dev/null +++ b/benchmarks/hookmap.bench.js @@ -0,0 +1,55 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { HookMap, SyncHook } = require("../lib"); +const { createBench, runBench } = require("./helpers"); + +async function main() { + const bench = createBench(); + + // --- HookMap#for on existing key (hot lookup path) --- + const warmMap = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 20; i++) { + warmMap.for(`key-${i}`).tap(`plugin-${i}`, () => {}); + } + bench.add("HookMap#for (existing key)", () => { + warmMap.for("key-10"); + }); + + bench.add("HookMap#get (existing key)", () => { + warmMap.get("key-10"); + }); + + // --- HookMap#for for a new key (factory + interceptors cold path) --- + bench.add("HookMap#for (new key, no interceptors)", () => { + const map = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 10; i++) { + map.for(`k-${i}`); + } + }); + + const interceptedTemplate = () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ + factory: (_key, hook) => hook + }); + return map; + }; + + bench.add("HookMap#for (new key, with interceptor)", () => { + const map = interceptedTemplate(); + for (let i = 0; i < 10; i++) { + map.for(`k-${i}`); + } + }); + + await runBench(bench); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/benchmarks/interceptors.bench.js b/benchmarks/interceptors.bench.js new file mode 100644 index 0000000..0b91fdc --- /dev/null +++ b/benchmarks/interceptors.bench.js @@ -0,0 +1,69 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncHook, AsyncSeriesHook } = require("../lib"); +const { createBench, runBench } = require("./helpers"); + +function makeSyncHookWithInterceptors(numTaps, interceptors) { + const hook = new SyncHook(["a"]); + for (const i of interceptors) hook.intercept(i); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + hook.call(1); + return hook; +} + +async function main() { + const bench = createBench(); + + // No interceptors (baseline) + const hookNoInt = makeSyncHookWithInterceptors(5, []); + bench.add("SyncHook#call (5 taps, no interceptors)", () => { + hookNoInt.call(1); + }); + + // Call interceptor only + const callInterceptor = makeSyncHookWithInterceptors(5, [{ call: () => {} }]); + bench.add("SyncHook#call (5 taps, call interceptor)", () => { + callInterceptor.call(1); + }); + + // Tap interceptor (runs per tap) + const tapInterceptor = makeSyncHookWithInterceptors(5, [{ tap: () => {} }]); + bench.add("SyncHook#call (5 taps, tap interceptor)", () => { + tapInterceptor.call(1); + }); + + // Register interceptor (only runs at registration time - measured during setup) + bench.add("SyncHook#tap with register interceptor (10 taps)", () => { + const hook = new SyncHook(["a"]); + hook.intercept({ register: (tap) => tap }); + for (let i = 0; i < 10; i++) { + hook.tap(`p-${i}`, () => {}); + } + }); + + // AsyncSeriesHook with interceptors + const asyncHook = new AsyncSeriesHook(["a"]); + asyncHook.intercept({ call: () => {} }); + for (let i = 0; i < 5; i++) { + asyncHook.tap(`p-${i}`, () => {}); + } + asyncHook.callAsync(1, () => {}); + bench.add("AsyncSeriesHook#callAsync (5 taps, call interceptor)", () => { + return new Promise((resolve) => { + asyncHook.callAsync(1, resolve); + }); + }); + + await runBench(bench); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/benchmarks/sync.bench.js b/benchmarks/sync.bench.js new file mode 100644 index 0000000..38ae40b --- /dev/null +++ b/benchmarks/sync.bench.js @@ -0,0 +1,151 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { + SyncHook, + SyncBailHook, + SyncWaterfallHook, + SyncLoopHook +} = require("../lib"); +const { createBench, runBench } = require("./helpers"); + +function makeSyncHook(numTaps) { + const hook = new SyncHook(["a", "b"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, (_a, _b) => {}); + } + // Force compilation so the benchmark measures the steady-state call path. + hook.call(1, 2); + return hook; +} + +function makeSyncBailHook(numTaps, bailAt = -1) { + const hook = new SyncBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, (v) => (i === bailAt ? v : undefined)); + } + hook.call(1); + return hook; +} + +function makeSyncWaterfallHook(numTaps) { + const hook = new SyncWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, (v) => v + 1); + } + hook.call(0); + return hook; +} + +function makeSyncLoopHook(numTaps) { + const hook = new SyncLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < 1) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + return hook; +} + +async function main() { + const bench = createBench(); + + // --- SyncHook.call --- + const hook0 = makeSyncHook(0); + bench.add("SyncHook#call (0 taps)", () => { + hook0.call(1, 2); + }); + + const hook1 = makeSyncHook(1); + bench.add("SyncHook#call (1 tap)", () => { + hook1.call(1, 2); + }); + + const hook5 = makeSyncHook(5); + bench.add("SyncHook#call (5 taps)", () => { + hook5.call(1, 2); + }); + + const hook20 = makeSyncHook(20); + bench.add("SyncHook#call (20 taps)", () => { + hook20.call(1, 2); + }); + + // --- SyncBailHook.call --- + const bailHookNoBail = makeSyncBailHook(10, -1); + bench.add("SyncBailHook#call (10 taps, no bail)", () => { + bailHookNoBail.call(1); + }); + + const bailHookMid = makeSyncBailHook(10, 5); + bench.add("SyncBailHook#call (10 taps, bail mid)", () => { + bailHookMid.call(1); + }); + + // --- SyncWaterfallHook.call --- + const waterfall5 = makeSyncWaterfallHook(5); + bench.add("SyncWaterfallHook#call (5 taps)", () => { + waterfall5.call(0); + }); + + const waterfall20 = makeSyncWaterfallHook(20); + bench.add("SyncWaterfallHook#call (20 taps)", () => { + waterfall20.call(0); + }); + + // --- SyncLoopHook.call --- + const loopHook = makeSyncLoopHook(5); + const loopState = { counts: [0, 0, 0, 0, 0] }; + bench.add( + "SyncLoopHook#call (5 taps)", + () => { + loopHook.call(loopState); + }, + { + beforeEach() { + for (let i = 0; i < loopState.counts.length; i++) { + loopState.counts[i] = 0; + } + } + } + ); + + // --- Tap registration (no compilation in hot loop) --- + bench.add("SyncHook#tap registration (10 taps)", () => { + const h = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) { + h.tap(`p-${i}`, () => {}); + } + }); + + bench.add("SyncHook#tap registration with stages (10 taps)", () => { + const h = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) { + h.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); + } + }); + + // --- First-call compile cost --- + bench.add("SyncHook first call compile (5 taps)", () => { + const h = new SyncHook(["a", "b"]); + for (let i = 0; i < 5; i++) { + h.tap(`p-${i}`, () => {}); + } + h.call(1, 2); + }); + + await runBench(bench); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 42f3e64..11bfa24 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import config from "eslint-config-webpack"; export default defineConfig([ { - ignores: [".changeset/"] + ignores: [".changeset/", "benchmarks/"] }, { extends: [config], diff --git a/lib/Hook.js b/lib/Hook.js index 6da14b2..e0515d1 100644 --- a/lib/Hook.js +++ b/lib/Hook.js @@ -65,22 +65,31 @@ class Hook { _tap(type, options, fn) { if (typeof options === "string") { - options = { - name: options - }; - } else if (typeof options !== "object" || options === null) { - throw new Error("Invalid tap options"); - } - if (typeof options.name === "string") { - options.name = options.name.trim(); - } - if (typeof options.name !== "string" || options.name === "") { - throw new Error("Missing name for tap"); - } - if (typeof options.context !== "undefined") { - deprecateContext(); + // Fast path: a string options ("name") is by far the most common + // case. Build the final descriptor in a single allocation instead + // of creating `{ name }` and then `Object.assign`ing it. + const name = options.trim(); + if (name === "") { + throw new Error("Missing name for tap"); + } + options = { type, fn, name }; + } else { + if (typeof options !== "object" || options === null) { + throw new Error("Invalid tap options"); + } + if (typeof options.name === "string") { + options.name = options.name.trim(); + } + if (typeof options.name !== "string" || options.name === "") { + throw new Error("Missing name for tap"); + } + if (typeof options.context !== "undefined") { + deprecateContext(); + } + // Preserve previous precedence: user-provided keys win over the + // internal `type`/`fn`. + options = Object.assign({ type, fn }, options); } - options = Object.assign({ type, fn }, options); options = this._runRegisterInterceptors(options); this._insert(options); } diff --git a/lib/HookCodeFactory.js b/lib/HookCodeFactory.js index 67e4663..1d9d512 100644 --- a/lib/HookCodeFactory.js +++ b/lib/HookCodeFactory.js @@ -77,7 +77,13 @@ class HookCodeFactory { } setup(instance, options) { - instance._x = options.taps.map((t) => t.fn); + const { taps } = options; + const { length } = taps; + const fns = Array.from({ length }); + for (let i = 0; i < length; i++) { + fns[i] = taps[i].fn; + } + instance._x = fns; } /** diff --git a/package-lock.json b/package-lock.json index 1a67583..ae40193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,15 @@ "@babel/preset-env": "^7.4.4", "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", + "@codspeed/tinybench-plugin": "^5.2.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", "prettier": "^3.5.3", - "prettier-1": "npm:prettier@^1" + "prettier-1": "npm:prettier@^1", + "tinybench": "^6.0.0" }, "engines": { "node": ">=6" @@ -2150,6 +2152,121 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@codspeed/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@codspeed/core/-/core-5.2.0.tgz", + "integrity": "sha512-CmDhpWjcOJg2iBOQ/BmBnSBq8qxlM3r4h8uvYDkoUaba+EKRT3T73BZtKuml/48jZMsB+4/FG2UbTBinDWtuvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.4.0", + "find-up": "^6.3.0", + "form-data": "^4.0.4", + "node-gyp-build": "^4.6.0" + } + }, + "node_modules/@codspeed/core/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@codspeed/core/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/tinybench-plugin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@codspeed/tinybench-plugin/-/tinybench-plugin-5.2.0.tgz", + "integrity": "sha512-LCmMFON3hdIRqiHC3W8oR0783cecRgA8x7cWMTnC9DgkIuyMrreHgQexnUGV3zsHgB084EXj/iPrWxR914/8Ng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@codspeed/core": "^5.2.0", + "stack-trace": "1.0.0-pre2" + }, + "peerDependencies": { + "tinybench": ">=4.0.1" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -4364,6 +4481,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4380,6 +4504,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -4895,6 +5031,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -5119,6 +5268,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6506,6 +6665,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6552,6 +6732,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -9818,6 +10015,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -9941,6 +10161,18 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10549,6 +10781,16 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11230,6 +11472,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -11567,6 +11819,16 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.0.tgz", + "integrity": "sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 0383f32..d9b97be 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "fix": "npm run fix:code && npm run fmt", "fix:code": "npm run lint:code -- --fix", "test": "jest", + "bench": "npm run bench:sync && npm run bench:async && npm run bench:hookmap && npm run bench:interceptors", + "bench:sync": "node benchmarks/sync.bench.js", + "bench:async": "node benchmarks/async.bench.js", + "bench:hookmap": "node benchmarks/hookmap.bench.js", + "bench:interceptors": "node benchmarks/interceptors.bench.js", "version": "changeset version", "release": "changeset publish" }, @@ -45,17 +50,19 @@ } }, "devDependencies": { - "@changesets/cli": "^2.30.0", - "@changesets/get-github-info": "^0.8.0", "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", + "@changesets/cli": "^2.30.0", + "@changesets/get-github-info": "^0.8.0", + "@codspeed/tinybench-plugin": "^5.2.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", "prettier": "^3.5.3", - "prettier-1": "npm:prettier@^1" + "prettier-1": "npm:prettier@^1", + "tinybench": "^6.0.0" }, "engines": { "node": ">=6" From 671859dc62b035408c6833355f1b2830c6b6bcd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 15:19:54 +0000 Subject: [PATCH 2/6] bench: split benchmarks by directory and expand coverage Restructure benchmarks/ into per-category directories (sync/, async/, hookmap/, interceptors/, registration/) with one bench file per hook type, add a recursive runner (benchmarks/run.js) that shares the CodSpeed session across suites, and add new benchmarks: - sync: variants for tap counts 0/1/3/5/10/20/50, arg counts 0..5, bail positions, waterfall returning vs. undefined, loop with 0/N reloops, args-reading tap. - async: sync/async/promise tap variants for Series, SeriesBail, SeriesWaterfall, Parallel, ParallelBail; new AsyncSeriesLoopHook benchmark; .promise() flavor coverage. - hookmap: hot get/for, missing key, cold factory with 0/1/3 interceptors; new MultiHook benchmarks (tap, isUsed, intercept). - interceptors: baseline vs. call/tap/register/combined/multiple for SyncHook and AsyncSeries/AsyncParallel; late-intercept re-compile case. - registration: tap/tapAsync/tapPromise with string/object/stage/ before options; per-hook-type first-call compile cost. The runner supports an optional subdir arg (node benchmarks/run.js sync), and individual files remain runnable stand-alone via the runIfMain shim in helpers.js. npm scripts bench:{sync,async,hookmap,interceptors, registration} target each category. --- benchmarks/README.md | 62 ++++--- benchmarks/async.bench.js | 144 ----------------- .../async/AsyncParallelBailHook.bench.js | 57 +++++++ benchmarks/async/AsyncParallelHook.bench.js | 59 +++++++ benchmarks/async/AsyncSeriesBailHook.bench.js | 57 +++++++ benchmarks/async/AsyncSeriesHook.bench.js | 70 ++++++++ benchmarks/async/AsyncSeriesLoopHook.bench.js | 58 +++++++ .../async/AsyncSeriesWaterfallHook.bench.js | 59 +++++++ benchmarks/helpers.js | 19 ++- benchmarks/hookmap.bench.js | 55 ------- benchmarks/hookmap/HookMap.bench.js | 60 +++++++ benchmarks/hookmap/MultiHook.bench.js | 49 ++++++ benchmarks/interceptors.bench.js | 69 -------- benchmarks/interceptors/async.bench.js | 64 ++++++++ benchmarks/interceptors/sync.bench.js | 82 ++++++++++ benchmarks/registration/compile.bench.js | 87 ++++++++++ benchmarks/registration/tap.bench.js | 59 +++++++ benchmarks/run.js | 77 +++++++++ benchmarks/sync.bench.js | 151 ------------------ benchmarks/sync/SyncBailHook.bench.js | 42 +++++ benchmarks/sync/SyncHook.bench.js | 55 +++++++ benchmarks/sync/SyncLoopHook.bench.js | 64 ++++++++ benchmarks/sync/SyncWaterfallHook.bench.js | 50 ++++++ package.json | 11 +- 24 files changed, 1117 insertions(+), 443 deletions(-) delete mode 100644 benchmarks/async.bench.js create mode 100644 benchmarks/async/AsyncParallelBailHook.bench.js create mode 100644 benchmarks/async/AsyncParallelHook.bench.js create mode 100644 benchmarks/async/AsyncSeriesBailHook.bench.js create mode 100644 benchmarks/async/AsyncSeriesHook.bench.js create mode 100644 benchmarks/async/AsyncSeriesLoopHook.bench.js create mode 100644 benchmarks/async/AsyncSeriesWaterfallHook.bench.js delete mode 100644 benchmarks/hookmap.bench.js create mode 100644 benchmarks/hookmap/HookMap.bench.js create mode 100644 benchmarks/hookmap/MultiHook.bench.js delete mode 100644 benchmarks/interceptors.bench.js create mode 100644 benchmarks/interceptors/async.bench.js create mode 100644 benchmarks/interceptors/sync.bench.js create mode 100644 benchmarks/registration/compile.bench.js create mode 100644 benchmarks/registration/tap.bench.js create mode 100644 benchmarks/run.js delete mode 100644 benchmarks/sync.bench.js create mode 100644 benchmarks/sync/SyncBailHook.bench.js create mode 100644 benchmarks/sync/SyncHook.bench.js create mode 100644 benchmarks/sync/SyncLoopHook.bench.js create mode 100644 benchmarks/sync/SyncWaterfallHook.bench.js diff --git a/benchmarks/README.md b/benchmarks/README.md index 580ea6d..d1b405c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -5,35 +5,61 @@ Micro-benchmarks for tapable's hook primitives, built with [CodSpeed](https://codspeed.io) via [`@codspeed/tinybench-plugin`](https://github.com/CodSpeedHQ/codspeed-node). +## Layout + +``` +benchmarks/ + helpers.js - shared createBench / runBench helpers + run.js - recursive runner for *.bench.js files + sync/ + SyncHook.bench.js + SyncBailHook.bench.js + SyncWaterfallHook.bench.js + SyncLoopHook.bench.js + async/ + AsyncSeriesHook.bench.js + AsyncSeriesBailHook.bench.js + AsyncSeriesWaterfallHook.bench.js + AsyncSeriesLoopHook.bench.js + AsyncParallelHook.bench.js + AsyncParallelBailHook.bench.js + hookmap/ + HookMap.bench.js + MultiHook.bench.js + interceptors/ + sync.bench.js + async.bench.js + registration/ + tap.bench.js - tap(), tapAsync(), tapPromise(), with stages/before + compile.bench.js - first-call compile cost per hook type +``` + +Each `*.bench.js` file exports an async `main()`. The runner +(`benchmarks/run.js`) discovers them recursively and executes them in one +process so the CodSpeed instrumentation session is shared. Any file can +also be run stand-alone (`node benchmarks/sync/SyncHook.bench.js`) thanks +to the `runIfMain(module, main)` shim in `helpers.js`. + ## Running locally ```bash -# All benchmarks +# Everything npm run bench -# A single suite +# A single suite (directory) npm run bench:sync npm run bench:async npm run bench:hookmap npm run bench:interceptors +npm run bench:registration + +# A single file +node benchmarks/sync/SyncHook.bench.js ``` -Local runs print a tinybench result table. The CodSpeed plugin is a no-op -outside of the CodSpeed runner, so nothing is uploaded from local runs. - -## What is covered - -- `benchmarks/sync.bench.js` — `SyncHook`, `SyncBailHook`, - `SyncWaterfallHook`, `SyncLoopHook`, plus tap registration and - first-call compile cost. -- `benchmarks/async.bench.js` — `AsyncSeriesHook`, - `AsyncSeriesBailHook`, `AsyncSeriesWaterfallHook`, `AsyncParallelHook`, - `AsyncParallelBailHook` (callback + promise forms). -- `benchmarks/hookmap.bench.js` — `HookMap#for` (hot/cold paths) - and `HookMap#get`. -- `benchmarks/interceptors.bench.js` — the interceptor slow paths - (`call`, `tap`, `register`) layered on top of `SyncHook` and - `AsyncSeriesHook`. +Local runs print a tinybench result table per file. The CodSpeed plugin +is a no-op outside of the CodSpeed runner, so nothing is uploaded from +local runs. ## CI diff --git a/benchmarks/async.bench.js b/benchmarks/async.bench.js deleted file mode 100644 index 1a6fd79..0000000 --- a/benchmarks/async.bench.js +++ /dev/null @@ -1,144 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { - AsyncSeriesHook, - AsyncSeriesBailHook, - AsyncSeriesWaterfallHook, - AsyncParallelHook, - AsyncParallelBailHook -} = require("../lib"); -const { createBench, runBench } = require("./helpers"); - -function syncTap() {} -function syncWaterfallTap(v) { - return v + 1; -} - -function makeAsyncSeriesHook(numTaps) { - const hook = new AsyncSeriesHook(["a"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, syncTap); - } - // Warm up (compile) once. - hook.callAsync(1, () => {}); - return hook; -} - -function makeAsyncSeriesWaterfallHook(numTaps) { - const hook = new AsyncSeriesWaterfallHook(["value"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, syncWaterfallTap); - } - hook.callAsync(0, () => {}); - return hook; -} - -function makeAsyncSeriesBailHook(numTaps) { - const hook = new AsyncSeriesBailHook(["a"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, syncTap); - } - hook.callAsync(1, () => {}); - return hook; -} - -function makeAsyncParallelHook(numTaps) { - const hook = new AsyncParallelHook(["a"]); - for (let i = 0; i < numTaps; i++) { - // Use async taps so parallel behavior is exercised. - hook.tapAsync(`plugin-${i}`, (_a, cb) => cb()); - } - hook.callAsync(1, () => {}); - return hook; -} - -function makeAsyncParallelBailHook(numTaps) { - const hook = new AsyncParallelBailHook(["a"]); - for (let i = 0; i < numTaps; i++) { - hook.tapAsync(`plugin-${i}`, (_a, cb) => cb()); - } - hook.callAsync(1, () => {}); - return hook; -} - -async function main() { - const bench = createBench(); - - // --- AsyncSeriesHook.callAsync (sync taps) --- - const seriesHook5 = makeAsyncSeriesHook(5); - bench.add("AsyncSeriesHook#callAsync (5 sync taps)", () => { - return new Promise((resolve) => { - seriesHook5.callAsync(1, resolve); - }); - }); - - const seriesHook20 = makeAsyncSeriesHook(20); - bench.add("AsyncSeriesHook#callAsync (20 sync taps)", () => { - return new Promise((resolve) => { - seriesHook20.callAsync(1, resolve); - }); - }); - - // --- AsyncSeriesHook.promise --- - const seriesPromise5 = makeAsyncSeriesHook(5); - bench.add("AsyncSeriesHook#promise (5 sync taps)", () => { - return seriesPromise5.promise(1); - }); - - // --- AsyncSeriesBailHook.callAsync --- - const seriesBail10 = makeAsyncSeriesBailHook(10); - bench.add("AsyncSeriesBailHook#callAsync (10 sync taps)", () => { - return new Promise((resolve) => { - seriesBail10.callAsync(1, resolve); - }); - }); - - // --- AsyncSeriesWaterfallHook.callAsync --- - const waterfall5 = makeAsyncSeriesWaterfallHook(5); - bench.add("AsyncSeriesWaterfallHook#callAsync (5 sync taps)", () => { - return new Promise((resolve) => { - waterfall5.callAsync(0, resolve); - }); - }); - - const waterfall20 = makeAsyncSeriesWaterfallHook(20); - bench.add("AsyncSeriesWaterfallHook#callAsync (20 sync taps)", () => { - return new Promise((resolve) => { - waterfall20.callAsync(0, resolve); - }); - }); - - // --- AsyncParallelHook.callAsync --- - const parallel5 = makeAsyncParallelHook(5); - bench.add("AsyncParallelHook#callAsync (5 async taps)", () => { - return new Promise((resolve) => { - parallel5.callAsync(1, resolve); - }); - }); - - const parallel20 = makeAsyncParallelHook(20); - bench.add("AsyncParallelHook#callAsync (20 async taps)", () => { - return new Promise((resolve) => { - parallel20.callAsync(1, resolve); - }); - }); - - // --- AsyncParallelBailHook.callAsync --- - const parallelBail10 = makeAsyncParallelBailHook(10); - bench.add("AsyncParallelBailHook#callAsync (10 async taps)", () => { - return new Promise((resolve) => { - parallelBail10.callAsync(1, resolve); - }); - }); - - await runBench(bench); -} - -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/benchmarks/async/AsyncParallelBailHook.bench.js b/benchmarks/async/AsyncParallelBailHook.bench.js new file mode 100644 index 0000000..370539e --- /dev/null +++ b/benchmarks/async/AsyncParallelBailHook.bench.js @@ -0,0 +1,57 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncParallelBailHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, kind, bailAt = -1) { + 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 if (kind === "async") { + hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + const noBail = makeHook(10, "sync", -1); + bench.add("AsyncParallelBailHook#callAsync (10 sync taps, no bail)", () => + callAsyncPromisified(noBail, 1) + ); + + const bailMid = makeHook(10, "sync", 4); + bench.add("AsyncParallelBailHook#callAsync (10 sync taps, bail mid)", () => + callAsyncPromisified(bailMid, 1) + ); + + const asyncNoBail = makeHook(5, "async", -1); + bench.add("AsyncParallelBailHook#callAsync (5 async taps, no bail)", () => + callAsyncPromisified(asyncNoBail, 1) + ); + + const asyncBail = makeHook(5, "async", 2); + bench.add("AsyncParallelBailHook#callAsync (5 async taps, bail mid)", () => + callAsyncPromisified(asyncBail, 1) + ); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/async/AsyncParallelHook.bench.js b/benchmarks/async/AsyncParallelHook.bench.js new file mode 100644 index 0000000..ebcd369 --- /dev/null +++ b/benchmarks/async/AsyncParallelHook.bench.js @@ -0,0 +1,59 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncParallelHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +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; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`AsyncParallelHook#callAsync (${n} sync taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, "async"); + bench.add(`AsyncParallelHook#callAsync (${n} async taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + for (const n of [5]) { + const hook = makeHook(n, "promise"); + bench.add(`AsyncParallelHook#callAsync (${n} promise taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesBailHook.bench.js b/benchmarks/async/AsyncSeriesBailHook.bench.js new file mode 100644 index 0000000..ce5ffda --- /dev/null +++ b/benchmarks/async/AsyncSeriesBailHook.bench.js @@ -0,0 +1,57 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncSeriesBailHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, kind, bailAt = -1) { + 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 if (kind === "async") { + hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + const noBail = makeHook(10, "sync", -1); + bench.add("AsyncSeriesBailHook#callAsync (10 sync taps, no bail)", () => + callAsyncPromisified(noBail, 1) + ); + + const bailMid = makeHook(10, "sync", 4); + bench.add("AsyncSeriesBailHook#callAsync (10 sync taps, bail mid)", () => + callAsyncPromisified(bailMid, 1) + ); + + const asyncNoBail = makeHook(5, "async", -1); + bench.add("AsyncSeriesBailHook#callAsync (5 async taps, no bail)", () => + callAsyncPromisified(asyncNoBail, 1) + ); + + const asyncBail = makeHook(5, "async", 2); + bench.add("AsyncSeriesBailHook#callAsync (5 async taps, bail mid)", () => + callAsyncPromisified(asyncBail, 1) + ); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesHook.bench.js b/benchmarks/async/AsyncSeriesHook.bench.js new file mode 100644 index 0000000..589bd2b --- /dev/null +++ b/benchmarks/async/AsyncSeriesHook.bench.js @@ -0,0 +1,70 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncSeriesHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesHook(["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; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`AsyncSeriesHook#callAsync (${n} sync taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, "async"); + bench.add(`AsyncSeriesHook#callAsync (${n} async taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + for (const n of [5]) { + const hook = makeHook(n, "promise"); + bench.add(`AsyncSeriesHook#callAsync (${n} promise taps)`, () => + callAsyncPromisified(hook, 1) + ); + } + + // .promise() flavor. + const promiseHook = makeHook(5, "sync"); + bench.add("AsyncSeriesHook#promise (5 sync taps)", () => + promiseHook.promise(1) + ); + + const promiseAsyncHook = makeHook(5, "async"); + bench.add("AsyncSeriesHook#promise (5 async taps)", () => + promiseAsyncHook.promise(1) + ); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesLoopHook.bench.js b/benchmarks/async/AsyncSeriesLoopHook.bench.js new file mode 100644 index 0000000..de28c8c --- /dev/null +++ b/benchmarks/async/AsyncSeriesLoopHook.bench.js @@ -0,0 +1,58 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncSeriesLoopHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, iterations) { + const hook = new AsyncSeriesLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < iterations) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + // Warm up compile. + hook.callAsync( + { counts: Array.from({ length: numTaps }, () => iterations) }, + () => {} + ); + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => hook.callAsync(value, resolve)); +} + +async function main() { + const bench = createBench(); + + for (const [n, iters] of [ + [3, 0], + [3, 2], + [10, 0] + ]) { + const hook = makeHook(n, iters); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add( + `AsyncSeriesLoopHook#callAsync (${n} sync taps, ${iters} reloops)`, + () => callAsyncPromisified(hook, state), + { beforeEach: () => resetState(state) } + ); + } + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesWaterfallHook.bench.js b/benchmarks/async/AsyncSeriesWaterfallHook.bench.js new file mode 100644 index 0000000..c89a5c3 --- /dev/null +++ b/benchmarks/async/AsyncSeriesWaterfallHook.bench.js @@ -0,0 +1,59 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncSeriesWaterfallHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + const name = `plugin-${i}`; + if (kind === "sync") { + hook.tap(name, (v) => v + 1); + } else if (kind === "async") { + hook.tapAsync(name, (v, cb) => cb(null, v + 1)); + } else if (kind === "promise") { + hook.tapPromise(name, (v) => Promise.resolve(v + 1)); + } + } + hook.callAsync(0, () => {}); + return hook; +} + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} sync taps)`, () => + callAsyncPromisified(hook, 0) + ); + } + + for (const n of [5]) { + const hook = makeHook(n, "async"); + bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} async taps)`, () => + callAsyncPromisified(hook, 0) + ); + } + + for (const n of [5]) { + const hook = makeHook(n, "promise"); + bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} promise taps)`, () => + callAsyncPromisified(hook, 0) + ); + } + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/helpers.js b/benchmarks/helpers.js index d776cee..ca84654 100644 --- a/benchmarks/helpers.js +++ b/benchmarks/helpers.js @@ -42,4 +42,21 @@ async function runBench(bench) { } } -module.exports = { createBench, runBench }; +/** + * Helper so each bench file can be both `require()`-d by the runner and + * executed directly via `node benchmarks//.bench.js`. + * + * @param {NodeModule} mod - the caller's `module` + * @param {() => Promise} main - the suite's async main + */ +function runIfMain(mod, main) { + if (require.main === mod) { + main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exitCode = 1; + }); + } +} + +module.exports = { createBench, runBench, runIfMain }; diff --git a/benchmarks/hookmap.bench.js b/benchmarks/hookmap.bench.js deleted file mode 100644 index a66b27e..0000000 --- a/benchmarks/hookmap.bench.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { HookMap, SyncHook } = require("../lib"); -const { createBench, runBench } = require("./helpers"); - -async function main() { - const bench = createBench(); - - // --- HookMap#for on existing key (hot lookup path) --- - const warmMap = new HookMap(() => new SyncHook(["x"])); - for (let i = 0; i < 20; i++) { - warmMap.for(`key-${i}`).tap(`plugin-${i}`, () => {}); - } - bench.add("HookMap#for (existing key)", () => { - warmMap.for("key-10"); - }); - - bench.add("HookMap#get (existing key)", () => { - warmMap.get("key-10"); - }); - - // --- HookMap#for for a new key (factory + interceptors cold path) --- - bench.add("HookMap#for (new key, no interceptors)", () => { - const map = new HookMap(() => new SyncHook(["x"])); - for (let i = 0; i < 10; i++) { - map.for(`k-${i}`); - } - }); - - const interceptedTemplate = () => { - const map = new HookMap(() => new SyncHook(["x"])); - map.intercept({ - factory: (_key, hook) => hook - }); - return map; - }; - - bench.add("HookMap#for (new key, with interceptor)", () => { - const map = interceptedTemplate(); - for (let i = 0; i < 10; i++) { - map.for(`k-${i}`); - } - }); - - await runBench(bench); -} - -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/benchmarks/hookmap/HookMap.bench.js b/benchmarks/hookmap/HookMap.bench.js new file mode 100644 index 0000000..5396190 --- /dev/null +++ b/benchmarks/hookmap/HookMap.bench.js @@ -0,0 +1,60 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { HookMap, SyncHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +async function main() { + const bench = createBench(); + + // --- Hot lookups on a warm map --- + const warmMap = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 20; i++) { + warmMap.for(`key-${i}`).tap(`plugin-${i}`, () => {}); + } + + bench.add("HookMap#for (existing key)", () => { + warmMap.for("key-10"); + }); + + bench.add("HookMap#get (existing key)", () => { + warmMap.get("key-10"); + }); + + bench.add("HookMap#get (missing key)", () => { + warmMap.get("not-there"); + }); + + // --- Cold path: factory + interceptors on new keys --- + bench.add("HookMap#for (10 new keys, no interceptors)", () => { + const map = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 10; i++) { + map.for(`k-${i}`); + } + }); + + bench.add("HookMap#for (10 new keys, 1 interceptor)", () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < 10; i++) { + map.for(`k-${i}`); + } + }); + + bench.add("HookMap#for (10 new keys, 3 interceptors)", () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < 10; i++) { + map.for(`k-${i}`); + } + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/hookmap/MultiHook.bench.js b/benchmarks/hookmap/MultiHook.bench.js new file mode 100644 index 0000000..d74aa3e --- /dev/null +++ b/benchmarks/hookmap/MultiHook.bench.js @@ -0,0 +1,49 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { MultiHook, SyncHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +async function main() { + const bench = createBench(); + + // 3 underlying SyncHooks, each gets 5 taps via MultiHook#tap. + const buildMulti = () => + new MultiHook([ + new SyncHook(["x"]), + new SyncHook(["x"]), + new SyncHook(["x"]) + ]); + + const multi = buildMulti(); + for (let i = 0; i < 5; i++) { + multi.tap(`plugin-${i}`, () => {}); + } + multi.hooks[0].call(1); + multi.hooks[1].call(1); + multi.hooks[2].call(1); + + bench.add("MultiHook#tap (3 hooks, 10 registrations)", () => { + const m = buildMulti(); + for (let i = 0; i < 10; i++) { + m.tap(`p-${i}`, () => {}); + } + }); + + bench.add("MultiHook#isUsed (3 hooks, 5 taps)", () => { + multi.isUsed(); + }); + + bench.add("MultiHook#intercept (3 hooks)", () => { + const m = buildMulti(); + for (let i = 0; i < 5; i++) m.tap(`p-${i}`, () => {}); + m.intercept({ call: () => {} }); + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/interceptors.bench.js b/benchmarks/interceptors.bench.js deleted file mode 100644 index 0b91fdc..0000000 --- a/benchmarks/interceptors.bench.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncHook, AsyncSeriesHook } = require("../lib"); -const { createBench, runBench } = require("./helpers"); - -function makeSyncHookWithInterceptors(numTaps, interceptors) { - const hook = new SyncHook(["a"]); - for (const i of interceptors) hook.intercept(i); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, () => {}); - } - hook.call(1); - return hook; -} - -async function main() { - const bench = createBench(); - - // No interceptors (baseline) - const hookNoInt = makeSyncHookWithInterceptors(5, []); - bench.add("SyncHook#call (5 taps, no interceptors)", () => { - hookNoInt.call(1); - }); - - // Call interceptor only - const callInterceptor = makeSyncHookWithInterceptors(5, [{ call: () => {} }]); - bench.add("SyncHook#call (5 taps, call interceptor)", () => { - callInterceptor.call(1); - }); - - // Tap interceptor (runs per tap) - const tapInterceptor = makeSyncHookWithInterceptors(5, [{ tap: () => {} }]); - bench.add("SyncHook#call (5 taps, tap interceptor)", () => { - tapInterceptor.call(1); - }); - - // Register interceptor (only runs at registration time - measured during setup) - bench.add("SyncHook#tap with register interceptor (10 taps)", () => { - const hook = new SyncHook(["a"]); - hook.intercept({ register: (tap) => tap }); - for (let i = 0; i < 10; i++) { - hook.tap(`p-${i}`, () => {}); - } - }); - - // AsyncSeriesHook with interceptors - const asyncHook = new AsyncSeriesHook(["a"]); - asyncHook.intercept({ call: () => {} }); - for (let i = 0; i < 5; i++) { - asyncHook.tap(`p-${i}`, () => {}); - } - asyncHook.callAsync(1, () => {}); - bench.add("AsyncSeriesHook#callAsync (5 taps, call interceptor)", () => { - return new Promise((resolve) => { - asyncHook.callAsync(1, resolve); - }); - }); - - await runBench(bench); -} - -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/benchmarks/interceptors/async.bench.js b/benchmarks/interceptors/async.bench.js new file mode 100644 index 0000000..9d11d94 --- /dev/null +++ b/benchmarks/interceptors/async.bench.js @@ -0,0 +1,64 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { AsyncSeriesHook, AsyncParallelHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function callAsyncPromisified(hook, value) { + return new Promise((resolve) => { + hook.callAsync(value, resolve); + }); +} + +async function main() { + const bench = createBench(); + + // Series: baseline vs interceptors. + const seriesBaseline = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) seriesBaseline.tap(`p-${i}`, () => {}); + seriesBaseline.callAsync(1, () => {}); + bench.add("AsyncSeriesHook#callAsync (5 sync taps, no interceptors)", () => + callAsyncPromisified(seriesBaseline, 1) + ); + + const seriesCall = new AsyncSeriesHook(["a"]); + seriesCall.intercept({ call: () => {} }); + for (let i = 0; i < 5; i++) seriesCall.tap(`p-${i}`, () => {}); + seriesCall.callAsync(1, () => {}); + bench.add("AsyncSeriesHook#callAsync (5 sync taps, call interceptor)", () => + callAsyncPromisified(seriesCall, 1) + ); + + const seriesTap = new AsyncSeriesHook(["a"]); + seriesTap.intercept({ tap: () => {} }); + for (let i = 0; i < 5; i++) seriesTap.tap(`p-${i}`, () => {}); + seriesTap.callAsync(1, () => {}); + bench.add("AsyncSeriesHook#callAsync (5 sync taps, tap interceptor)", () => + callAsyncPromisified(seriesTap, 1) + ); + + // Parallel: baseline vs interceptors. + const parallelBaseline = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) + parallelBaseline.tapAsync(`p-${i}`, (_a, cb) => cb()); + parallelBaseline.callAsync(1, () => {}); + bench.add("AsyncParallelHook#callAsync (5 async taps, no interceptors)", () => + callAsyncPromisified(parallelBaseline, 1) + ); + + const parallelAll = new AsyncParallelHook(["a"]); + parallelAll.intercept({ call: () => {}, tap: () => {} }); + for (let i = 0; i < 5; i++) parallelAll.tapAsync(`p-${i}`, (_a, cb) => cb()); + parallelAll.callAsync(1, () => {}); + bench.add( + "AsyncParallelHook#callAsync (5 async taps, call + tap interceptor)", + () => callAsyncPromisified(parallelAll, 1) + ); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/interceptors/sync.bench.js b/benchmarks/interceptors/sync.bench.js new file mode 100644 index 0000000..b84e65d --- /dev/null +++ b/benchmarks/interceptors/sync.bench.js @@ -0,0 +1,82 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, interceptors) { + const hook = new SyncHook(["a"]); + for (const i of interceptors) hook.intercept(i); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + hook.call(1); + return hook; +} + +async function main() { + const bench = createBench(); + + // Baseline. + const baseline = makeHook(5, []); + bench.add("SyncHook#call (5 taps, no interceptors)", () => { + baseline.call(1); + }); + + // Individual interceptor kinds. + const callOnly = makeHook(5, [{ call: () => {} }]); + bench.add("SyncHook#call (5 taps, call interceptor)", () => { + callOnly.call(1); + }); + + const tapOnly = makeHook(5, [{ tap: () => {} }]); + bench.add("SyncHook#call (5 taps, tap interceptor)", () => { + tapOnly.call(1); + }); + + // All hook-time interceptor kinds combined. + const combined = makeHook(5, [ + { + call: () => {}, + tap: () => {} + } + ]); + bench.add("SyncHook#call (5 taps, call + tap interceptors)", () => { + combined.call(1); + }); + + // Multiple interceptors layered. + const many = makeHook(5, [ + { call: () => {} }, + { tap: () => {} }, + { call: () => {} } + ]); + bench.add("SyncHook#call (5 taps, 3 interceptors)", () => { + many.call(1); + }); + + // Register interceptor (only runs at tap time). + bench.add("SyncHook#tap with register interceptor (10 taps)", () => { + const hook = new SyncHook(["a"]); + hook.intercept({ register: (tap) => tap }); + for (let i = 0; i < 10; i++) { + hook.tap(`p-${i}`, () => {}); + } + }); + + // Late interceptor add: must reset compilation. + bench.add("SyncHook#intercept re-register (10 taps)", () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) hook.tap(`p-${i}`, () => {}); + hook.call(1); + hook.intercept({ register: (tap) => tap }); + hook.call(1); + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/registration/compile.bench.js b/benchmarks/registration/compile.bench.js new file mode 100644 index 0000000..d49d6d1 --- /dev/null +++ b/benchmarks/registration/compile.bench.js @@ -0,0 +1,87 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { + SyncHook, + SyncBailHook, + SyncWaterfallHook, + SyncLoopHook, + AsyncSeriesHook, + AsyncSeriesBailHook, + AsyncSeriesWaterfallHook, + AsyncParallelHook, + AsyncParallelBailHook +} = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +// Measures the combined cost of first registration + code compilation for +// each hook type. The first `.call()` / `.callAsync()` triggers code gen. + +async function main() { + const bench = createBench(); + + bench.add("SyncHook: tap 5 + first call (compile)", () => { + const hook = new SyncHook(["a", "b"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.call(1, 2); + }); + + bench.add("SyncBailHook: tap 5 + first call (compile)", () => { + const hook = new SyncBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call(1); + }); + + bench.add("SyncWaterfallHook: tap 5 + first call (compile)", () => { + const hook = new SyncWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.call(0); + }); + + bench.add("SyncLoopHook: tap 5 + first call (compile)", () => { + const hook = new SyncLoopHook(["s"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call({}); + }); + + bench.add("AsyncSeriesHook: tap 5 + first callAsync (compile)", () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.callAsync(1, () => {}); + }); + + bench.add("AsyncSeriesBailHook: tap 5 + first callAsync (compile)", () => { + const hook = new AsyncSeriesBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.callAsync(1, () => {}); + }); + + bench.add( + "AsyncSeriesWaterfallHook: tap 5 + first callAsync (compile)", + () => { + const hook = new AsyncSeriesWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.callAsync(0, () => {}); + } + ); + + bench.add("AsyncParallelHook: tap 5 + first callAsync (compile)", () => { + const hook = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + hook.callAsync(1, () => {}); + }); + + bench.add("AsyncParallelBailHook: tap 5 + first callAsync (compile)", () => { + const hook = new AsyncParallelBailHook(["a"]); + for (let i = 0; i < 5; i++) + hook.tapAsync(`p-${i}`, (_a, cb) => cb(null, undefined)); + hook.callAsync(1, () => {}); + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/registration/tap.bench.js b/benchmarks/registration/tap.bench.js new file mode 100644 index 0000000..eb27396 --- /dev/null +++ b/benchmarks/registration/tap.bench.js @@ -0,0 +1,59 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncHook, AsyncSeriesHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +async function main() { + const bench = createBench(); + + bench.add("SyncHook#tap (10 taps, string options)", () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) { + hook.tap(`p-${i}`, () => {}); + } + }); + + bench.add("SyncHook#tap (10 taps, object options)", () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) { + hook.tap({ name: `p-${i}` }, () => {}); + } + }); + + bench.add("SyncHook#tap (10 taps, stages)", () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < 10; i++) { + hook.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); + } + }); + + bench.add("SyncHook#tap (10 taps, alternating before)", () => { + const hook = new SyncHook(["a"]); + hook.tap("first", () => {}); + for (let i = 0; i < 9; i++) { + hook.tap({ name: `p-${i}`, before: "first" }, () => {}); + } + }); + + bench.add("AsyncSeriesHook#tapAsync (10 taps, string options)", () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 10; i++) { + hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + } + }); + + bench.add("AsyncSeriesHook#tapPromise (10 taps, string options)", () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 10; i++) { + hook.tapPromise(`p-${i}`, () => Promise.resolve()); + } + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/run.js b/benchmarks/run.js new file mode 100644 index 0000000..529485c --- /dev/null +++ b/benchmarks/run.js @@ -0,0 +1,77 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +/* + * Benchmark runner. + * + * Recursively discovers every `*.bench.js` file below `benchmarks/` + * (optionally scoped to a subdirectory passed as the first CLI arg), + * then runs them sequentially in a single Node.js process so that the + * CodSpeed instrumentation session is shared across suites. + * + * Each bench file must `module.exports = async function main() { ... }` + * and use `runIfMain(module, main)` from `helpers.js` so it can also be + * executed stand-alone with `node benchmarks//.bench.js`. + */ + +const fs = require("fs"); +const path = require("path"); + +function findBenches(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // Skip internal / hidden folders and node_modules. + if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findBenches(fullPath)); + } else if (entry.isFile() && entry.name.endsWith(".bench.js")) { + results.push(fullPath); + } + } + return results; +} + +async function main() { + const benchmarksDir = __dirname; + const subdirArg = process.argv[2]; + const rootDir = subdirArg + ? path.resolve(benchmarksDir, subdirArg) + : benchmarksDir; + + if (!fs.existsSync(rootDir)) { + // eslint-disable-next-line no-console + console.error(`Directory not found: ${rootDir}`); + process.exit(1); + } + + const files = findBenches(rootDir).sort(); + if (files.length === 0) { + // eslint-disable-next-line no-console + console.error(`No *.bench.js files found under ${rootDir}`); + process.exit(1); + } + + for (const file of files) { + const rel = path.relative(benchmarksDir, file); + // eslint-disable-next-line no-console + console.log(`\n=== ${rel} ===\n`); + // eslint-disable-next-line global-require, import/no-dynamic-require + const mod = require(file); + if (typeof mod !== "function") { + throw new TypeError( + `${rel} must export an async function as its default export` + ); + } + await mod(); + } +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/benchmarks/sync.bench.js b/benchmarks/sync.bench.js deleted file mode 100644 index 38ae40b..0000000 --- a/benchmarks/sync.bench.js +++ /dev/null @@ -1,151 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { - SyncHook, - SyncBailHook, - SyncWaterfallHook, - SyncLoopHook -} = require("../lib"); -const { createBench, runBench } = require("./helpers"); - -function makeSyncHook(numTaps) { - const hook = new SyncHook(["a", "b"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, (_a, _b) => {}); - } - // Force compilation so the benchmark measures the steady-state call path. - hook.call(1, 2); - return hook; -} - -function makeSyncBailHook(numTaps, bailAt = -1) { - const hook = new SyncBailHook(["a"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, (v) => (i === bailAt ? v : undefined)); - } - hook.call(1); - return hook; -} - -function makeSyncWaterfallHook(numTaps) { - const hook = new SyncWaterfallHook(["value"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, (v) => v + 1); - } - hook.call(0); - return hook; -} - -function makeSyncLoopHook(numTaps) { - const hook = new SyncLoopHook(["state"]); - for (let i = 0; i < numTaps; i++) { - const idx = i; - hook.tap(`plugin-${idx}`, (state) => { - if (state.counts[idx] < 1) { - state.counts[idx]++; - return true; - } - return undefined; - }); - } - return hook; -} - -async function main() { - const bench = createBench(); - - // --- SyncHook.call --- - const hook0 = makeSyncHook(0); - bench.add("SyncHook#call (0 taps)", () => { - hook0.call(1, 2); - }); - - const hook1 = makeSyncHook(1); - bench.add("SyncHook#call (1 tap)", () => { - hook1.call(1, 2); - }); - - const hook5 = makeSyncHook(5); - bench.add("SyncHook#call (5 taps)", () => { - hook5.call(1, 2); - }); - - const hook20 = makeSyncHook(20); - bench.add("SyncHook#call (20 taps)", () => { - hook20.call(1, 2); - }); - - // --- SyncBailHook.call --- - const bailHookNoBail = makeSyncBailHook(10, -1); - bench.add("SyncBailHook#call (10 taps, no bail)", () => { - bailHookNoBail.call(1); - }); - - const bailHookMid = makeSyncBailHook(10, 5); - bench.add("SyncBailHook#call (10 taps, bail mid)", () => { - bailHookMid.call(1); - }); - - // --- SyncWaterfallHook.call --- - const waterfall5 = makeSyncWaterfallHook(5); - bench.add("SyncWaterfallHook#call (5 taps)", () => { - waterfall5.call(0); - }); - - const waterfall20 = makeSyncWaterfallHook(20); - bench.add("SyncWaterfallHook#call (20 taps)", () => { - waterfall20.call(0); - }); - - // --- SyncLoopHook.call --- - const loopHook = makeSyncLoopHook(5); - const loopState = { counts: [0, 0, 0, 0, 0] }; - bench.add( - "SyncLoopHook#call (5 taps)", - () => { - loopHook.call(loopState); - }, - { - beforeEach() { - for (let i = 0; i < loopState.counts.length; i++) { - loopState.counts[i] = 0; - } - } - } - ); - - // --- Tap registration (no compilation in hot loop) --- - bench.add("SyncHook#tap registration (10 taps)", () => { - const h = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) { - h.tap(`p-${i}`, () => {}); - } - }); - - bench.add("SyncHook#tap registration with stages (10 taps)", () => { - const h = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) { - h.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); - } - }); - - // --- First-call compile cost --- - bench.add("SyncHook first call compile (5 taps)", () => { - const h = new SyncHook(["a", "b"]); - for (let i = 0; i < 5; i++) { - h.tap(`p-${i}`, () => {}); - } - h.call(1, 2); - }); - - await runBench(bench); -} - -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/benchmarks/sync/SyncBailHook.bench.js b/benchmarks/sync/SyncBailHook.bench.js new file mode 100644 index 0000000..c2b49df --- /dev/null +++ b/benchmarks/sync/SyncBailHook.bench.js @@ -0,0 +1,42 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncBailHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, bailAt = -1) { + const hook = new SyncBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (v) => (idx === bailAt ? v : undefined)); + } + hook.call(1); + return hook; +} + +async function main() { + const bench = createBench(); + + // No bail - full chain walk. + for (const n of [1, 5, 10, 20]) { + const hook = makeHook(n, -1); + bench.add(`SyncBailHook#call (${n} taps, no bail)`, () => { + hook.call(1); + }); + } + + // Bail at different positions for a 10-tap hook. + for (const pos of [0, 4, 9]) { + const hook = makeHook(10, pos); + bench.add(`SyncBailHook#call (10 taps, bail at ${pos})`, () => { + hook.call(1); + }); + } + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/sync/SyncHook.bench.js b/benchmarks/sync/SyncHook.bench.js new file mode 100644 index 0000000..46b4163 --- /dev/null +++ b/benchmarks/sync/SyncHook.bench.js @@ -0,0 +1,55 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, args = ["a", "b"]) { + const hook = new SyncHook(args); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + // Force compile so steady-state call path is measured. + hook.call(...args.map((_, i) => i)); + return hook; +} + +async function main() { + const bench = createBench(); + + for (const n of [0, 1, 3, 5, 10, 20, 50]) { + const hook = makeHook(n); + bench.add(`SyncHook#call (${n} taps, 2 args)`, () => { + hook.call(1, 2); + }); + } + + // Argument count variations (keeps 5 taps constant). + for (const argCount of [0, 1, 3, 5]) { + const args = Array.from({ length: argCount }, (_, i) => `a${i}`); + const hook = makeHook(5, args); + const callArgs = args.map((_, i) => i); + bench.add(`SyncHook#call (5 taps, ${argCount} args)`, () => { + hook.call(...callArgs); + }); + } + + // Single tap that touches its args (prevents the engine from assuming + // arguments are unused). + const touchingHook = new SyncHook(["a", "b"]); + let sink = 0; + touchingHook.tap("touching", (a, b) => { + sink = a + b; + }); + touchingHook.call(1, 2); + bench.add("SyncHook#call (1 tap, reads args)", () => { + touchingHook.call(sink, 2); + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/sync/SyncLoopHook.bench.js b/benchmarks/sync/SyncLoopHook.bench.js new file mode 100644 index 0000000..a208a99 --- /dev/null +++ b/benchmarks/sync/SyncLoopHook.bench.js @@ -0,0 +1,64 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncLoopHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, iterations) { + const hook = new SyncLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < iterations) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) { + state.counts[i] = 0; + } +} + +async function main() { + const bench = createBench(); + + // Single-iteration loop (each tap returns undefined). + for (const n of [3, 10]) { + const hook = makeHook(n, 0); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add( + `SyncLoopHook#call (${n} taps, 0 reloops)`, + () => { + hook.call(state); + }, + { beforeEach: () => resetState(state) } + ); + } + + // Multi-iteration loop - taps trigger reloops. + for (const iterations of [1, 3]) { + const n = 3; + const hook = makeHook(n, iterations); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add( + `SyncLoopHook#call (${n} taps, ${iterations} reloops each)`, + () => { + hook.call(state); + }, + { beforeEach: () => resetState(state) } + ); + } + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/benchmarks/sync/SyncWaterfallHook.bench.js b/benchmarks/sync/SyncWaterfallHook.bench.js new file mode 100644 index 0000000..cdf0797 --- /dev/null +++ b/benchmarks/sync/SyncWaterfallHook.bench.js @@ -0,0 +1,50 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php +*/ +"use strict"; + +const { SyncWaterfallHook } = require("../../lib"); +const { createBench, runBench, runIfMain } = require("../helpers"); + +function makeHook(numTaps, returning = true) { + const hook = new SyncWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, returning ? (v) => v + 1 : () => undefined); + } + hook.call(0); + return hook; +} + +async function main() { + const bench = createBench(); + + for (const n of [1, 5, 10, 20, 50]) { + const hook = makeHook(n, true); + bench.add(`SyncWaterfallHook#call (${n} taps, all return)`, () => { + hook.call(0); + }); + } + + // Taps returning undefined - initial value is passed through. + for (const n of [5, 20]) { + const hook = makeHook(n, false); + bench.add(`SyncWaterfallHook#call (${n} taps, all undefined)`, () => { + hook.call(0); + }); + } + + // Mixed: half return, half don't. + const mixed = new SyncWaterfallHook(["value"]); + for (let i = 0; i < 10; i++) { + mixed.tap(`plugin-${i}`, i % 2 === 0 ? (v) => v + 1 : () => undefined); + } + mixed.call(0); + bench.add("SyncWaterfallHook#call (10 taps, mixed return)", () => { + mixed.call(0); + }); + + await runBench(bench); +} + +module.exports = main; +runIfMain(module, main); diff --git a/package.json b/package.json index d9b97be..7bf3867 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "fix": "npm run fix:code && npm run fmt", "fix:code": "npm run lint:code -- --fix", "test": "jest", - "bench": "npm run bench:sync && npm run bench:async && npm run bench:hookmap && npm run bench:interceptors", - "bench:sync": "node benchmarks/sync.bench.js", - "bench:async": "node benchmarks/async.bench.js", - "bench:hookmap": "node benchmarks/hookmap.bench.js", - "bench:interceptors": "node benchmarks/interceptors.bench.js", + "bench": "node benchmarks/run.js", + "bench:sync": "node benchmarks/run.js sync", + "bench:async": "node benchmarks/run.js async", + "bench:hookmap": "node benchmarks/run.js hookmap", + "bench:interceptors": "node benchmarks/run.js interceptors", + "bench:registration": "node benchmarks/run.js registration", "version": "changeset version", "release": "changeset publish" }, From b9a88b153b4232e3b0d7c5493d9c46f9e92b7f3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 18:04:40 +0000 Subject: [PATCH 3/6] perf: fast-path Hook#_insert, skip empty _runRegisterInterceptors, inline HookMap#for lookup - Hook#_insert: add an O(1) fast path for the overwhelmingly common append case (no before, consistent stage). Previously the shift loop always ran at least once, performing one unnecessary write per tap. - Hook#_runRegisterInterceptors: early-return when there are no interceptors and use an indexed loop instead of for...of to avoid iterator allocation. - HookMap#for: inline the _map.get() lookup instead of delegating through this.get(key); this is hit on every hook access in consumers like webpack, so saving a method dispatch is worth it. Impact (local micro-benchmarks): - SyncHook#tap (10 taps, string options): ~482 ns -> ~397 ns (~18%). - AsyncSeriesHook#tapAsync / tapPromise (10 taps): ~480 ns -> ~400 ns. - SyncHook: tap 5 + first call (compile): ~4350 ns -> ~4150 ns (~5%). - HookMap#for (existing key): ~80 ns -> ~76 ns (~6%). - .call() paths are unchanged. All 98 existing tests still pass. --- .changeset/perf-tap-registration.md | 26 +++++++++++----- lib/Hook.js | 48 ++++++++++++++++++++--------- lib/HookMap.js | 8 +++-- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/.changeset/perf-tap-registration.md b/.changeset/perf-tap-registration.md index 1231d8a..4fe6b1d 100644 --- a/.changeset/perf-tap-registration.md +++ b/.changeset/perf-tap-registration.md @@ -2,12 +2,22 @@ "tapable": patch --- -Perf: reduce tap-registration allocations. +Perf: reduce tap-registration and HookMap-lookup overhead. -`Hook#_tap` now 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`. In -micro-benchmarks, registering 10 taps on a `SyncHook` is roughly 2× faster. -`HookCodeFactory#setup` also builds `_x` with a preallocated array + explicit -loop instead of `Array.prototype.map`, trimming a small amount of work on -every hook compile. +- `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 is 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`, trimming work on every compile. + +Registering 10 taps on a `SyncHook` is roughly 2× faster in the +micro-benchmarks; `HookMap#for (existing key)` is ~6% faster. The `.call()` +path is unchanged. diff --git a/lib/Hook.js b/lib/Hook.js index e0515d1..dd826fb 100644 --- a/lib/Hook.js +++ b/lib/Hook.js @@ -107,7 +107,12 @@ class Hook { } _runRegisterInterceptors(options) { - for (const interceptor of this.interceptors) { + const { interceptors } = this; + const { length } = interceptors; + // Common case: no interceptors. + if (length === 0) return options; + for (let i = 0; i < length; i++) { + const interceptor = interceptors[i]; if (interceptor.register) { const newOptions = interceptor.register(options); if (newOptions !== undefined) { @@ -155,21 +160,36 @@ class Hook { _insert(item) { this._resetCompilation(); - let before; - if (typeof item.before === "string") { - before = new Set([item.before]); - } else if (Array.isArray(item.before)) { - before = new Set(item.before); + const { taps } = this; + const itemBefore = item.before; + const hasBefore = + typeof itemBefore === "string" || Array.isArray(itemBefore); + const itemStage = typeof item.stage === "number" ? item.stage : 0; + + // Fast path: the overwhelmingly common `hook.tap("name", fn)` case + // has no `before` and default stage 0. If the list is empty or the + // last tap's stage is <= the new item's stage the item belongs at + // the end - append in O(1), skipping the Set allocation and the + // shift loop. + if (!hasBefore) { + const n = taps.length; + if (n === 0 || (taps[n - 1].stage || 0) <= itemStage) { + taps[n] = item; + return; + } } - let stage = 0; - if (typeof item.stage === "number") { - stage = item.stage; + + let before; + if (typeof itemBefore === "string") { + before = new Set([itemBefore]); + } else if (Array.isArray(itemBefore)) { + before = new Set(itemBefore); } - let i = this.taps.length; + let i = taps.length; while (i > 0) { i--; - const tap = this.taps[i]; - this.taps[i + 1] = tap; + const tap = taps[i]; + taps[i + 1] = tap; const xStage = tap.stage || 0; if (before) { if (before.has(tap.name)) { @@ -180,13 +200,13 @@ class Hook { continue; } } - if (xStage > stage) { + if (xStage > itemStage) { continue; } i++; break; } - this.taps[i] = item; + taps[i] = item; } } diff --git a/lib/HookMap.js b/lib/HookMap.js index 8fdc5d6..ca91cd6 100644 --- a/lib/HookMap.js +++ b/lib/HookMap.js @@ -21,7 +21,11 @@ class HookMap { } for(key) { - const hook = this.get(key); + // Hot path: inline the map lookup to skip the `this.get(key)` + // indirection. This gets hit on every hook access in consumers + // like webpack. + const map = this._map; + const hook = map.get(key); if (hook !== undefined) { return hook; } @@ -30,7 +34,7 @@ class HookMap { for (let i = 0; i < interceptors.length; i++) { newHook = interceptors[i].factory(key, newHook); } - this._map.set(key, newHook); + map.set(key, newHook); return newHook; } From f72b1fde87e4a3ff803363c8f32445d9707eff86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 18:20:30 +0000 Subject: [PATCH 4/6] perf: tighten HookCodeFactory compile path and MultiHook iteration Compile-path (HookCodeFactory) wins: - init: use Array.slice() instead of spread to skip the iterator protocol, and reset a new _joinedArgs cache. - args: memoize the common no-before/no-after result so arguments are joined once per compile rather than once per tap. - needContext: indexed loop instead of for...of. - callTapsSeries: inline findIndex, cache taps and tapsLength, hoist the tap-invariant doneBreak closure out of the compile-time loop. - callTapsParallel: cache taps / tapsLength, hoist both done and doneBreak closures out of the loop - they don't depend on i. MultiHook: indexed loops with cached `this.hooks`. Result: SyncHook tap-5+first-call (compile) dropped from ~4150ns to ~3700ns (~11% faster) and similar improvements across other hook types. The .call() path is unchanged. All 98 tests still pass. --- .changeset/perf-tap-registration.md | 22 ++++++--- lib/HookCodeFactory.js | 75 ++++++++++++++++++++--------- lib/MultiHook.js | 25 ++++++---- 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/.changeset/perf-tap-registration.md b/.changeset/perf-tap-registration.md index 4fe6b1d..5d12ae6 100644 --- a/.changeset/perf-tap-registration.md +++ b/.changeset/perf-tap-registration.md @@ -2,13 +2,13 @@ "tapable": patch --- -Perf: reduce tap-registration and HookMap-lookup overhead. +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 is consistent with the last tap) - the previous + `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`. @@ -16,8 +16,18 @@ Perf: reduce tap-registration and HookMap-lookup overhead. `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`, trimming work on every compile. + 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 in the -micro-benchmarks; `HookMap#for (existing key)` is ~6% faster. The `.call()` -path is unchanged. +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. diff --git a/lib/HookCodeFactory.js b/lib/HookCodeFactory.js index 1d9d512..d3d5fa6 100644 --- a/lib/HookCodeFactory.js +++ b/lib/HookCodeFactory.js @@ -91,12 +91,16 @@ class HookCodeFactory { */ init(options) { this.options = options; - this._args = [...options.args]; + // slice() avoids the iterator protocol overhead of [...arr]. + // eslint-disable-next-line unicorn/prefer-spread + this._args = options.args.slice(); + this._joinedArgs = undefined; } deinit() { this.options = undefined; this._args = undefined; + this._joinedArgs = undefined; } contentWithInterceptors(options) { @@ -171,7 +175,10 @@ class HookCodeFactory { } needContext() { - for (const tap of this.options.taps) if (tap.context) return true; + const { taps } = this.options; + for (let i = 0; i < taps.length; i++) { + if (taps[i].context) return true; + } return false; } @@ -280,17 +287,30 @@ class HookCodeFactory { doneReturns, rethrowIfPossible }) { - if (this.options.taps.length === 0) return onDone(); - const firstAsync = this.options.taps.findIndex((t) => t.type !== "sync"); + const { taps } = this.options; + const tapsLength = taps.length; + if (tapsLength === 0) return onDone(); + // Inlined findIndex to avoid the callback allocation. + let firstAsync = -1; + for (let i = 0; i < tapsLength; i++) { + if (taps[i].type !== "sync") { + firstAsync = i; + break; + } + } const somethingReturns = resultReturns || doneReturns; + // doneBreak doesn't depend on the loop variable - hoist to allocate once. + const doneBreak = (skipDone) => { + if (skipDone) return ""; + return onDone(); + }; let code = ""; let current = onDone; let unrollCounter = 0; - for (let j = this.options.taps.length - 1; j >= 0; j--) { + for (let j = tapsLength - 1; j >= 0; j--) { const i = j; const unroll = - current !== onDone && - (this.options.taps[i].type !== "sync" || unrollCounter++ > 20); + current !== onDone && (taps[i].type !== "sync" || unrollCounter++ > 20); if (unroll) { unrollCounter = 0; code += `function _next${i}() {\n`; @@ -299,10 +319,6 @@ class HookCodeFactory { current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`; } const done = current; - const doneBreak = (skipDone) => { - if (skipDone) return ""; - return onDone(); - }; const content = this.callTap(i, { onError: (error) => onError(i, error, done, doneBreak), onResult: @@ -376,7 +392,9 @@ class HookCodeFactory { rethrowIfPossible, onTap = (i, run) => run() }) { - if (this.options.taps.length <= 1) { + const { taps } = this.options; + const tapsLength = taps.length; + if (tapsLength <= 1) { return this.callTapsSeries({ onError, onResult, @@ -384,23 +402,25 @@ class HookCodeFactory { rethrowIfPossible }); } + // done and doneBreak don't depend on the loop variable - hoist them + // so they're allocated once per compile instead of once per tap. + const done = () => { + if (onDone) return "if(--_counter === 0) _done();\n"; + return "--_counter;"; + }; + const doneBreak = (skipDone) => { + if (skipDone || !onDone) return "_counter = 0;\n"; + return "_counter = 0;\n_done();\n"; + }; let code = ""; code += "do {\n"; - code += `var _counter = ${this.options.taps.length};\n`; + code += `var _counter = ${tapsLength};\n`; if (onDone) { code += "var _done = (function() {\n"; code += onDone(); code += "});\n"; } - for (let i = 0; i < this.options.taps.length; i++) { - const done = () => { - if (onDone) return "if(--_counter === 0) _done();\n"; - return "--_counter;"; - }; - const doneBreak = (skipDone) => { - if (skipDone || !onDone) return "_counter = 0;\n"; - return "_counter = 0;\n_done();\n"; - }; + for (let i = 0; i < tapsLength; i++) { code += "if(_counter <= 0) break;\n"; code += onTap( i, @@ -434,13 +454,22 @@ class HookCodeFactory { } args({ before, after } = {}) { + // Hot during code generation (called once per tap + per interceptor). + // Cache the common no-before/no-after result so we only join once. + if (before === undefined && after === undefined) { + let joined = this._joinedArgs; + if (joined === undefined) { + joined = this._args.length === 0 ? "" : this._args.join(", "); + this._joinedArgs = joined; + } + return joined; + } let allArgs = this._args; if (before) allArgs = [before, ...allArgs]; if (after) allArgs = [...allArgs, after]; if (allArgs.length === 0) { return ""; } - return allArgs.join(", "); } diff --git a/lib/MultiHook.js b/lib/MultiHook.js index 8041264..900abbd 100644 --- a/lib/MultiHook.js +++ b/lib/MultiHook.js @@ -11,33 +11,38 @@ class MultiHook { } tap(options, fn) { - for (const hook of this.hooks) { - hook.tap(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tap(options, fn); } } tapAsync(options, fn) { - for (const hook of this.hooks) { - hook.tapAsync(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tapAsync(options, fn); } } tapPromise(options, fn) { - for (const hook of this.hooks) { - hook.tapPromise(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tapPromise(options, fn); } } isUsed() { - for (const hook of this.hooks) { - if (hook.isUsed()) return true; + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].isUsed()) return true; } return false; } intercept(interceptor) { - for (const hook of this.hooks) { - hook.intercept(interceptor); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].intercept(interceptor); } } From 699f21f42f513565a9f26db4cabe072c52ef4652 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 12:51:15 +0000 Subject: [PATCH 5/6] bench: restructure to enhanced-resolve layout Mirror the benchmark structure used in webpack/enhanced-resolve: - benchmark/ (was: benchmarks/) - run.mjs ESM entry, auto-discovers cases, supports BENCH_FILTER / CLI substring - with-codspeed.mjs local @codspeed/core <-> tinybench bridge (simulation / walltime / disabled), ported from webpack via enhanced-resolve - README.md layout docs + case index - cases//index.bench.mjs default export: register(bench, ctx) Each bench body now loops N times internally so the measurement captures the hook call path itself rather than tinybench harness overhead - same approach enhanced-resolve uses. tinybench v6 API (Bench + hrtimeNow) is used directly. Cases (16): sync-hook, sync-bail-hook, sync-waterfall-hook, sync-loop-hook, async-series-hook, async-series-bail-hook, async-series-waterfall-hook, async-series-loop-hook, async-parallel-hook, async-parallel-bail-hook, hook-map, multi-hook, interceptors-sync, interceptors-async, tap-registration, hook-compile Supporting changes: - package.json: single "benchmark" script with the same V8 flags as enhanced-resolve (--no-opt --predictable --hash-seed=1 ... --expose-gc); these are required for deterministic CodSpeed runs. - Swapped @codspeed/tinybench-plugin for @codspeed/core (the plugin reaches into tinybench v6 task internals that are now private; enhanced-resolve hit the same issue and switched). - .github/workflows/codspeed.yml renamed to benchmarks.yml and updated to match enhanced-resolve: CodSpeedHQ/action@v4.10.4 with mode: "simulation", id-token: write permission for OIDC, concurrency group, explicit pull-requests: write on the job. - eslint config: update ignores from benchmarks/ to benchmark/. --- .github/workflows/benchmarks.yml | 40 ++++ .github/workflows/codspeed.yml | 38 ---- benchmark/README.md | 95 ++++++++++ .../async-parallel-bail-hook/index.bench.mjs | 70 +++++++ .../cases/async-parallel-hook/index.bench.mjs | 61 ++++++ .../async-series-bail-hook/index.bench.mjs | 71 +++++++ .../cases/async-series-hook/index.bench.mjs | 92 +++++++++ .../async-series-loop-hook/index.bench.mjs | 67 +++++++ .../index.bench.mjs | 67 +++++++ benchmark/cases/hook-compile/index.bench.mjs | 90 +++++++++ benchmark/cases/hook-map/index.bench.mjs | 69 +++++++ .../cases/interceptors-async/index.bench.mjs | 72 +++++++ .../cases/interceptors-sync/index.bench.mjs | 66 +++++++ benchmark/cases/multi-hook/index.bench.mjs | 51 +++++ .../cases/sync-bail-hook/index.bench.mjs | 49 +++++ benchmark/cases/sync-hook/index.bench.mjs | 49 +++++ .../cases/sync-loop-hook/index.bench.mjs | 53 ++++++ .../cases/sync-waterfall-hook/index.bench.mjs | 58 ++++++ .../cases/tap-registration/index.bench.mjs | 80 ++++++++ benchmark/run.mjs | 118 ++++++++++++ benchmark/with-codspeed.mjs | 178 ++++++++++++++++++ benchmarks/README.md | 68 ------- .../async/AsyncParallelBailHook.bench.js | 57 ------ benchmarks/async/AsyncParallelHook.bench.js | 59 ------ benchmarks/async/AsyncSeriesBailHook.bench.js | 57 ------ benchmarks/async/AsyncSeriesHook.bench.js | 70 ------- benchmarks/async/AsyncSeriesLoopHook.bench.js | 58 ------ .../async/AsyncSeriesWaterfallHook.bench.js | 59 ------ benchmarks/helpers.js | 62 ------ benchmarks/hookmap/HookMap.bench.js | 60 ------ benchmarks/hookmap/MultiHook.bench.js | 49 ----- benchmarks/interceptors/async.bench.js | 64 ------- benchmarks/interceptors/sync.bench.js | 82 -------- benchmarks/registration/compile.bench.js | 87 --------- benchmarks/registration/tap.bench.js | 59 ------ benchmarks/run.js | 77 -------- benchmarks/sync/SyncBailHook.bench.js | 42 ----- benchmarks/sync/SyncHook.bench.js | 55 ------ benchmarks/sync/SyncLoopHook.bench.js | 64 ------- benchmarks/sync/SyncWaterfallHook.bench.js | 50 ----- eslint.config.mjs | 2 +- package-lock.json | 26 +-- package.json | 9 +- 43 files changed, 1500 insertions(+), 1250 deletions(-) create mode 100644 .github/workflows/benchmarks.yml delete mode 100644 .github/workflows/codspeed.yml create mode 100644 benchmark/README.md create mode 100644 benchmark/cases/async-parallel-bail-hook/index.bench.mjs create mode 100644 benchmark/cases/async-parallel-hook/index.bench.mjs create mode 100644 benchmark/cases/async-series-bail-hook/index.bench.mjs create mode 100644 benchmark/cases/async-series-hook/index.bench.mjs create mode 100644 benchmark/cases/async-series-loop-hook/index.bench.mjs create mode 100644 benchmark/cases/async-series-waterfall-hook/index.bench.mjs create mode 100644 benchmark/cases/hook-compile/index.bench.mjs create mode 100644 benchmark/cases/hook-map/index.bench.mjs create mode 100644 benchmark/cases/interceptors-async/index.bench.mjs create mode 100644 benchmark/cases/interceptors-sync/index.bench.mjs create mode 100644 benchmark/cases/multi-hook/index.bench.mjs create mode 100644 benchmark/cases/sync-bail-hook/index.bench.mjs create mode 100644 benchmark/cases/sync-hook/index.bench.mjs create mode 100644 benchmark/cases/sync-loop-hook/index.bench.mjs create mode 100644 benchmark/cases/sync-waterfall-hook/index.bench.mjs create mode 100644 benchmark/cases/tap-registration/index.bench.mjs create mode 100644 benchmark/run.mjs create mode 100644 benchmark/with-codspeed.mjs delete mode 100644 benchmarks/README.md delete mode 100644 benchmarks/async/AsyncParallelBailHook.bench.js delete mode 100644 benchmarks/async/AsyncParallelHook.bench.js delete mode 100644 benchmarks/async/AsyncSeriesBailHook.bench.js delete mode 100644 benchmarks/async/AsyncSeriesHook.bench.js delete mode 100644 benchmarks/async/AsyncSeriesLoopHook.bench.js delete mode 100644 benchmarks/async/AsyncSeriesWaterfallHook.bench.js delete mode 100644 benchmarks/helpers.js delete mode 100644 benchmarks/hookmap/HookMap.bench.js delete mode 100644 benchmarks/hookmap/MultiHook.bench.js delete mode 100644 benchmarks/interceptors/async.bench.js delete mode 100644 benchmarks/interceptors/sync.bench.js delete mode 100644 benchmarks/registration/compile.bench.js delete mode 100644 benchmarks/registration/tap.bench.js delete mode 100644 benchmarks/run.js delete mode 100644 benchmarks/sync/SyncBailHook.bench.js delete mode 100644 benchmarks/sync/SyncHook.bench.js delete mode 100644 benchmarks/sync/SyncLoopHook.bench.js delete mode 100644 benchmarks/sync/SyncWaterfallHook.bench.js diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..e86e647 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -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" diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml deleted file mode 100644 index 8c516fc..0000000 --- a/.github/workflows/codspeed.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CodSpeed - -on: - push: - branches: [main] - pull_request: - branches: [main] - # Allow manual runs from the Actions tab. - workflow_dispatch: - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - benchmarks: - name: Run benchmarks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Use Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: lts/* - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run benchmarks and upload to CodSpeed - uses: CodSpeedHQ/action@v3 - with: - # Run all benchmark files. CodSpeed instruments them automatically - # via @codspeed/tinybench-plugin, which is wired up in benchmarks/helpers.js. - run: npm run bench - token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..2409aa5 --- /dev/null +++ b/benchmark/README.md @@ -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/ + └── / + └── 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. diff --git a/benchmark/cases/async-parallel-bail-hook/index.bench.mjs b/benchmark/cases/async-parallel-bail-hook/index.bench.mjs new file mode 100644 index 0000000..64cc518 --- /dev/null +++ b/benchmark/cases/async-parallel-bail-hook/index.bench.mjs @@ -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) + ); + } +} diff --git a/benchmark/cases/async-parallel-hook/index.bench.mjs b/benchmark/cases/async-parallel-hook/index.bench.mjs new file mode 100644 index 0000000..95b6404 --- /dev/null +++ b/benchmark/cases/async-parallel-hook/index.bench.mjs @@ -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)); + } +} diff --git a/benchmark/cases/async-series-bail-hook/index.bench.mjs b/benchmark/cases/async-series-bail-hook/index.bench.mjs new file mode 100644 index 0000000..71df5da --- /dev/null +++ b/benchmark/cases/async-series-bail-hook/index.bench.mjs @@ -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) + ); + } +} diff --git a/benchmark/cases/async-series-hook/index.bench.mjs b/benchmark/cases/async-series-hook/index.bench.mjs new file mode 100644 index 0000000..4ef1e8b --- /dev/null +++ b/benchmark/cases/async-series-hook/index.bench.mjs @@ -0,0 +1,92 @@ +/* + * async-series-hook + * + * AsyncSeriesHook under its three tap flavors: + * - sync taps (`hook.tap`) -> generated code falls through + * - async taps (`hook.tapAsync`) -> callback continuations + * - promise taps (`hook.tapPromise`) -> .then() continuations + * + * A batch of callAsync / promise invocations runs per iteration so the + * measured body dominates the tinybench scheduler overhead. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesHook } = tapable; + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesHook(["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 runCallbackBatch(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); + } + }); +} + +async function runPromiseBatch(hook) { + for (let i = 0; i < INNER_ITERATIONS; i++) { + await hook.promise(1); + } +} + +/** + * @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-series-hook: callAsync, ${n} sync taps`, () => + runCallbackBatch(hook) + ); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, "async"); + bench.add(`async-series-hook: callAsync, ${n} async taps`, () => + runCallbackBatch(hook) + ); + } + + { + const hook = makeHook(5, "promise"); + bench.add("async-series-hook: callAsync, 5 promise taps", () => + runCallbackBatch(hook) + ); + } + + { + const hook = makeHook(5, "sync"); + bench.add("async-series-hook: promise, 5 sync taps", () => + runPromiseBatch(hook) + ); + } + + { + const hook = makeHook(5, "async"); + bench.add("async-series-hook: promise, 5 async taps", () => + runPromiseBatch(hook) + ); + } +} diff --git a/benchmark/cases/async-series-loop-hook/index.bench.mjs b/benchmark/cases/async-series-loop-hook/index.bench.mjs new file mode 100644 index 0000000..356166c --- /dev/null +++ b/benchmark/cases/async-series-loop-hook/index.bench.mjs @@ -0,0 +1,67 @@ +/* + * async-series-loop-hook + * + * AsyncSeriesLoopHook is the async cousin of SyncLoopHook - re-runs the + * tap chain while any tap signals "loop again" (non-undefined value). + * Covers 0 reloops (single pass) and a small multi-pass case. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesLoopHook } = tapable; + +function makeHook(numTaps, reloops) { + const hook = new AsyncSeriesLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < reloops) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + hook.callAsync( + { counts: Array.from({ length: numTaps }, () => reloops) }, + () => {} + ); + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; +} + +const INNER_ITERATIONS = 100; + +function runBatch(hook, state) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const next = (err) => { + if (err) return reject(err); + resetState(state); + if (--remaining === 0) return resolve(); + hook.callAsync(state, next); + }; + resetState(state); + hook.callAsync(state, next); + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const [n, reloops] of [ + [3, 0], + [3, 2], + [10, 0] + ]) { + const hook = makeHook(n, reloops); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add(`async-series-loop-hook: ${n} taps, ${reloops} reloops`, () => + runBatch(hook, state) + ); + } +} diff --git a/benchmark/cases/async-series-waterfall-hook/index.bench.mjs b/benchmark/cases/async-series-waterfall-hook/index.bench.mjs new file mode 100644 index 0000000..a738733 --- /dev/null +++ b/benchmark/cases/async-series-waterfall-hook/index.bench.mjs @@ -0,0 +1,67 @@ +/* + * async-series-waterfall-hook + * + * AsyncSeriesWaterfallHook threads a value through a chain of taps where + * each tap can be sync, callback-async, or promise-async. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesWaterfallHook } = tapable; + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + const name = `plugin-${i}`; + if (kind === "sync") { + hook.tap(name, (v) => v + 1); + } else if (kind === "async") { + hook.tapAsync(name, (v, cb) => cb(null, v + 1)); + } else if (kind === "promise") { + hook.tapPromise(name, (v) => Promise.resolve(v + 1)); + } + } + hook.callAsync(0, () => {}); + 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(0, 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-series-waterfall-hook: ${n} sync taps`, () => + runBatch(hook) + ); + } + + { + const hook = makeHook(5, "async"); + bench.add("async-series-waterfall-hook: 5 async taps", () => + runBatch(hook) + ); + } + + { + const hook = makeHook(5, "promise"); + bench.add("async-series-waterfall-hook: 5 promise taps", () => + runBatch(hook) + ); + } +} diff --git a/benchmark/cases/hook-compile/index.bench.mjs b/benchmark/cases/hook-compile/index.bench.mjs new file mode 100644 index 0000000..c29011e --- /dev/null +++ b/benchmark/cases/hook-compile/index.bench.mjs @@ -0,0 +1,90 @@ +/* + * hook-compile + * + * Measures first-call compile cost per hook type. Each iteration builds + * a fresh hook with 5 taps and forces code generation by calling it once. + * This is the path that webpack hits every time the tap-set changes. + */ + +import tapable from "../../../lib/index.js"; + +const { + SyncHook, + SyncBailHook, + SyncWaterfallHook, + SyncLoopHook, + AsyncSeriesHook, + AsyncSeriesBailHook, + AsyncSeriesWaterfallHook, + AsyncParallelHook, + AsyncParallelBailHook +} = tapable; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add("hook-compile: SyncHook, 5 taps + first call", () => { + const hook = new SyncHook(["a", "b"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.call(1, 2); + }); + + bench.add("hook-compile: SyncBailHook, 5 taps + first call", () => { + const hook = new SyncBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call(1); + }); + + bench.add("hook-compile: SyncWaterfallHook, 5 taps + first call", () => { + const hook = new SyncWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.call(0); + }); + + bench.add("hook-compile: SyncLoopHook, 5 taps + first call", () => { + const hook = new SyncLoopHook(["s"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call({}); + }); + + bench.add("hook-compile: AsyncSeriesHook, 5 taps + first callAsync", () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.callAsync(1, () => {}); + }); + + bench.add( + "hook-compile: AsyncSeriesBailHook, 5 taps + first callAsync", + () => { + const hook = new AsyncSeriesBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.callAsync(1, () => {}); + } + ); + + bench.add( + "hook-compile: AsyncSeriesWaterfallHook, 5 taps + first callAsync", + () => { + const hook = new AsyncSeriesWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.callAsync(0, () => {}); + } + ); + + bench.add("hook-compile: AsyncParallelHook, 5 taps + first callAsync", () => { + const hook = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + hook.callAsync(1, () => {}); + }); + + bench.add( + "hook-compile: AsyncParallelBailHook, 5 taps + first callAsync", + () => { + const hook = new AsyncParallelBailHook(["a"]); + for (let i = 0; i < 5; i++) + hook.tapAsync(`p-${i}`, (_a, cb) => cb(null, undefined)); + hook.callAsync(1, () => {}); + } + ); +} diff --git a/benchmark/cases/hook-map/index.bench.mjs b/benchmark/cases/hook-map/index.bench.mjs new file mode 100644 index 0000000..f242517 --- /dev/null +++ b/benchmark/cases/hook-map/index.bench.mjs @@ -0,0 +1,69 @@ +/* + * hook-map + * + * HookMap is the keyed sub-hook container used by plugin systems + * (webpack compilation.hooks.*). Hot paths: + * - `map.for(key)` on an already-populated key (pure Map.get) + * - `map.for(key)` on a new key (factory + interceptor walk) + * - `map.get(key)` for existing / missing keys + */ + +import tapable from "../../../lib/index.js"; + +const { HookMap, SyncHook } = tapable; + +const LOOKUP_ITERATIONS = 2000; +const COLD_KEYS = 10; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + const warm = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 20; i++) { + warm.for(`key-${i}`).tap(`plugin-${i}`, () => {}); + } + + bench.add("hook-map: for(existing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.for("key-10"); + } + }); + + bench.add("hook-map: get(existing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.get("key-10"); + } + }); + + bench.add("hook-map: get(missing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.get("not-there"); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, no interceptors`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, 1 interceptor`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, 3 interceptors`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); +} diff --git a/benchmark/cases/interceptors-async/index.bench.mjs b/benchmark/cases/interceptors-async/index.bench.mjs new file mode 100644 index 0000000..4a94a2d --- /dev/null +++ b/benchmark/cases/interceptors-async/index.bench.mjs @@ -0,0 +1,72 @@ +/* + * interceptors-async + * + * Interceptor overhead on the async hooks. Covers both AsyncSeriesHook + * (serialized, one-tap-at-a-time) and AsyncParallelHook (fan-out). + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesHook, AsyncParallelHook } = tapable; + +function runBatch(hook, iterations) { + return new Promise((resolve, reject) => { + let remaining = iterations; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < iterations; i++) { + hook.callAsync(1, done); + } + }); +} + +const INNER_ITERATIONS = 200; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + // --- AsyncSeriesHook --- + const seriesBaseline = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) seriesBaseline.tap(`p-${i}`, () => {}); + seriesBaseline.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, no interceptors", () => + runBatch(seriesBaseline, INNER_ITERATIONS) + ); + + const seriesCall = new AsyncSeriesHook(["a"]); + seriesCall.intercept({ call: () => {} }); + for (let i = 0; i < 5; i++) seriesCall.tap(`p-${i}`, () => {}); + seriesCall.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, call interceptor", () => + runBatch(seriesCall, INNER_ITERATIONS) + ); + + const seriesTap = new AsyncSeriesHook(["a"]); + seriesTap.intercept({ tap: () => {} }); + for (let i = 0; i < 5; i++) seriesTap.tap(`p-${i}`, () => {}); + seriesTap.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, tap interceptor", () => + runBatch(seriesTap, INNER_ITERATIONS) + ); + + // --- AsyncParallelHook --- + const parallelBaseline = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) + parallelBaseline.tapAsync(`p-${i}`, (_a, cb) => cb()); + parallelBaseline.callAsync(1, () => {}); + bench.add("interceptors-async: parallel, 5 async taps, no interceptors", () => + runBatch(parallelBaseline, INNER_ITERATIONS) + ); + + const parallelAll = new AsyncParallelHook(["a"]); + parallelAll.intercept({ call: () => {}, tap: () => {} }); + for (let i = 0; i < 5; i++) parallelAll.tapAsync(`p-${i}`, (_a, cb) => cb()); + parallelAll.callAsync(1, () => {}); + bench.add( + "interceptors-async: parallel, 5 async taps, call + tap interceptor", + () => runBatch(parallelAll, INNER_ITERATIONS) + ); +} diff --git a/benchmark/cases/interceptors-sync/index.bench.mjs b/benchmark/cases/interceptors-sync/index.bench.mjs new file mode 100644 index 0000000..4544425 --- /dev/null +++ b/benchmark/cases/interceptors-sync/index.bench.mjs @@ -0,0 +1,66 @@ +/* + * interceptors-sync + * + * Measures how interceptors slow down the SyncHook `.call()` path. The + * baseline no-interceptor run is included so delta is visible. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook } = tapable; + +function makeHook(numTaps, interceptors) { + const hook = new SyncHook(["a"]); + for (const i of interceptors) hook.intercept(i); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + hook.call(1); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + const baseline = makeHook(5, []); + bench.add("interceptors-sync: 5 taps, no interceptors", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) baseline.call(1); + }); + + const call = makeHook(5, [{ call: () => {} }]); + bench.add("interceptors-sync: 5 taps, call interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) call.call(1); + }); + + const tap = makeHook(5, [{ tap: () => {} }]); + bench.add("interceptors-sync: 5 taps, tap interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) tap.call(1); + }); + + const combined = makeHook(5, [{ call: () => {}, tap: () => {} }]); + bench.add("interceptors-sync: 5 taps, call + tap interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) combined.call(1); + }); + + const many = makeHook(5, [ + { call: () => {} }, + { tap: () => {} }, + { call: () => {} } + ]); + bench.add("interceptors-sync: 5 taps, 3 interceptors", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) many.call(1); + }); + + // Register interceptor runs at tap time only. + bench.add( + "interceptors-sync: register interceptor + 10 tap registrations", + () => { + const h = new SyncHook(["a"]); + h.intercept({ register: (t) => t }); + for (let i = 0; i < 10; i++) h.tap(`p-${i}`, () => {}); + } + ); +} diff --git a/benchmark/cases/multi-hook/index.bench.mjs b/benchmark/cases/multi-hook/index.bench.mjs new file mode 100644 index 0000000..c6796ef --- /dev/null +++ b/benchmark/cases/multi-hook/index.bench.mjs @@ -0,0 +1,51 @@ +/* + * multi-hook + * + * MultiHook fans operations (tap / intercept / isUsed) across a small set + * of underlying hooks. Covers the hot fan-out loop and the boolean-short + * `isUsed` check. + */ + +import tapable from "../../../lib/index.js"; + +const { MultiHook, SyncHook } = tapable; + +function makeMulti() { + return new MultiHook([ + new SyncHook(["x"]), + new SyncHook(["x"]), + new SyncHook(["x"]) + ]); +} + +const TAP_COUNT = 10; +const IS_USED_ITERATIONS = 2000; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add(`multi-hook: tap x ${TAP_COUNT} across 3 hooks`, () => { + const multi = makeMulti(); + for (let i = 0; i < TAP_COUNT; i++) { + multi.tap(`p-${i}`, () => {}); + } + }); + + const usedMulti = makeMulti(); + for (let i = 0; i < 5; i++) { + usedMulti.tap(`p-${i}`, () => {}); + } + + bench.add("multi-hook: isUsed (3 hooks, 5 taps)", () => { + for (let i = 0; i < IS_USED_ITERATIONS; i++) { + usedMulti.isUsed(); + } + }); + + bench.add("multi-hook: intercept across 3 hooks", () => { + const multi = makeMulti(); + for (let i = 0; i < 5; i++) multi.tap(`p-${i}`, () => {}); + multi.intercept({ call: () => {} }); + }); +} diff --git a/benchmark/cases/sync-bail-hook/index.bench.mjs b/benchmark/cases/sync-bail-hook/index.bench.mjs new file mode 100644 index 0000000..6ebef03 --- /dev/null +++ b/benchmark/cases/sync-bail-hook/index.bench.mjs @@ -0,0 +1,49 @@ +/* + * sync-bail-hook + * + * Measures SyncBailHook.call() with taps that either bail early (returning + * a value) or pass through (returning undefined). Bail position changes + * how many taps run per call, so it directly exercises the conditional + * structure of the generated code. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncBailHook } = tapable; + +function makeHook(numTaps, bailAt) { + const hook = new SyncBailHook(["value"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (v) => (idx === bailAt ? v : undefined)); + } + hook.call(1); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + // Full chain walk - no tap bails. + for (const n of [1, 5, 10, 20]) { + const hook = makeHook(n, -1); + bench.add(`sync-bail-hook: ${n} taps, no bail`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1); + } + }); + } + + // Bail at start / middle / end of a 10-tap chain. + for (const pos of [0, 4, 9]) { + const hook = makeHook(10, pos); + bench.add(`sync-bail-hook: 10 taps, bail at index ${pos}`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1); + } + }); + } +} diff --git a/benchmark/cases/sync-hook/index.bench.mjs b/benchmark/cases/sync-hook/index.bench.mjs new file mode 100644 index 0000000..e88318d --- /dev/null +++ b/benchmark/cases/sync-hook/index.bench.mjs @@ -0,0 +1,49 @@ +/* + * sync-hook + * + * Steady-state `.call()` cost for SyncHook at varying tap counts. Each + * bench body loops over the hook many times so the measurement captures + * the generated call-path rather than the tinybench harness overhead. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook } = tapable; + +function makeHook(numTaps, argNames = ["a", "b"]) { + const hook = new SyncHook(argNames); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + // Force compilation so the benchmark measures the steady-state call path. + hook.call(...argNames.map((_, i) => i)); + return hook; +} + +const INNER_ITERATIONS = 2000; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [0, 1, 5, 10, 20, 50]) { + const hook = makeHook(n); + bench.add(`sync-hook: call with ${n} taps`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1, 2); + } + }); + } + + // Argument-count variations with a fixed 5-tap chain. + for (const argCount of [0, 1, 3, 5]) { + const args = Array.from({ length: argCount }, (_, i) => `a${i}`); + const hook = makeHook(5, args); + const callArgs = args.map((_, i) => i); + bench.add(`sync-hook: call 5 taps / ${argCount} args`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(...callArgs); + } + }); + } +} diff --git a/benchmark/cases/sync-loop-hook/index.bench.mjs b/benchmark/cases/sync-loop-hook/index.bench.mjs new file mode 100644 index 0000000..9dd4372 --- /dev/null +++ b/benchmark/cases/sync-loop-hook/index.bench.mjs @@ -0,0 +1,53 @@ +/* + * sync-loop-hook + * + * SyncLoopHook re-runs the tap chain while any tap returns a non-undefined + * value. The case covers: + * - single-pass (every tap returns undefined on the first run) + * - multi-pass (each tap asks for N reloops before settling) + */ + +import tapable from "../../../lib/index.js"; + +const { SyncLoopHook } = tapable; + +function makeHook(numTaps, reloops) { + const hook = new SyncLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < reloops) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; +} + +const INNER_ITERATIONS = 500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const [n, reloops] of [ + [3, 0], + [10, 0], + [3, 2] + ]) { + const hook = makeHook(n, reloops); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add(`sync-loop-hook: ${n} taps, ${reloops} reloops`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + resetState(state); + hook.call(state); + } + }); + } +} diff --git a/benchmark/cases/sync-waterfall-hook/index.bench.mjs b/benchmark/cases/sync-waterfall-hook/index.bench.mjs new file mode 100644 index 0000000..bc870d9 --- /dev/null +++ b/benchmark/cases/sync-waterfall-hook/index.bench.mjs @@ -0,0 +1,58 @@ +/* + * sync-waterfall-hook + * + * SyncWaterfallHook threads a value through each tap. Covers three shapes + * that hit different branches of the generated code: + * - every tap returns a new value (value is overwritten on each step) + * - every tap returns undefined (initial value threaded through) + * - mixed returns + */ + +import tapable from "../../../lib/index.js"; + +const { SyncWaterfallHook } = tapable; + +function makeHook(numTaps, returning) { + const hook = new SyncWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, returning ? (v) => v + 1 : () => undefined); + } + hook.call(0); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [1, 5, 20, 50]) { + const hook = makeHook(n, true); + bench.add(`sync-waterfall-hook: ${n} taps, all return`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(0); + } + }); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, false); + bench.add(`sync-waterfall-hook: ${n} taps, all undefined`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(0); + } + }); + } + + const mixed = new SyncWaterfallHook(["value"]); + for (let i = 0; i < 10; i++) { + mixed.tap(`plugin-${i}`, i % 2 === 0 ? (v) => v + 1 : () => undefined); + } + mixed.call(0); + bench.add("sync-waterfall-hook: 10 taps, mixed return", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + mixed.call(0); + } + }); +} diff --git a/benchmark/cases/tap-registration/index.bench.mjs b/benchmark/cases/tap-registration/index.bench.mjs new file mode 100644 index 0000000..44dc989 --- /dev/null +++ b/benchmark/cases/tap-registration/index.bench.mjs @@ -0,0 +1,80 @@ +/* + * tap-registration + * + * Measures Hook#tap / tapAsync / tapPromise at the registration step, + * with the four kinds of options shapes that hit different code paths + * in Hook.js (_tap, _insert): + * - string options (most common - plugin name only) + * - object options (same thing but wrapped) + * - stages (numeric ordering) + * - `before` constraint (forces the shift loop to scan) + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook, AsyncSeriesHook } = tapable; + +const TAP_COUNT = 10; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, string options`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap(`p-${i}`, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, object options`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap({ name: `p-${i}` }, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, with stages`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, alternating before`, + () => { + const hook = new SyncHook(["a"]); + hook.tap("first", () => {}); + for (let i = 0; i < TAP_COUNT - 1; i++) { + hook.tap({ name: `p-${i}`, before: "first" }, () => {}); + } + } + ); + + bench.add(`tap-registration: AsyncSeriesHook tapAsync x ${TAP_COUNT}`, () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + } + }); + + bench.add( + `tap-registration: AsyncSeriesHook tapPromise x ${TAP_COUNT}`, + () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tapPromise(`p-${i}`, () => Promise.resolve()); + } + } + ); +} diff --git a/benchmark/run.mjs b/benchmark/run.mjs new file mode 100644 index 0000000..65f5254 --- /dev/null +++ b/benchmark/run.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/* + * Benchmark entry point for tapable. + * + * Discovers every directory under ./cases/ that contains an `index.bench.mjs` + * file, calls its default-exported `register(bench, ctx)` function to + * populate tinybench tasks, then runs them all. + * + * The bench is wrapped with a local `withCodSpeed()` bridge (ported from + * enhanced-resolve / webpack) so the same entry point works for: + * - local development (`npm run benchmark`) -> wall-clock measurements + * printed to the terminal; the wrapper detects that CodSpeed is not + * active and returns the bench untouched + * - CI under CodSpeedHQ/action -> the wrapper switches to instrumentation + * mode automatically and results are uploaded to codspeed.io + * + * See ./README.md for the layout of individual cases. + */ + +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { Bench, hrtimeNow } from "tinybench"; +import { withCodSpeed } from "./with-codspeed.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const casesPath = path.join(__dirname, "cases"); + +/** + * Filter expression from CLI or env (e.g. `npm run benchmark -- sync`). + * A case is included if its directory name contains this substring. Empty + * means "include everything". + */ +const filter = process.env.BENCH_FILTER || process.argv[2] || ""; + +const bench = withCodSpeed( + new Bench({ + name: "tapable", + now: hrtimeNow, + throws: true, + warmup: true, + warmupIterations: 5, + // Kept deliberately low: each task's body already loops over many + // hook calls, and we want wall-clock runs to finish in a few + // seconds. CodSpeed's simulation mode ignores this and instruments + // exactly one iteration per task. + iterations: 20 + }) +); + +const caseDirs = (await fs.readdir(casesPath, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => !filter || name.includes(filter)) + .sort(); + +if (caseDirs.length === 0) { + console.error( + filter + ? `No benchmark cases matched filter "${filter}"` + : "No benchmark cases found" + ); + process.exit(1); +} + +for (const caseName of caseDirs) { + const benchFile = path.join(casesPath, caseName, "index.bench.mjs"); + try { + await fs.access(benchFile); + } catch { + console.warn(`[skip] ${caseName}: no index.bench.mjs`); + continue; + } + const mod = await import(pathToFileURL(benchFile).href); + if (typeof mod.default !== "function") { + throw new Error( + `${caseName}/index.bench.mjs must export a default function` + ); + } + await mod.default(bench, { + caseName, + caseDir: path.join(casesPath, caseName) + }); + console.log(`Registered: ${caseName}`); +} + +console.log(`\nRunning ${bench.tasks.length} tasks...\n`); +await bench.run(); + +// Pretty-print results. Kept simple on purpose - CodSpeed uploads its own +// data in CI; this table is for humans running locally. +const rows = bench.tasks.map((task) => { + const r = task.result; + if (!r) return { name: task.name, status: "no result" }; + // tinybench v6 result shape: latency / throughput objects. + const lat = r.latency; + const tp = r.throughput; + return { + name: task.name, + "ops/s": tp?.mean?.toFixed(2) ?? "n/a", + "mean (ms)": lat?.mean?.toFixed(4) ?? "n/a", + "p99 (ms)": lat?.p99?.toFixed(4) ?? "n/a", + "rme (%)": lat?.rme?.toFixed(2) ?? "n/a", + samples: lat?.samplesCount ?? 0 + }; +}); +console.log(); +console.table(rows); + +// Exit non-zero if any task threw, so CI picks it up. +const failed = bench.tasks.filter((t) => t.result?.error); +if (failed.length > 0) { + console.error(`\n${failed.length} task(s) errored:`); + for (const t of failed) { + console.error(` - ${t.name}: ${t.result?.error?.message}`); + } + process.exit(1); +} diff --git a/benchmark/with-codspeed.mjs b/benchmark/with-codspeed.mjs new file mode 100644 index 0000000..fb87a0f --- /dev/null +++ b/benchmark/with-codspeed.mjs @@ -0,0 +1,178 @@ +/* + * CodSpeed <-> tinybench bridge for tapable benchmarks. + * + * Ported from the equivalent wrapper in enhanced-resolve (which in turn + * was ported from webpack's test/BenchmarkTestCases.benchmark.mjs). + * + * Why not @codspeed/tinybench-plugin? + * That package accesses tinybench Task internals (task.fn, task.fnOpts) + * that were made private in tinybench v6, causing a TypeError in + * simulation mode. webpack and enhanced-resolve hit the same issue and + * use @codspeed/core directly - we follow their lead. + * + * Modes (via getCodspeedRunnerMode() from @codspeed/core): + * "disabled" - returns the bench untouched (local runs) + * "simulation" - overrides bench.run/runSync for CodSpeed instrumentation + * "walltime" - left untouched; tinybench's built-in timing is used + */ + +import path from "path"; +import { fileURLToPath } from "url"; +import { + InstrumentHooks, + getCodspeedRunnerMode, + setupCore, + teardownCore +} from "@codspeed/core"; + +/** @typedef {import("tinybench").Bench} Bench */ +/** @typedef {import("tinybench").Task} Task */ +/** @typedef {() => unknown | Promise} Fn */ + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + ".." +); + +/** + * Capture the file that invoked bench.add() so we can build a stable URI + * for CodSpeed to identify the benchmark. + * @returns {string} calling file path relative to the repo root + */ +function getCallingFile() { + const dummy = {}; + const prev = Error.prepareStackTrace; + const prevLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 10; + Error.prepareStackTrace = (_err, trace) => trace; + Error.captureStackTrace(dummy, getCallingFile); + const trace = /** @type {NodeJS.CallSite[]} */ ( + /** @type {{ stack: unknown }} */ (dummy).stack + ); + Error.prepareStackTrace = prev; + Error.stackTraceLimit = prevLimit; + + let file = /** @type {string} */ (trace[1].getFileName() || ""); + if (file.startsWith("file://")) file = fileURLToPath(file); + if (!file) return ""; + return path.relative(repoRoot, file); +} + +/** + * @typedef {{ uri: string, fn: Fn, opts: object | undefined }} TaskMeta + * @type {WeakMap>} + */ +const metaMap = new WeakMap(); + +/** + * @param {Bench} bench + * @returns {Map} + */ +function getOrCreateMeta(bench) { + let m = metaMap.get(bench); + if (!m) { + m = new Map(); + metaMap.set(bench, m); + } + return m; +} + +/** + * Wrap a tinybench Bench so that CodSpeed simulation mode instruments each + * task. In "disabled" and "walltime" modes the bench is returned as-is. + * + * @param {Bench} bench + * @returns {Bench} + */ +export function withCodSpeed(bench) { + const mode = getCodspeedRunnerMode(); + if (mode === "disabled" || mode === "walltime") return bench; + + // --- simulation mode --- + + const meta = getOrCreateMeta(bench); + const rawAdd = bench.add.bind(bench); + + bench.add = (name, fn, opts) => { + const callingFile = getCallingFile(); + const uri = `${callingFile}::${name}`; + meta.set(name, { uri, fn, opts }); + return rawAdd(name, fn, opts); + }; + + const setup = () => { + setupCore(); + console.log("[CodSpeed] running in simulation mode"); + }; + + const teardown = () => { + teardownCore(); + console.log(`[CodSpeed] Done running ${bench.tasks.length} benches.`); + return bench.tasks; + }; + + /** + * @param {Fn} fn + * @param {boolean} isAsync + * @returns {Fn} + */ + const wrapFrame = (fn, isAsync) => { + if (isAsync) { + return async function __codspeed_root_frame__() { + await fn(); + }; + } + return function __codspeed_root_frame__() { + fn(); + }; + }; + + bench.run = async () => { + setup(); + for (const task of bench.tasks) { + const m = /** @type {TaskMeta} */ (meta.get(task.name)); + + // Warm-up: run the body a few times to stabilise caches / JIT. + for (let i = 0; i < bench.iterations - 1; i++) { + await m.fn(); + } + + // Instrumented run. + global.gc?.(); + InstrumentHooks.startBenchmark(); + await wrapFrame(m.fn, true)(); + InstrumentHooks.stopBenchmark(); + InstrumentHooks.setExecutedBenchmark(process.pid, m.uri); + + console.log( + `[CodSpeed] ${ + InstrumentHooks.isInstrumented() ? "Measured" : "Checked" + } ${m.uri}` + ); + } + return teardown(); + }; + + bench.runSync = () => { + setup(); + for (const task of bench.tasks) { + const m = /** @type {TaskMeta} */ (meta.get(task.name)); + for (let i = 0; i < bench.iterations - 1; i++) { + m.fn(); + } + global.gc?.(); + InstrumentHooks.startBenchmark(); + wrapFrame(m.fn, false)(); + InstrumentHooks.stopBenchmark(); + InstrumentHooks.setExecutedBenchmark(process.pid, m.uri); + console.log( + `[CodSpeed] ${ + InstrumentHooks.isInstrumented() ? "Measured" : "Checked" + } ${m.uri}` + ); + } + return teardown(); + }; + + return bench; +} diff --git a/benchmarks/README.md b/benchmarks/README.md deleted file mode 100644 index d1b405c..0000000 --- a/benchmarks/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# tapable benchmarks - -Micro-benchmarks for tapable's hook primitives, built with -[tinybench](https://github.com/tinylibs/tinybench) and instrumented for -[CodSpeed](https://codspeed.io) via -[`@codspeed/tinybench-plugin`](https://github.com/CodSpeedHQ/codspeed-node). - -## Layout - -``` -benchmarks/ - helpers.js - shared createBench / runBench helpers - run.js - recursive runner for *.bench.js files - sync/ - SyncHook.bench.js - SyncBailHook.bench.js - SyncWaterfallHook.bench.js - SyncLoopHook.bench.js - async/ - AsyncSeriesHook.bench.js - AsyncSeriesBailHook.bench.js - AsyncSeriesWaterfallHook.bench.js - AsyncSeriesLoopHook.bench.js - AsyncParallelHook.bench.js - AsyncParallelBailHook.bench.js - hookmap/ - HookMap.bench.js - MultiHook.bench.js - interceptors/ - sync.bench.js - async.bench.js - registration/ - tap.bench.js - tap(), tapAsync(), tapPromise(), with stages/before - compile.bench.js - first-call compile cost per hook type -``` - -Each `*.bench.js` file exports an async `main()`. The runner -(`benchmarks/run.js`) discovers them recursively and executes them in one -process so the CodSpeed instrumentation session is shared. Any file can -also be run stand-alone (`node benchmarks/sync/SyncHook.bench.js`) thanks -to the `runIfMain(module, main)` shim in `helpers.js`. - -## Running locally - -```bash -# Everything -npm run bench - -# A single suite (directory) -npm run bench:sync -npm run bench:async -npm run bench:hookmap -npm run bench:interceptors -npm run bench:registration - -# A single file -node benchmarks/sync/SyncHook.bench.js -``` - -Local runs print a tinybench result table per file. The CodSpeed plugin -is a no-op outside of the CodSpeed runner, so nothing is uploaded from -local runs. - -## CI - -`.github/workflows/codspeed.yml` runs `npm run bench` on push to `main` -and on pull requests, uploading results to CodSpeed. Set the -`CODSPEED_TOKEN` repository secret to enable uploads. diff --git a/benchmarks/async/AsyncParallelBailHook.bench.js b/benchmarks/async/AsyncParallelBailHook.bench.js deleted file mode 100644 index 370539e..0000000 --- a/benchmarks/async/AsyncParallelBailHook.bench.js +++ /dev/null @@ -1,57 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncParallelBailHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, kind, bailAt = -1) { - 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 if (kind === "async") { - hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); - } - } - hook.callAsync(1, () => {}); - return hook; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - const noBail = makeHook(10, "sync", -1); - bench.add("AsyncParallelBailHook#callAsync (10 sync taps, no bail)", () => - callAsyncPromisified(noBail, 1) - ); - - const bailMid = makeHook(10, "sync", 4); - bench.add("AsyncParallelBailHook#callAsync (10 sync taps, bail mid)", () => - callAsyncPromisified(bailMid, 1) - ); - - const asyncNoBail = makeHook(5, "async", -1); - bench.add("AsyncParallelBailHook#callAsync (5 async taps, no bail)", () => - callAsyncPromisified(asyncNoBail, 1) - ); - - const asyncBail = makeHook(5, "async", 2); - bench.add("AsyncParallelBailHook#callAsync (5 async taps, bail mid)", () => - callAsyncPromisified(asyncBail, 1) - ); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/async/AsyncParallelHook.bench.js b/benchmarks/async/AsyncParallelHook.bench.js deleted file mode 100644 index ebcd369..0000000 --- a/benchmarks/async/AsyncParallelHook.bench.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncParallelHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -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; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - for (const n of [1, 5, 20]) { - const hook = makeHook(n, "sync"); - bench.add(`AsyncParallelHook#callAsync (${n} sync taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - for (const n of [5, 20]) { - const hook = makeHook(n, "async"); - bench.add(`AsyncParallelHook#callAsync (${n} async taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - for (const n of [5]) { - const hook = makeHook(n, "promise"); - bench.add(`AsyncParallelHook#callAsync (${n} promise taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesBailHook.bench.js b/benchmarks/async/AsyncSeriesBailHook.bench.js deleted file mode 100644 index ce5ffda..0000000 --- a/benchmarks/async/AsyncSeriesBailHook.bench.js +++ /dev/null @@ -1,57 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncSeriesBailHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, kind, bailAt = -1) { - 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 if (kind === "async") { - hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); - } - } - hook.callAsync(1, () => {}); - return hook; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - const noBail = makeHook(10, "sync", -1); - bench.add("AsyncSeriesBailHook#callAsync (10 sync taps, no bail)", () => - callAsyncPromisified(noBail, 1) - ); - - const bailMid = makeHook(10, "sync", 4); - bench.add("AsyncSeriesBailHook#callAsync (10 sync taps, bail mid)", () => - callAsyncPromisified(bailMid, 1) - ); - - const asyncNoBail = makeHook(5, "async", -1); - bench.add("AsyncSeriesBailHook#callAsync (5 async taps, no bail)", () => - callAsyncPromisified(asyncNoBail, 1) - ); - - const asyncBail = makeHook(5, "async", 2); - bench.add("AsyncSeriesBailHook#callAsync (5 async taps, bail mid)", () => - callAsyncPromisified(asyncBail, 1) - ); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesHook.bench.js b/benchmarks/async/AsyncSeriesHook.bench.js deleted file mode 100644 index 589bd2b..0000000 --- a/benchmarks/async/AsyncSeriesHook.bench.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncSeriesHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, kind) { - const hook = new AsyncSeriesHook(["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; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - for (const n of [1, 5, 20]) { - const hook = makeHook(n, "sync"); - bench.add(`AsyncSeriesHook#callAsync (${n} sync taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - for (const n of [5, 20]) { - const hook = makeHook(n, "async"); - bench.add(`AsyncSeriesHook#callAsync (${n} async taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - for (const n of [5]) { - const hook = makeHook(n, "promise"); - bench.add(`AsyncSeriesHook#callAsync (${n} promise taps)`, () => - callAsyncPromisified(hook, 1) - ); - } - - // .promise() flavor. - const promiseHook = makeHook(5, "sync"); - bench.add("AsyncSeriesHook#promise (5 sync taps)", () => - promiseHook.promise(1) - ); - - const promiseAsyncHook = makeHook(5, "async"); - bench.add("AsyncSeriesHook#promise (5 async taps)", () => - promiseAsyncHook.promise(1) - ); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesLoopHook.bench.js b/benchmarks/async/AsyncSeriesLoopHook.bench.js deleted file mode 100644 index de28c8c..0000000 --- a/benchmarks/async/AsyncSeriesLoopHook.bench.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncSeriesLoopHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, iterations) { - const hook = new AsyncSeriesLoopHook(["state"]); - for (let i = 0; i < numTaps; i++) { - const idx = i; - hook.tap(`plugin-${idx}`, (state) => { - if (state.counts[idx] < iterations) { - state.counts[idx]++; - return true; - } - return undefined; - }); - } - // Warm up compile. - hook.callAsync( - { counts: Array.from({ length: numTaps }, () => iterations) }, - () => {} - ); - return hook; -} - -function resetState(state) { - for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => hook.callAsync(value, resolve)); -} - -async function main() { - const bench = createBench(); - - for (const [n, iters] of [ - [3, 0], - [3, 2], - [10, 0] - ]) { - const hook = makeHook(n, iters); - const state = { counts: Array.from({ length: n }, () => 0) }; - bench.add( - `AsyncSeriesLoopHook#callAsync (${n} sync taps, ${iters} reloops)`, - () => callAsyncPromisified(hook, state), - { beforeEach: () => resetState(state) } - ); - } - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/async/AsyncSeriesWaterfallHook.bench.js b/benchmarks/async/AsyncSeriesWaterfallHook.bench.js deleted file mode 100644 index c89a5c3..0000000 --- a/benchmarks/async/AsyncSeriesWaterfallHook.bench.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncSeriesWaterfallHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, kind) { - const hook = new AsyncSeriesWaterfallHook(["value"]); - for (let i = 0; i < numTaps; i++) { - const name = `plugin-${i}`; - if (kind === "sync") { - hook.tap(name, (v) => v + 1); - } else if (kind === "async") { - hook.tapAsync(name, (v, cb) => cb(null, v + 1)); - } else if (kind === "promise") { - hook.tapPromise(name, (v) => Promise.resolve(v + 1)); - } - } - hook.callAsync(0, () => {}); - return hook; -} - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - for (const n of [1, 5, 20]) { - const hook = makeHook(n, "sync"); - bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} sync taps)`, () => - callAsyncPromisified(hook, 0) - ); - } - - for (const n of [5]) { - const hook = makeHook(n, "async"); - bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} async taps)`, () => - callAsyncPromisified(hook, 0) - ); - } - - for (const n of [5]) { - const hook = makeHook(n, "promise"); - bench.add(`AsyncSeriesWaterfallHook#callAsync (${n} promise taps)`, () => - callAsyncPromisified(hook, 0) - ); - } - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/helpers.js b/benchmarks/helpers.js deleted file mode 100644 index ca84654..0000000 --- a/benchmarks/helpers.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { Bench } = require("tinybench"); -const { withCodSpeed } = require("@codspeed/tinybench-plugin"); - -/** - * Create a new tinybench Bench, wrapped with CodSpeed instrumentation - * when running under the CodSpeed runner. When running locally the - * plugin is a no-op and regular tinybench output is produced. - * - * @param {import("tinybench").BenchOptions} [options] - * @returns {Bench} - */ -function createBench(options) { - return withCodSpeed( - new Bench({ - // Keep runs short by default - CodSpeed uses its own measurement - // strategy, and locally we only need enough iterations for a - // stable signal. - time: 500, - warmupTime: 100, - ...options - }) - ); -} - -/** - * Run a bench and print a result table when executed locally. - * - * @param {Bench} bench - */ -async function runBench(bench) { - await bench.run(); - // Only print a table for local runs. Under CodSpeed the output is - // collected by the runner and the table would be noisy. - if (!process.env.CODSPEED) { - // eslint-disable-next-line no-console - console.table(bench.table()); - } -} - -/** - * Helper so each bench file can be both `require()`-d by the runner and - * executed directly via `node benchmarks//.bench.js`. - * - * @param {NodeModule} mod - the caller's `module` - * @param {() => Promise} main - the suite's async main - */ -function runIfMain(mod, main) { - if (require.main === mod) { - main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exitCode = 1; - }); - } -} - -module.exports = { createBench, runBench, runIfMain }; diff --git a/benchmarks/hookmap/HookMap.bench.js b/benchmarks/hookmap/HookMap.bench.js deleted file mode 100644 index 5396190..0000000 --- a/benchmarks/hookmap/HookMap.bench.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { HookMap, SyncHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -async function main() { - const bench = createBench(); - - // --- Hot lookups on a warm map --- - const warmMap = new HookMap(() => new SyncHook(["x"])); - for (let i = 0; i < 20; i++) { - warmMap.for(`key-${i}`).tap(`plugin-${i}`, () => {}); - } - - bench.add("HookMap#for (existing key)", () => { - warmMap.for("key-10"); - }); - - bench.add("HookMap#get (existing key)", () => { - warmMap.get("key-10"); - }); - - bench.add("HookMap#get (missing key)", () => { - warmMap.get("not-there"); - }); - - // --- Cold path: factory + interceptors on new keys --- - bench.add("HookMap#for (10 new keys, no interceptors)", () => { - const map = new HookMap(() => new SyncHook(["x"])); - for (let i = 0; i < 10; i++) { - map.for(`k-${i}`); - } - }); - - bench.add("HookMap#for (10 new keys, 1 interceptor)", () => { - const map = new HookMap(() => new SyncHook(["x"])); - map.intercept({ factory: (_k, hook) => hook }); - for (let i = 0; i < 10; i++) { - map.for(`k-${i}`); - } - }); - - bench.add("HookMap#for (10 new keys, 3 interceptors)", () => { - const map = new HookMap(() => new SyncHook(["x"])); - map.intercept({ factory: (_k, hook) => hook }); - map.intercept({ factory: (_k, hook) => hook }); - map.intercept({ factory: (_k, hook) => hook }); - for (let i = 0; i < 10; i++) { - map.for(`k-${i}`); - } - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/hookmap/MultiHook.bench.js b/benchmarks/hookmap/MultiHook.bench.js deleted file mode 100644 index d74aa3e..0000000 --- a/benchmarks/hookmap/MultiHook.bench.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { MultiHook, SyncHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -async function main() { - const bench = createBench(); - - // 3 underlying SyncHooks, each gets 5 taps via MultiHook#tap. - const buildMulti = () => - new MultiHook([ - new SyncHook(["x"]), - new SyncHook(["x"]), - new SyncHook(["x"]) - ]); - - const multi = buildMulti(); - for (let i = 0; i < 5; i++) { - multi.tap(`plugin-${i}`, () => {}); - } - multi.hooks[0].call(1); - multi.hooks[1].call(1); - multi.hooks[2].call(1); - - bench.add("MultiHook#tap (3 hooks, 10 registrations)", () => { - const m = buildMulti(); - for (let i = 0; i < 10; i++) { - m.tap(`p-${i}`, () => {}); - } - }); - - bench.add("MultiHook#isUsed (3 hooks, 5 taps)", () => { - multi.isUsed(); - }); - - bench.add("MultiHook#intercept (3 hooks)", () => { - const m = buildMulti(); - for (let i = 0; i < 5; i++) m.tap(`p-${i}`, () => {}); - m.intercept({ call: () => {} }); - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/interceptors/async.bench.js b/benchmarks/interceptors/async.bench.js deleted file mode 100644 index 9d11d94..0000000 --- a/benchmarks/interceptors/async.bench.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { AsyncSeriesHook, AsyncParallelHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function callAsyncPromisified(hook, value) { - return new Promise((resolve) => { - hook.callAsync(value, resolve); - }); -} - -async function main() { - const bench = createBench(); - - // Series: baseline vs interceptors. - const seriesBaseline = new AsyncSeriesHook(["a"]); - for (let i = 0; i < 5; i++) seriesBaseline.tap(`p-${i}`, () => {}); - seriesBaseline.callAsync(1, () => {}); - bench.add("AsyncSeriesHook#callAsync (5 sync taps, no interceptors)", () => - callAsyncPromisified(seriesBaseline, 1) - ); - - const seriesCall = new AsyncSeriesHook(["a"]); - seriesCall.intercept({ call: () => {} }); - for (let i = 0; i < 5; i++) seriesCall.tap(`p-${i}`, () => {}); - seriesCall.callAsync(1, () => {}); - bench.add("AsyncSeriesHook#callAsync (5 sync taps, call interceptor)", () => - callAsyncPromisified(seriesCall, 1) - ); - - const seriesTap = new AsyncSeriesHook(["a"]); - seriesTap.intercept({ tap: () => {} }); - for (let i = 0; i < 5; i++) seriesTap.tap(`p-${i}`, () => {}); - seriesTap.callAsync(1, () => {}); - bench.add("AsyncSeriesHook#callAsync (5 sync taps, tap interceptor)", () => - callAsyncPromisified(seriesTap, 1) - ); - - // Parallel: baseline vs interceptors. - const parallelBaseline = new AsyncParallelHook(["a"]); - for (let i = 0; i < 5; i++) - parallelBaseline.tapAsync(`p-${i}`, (_a, cb) => cb()); - parallelBaseline.callAsync(1, () => {}); - bench.add("AsyncParallelHook#callAsync (5 async taps, no interceptors)", () => - callAsyncPromisified(parallelBaseline, 1) - ); - - const parallelAll = new AsyncParallelHook(["a"]); - parallelAll.intercept({ call: () => {}, tap: () => {} }); - for (let i = 0; i < 5; i++) parallelAll.tapAsync(`p-${i}`, (_a, cb) => cb()); - parallelAll.callAsync(1, () => {}); - bench.add( - "AsyncParallelHook#callAsync (5 async taps, call + tap interceptor)", - () => callAsyncPromisified(parallelAll, 1) - ); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/interceptors/sync.bench.js b/benchmarks/interceptors/sync.bench.js deleted file mode 100644 index b84e65d..0000000 --- a/benchmarks/interceptors/sync.bench.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, interceptors) { - const hook = new SyncHook(["a"]); - for (const i of interceptors) hook.intercept(i); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, () => {}); - } - hook.call(1); - return hook; -} - -async function main() { - const bench = createBench(); - - // Baseline. - const baseline = makeHook(5, []); - bench.add("SyncHook#call (5 taps, no interceptors)", () => { - baseline.call(1); - }); - - // Individual interceptor kinds. - const callOnly = makeHook(5, [{ call: () => {} }]); - bench.add("SyncHook#call (5 taps, call interceptor)", () => { - callOnly.call(1); - }); - - const tapOnly = makeHook(5, [{ tap: () => {} }]); - bench.add("SyncHook#call (5 taps, tap interceptor)", () => { - tapOnly.call(1); - }); - - // All hook-time interceptor kinds combined. - const combined = makeHook(5, [ - { - call: () => {}, - tap: () => {} - } - ]); - bench.add("SyncHook#call (5 taps, call + tap interceptors)", () => { - combined.call(1); - }); - - // Multiple interceptors layered. - const many = makeHook(5, [ - { call: () => {} }, - { tap: () => {} }, - { call: () => {} } - ]); - bench.add("SyncHook#call (5 taps, 3 interceptors)", () => { - many.call(1); - }); - - // Register interceptor (only runs at tap time). - bench.add("SyncHook#tap with register interceptor (10 taps)", () => { - const hook = new SyncHook(["a"]); - hook.intercept({ register: (tap) => tap }); - for (let i = 0; i < 10; i++) { - hook.tap(`p-${i}`, () => {}); - } - }); - - // Late interceptor add: must reset compilation. - bench.add("SyncHook#intercept re-register (10 taps)", () => { - const hook = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) hook.tap(`p-${i}`, () => {}); - hook.call(1); - hook.intercept({ register: (tap) => tap }); - hook.call(1); - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/registration/compile.bench.js b/benchmarks/registration/compile.bench.js deleted file mode 100644 index d49d6d1..0000000 --- a/benchmarks/registration/compile.bench.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { - SyncHook, - SyncBailHook, - SyncWaterfallHook, - SyncLoopHook, - AsyncSeriesHook, - AsyncSeriesBailHook, - AsyncSeriesWaterfallHook, - AsyncParallelHook, - AsyncParallelBailHook -} = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -// Measures the combined cost of first registration + code compilation for -// each hook type. The first `.call()` / `.callAsync()` triggers code gen. - -async function main() { - const bench = createBench(); - - bench.add("SyncHook: tap 5 + first call (compile)", () => { - const hook = new SyncHook(["a", "b"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); - hook.call(1, 2); - }); - - bench.add("SyncBailHook: tap 5 + first call (compile)", () => { - const hook = new SyncBailHook(["a"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); - hook.call(1); - }); - - bench.add("SyncWaterfallHook: tap 5 + first call (compile)", () => { - const hook = new SyncWaterfallHook(["v"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); - hook.call(0); - }); - - bench.add("SyncLoopHook: tap 5 + first call (compile)", () => { - const hook = new SyncLoopHook(["s"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); - hook.call({}); - }); - - bench.add("AsyncSeriesHook: tap 5 + first callAsync (compile)", () => { - const hook = new AsyncSeriesHook(["a"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); - hook.callAsync(1, () => {}); - }); - - bench.add("AsyncSeriesBailHook: tap 5 + first callAsync (compile)", () => { - const hook = new AsyncSeriesBailHook(["a"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); - hook.callAsync(1, () => {}); - }); - - bench.add( - "AsyncSeriesWaterfallHook: tap 5 + first callAsync (compile)", - () => { - const hook = new AsyncSeriesWaterfallHook(["v"]); - for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); - hook.callAsync(0, () => {}); - } - ); - - bench.add("AsyncParallelHook: tap 5 + first callAsync (compile)", () => { - const hook = new AsyncParallelHook(["a"]); - for (let i = 0; i < 5; i++) hook.tapAsync(`p-${i}`, (_a, cb) => cb()); - hook.callAsync(1, () => {}); - }); - - bench.add("AsyncParallelBailHook: tap 5 + first callAsync (compile)", () => { - const hook = new AsyncParallelBailHook(["a"]); - for (let i = 0; i < 5; i++) - hook.tapAsync(`p-${i}`, (_a, cb) => cb(null, undefined)); - hook.callAsync(1, () => {}); - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/registration/tap.bench.js b/benchmarks/registration/tap.bench.js deleted file mode 100644 index eb27396..0000000 --- a/benchmarks/registration/tap.bench.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncHook, AsyncSeriesHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -async function main() { - const bench = createBench(); - - bench.add("SyncHook#tap (10 taps, string options)", () => { - const hook = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) { - hook.tap(`p-${i}`, () => {}); - } - }); - - bench.add("SyncHook#tap (10 taps, object options)", () => { - const hook = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) { - hook.tap({ name: `p-${i}` }, () => {}); - } - }); - - bench.add("SyncHook#tap (10 taps, stages)", () => { - const hook = new SyncHook(["a"]); - for (let i = 0; i < 10; i++) { - hook.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); - } - }); - - bench.add("SyncHook#tap (10 taps, alternating before)", () => { - const hook = new SyncHook(["a"]); - hook.tap("first", () => {}); - for (let i = 0; i < 9; i++) { - hook.tap({ name: `p-${i}`, before: "first" }, () => {}); - } - }); - - bench.add("AsyncSeriesHook#tapAsync (10 taps, string options)", () => { - const hook = new AsyncSeriesHook(["a"]); - for (let i = 0; i < 10; i++) { - hook.tapAsync(`p-${i}`, (_a, cb) => cb()); - } - }); - - bench.add("AsyncSeriesHook#tapPromise (10 taps, string options)", () => { - const hook = new AsyncSeriesHook(["a"]); - for (let i = 0; i < 10; i++) { - hook.tapPromise(`p-${i}`, () => Promise.resolve()); - } - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/run.js b/benchmarks/run.js deleted file mode 100644 index 529485c..0000000 --- a/benchmarks/run.js +++ /dev/null @@ -1,77 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -/* - * Benchmark runner. - * - * Recursively discovers every `*.bench.js` file below `benchmarks/` - * (optionally scoped to a subdirectory passed as the first CLI arg), - * then runs them sequentially in a single Node.js process so that the - * CodSpeed instrumentation session is shared across suites. - * - * Each bench file must `module.exports = async function main() { ... }` - * and use `runIfMain(module, main)` from `helpers.js` so it can also be - * executed stand-alone with `node benchmarks//.bench.js`. - */ - -const fs = require("fs"); -const path = require("path"); - -function findBenches(dir) { - const results = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - // Skip internal / hidden folders and node_modules. - if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue; - if (entry.name === "node_modules") continue; - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findBenches(fullPath)); - } else if (entry.isFile() && entry.name.endsWith(".bench.js")) { - results.push(fullPath); - } - } - return results; -} - -async function main() { - const benchmarksDir = __dirname; - const subdirArg = process.argv[2]; - const rootDir = subdirArg - ? path.resolve(benchmarksDir, subdirArg) - : benchmarksDir; - - if (!fs.existsSync(rootDir)) { - // eslint-disable-next-line no-console - console.error(`Directory not found: ${rootDir}`); - process.exit(1); - } - - const files = findBenches(rootDir).sort(); - if (files.length === 0) { - // eslint-disable-next-line no-console - console.error(`No *.bench.js files found under ${rootDir}`); - process.exit(1); - } - - for (const file of files) { - const rel = path.relative(benchmarksDir, file); - // eslint-disable-next-line no-console - console.log(`\n=== ${rel} ===\n`); - // eslint-disable-next-line global-require, import/no-dynamic-require - const mod = require(file); - if (typeof mod !== "function") { - throw new TypeError( - `${rel} must export an async function as its default export` - ); - } - await mod(); - } -} - -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/benchmarks/sync/SyncBailHook.bench.js b/benchmarks/sync/SyncBailHook.bench.js deleted file mode 100644 index c2b49df..0000000 --- a/benchmarks/sync/SyncBailHook.bench.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncBailHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, bailAt = -1) { - const hook = new SyncBailHook(["a"]); - for (let i = 0; i < numTaps; i++) { - const idx = i; - hook.tap(`plugin-${idx}`, (v) => (idx === bailAt ? v : undefined)); - } - hook.call(1); - return hook; -} - -async function main() { - const bench = createBench(); - - // No bail - full chain walk. - for (const n of [1, 5, 10, 20]) { - const hook = makeHook(n, -1); - bench.add(`SyncBailHook#call (${n} taps, no bail)`, () => { - hook.call(1); - }); - } - - // Bail at different positions for a 10-tap hook. - for (const pos of [0, 4, 9]) { - const hook = makeHook(10, pos); - bench.add(`SyncBailHook#call (10 taps, bail at ${pos})`, () => { - hook.call(1); - }); - } - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/sync/SyncHook.bench.js b/benchmarks/sync/SyncHook.bench.js deleted file mode 100644 index 46b4163..0000000 --- a/benchmarks/sync/SyncHook.bench.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, args = ["a", "b"]) { - const hook = new SyncHook(args); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, () => {}); - } - // Force compile so steady-state call path is measured. - hook.call(...args.map((_, i) => i)); - return hook; -} - -async function main() { - const bench = createBench(); - - for (const n of [0, 1, 3, 5, 10, 20, 50]) { - const hook = makeHook(n); - bench.add(`SyncHook#call (${n} taps, 2 args)`, () => { - hook.call(1, 2); - }); - } - - // Argument count variations (keeps 5 taps constant). - for (const argCount of [0, 1, 3, 5]) { - const args = Array.from({ length: argCount }, (_, i) => `a${i}`); - const hook = makeHook(5, args); - const callArgs = args.map((_, i) => i); - bench.add(`SyncHook#call (5 taps, ${argCount} args)`, () => { - hook.call(...callArgs); - }); - } - - // Single tap that touches its args (prevents the engine from assuming - // arguments are unused). - const touchingHook = new SyncHook(["a", "b"]); - let sink = 0; - touchingHook.tap("touching", (a, b) => { - sink = a + b; - }); - touchingHook.call(1, 2); - bench.add("SyncHook#call (1 tap, reads args)", () => { - touchingHook.call(sink, 2); - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/sync/SyncLoopHook.bench.js b/benchmarks/sync/SyncLoopHook.bench.js deleted file mode 100644 index a208a99..0000000 --- a/benchmarks/sync/SyncLoopHook.bench.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncLoopHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, iterations) { - const hook = new SyncLoopHook(["state"]); - for (let i = 0; i < numTaps; i++) { - const idx = i; - hook.tap(`plugin-${idx}`, (state) => { - if (state.counts[idx] < iterations) { - state.counts[idx]++; - return true; - } - return undefined; - }); - } - return hook; -} - -function resetState(state) { - for (let i = 0; i < state.counts.length; i++) { - state.counts[i] = 0; - } -} - -async function main() { - const bench = createBench(); - - // Single-iteration loop (each tap returns undefined). - for (const n of [3, 10]) { - const hook = makeHook(n, 0); - const state = { counts: Array.from({ length: n }, () => 0) }; - bench.add( - `SyncLoopHook#call (${n} taps, 0 reloops)`, - () => { - hook.call(state); - }, - { beforeEach: () => resetState(state) } - ); - } - - // Multi-iteration loop - taps trigger reloops. - for (const iterations of [1, 3]) { - const n = 3; - const hook = makeHook(n, iterations); - const state = { counts: Array.from({ length: n }, () => 0) }; - bench.add( - `SyncLoopHook#call (${n} taps, ${iterations} reloops each)`, - () => { - hook.call(state); - }, - { beforeEach: () => resetState(state) } - ); - } - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/benchmarks/sync/SyncWaterfallHook.bench.js b/benchmarks/sync/SyncWaterfallHook.bench.js deleted file mode 100644 index cdf0797..0000000 --- a/benchmarks/sync/SyncWaterfallHook.bench.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php -*/ -"use strict"; - -const { SyncWaterfallHook } = require("../../lib"); -const { createBench, runBench, runIfMain } = require("../helpers"); - -function makeHook(numTaps, returning = true) { - const hook = new SyncWaterfallHook(["value"]); - for (let i = 0; i < numTaps; i++) { - hook.tap(`plugin-${i}`, returning ? (v) => v + 1 : () => undefined); - } - hook.call(0); - return hook; -} - -async function main() { - const bench = createBench(); - - for (const n of [1, 5, 10, 20, 50]) { - const hook = makeHook(n, true); - bench.add(`SyncWaterfallHook#call (${n} taps, all return)`, () => { - hook.call(0); - }); - } - - // Taps returning undefined - initial value is passed through. - for (const n of [5, 20]) { - const hook = makeHook(n, false); - bench.add(`SyncWaterfallHook#call (${n} taps, all undefined)`, () => { - hook.call(0); - }); - } - - // Mixed: half return, half don't. - const mixed = new SyncWaterfallHook(["value"]); - for (let i = 0; i < 10; i++) { - mixed.tap(`plugin-${i}`, i % 2 === 0 ? (v) => v + 1 : () => undefined); - } - mixed.call(0); - bench.add("SyncWaterfallHook#call (10 taps, mixed return)", () => { - mixed.call(0); - }); - - await runBench(bench); -} - -module.exports = main; -runIfMain(module, main); diff --git a/eslint.config.mjs b/eslint.config.mjs index 11bfa24..2eaf839 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import config from "eslint-config-webpack"; export default defineConfig([ { - ignores: [".changeset/", "benchmarks/"] + ignores: [".changeset/", "benchmark/"] }, { extends: [config], diff --git a/package-lock.json b/package-lock.json index ae40193..f84187d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@babel/preset-env": "^7.4.4", "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", - "@codspeed/tinybench-plugin": "^5.2.0", + "@codspeed/core": "^5.2.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", @@ -2253,20 +2253,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@codspeed/tinybench-plugin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@codspeed/tinybench-plugin/-/tinybench-plugin-5.2.0.tgz", - "integrity": "sha512-LCmMFON3hdIRqiHC3W8oR0783cecRgA8x7cWMTnC9DgkIuyMrreHgQexnUGV3zsHgB084EXj/iPrWxR914/8Ng==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@codspeed/core": "^5.2.0", - "stack-trace": "1.0.0-pre2" - }, - "peerDependencies": { - "tinybench": ">=4.0.1" - } - }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -11472,16 +11458,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/stack-trace": { - "version": "1.0.0-pre2", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", - "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 7bf3867..4f79870 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,7 @@ "fix": "npm run fix:code && npm run fmt", "fix:code": "npm run lint:code -- --fix", "test": "jest", - "bench": "node benchmarks/run.js", - "bench:sync": "node benchmarks/run.js sync", - "bench:async": "node benchmarks/run.js async", - "bench:hookmap": "node benchmarks/run.js hookmap", - "bench:interceptors": "node benchmarks/run.js interceptors", - "bench:registration": "node benchmarks/run.js registration", + "benchmark": "node --max-old-space-size=4096 --hash-seed=1 --random-seed=1 --no-opt --predictable --predictable-gc-schedule --interpreted-frames-native-stack --allow-natives-syntax --expose-gc --no-concurrent-sweeping ./benchmark/run.mjs", "version": "changeset version", "release": "changeset publish" }, @@ -55,7 +50,7 @@ "@babel/preset-env": "^7.4.4", "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", - "@codspeed/tinybench-plugin": "^5.2.0", + "@codspeed/core": "^5.2.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", From ea3bf7c84e0ebec67a724f6a461edab388ab56c3 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 20 Apr 2026 17:25:09 +0300 Subject: [PATCH 6/6] refactor: code --- benchmark/cases/hook-compile/index.bench.mjs | 3 +- .../cases/interceptors-async/index.bench.mjs | 3 +- .../cases/interceptors-sync/index.bench.mjs | 6 +- benchmark/with-codspeed.mjs | 2 + eslint.config.mjs | 20 ++++- lib/Hook.js | 87 +++++++------------ lib/HookCodeFactory.js | 83 +++++------------- lib/HookMap.js | 8 +- lib/MultiHook.js | 25 +++--- package-lock.json | 80 ++++++++--------- package.json | 6 +- 11 files changed, 135 insertions(+), 188 deletions(-) diff --git a/benchmark/cases/hook-compile/index.bench.mjs b/benchmark/cases/hook-compile/index.bench.mjs index c29011e..0d58d3e 100644 --- a/benchmark/cases/hook-compile/index.bench.mjs +++ b/benchmark/cases/hook-compile/index.bench.mjs @@ -82,8 +82,9 @@ export default function register(bench) { "hook-compile: AsyncParallelBailHook, 5 taps + first callAsync", () => { const hook = new AsyncParallelBailHook(["a"]); - for (let i = 0; i < 5; i++) + for (let i = 0; i < 5; i++) { hook.tapAsync(`p-${i}`, (_a, cb) => cb(null, undefined)); + } hook.callAsync(1, () => {}); } ); diff --git a/benchmark/cases/interceptors-async/index.bench.mjs b/benchmark/cases/interceptors-async/index.bench.mjs index 4a94a2d..485f17d 100644 --- a/benchmark/cases/interceptors-async/index.bench.mjs +++ b/benchmark/cases/interceptors-async/index.bench.mjs @@ -54,8 +54,9 @@ export default function register(bench) { // --- AsyncParallelHook --- const parallelBaseline = new AsyncParallelHook(["a"]); - for (let i = 0; i < 5; i++) + for (let i = 0; i < 5; i++) { parallelBaseline.tapAsync(`p-${i}`, (_a, cb) => cb()); + } parallelBaseline.callAsync(1, () => {}); bench.add("interceptors-async: parallel, 5 async taps, no interceptors", () => runBatch(parallelBaseline, INNER_ITERATIONS) diff --git a/benchmark/cases/interceptors-sync/index.bench.mjs b/benchmark/cases/interceptors-sync/index.bench.mjs index 4544425..576405d 100644 --- a/benchmark/cases/interceptors-sync/index.bench.mjs +++ b/benchmark/cases/interceptors-sync/index.bench.mjs @@ -58,9 +58,9 @@ export default function register(bench) { bench.add( "interceptors-sync: register interceptor + 10 tap registrations", () => { - const h = new SyncHook(["a"]); - h.intercept({ register: (t) => t }); - for (let i = 0; i < 10; i++) h.tap(`p-${i}`, () => {}); + const hook = new SyncHook(["a"]); + hook.intercept({ register: (t) => t }); + for (let i = 0; i < 10; i++) hook.tap(`p-${i}`, () => {}); } ); } diff --git a/benchmark/with-codspeed.mjs b/benchmark/with-codspeed.mjs index fb87a0f..4924e18 100644 --- a/benchmark/with-codspeed.mjs +++ b/benchmark/with-codspeed.mjs @@ -118,10 +118,12 @@ export function withCodSpeed(bench) { */ const wrapFrame = (fn, isAsync) => { if (isAsync) { + // eslint-disable-next-line camelcase return async function __codspeed_root_frame__() { await fn(); }; } + // eslint-disable-next-line camelcase return function __codspeed_root_frame__() { fn(); }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 2eaf839..9075656 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import config from "eslint-config-webpack"; export default defineConfig([ { - ignores: [".changeset/", "benchmark/"] + ignores: [".changeset/"] }, { extends: [config], @@ -12,11 +12,27 @@ export default defineConfig([ } }, { + files: ["lib/__tests__/**/*.js"], languageOptions: { parserOptions: { ecmaVersion: 2018 } + } + }, + { + files: ["benchmark/**/*.mjs"], + languageOptions: { + parserOptions: { + ecmaVersion: 2022 + } }, - files: ["lib/__tests__/**/*.js"] + rules: { + "no-console": "off", + "import/namespace": "off", + "n/hashbang": "off", + "n/no-unsupported-features/es-syntax": "off", + "n/no-unsupported-features/node-builtins": "off", + "n/no-process-exit": "off" + } } ]); diff --git a/lib/Hook.js b/lib/Hook.js index dd826fb..6da14b2 100644 --- a/lib/Hook.js +++ b/lib/Hook.js @@ -65,31 +65,22 @@ class Hook { _tap(type, options, fn) { if (typeof options === "string") { - // Fast path: a string options ("name") is by far the most common - // case. Build the final descriptor in a single allocation instead - // of creating `{ name }` and then `Object.assign`ing it. - const name = options.trim(); - if (name === "") { - throw new Error("Missing name for tap"); - } - options = { type, fn, name }; - } else { - if (typeof options !== "object" || options === null) { - throw new Error("Invalid tap options"); - } - if (typeof options.name === "string") { - options.name = options.name.trim(); - } - if (typeof options.name !== "string" || options.name === "") { - throw new Error("Missing name for tap"); - } - if (typeof options.context !== "undefined") { - deprecateContext(); - } - // Preserve previous precedence: user-provided keys win over the - // internal `type`/`fn`. - options = Object.assign({ type, fn }, options); + options = { + name: options + }; + } else if (typeof options !== "object" || options === null) { + throw new Error("Invalid tap options"); + } + if (typeof options.name === "string") { + options.name = options.name.trim(); } + if (typeof options.name !== "string" || options.name === "") { + throw new Error("Missing name for tap"); + } + if (typeof options.context !== "undefined") { + deprecateContext(); + } + options = Object.assign({ type, fn }, options); options = this._runRegisterInterceptors(options); this._insert(options); } @@ -107,12 +98,7 @@ class Hook { } _runRegisterInterceptors(options) { - const { interceptors } = this; - const { length } = interceptors; - // Common case: no interceptors. - if (length === 0) return options; - for (let i = 0; i < length; i++) { - const interceptor = interceptors[i]; + for (const interceptor of this.interceptors) { if (interceptor.register) { const newOptions = interceptor.register(options); if (newOptions !== undefined) { @@ -160,36 +146,21 @@ class Hook { _insert(item) { this._resetCompilation(); - const { taps } = this; - const itemBefore = item.before; - const hasBefore = - typeof itemBefore === "string" || Array.isArray(itemBefore); - const itemStage = typeof item.stage === "number" ? item.stage : 0; - - // Fast path: the overwhelmingly common `hook.tap("name", fn)` case - // has no `before` and default stage 0. If the list is empty or the - // last tap's stage is <= the new item's stage the item belongs at - // the end - append in O(1), skipping the Set allocation and the - // shift loop. - if (!hasBefore) { - const n = taps.length; - if (n === 0 || (taps[n - 1].stage || 0) <= itemStage) { - taps[n] = item; - return; - } - } - let before; - if (typeof itemBefore === "string") { - before = new Set([itemBefore]); - } else if (Array.isArray(itemBefore)) { - before = new Set(itemBefore); + if (typeof item.before === "string") { + before = new Set([item.before]); + } else if (Array.isArray(item.before)) { + before = new Set(item.before); + } + let stage = 0; + if (typeof item.stage === "number") { + stage = item.stage; } - let i = taps.length; + let i = this.taps.length; while (i > 0) { i--; - const tap = taps[i]; - taps[i + 1] = tap; + const tap = this.taps[i]; + this.taps[i + 1] = tap; const xStage = tap.stage || 0; if (before) { if (before.has(tap.name)) { @@ -200,13 +171,13 @@ class Hook { continue; } } - if (xStage > itemStage) { + if (xStage > stage) { continue; } i++; break; } - taps[i] = item; + this.taps[i] = item; } } diff --git a/lib/HookCodeFactory.js b/lib/HookCodeFactory.js index d3d5fa6..67e4663 100644 --- a/lib/HookCodeFactory.js +++ b/lib/HookCodeFactory.js @@ -77,13 +77,7 @@ class HookCodeFactory { } setup(instance, options) { - const { taps } = options; - const { length } = taps; - const fns = Array.from({ length }); - for (let i = 0; i < length; i++) { - fns[i] = taps[i].fn; - } - instance._x = fns; + instance._x = options.taps.map((t) => t.fn); } /** @@ -91,16 +85,12 @@ class HookCodeFactory { */ init(options) { this.options = options; - // slice() avoids the iterator protocol overhead of [...arr]. - // eslint-disable-next-line unicorn/prefer-spread - this._args = options.args.slice(); - this._joinedArgs = undefined; + this._args = [...options.args]; } deinit() { this.options = undefined; this._args = undefined; - this._joinedArgs = undefined; } contentWithInterceptors(options) { @@ -175,10 +165,7 @@ class HookCodeFactory { } needContext() { - const { taps } = this.options; - for (let i = 0; i < taps.length; i++) { - if (taps[i].context) return true; - } + for (const tap of this.options.taps) if (tap.context) return true; return false; } @@ -287,30 +274,17 @@ class HookCodeFactory { doneReturns, rethrowIfPossible }) { - const { taps } = this.options; - const tapsLength = taps.length; - if (tapsLength === 0) return onDone(); - // Inlined findIndex to avoid the callback allocation. - let firstAsync = -1; - for (let i = 0; i < tapsLength; i++) { - if (taps[i].type !== "sync") { - firstAsync = i; - break; - } - } + if (this.options.taps.length === 0) return onDone(); + const firstAsync = this.options.taps.findIndex((t) => t.type !== "sync"); const somethingReturns = resultReturns || doneReturns; - // doneBreak doesn't depend on the loop variable - hoist to allocate once. - const doneBreak = (skipDone) => { - if (skipDone) return ""; - return onDone(); - }; let code = ""; let current = onDone; let unrollCounter = 0; - for (let j = tapsLength - 1; j >= 0; j--) { + for (let j = this.options.taps.length - 1; j >= 0; j--) { const i = j; const unroll = - current !== onDone && (taps[i].type !== "sync" || unrollCounter++ > 20); + current !== onDone && + (this.options.taps[i].type !== "sync" || unrollCounter++ > 20); if (unroll) { unrollCounter = 0; code += `function _next${i}() {\n`; @@ -319,6 +293,10 @@ class HookCodeFactory { current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`; } const done = current; + const doneBreak = (skipDone) => { + if (skipDone) return ""; + return onDone(); + }; const content = this.callTap(i, { onError: (error) => onError(i, error, done, doneBreak), onResult: @@ -392,9 +370,7 @@ class HookCodeFactory { rethrowIfPossible, onTap = (i, run) => run() }) { - const { taps } = this.options; - const tapsLength = taps.length; - if (tapsLength <= 1) { + if (this.options.taps.length <= 1) { return this.callTapsSeries({ onError, onResult, @@ -402,25 +378,23 @@ class HookCodeFactory { rethrowIfPossible }); } - // done and doneBreak don't depend on the loop variable - hoist them - // so they're allocated once per compile instead of once per tap. - const done = () => { - if (onDone) return "if(--_counter === 0) _done();\n"; - return "--_counter;"; - }; - const doneBreak = (skipDone) => { - if (skipDone || !onDone) return "_counter = 0;\n"; - return "_counter = 0;\n_done();\n"; - }; let code = ""; code += "do {\n"; - code += `var _counter = ${tapsLength};\n`; + code += `var _counter = ${this.options.taps.length};\n`; if (onDone) { code += "var _done = (function() {\n"; code += onDone(); code += "});\n"; } - for (let i = 0; i < tapsLength; i++) { + for (let i = 0; i < this.options.taps.length; i++) { + const done = () => { + if (onDone) return "if(--_counter === 0) _done();\n"; + return "--_counter;"; + }; + const doneBreak = (skipDone) => { + if (skipDone || !onDone) return "_counter = 0;\n"; + return "_counter = 0;\n_done();\n"; + }; code += "if(_counter <= 0) break;\n"; code += onTap( i, @@ -454,22 +428,13 @@ class HookCodeFactory { } args({ before, after } = {}) { - // Hot during code generation (called once per tap + per interceptor). - // Cache the common no-before/no-after result so we only join once. - if (before === undefined && after === undefined) { - let joined = this._joinedArgs; - if (joined === undefined) { - joined = this._args.length === 0 ? "" : this._args.join(", "); - this._joinedArgs = joined; - } - return joined; - } let allArgs = this._args; if (before) allArgs = [before, ...allArgs]; if (after) allArgs = [...allArgs, after]; if (allArgs.length === 0) { return ""; } + return allArgs.join(", "); } diff --git a/lib/HookMap.js b/lib/HookMap.js index ca91cd6..8fdc5d6 100644 --- a/lib/HookMap.js +++ b/lib/HookMap.js @@ -21,11 +21,7 @@ class HookMap { } for(key) { - // Hot path: inline the map lookup to skip the `this.get(key)` - // indirection. This gets hit on every hook access in consumers - // like webpack. - const map = this._map; - const hook = map.get(key); + const hook = this.get(key); if (hook !== undefined) { return hook; } @@ -34,7 +30,7 @@ class HookMap { for (let i = 0; i < interceptors.length; i++) { newHook = interceptors[i].factory(key, newHook); } - map.set(key, newHook); + this._map.set(key, newHook); return newHook; } diff --git a/lib/MultiHook.js b/lib/MultiHook.js index 900abbd..8041264 100644 --- a/lib/MultiHook.js +++ b/lib/MultiHook.js @@ -11,38 +11,33 @@ class MultiHook { } tap(options, fn) { - const { hooks } = this; - for (let i = 0; i < hooks.length; i++) { - hooks[i].tap(options, fn); + for (const hook of this.hooks) { + hook.tap(options, fn); } } tapAsync(options, fn) { - const { hooks } = this; - for (let i = 0; i < hooks.length; i++) { - hooks[i].tapAsync(options, fn); + for (const hook of this.hooks) { + hook.tapAsync(options, fn); } } tapPromise(options, fn) { - const { hooks } = this; - for (let i = 0; i < hooks.length; i++) { - hooks[i].tapPromise(options, fn); + for (const hook of this.hooks) { + hook.tapPromise(options, fn); } } isUsed() { - const { hooks } = this; - for (let i = 0; i < hooks.length; i++) { - if (hooks[i].isUsed()) return true; + for (const hook of this.hooks) { + if (hook.isUsed()) return true; } return false; } intercept(interceptor) { - const { hooks } = this; - for (let i = 0; i < hooks.length; i++) { - hooks[i].intercept(interceptor); + for (const hook of this.hooks) { + hook.intercept(interceptor); } } diff --git a/package-lock.json b/package-lock.json index f84187d..6c8f4bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,15 +11,15 @@ "devDependencies": { "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", - "@changesets/cli": "^2.30.0", + "@changesets/cli": "^2.31.0", "@changesets/get-github-info": "^0.8.0", - "@codspeed/core": "^5.2.0", + "@codspeed/core": "^5.3.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", - "prettier": "^3.5.3", + "prettier": "^3.8.3", "prettier-1": "npm:prettier@^1", "tinybench": "^6.0.0" }, @@ -1786,13 +1786,13 @@ "license": "MIT" }, "node_modules/@changesets/apply-release-plan": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.0.tgz", - "integrity": "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/config": "^3.1.3", + "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", @@ -1847,14 +1847,14 @@ } }, "node_modules/@changesets/assemble-release-plan": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz", - "integrity": "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==", + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", "dev": true, "license": "MIT", "dependencies": { "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", + "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", @@ -1885,19 +1885,19 @@ } }, "node_modules/@changesets/cli": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.30.0.tgz", - "integrity": "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/apply-release-plan": "^7.1.0", - "@changesets/assemble-release-plan": "^6.0.9", + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", - "@changesets/config": "^3.1.3", + "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/get-release-plan": "^4.0.15", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", @@ -1936,14 +1936,14 @@ } }, "node_modules/@changesets/config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.3.tgz", - "integrity": "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", "dev": true, "license": "MIT", "dependencies": { "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", + "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", @@ -1963,9 +1963,9 @@ } }, "node_modules/@changesets/get-dependents-graph": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.3.tgz", - "integrity": "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", "dev": true, "license": "MIT", "dependencies": { @@ -2000,14 +2000,14 @@ } }, "node_modules/@changesets/get-release-plan": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.15.tgz", - "integrity": "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/config": "^3.1.3", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", @@ -2153,9 +2153,9 @@ } }, "node_modules/@codspeed/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@codspeed/core/-/core-5.2.0.tgz", - "integrity": "sha512-CmDhpWjcOJg2iBOQ/BmBnSBq8qxlM3r4h8uvYDkoUaba+EKRT3T73BZtKuml/48jZMsB+4/FG2UbTBinDWtuvw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@codspeed/core/-/core-5.3.0.tgz", + "integrity": "sha512-++/kkPHPFI+dzX6FD+w+jFgKk8rgm5O1Tpg5nKUuFLENaB9ma///CZv76HdYZTJHu46dEeWwxGH+z0G3lLh59Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9989,9 +9989,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10678,9 +10678,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 4f79870..a1aa075 100644 --- a/package.json +++ b/package.json @@ -48,15 +48,15 @@ "devDependencies": { "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", - "@changesets/cli": "^2.30.0", + "@changesets/cli": "^2.31.0", "@changesets/get-github-info": "^0.8.0", - "@codspeed/core": "^5.2.0", + "@codspeed/core": "^5.3.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", - "prettier": "^3.5.3", + "prettier": "^3.8.3", "prettier-1": "npm:prettier@^1", "tinybench": "^6.0.0" },