Skip to content

perf: improve resolver cache hit rate#481

Merged
alexander-akait merged 2 commits into
webpack:mainfrom
3ru:perf/improve-cache-hit-rate
Mar 12, 2026
Merged

perf: improve resolver cache hit rate#481
alexander-akait merged 2 commits into
webpack:mainfrom
3ru:perf/improve-cache-hit-rate

Conversation

@3ru

@3ru 3ru commented Mar 11, 2026

Copy link
Copy Markdown
Member

Summary

This optimization aligns unsafe cache keys by package rather than by importer path, reducing redundant request re-resolution within the same package.

It is most effective when multiple importers in one package repeatedly resolve the same package-internal imports or package-local modules through different relative or absolute request forms.

This work is based on #449

What kind of change does this PR introduce?
perf

Did you add tests for your changes?

Yes

  • reusing normalized in-package relative requests
  • keeping cache entries distinct across package boundaries
  • avoiding incorrect cache reuse for bare specifiers when a nested node_modules shadows the package root

Does this PR introduce a breaking change?

no

If relevant, what needs to be documented once your changes are merged or what have you already documented?

no

Use of AI

for making test cases

@3ru 3ru changed the title Perf/improve cache hit rate perf: improve resolver cache hit rate Mar 11, 2026
Comment thread lib/UnsafeCachePlugin.js
/** @typedef {import("./Resolver").ResolveContextYield} ResolveContextYield */
/** @typedef {{ [k: string]: undefined | ResolveRequest | ResolveRequest[] }} Cache */

const RELATIVE_REQUEST_REGEXP = /^\.\.?(?:\/|$)/;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

if (/^\.\.?(?:\/|$)/.test(innerRequest) && request.relativePath) {

ported it from here
we can refactor these later if needed

Comment thread lib/ResolverFactory.js

// described-resolve
plugins.push(new NextPlugin("described-resolve", "raw-resolve"));
if (unsafeCache) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved UnsafeCachePlugin to described-resolve to access descriptionFileRoot and relativePath for cache key normalization

Comment thread lib/UnsafeCachePlugin.js
function getCachePath(request) {
if (request.descriptionFileRoot && !request.module) {
return request.descriptionFileRoot;
}

@3ru 3ru Mar 11, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Normalization to descriptionFileRoot is limited to !request.module because bare specifiers are resolved from the caller's directory, not the package root

ex:

  • react from src/b -> <pkg>/node_modules/react
  • react from src/components -> <pkg>/src/components/node_modules/react

Even within the same package, resolution results vary based on local node_modules. Collapsing these into a single package-root-based cache key would cause incorrect reuse of previously cached results in different directories

tests: https://github.com/webpack/enhanced-resolve/pull/481/changes#diff-096efe7994b26e07729023ef9d1da61fc126ecd1adf6e25e0014911dcdfc8eecR246-R250

but this is a niche case that it's not worth considering. What do you think?

@codecov

codecov Bot commented Mar 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.40%. Comparing base (c1093c9) to head (81574fa).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/UnsafeCachePlugin.js 90.47% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #481      +/-   ##
==========================================
- Coverage   93.43%   93.40%   -0.04%     
==========================================
  Files          50       50              
  Lines        2483     2502      +19     
  Branches      751      759       +8     
==========================================
+ Hits         2320     2337      +17     
- Misses        134      136       +2     
  Partials       29       29              
Flag Coverage Δ
integration 93.40% <92.30%> (-0.04%) ⬇️

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.

@alexander-akait

Copy link
Copy Markdown
Member

@3ru Looks good for me, can you measure before and now? I think we need to add tinybench and codespeed here to collect information, it is out of scope this PR

@3ru

3ru commented Mar 12, 2026

Copy link
Copy Markdown
Member Author

@alexander-akait I ran some experiments

Ran each package from common-libs/package.json individually. For every package I pulled all require()/import calls from its source files and threw them at resolve.create.sync({ unsafeCache: cache }) on both main and this PR

Package Resolve calls Hit rate (main → PR) Cache keys (main → PR)
@material-ui/core 7,148 27.0% → 38.8% 5,079 → 4,226 (−16.8%)
@material-ui/icons 49,819 77.7% → 77.7% 11,119 → 11,119
date-fns 11,548 35.1% → 36.0% 7,490 → 7,394 (−1.3%)
rxjs 6,184 24.3% → 32.7% 1,570 → 840 (−46.5%)
lodash 2,849 65.1% → 75.9% 990 → 682 (−31.1%)
moment 804 73.0% → 84.5% 203 → 110 (−45.8%)
@material-ui/lab 1,251 26.7% → 29.8% 906 → 867 (−4.3%)
lodash-es 2,309 72.2% → 72.2% 641 → 641
underscore 875 63.3% → 63.3% 321 → 321
vue 864 0.5% → 0.5% 17 → 17
@babel/runtime 241 63.1% → 63.1% 89 → 89

Hit rate = % of resolve calls served from cache (higher is better).
Cache keys = distinct entries stored (fewer means less memory pressure).

The wins are concentrated in packages with deep directory trees like rxjs, moment, lodash. A lot of their files end up resolving the same relative imports from different subdirectories, which this PR correctly folds into a single cache entry. Flat packages like lodash-es, icons, and underscore are basically unaffected

@alexander-akait alexander-akait merged commit 8148159 into webpack:main Mar 12, 2026
26 of 28 checks passed
@alexander-akait

Copy link
Copy Markdown
Member

@3ru Thanks ⭐

alexander-akait pushed a commit that referenced this pull request Apr 17, 2026
Extends the cache-hit work from PR #481 with additional hot-path wins that
don't change public behavior or semantics:

- UnsafeCachePlugin.getCacheId: build the cache id by direct string concat
  with a `\0` separator instead of `JSON.stringify`ing a 6-key object per
  lookup. Paths, requests, queries and fragments produced by parseIdentifier
  never contain raw `\0`; context is still JSON-stringified when included,
  which escapes any `\0` there.
- util/path.isRelativeRequest: char-code-based check that replaces the
  `/^\.\.?(?:\/|$)/` regex used in UnsafeCachePlugin and getInnerRequest.
- DescriptionFileUtils.cdUp: single reverse char-code scan instead of two
  `lastIndexOf` calls (POSIX strings don't need to be fully re-scanned for
  a backslash that isn't there).
- DescriptionFilePlugin: skip the global-regex backslash replace when the
  relative slice doesn't contain any backslashes.

Also adds `benchmark/cases/unsafe-cache-miss-heavy/` which exercises the
cache-id path directly (a miss pass followed by a hit pass over 42 unique
requests, so `getCacheId` is the dominant cost) and unit tests for
`isRelativeRequest`.

Local results (Node 22, warm fs cache):
  unsafe-cache (ON, 3x repeat):        11.5k → 14.5k ops/s  (+25%)
  unsafe-cache-miss-heavy:              2.2k →  2.8k ops/s  (+27%)
  realistic-midsize (warm cache):       4.2k →  4.6k ops/s  (+9%)
  realistic-midsize (cold cache):       42.5 → 46.6  ops/s  (+10%)

One internal change: UnsafeCachePlugin cache keys are no longer valid JSON,
so test/yield.test.js was updated to split on the `\0` separator instead
of `JSON.parse`ing a key. The cache-key format has never been part of the
public API.

https://claude.ai/code/session_014HMqKsoba7AvAVojZWECzz
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