Skip to content

module: avoid allocating a cache key string for every require()#63884

Open
MarshallOfSound wants to merge 1 commit into
nodejs:mainfrom
MarshallOfSound:perf/cache-only
Open

module: avoid allocating a cache key string for every require()#63884
MarshallOfSound wants to merge 1 commit into
nodejs:mainfrom
MarshallOfSound:perf/cache-only

Conversation

@MarshallOfSound

Copy link
Copy Markdown
Member

Description of Change

Every require() call — including fully cached ones — allocates a fresh `${parent.path}\x00${request}` string to key the relative-resolve fast path in Module._load. On cache hits that allocation and its GC churn are the largest per-call cost.

This PR splits the cache into two levels so hits do two allocation-free lookups instead: an outer SafeMap keyed by parent.path (a string the module system already retains, with a V8-cached hash) and an inner { __proto__: null } object keyed by the raw request.

  • The inner level is a plain object rather than a Map on purpose: property access internalizes the key, which handles dynamically built specifiers (require(base + name)) far better than Map.get's per-call hash-and-compare — and it preserves the string coercion the old template literal performed.
  • Invalidation is unchanged: stale hits delete the inner entry, failed loads clean up in the existing finally, and delete require.cache[...] + re-require behaves as before.
  • Memory is neutral (12.97 → 12.91 MB heap after loading a 2,000-directory tree) since the concatenated keys are no longer retained.

Observable edge: parent.path is now an identity Map key rather than being string-coerced, and is read twice on the first load into a directory — only reachable if module.path is replaced with a non-string or an accessor, neither of which is documented usage.

benchmark/compare.js shows significant wins on every relevant module-loader.js config — cached +7.8…10.7%, cold +5.6…8.0% — with clean nulls on the circular/deep benchmarks where this path isn't hot.

Full benchmark results

10 samples per configuration, Welch t-test (*** p<0.001, ** p<0.01, * p<0.05):

module/module-loader.js cache='true'  n=1000 files=500 dir='abs' name=''            +10.72% ***
module/module-loader.js cache='true'  n=1000 files=500 dir='abs' name='/'            +8.08% ***
module/module-loader.js cache='true'  n=1000 files=500 dir='abs' name='/index.js'    +7.79% ***
module/module-loader.js cache='true'  n=1000 files=500 dir='rel' name=''            +10.30% ***
module/module-loader.js cache='true'  n=1000 files=500 dir='rel' name='/'           +10.20% ***
module/module-loader.js cache='true'  n=1000 files=500 dir='rel' name='/index.js'    +9.24% ***
module/module-loader.js cache='false' n=1000 files=500 dir='abs' name=''             +6.20% ***
module/module-loader.js cache='false' n=1000 files=500 dir='abs' name='/'            +6.18% ***
module/module-loader.js cache='false' n=1000 files=500 dir='abs' name='/index.js'    +7.89% **
module/module-loader.js cache='false' n=1000 files=500 dir='rel' name=''             +8.01% ***
module/module-loader.js cache='false' n=1000 files=500 dir='rel' name='/'            +5.61% *
module/module-loader.js cache='false' n=1000 files=500 dir='rel' name='/index.js'    +6.79% ***
module/module-require.js n=10000 type='.js'                                          +3.06% **
module/module-require.js n=10000 type='.json'                                        +2.67% ***
module/module-require.js n=10000 type='dir'                                          +0.02%
module/module-loader-circular.js n=1000                                              +0.22%
module/module-loader-deep.js cache='true' files=1000 (both exts)                  ±1.9% (n.s.)

Ad-hoc workload-shape microbenchmarks land between −9% (300 dynamic-specifier children) and −58% (cached-require hot loop) load-phase time, neutral on cold loads — scripts and fixture generators: https://gist.github.com/MarshallOfSound/ad35b07ad5ebf9c5fec8e4daa4ec04b4

Checklist

  • I have built and tested this change
  • test/parallel/test-module-*, test-require-*, test-cjs-* pass
  • Manually verified cache-hit identity, delete require.cache + re-require, failed-load retry, circular requires, require.resolve with paths

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders

@nodejs-github-bot nodejs-github-bot added module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. labels Jun 12, 2026
The relative resolve cache was keyed by a concatenated
${parent.path}\x00${request} string, allocating a new key for every
require() call including fully cached ones. Key the cache by the
parent directory first (a Map keyed by the already-retained
module.path string) and then by the request (a dictionary object,
whose property access internalizes dynamically-constructed
specifiers). Faster on every measured workload shape and slightly
smaller in memory, since the concatenated keys are no longer
retained.

Signed-off-by: Sam Attard <sattard@anthropic.com>
@MarshallOfSound MarshallOfSound marked this pull request as ready for review June 12, 2026 18:23

@mcollina mcollina 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants