Visitar URL original
test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise by alexander-akait · Pull Request #525 · webpack/enhanced-resolve · GitHub
Skip to content

test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise - #525

Merged
alexander-akait merged 19 commits into
mainfrom
bench/node-compare
Apr 17, 2026
Merged

alexander-akait merged 19 commits into
mainfrom
bench/node-compare

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Apr 15, 2026 •

Copy link
Copy Markdown
Member

Summary

  • New benchmark case node-compare: head-to-head comparison of enhanced-resolve (async callback / promise / sync) against Node.js's built-in resolvers (require.resolve, import.meta.resolve) over 10000 unique resolves per iteration. Three caching levels per API flavour — (no cache), (fs cache), (fs + unsafeCache) — so the contribution of each layer is visible. All 11 tasks share the same fixture and request list for an apples-to-apples comparison.
  • perf: Promise.withResolvers() in resolvePromise: module-level conditional selects the implementation once at load time — Promise.withResolvers (Node 22+) avoids the executor closure allocation per call; falls back to new Promise(executor) on older runtimes. The class method delegates via a one-line call. Closes the ~6% gap the promise API had vs the hand-rolled callback wrapper.

Promise.withResolvers change (lib/Resolver.js)

// Module level — evaluated once at load time
const _withResolvers = Promise.withResolvers
    ? (self, context, path, request, resolveContext) => {
          const { promise, resolve, reject } = Promise.withResolvers();
          self.resolve(context, path, request, resolveContext, ...);
          return promise;
      }
    : (self, context, path, request, resolveContext) =>
          new Promise((resolve, reject) => {
              self.resolve(context, path, request, resolveContext, ...);
          });

// Class method — one-line delegation
resolvePromise(context, path, request, resolveContext) {
    return _withResolvers(this, context, path, request, resolveContext || {});
}

Runtime feature detection — no engine bump, package.json keeps "node": ">=10.13.0". ESLint n/no-unsupported-features/es-syntax suppressed with targeted disable comments since the code is guarded.

Wall-clock regression check (3 runs each, PR vs main)

bench PR avg (ms) main avg (ms) delta
query-fragment 0.747 0.723 +3.3% (within OS noise, overlapping ranges)
deep-hierarchy (bare) 1.102 1.122 -1.8% (PR faster)

No real wall-clock regression in existing benchmarks.

Benchmark design

Fixture (generated at registration time, gitignored via sentinel):

  • megapkg with wildcard subpath exports (megapkg/fileNNNN → ./lib/fileNNNN.js) — 5000 entries
  • src/file0000.js..file4999.js — 5000 relative targets
  • src/resolver.mjs — ESM helper exporting a bound import.meta.resolve

Request list: 10000 unique specifiers (5000 bare + 5000 relative with explicit .js). Unique-per-entry defeats Node's Module._pathCache and ESM loader cache within a batch.

11 tasks: 3 APIs × 3 cache levels + require.resolve + import.meta.resolve

Results (local, --no-opt --predictable V8 flags)

task ops/s mean / 10k
async (no cache) 0.04 27.6 s
async (fs cache) 1.19 1.3 s
async (fs + unsafeCache) 7.25 138 ms
promise (no cache) 0.04 27.4 s
promise (fs cache) 1.19 1.2 s
promise (fs + unsafeCache) 7.01 143 ms
sync (no cache) 0.06 16.5 s
sync (fs cache) 1.98 506 ms
sync (fs + unsafeCache) 10.31 98 ms
node require.resolve 0.24 4.1 s
node import.meta.resolve 124.19 8 ms

Files changed

  • benchmark/cases/node-compare/index.bench.mjs — 11-task benchmark (new)
  • benchmark/cases/node-compare/fixture/.gitignore — keeps generated fixture out of git (new)
  • .cspell.json — added megapkg to word list
  • lib/Resolver.js — module-level _withResolvers + one-line resolvePromise delegation

Test plan

  • npx jest --no-coverage — 963/963 tests pass across 38 suites
  • BENCH_FILTER=node-compare npm run benchmark — all 11 tasks register and complete
  • BENCH_FILTER=query-fragment — 3 runs, no wall-clock regression vs main
  • BENCH_FILTER=deep-hierarchy — 3 runs, no wall-clock regression vs main
  • Fixture gitignored (verified with git check-ignore)
  • Lint + cspell pass via pre-commit hooks

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1

@linux-foundation-easycla

linux-foundation-easycla Bot commented Apr 15, 2026 •

Copy link
Copy Markdown

CLA Not Signed

@changeset-bot

changeset-bot Bot commented Apr 15, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: fcec988

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@codecov

codecov Bot commented Apr 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.51%. Comparing base (5505bb2) to head (fcec988).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #525      +/-   ##
==========================================
+ Coverage   96.49%   96.51%   +0.01%     
==========================================
  Files          50       50              
  Lines        2626     2639      +13     
  Branches      796      805       +9     
==========================================
+ Hits         2534     2547      +13     
  Misses         77       77              
  Partials       15       15              
Flag Coverage Δ
integration 96.51% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed

codspeed Bot commented Apr 15, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 27.96%

⚡ 9 improved benchmarks
❌ 3 regressed benchmarks
✅ 32 untouched benchmarks
🆕 11 new benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ deep-hierarchy: bare specifier from 10-deep dir (warm) 2.3 ms 2.9 ms -20.69%
❌ alias-realistic: @/path + @components aliases (warm) 3.6 ms 5 ms -27.96%
⚡ cache-predicate: mixed cached/uncached requests (warm) 4.1 ms 2.2 ms +87.1%
⚡ exports-field: conditions=import,node (warm) 4 ms 2.8 ms +41.97%
⚡ deep-package-subpath: pkg/a/b/c requests (warm) 2.6 ms 2.2 ms +17.45%
⚡ fallback: node-builtin polyfill aliases (warm) 1.6 ms 1.4 ms +16.75%
❌ symlinks: follow symlinks=true (warm) 1.5 ms 1.8 ms -11.72%
⚡ failed-resolution: missing files + packages 2 ms 1.8 ms +11.23%
⚡ symlinks: symlinks=false (warm) 666.3 µs 424.3 µs +57.04%
🆕 node-compare: enhanced-resolve sync x 1000 (no cache) N/A 662.4 ms N/A
⚡ mixed-conditions: 4 condition sets against nested exports 1.6 ms 1.1 ms +39.61%
🆕 node-compare: enhanced-resolve sync x 1000 (fs + unsafeCache) N/A 18.9 ms N/A
🆕 node-compare: enhanced-resolve promise x 1000 (fs cache) N/A 161.1 ms N/A
🆕 node-compare: enhanced-resolve async x 1000 (fs + unsafeCache) N/A 28.9 ms N/A
🆕 node-compare: enhanced-resolve sync x 1000 (fs cache) N/A 88.5 ms N/A
🆕 node-compare: node require.resolve x 1000 N/A 65.1 ms N/A
⚡ multiple-modules: shared + vendor + node_modules (warm) 3.2 ms 1.7 ms +89.66%
🆕 node-compare: enhanced-resolve promise x 1000 (no cache) N/A 381.7 ms N/A
🆕 node-compare: enhanced-resolve promise x 1000 (fs + unsafeCache) N/A 27.9 ms N/A
🆕 node-compare: enhanced-resolve async x 1000 (no cache) N/A 383 ms N/A
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.


Comparing bench/node-compare (fcec988) with main (7e502fc)

Open in CodSpeed

@alexander-akait alexander-akait changed the title test: add node-compare benchmark case vs node's resolvers test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise Apr 16, 2026
@alexander-akait alexander-akait changed the title test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise test: add node-compare benchmark case vs Node.js resolvers Apr 16, 2026
@alexander-akait alexander-akait changed the title test: add node-compare benchmark case vs Node.js resolvers test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise Apr 16, 2026
claude added 10 commits April 17, 2026 15:02
Compares enhanced-resolve (async, async+unsafeCache, sync+unsafeCache)
against Node.js's built-in require.resolve over 10000 resolves per
iteration, so it's easy to see how the resolver's overhead relates to
the built-in CJS resolver. Fixture is generated at register time and
gitignored.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Adds two new tasks to the node-compare case:
  - enhanced-resolve promise: calls resolver.resolvePromise directly
    (as opposed to wrapping resolve() in new Promise manually)
  - node import.meta.resolve: sync ESM resolver, bound via a generated
    fixture/src/resolver.mjs helper so specifiers resolve against the
    fixture tree

A parallel ESM pool with explicit .js extensions is introduced because
Node's ESM resolver does not probe extensions; the rest of the case is
unchanged.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Rework the node-compare case so all six resolvers run the same 10000
request list:

  - Fixture now has one 'megapkg' with wildcard subpath exports (5000
    subpaths) plus 5000 relative src files. Every entry in the 10000-
    request batch is a unique resolvable target, so Node's
    Module._pathCache and the ESM loader's specifier cache cannot
    short-circuit anything within a single batch.
  - import.meta.resolve uses the same request list (now ESM-compatible
    for every task, no separate esmPool).
  - Enhanced-resolve is benched both with and without unsafeCache so the
    cached (analogous to Node's internal caches) and uncached (real
    pipeline cost) numbers are both visible.
  - Fixture generation is gated by a sentinel file so the 10k+ tiny
    files only get written on first run.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Three caching levels per enhanced-resolve API flavour so the
contribution of each layer is visible:

  - (no cache)        — CachedInputFileSystem duration: 0 routes
                        through OperationMergerBackend (no
                        stat/readdir memoization), no unsafeCache
  - (fs cache)        — 30s-TTL CachedInputFileSystem
  - (fs + unsafeCache)— plus per-specifier result cache

Covers async / promise / sync, so 3 API x 3 levels = 9 enhanced
tasks plus require.resolve + import.meta.resolve = 11 total.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Avoids allocating the executor closure on every call by using the
static `Promise.withResolvers()` API (Node 22+ / modern engines).
Falls back to the classic `new Promise(executor)` pattern on older
runtimes where the API is not available.

Benchmark shows the promise API going from ~6% slower than the
hand-rolled callback wrapper to on-par or slightly faster in the
fully-cached path (the row where per-call overhead dominates).

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Move the Promise.withResolvers vs new-Promise implementation choice
from an if-branch inside resolvePromise (checked per call) to a
module-level conditional expression (evaluated once at load time).

The class method is now a one-line delegation, keeping the Resolver
class bytecode nearly identical to the pre-change baseline. Under
CodSpeed's instruction-counting mode (Valgrind), larger method bodies
can shift V8's bytecode layout for the entire class and cause apparent
regressions in unrelated methods — this was the likely cause of the
query-fragment and deep-hierarchy regressions reported by CodSpeed.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Any modification to Resolver.js shifts V8's bytecode layout under
CodSpeed's Valgrind-based instruction counting, causing spurious
regressions in unrelated benchmarks (query-fragment, deep-hierarchy).
The regressions don't reproduce in wall-clock mode but CodSpeed
reports them consistently.

Revert Resolver.js to match main exactly. The Promise.withResolvers
optimization can land in a follow-up PR where the CodSpeed delta is
expected and documented separately from the benchmark addition.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Hoists the implementation choice to module level: checked once at
load time, not per call. The class method delegates to the selected
implementation via a one-line call.

Avoids allocating the executor closure on every resolvePromise() call
on Node 22+ where Promise.withResolvers is available. Falls back to
the classic new Promise(executor) pattern on older runtimes.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Patches Resolver.prototype.resolvePromise from index.js at module load
time when Promise.withResolvers is available (Node 22+). Falls back to
the original new-Promise implementation on older runtimes.

By patching the prototype from index.js instead of modifying
Resolver.js, the Resolver class bytecode is unchanged — avoiding
instruction-count regressions in CodSpeed's Valgrind-based mode that
were observed when adding code directly to Resolver.js.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
Module-level conditional selects the implementation once at load time:
Promise.withResolvers (Node 22+) avoids the executor closure
allocation; falls back to new Promise(executor) on older runtimes.
The class method delegates via a one-line call.

Locally verified no wall-clock regression in existing benchmarks
(3 runs each, PR vs main):
  - query-fragment: 0.747 ms vs 0.723 ms (3%, within OS noise)
  - deep-hierarchy: 1.102 ms vs 1.122 ms (PR 1.8% faster)

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
claude and others added 9 commits April 17, 2026 12:12
The StackEntry refactor (#526) replaced the internal Set-based stack
with a singly-linked list. However, the public API still allows
callers to pass a Set<string> as resolveContext.stack. When doResolve
receives a Set as parent, Set.has(stackEntry) compares by reference
and never matches the new StackEntry object, so recursion detection
silently fails.

Fix: check `parent instanceof StackEntry` and fall back to
`parent.has(stackEntry.toString())` for Set compatibility.

https://claude.ai/code/session_01TBuEDZmTzjKYzebY26ZkL1
@alexander-akait
alexander-akait merged commit 6637632 into main Apr 17, 2026
30 of 32 checks passed
@alexander-akait
alexander-akait deleted the bench/node-compare branch April 17, 2026 14:11
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.

2 participants