perf: skip QueriedHook in Sync*Hook call() when there are no interceptors - #64
Merged
Merged
Conversation
ahabhgk
approved these changes
Sep 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
SyncHook,SyncBailHookandSyncWaterfallHook.call()went throughcallStageRange(this.queryStageRange(allStageRange), ...args)on every call. Each call allocated a newQueriedHook(which re-filters all taps into a newtapsInRangearray), rest-argument arrays, anargs.slicecopy and a result callback, and ran the interceptor loops even when there were none.rspack's
StatsFactorycalls 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 callsstats.toJson({ chunkModules, reasons, ... }). One suchtoJsonmakes:Sync*Hook.call()calls, over only 33 distinct hooksBefore 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_propertyfromQueriedHook's class fields (1.81 s),callAsyncStageRange(1.54 s),call(1.35 s) andcallStageRange(1.10 s).What
When a hook has no interceptors,
call()runs the taps directly from a cached list,_tapsInAllStages(): the same tapsqueryStageRange(allStageRange)selects. The cache is recomputed whenhook.tapsis replaced or its length changes, which coverstap()and code that reassigns or splicestaps(such as rspack's child compiler copyingtaps). In the app above: 11,106,380 lookups, 33 recomputes.Behavior stays the same:
callStageRangeonly rethrows truthy errors.test/CallFastPath.test.jscheckscall()againstcallStageRangeover all stages for each hook type (no taps, return values, stages, a thrown error, a thrownundefined), plus the cache invalidation cases.Not handled: changing
tapsin place without changing its identity or length, e.g. reordering it, or clearinginterceptorsafter aregisterinterceptor 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):SyncHookSyncBailHookSyncWaterfallHookThe app:
toJsoncalled repeatedly on the same compilation in one process (rspack 2.2.6-canary-171bd48c, Node 22.16, Linux x64). ThetoJsonoutput had the same sha1 in every variant.toJsontimeWhole-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