Skip to content

perf: skip QueriedHook in Sync*Hook call() when there are no interceptors - #64

Merged
ahabhgk merged 1 commit into
rstackjs:mainfrom
stormslowly:perf/sync-hook-call-fast-path
Sep 20, 2026
Merged

ahabhgk merged 1 commit into
rstackjs:mainfrom
stormslowly:perf/sync-hook-call-fast-path

Conversation

@stormslowly

@stormslowly stormslowly commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Why

SyncHook, SyncBailHook and SyncWaterfallHook .call() went through callStageRange(this.queryStageRange(allStageRange), ...args) on every call. Each call allocated a new QueriedHook (which re-filters all taps into a new tapsInRange array), rest-argument arrays, an args.slice copy and a result callback, and ran the interceptor loops even when there were none.

rspack's StatsFactory calls these hooks for every stats item, so this adds up on large stats output. In an internal large app (C) (1,862 chunks, 64,655 chunk-module entries, ~3.49M module reasons), a bundle-analysis plugin calls stats.toJson({ chunkModules, reasons, ... }). One such toJson makes:

  • 11,106,380 Sync*Hook.call() calls, over only 33 distinct hooks
  • 0 of them on a hook with interceptors

Before this change, GC took 40% of the samples in a CPU profile of one 64 s toJson. The top lite-tapable self-time frames were _define_property from QueriedHook's class fields (1.81 s), callAsyncStageRange (1.54 s), call (1.35 s) and callStageRange (1.10 s).

What

When a hook has no interceptors, call() runs the taps directly from a cached list, _tapsInAllStages(): the same taps queryStageRange(allStageRange) selects. The cache is recomputed when hook.taps is replaced or its length changes, which covers tap() and code that reassigns or splices taps (such as rspack's child compiler copying taps). In the app above: 11,106,380 lookups, 33 recomputes.

Behavior stays the same:

  • Taps are still a snapshot per call: a tap registered during a call runs from the next call on.
  • Bail and waterfall results are unchanged.
  • Falsy thrown values are still swallowed, as callStageRange only rethrows truthy errors.
  • Hooks with interceptors keep the old path.

test/CallFastPath.test.js checks call() against callStageRange over all stages for each hook type (no taps, return values, stages, a thrown error, a thrown undefined), plus the cache invalidation cases.

Not handled: changing taps in place without changing its identity or length, e.g. reordering it, or clearing interceptors after a register interceptor rewrote the taps. I found no such code in rspack or webpack.

Results

Micro-benchmark: 10M call()s on a hook with 2 taps, median of 3 runs (Node 22.16, Apple M4 Pro):

Hook Before After
SyncHook 146 ns/call 16.5 ns/call
SyncBailHook 147 ns/call 16.8 ns/call
SyncWaterfallHook 153 ns/call 19.7 ns/call

The app: toJson called repeatedly on the same compilation in one process (rspack 2.2.6-canary-171bd48c, Node 22.16, Linux x64). The toJson output had the same sha1 in every variant.

Variant toJson time
Baseline 78.2 s (83.2 s / 73.2 s)
This PR 66.0 s (−12.2 s, −15.6%)
This PR + reusing StatsFactory item-type strings (web-infra-dev/rspack#15779) 50.8 s (−35%)

Whole-build wall time could not be measured reliably. In this app, stats generation switches between two modes (~55 s or ~115 s) independently of the code under test, and one switch is ~10% of build time. A/B runs of this change alone came out −7% on one rspack version and +3% on another, both decided by which mode each run hit. Comparing only runs in the same (slow) mode on rspack 2.2.6: 113.0 s → 104.1 s (−8.9 s), consistent with the in-process number.

Micro-benchmark script
// node bench.cjs <path to dist/index.cjs> <SyncHook|SyncBailHook|SyncWaterfallHook>
const { SyncHook, SyncBailHook, SyncWaterfallHook } = require(process.argv[2]);
const N = 10_000_000;
const cases = {
  SyncHook: () => { const h = new SyncHook(['a', 'b']); h.tap('A', () => {}); h.tap('B', () => {}); return h; },
  SyncBailHook: () => { const h = new SyncBailHook(['a', 'b']); h.tap('A', () => undefined); h.tap('B', () => undefined); return h; },
  SyncWaterfallHook: () => { const h = new SyncWaterfallHook(['a', 'b']); h.tap('A', a => a); h.tap('B', a => a); return h; },
};
const name = process.argv[3];
const hook = cases[name]();
const obj = {};
for (let i = 0; i < 100_000; i++) hook.call(obj, i);
const t = process.hrtime.bigint();
for (let i = 0; i < N; i++) hook.call(obj, i);
const ms = Number(process.hrtime.bigint() - t) / 1e6;
console.log(JSON.stringify({ name, ms: Math.round(ms), nsPerCall: +(ms * 1e6 / N).toFixed(1) }));

@chenjiahan chenjiahan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, @ahabhgk cc~

@ahabhgk
ahabhgk merged commit 772fee3 into rstackjs:main Sep 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants