Commit 471fe813bb3 for nodejs
commit 471fe813bb38c687a1bde205d98f7750c23a664e
Author: Samuel Attard <sattard@anthropic.com>
Date: Mon Sep 28 16:30:13 2026 -0700
module: avoid allocating a cache key string for every require()
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>
PR-URL: https://github.com/nodejs/node/pull/63884
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js
index 1995eef579e..2869abba869 100644
--- a/lib/internal/modules/cjs/loader.js
+++ b/lib/internal/modules/cjs/loader.js
@@ -229,7 +229,7 @@ let { startTimer, endTimer } = debugWithTimer('module_timer', (start, end) => {
const { tracingChannel } = require('diagnostics_channel');
const onRequire = getLazy(() => tracingChannel('module.require'));
-const relativeResolveCache = { __proto__: null };
+const relativeResolveCache = new SafeMap();
let requireDepth = 0;
let isPreloading = false;
@@ -1339,17 +1339,19 @@ function loadBuiltinWithHooks(id, url, format) {
* @returns {object}
*/
Module._load = function(request, parent, isMain, internalOptions = kEmptyObject) {
- let relResolveCacheIdentifier;
+ let parentPath;
+ let relResolveCacheByDir;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
- // Fast path for (lazy loaded) modules in the same directory. The indirect
- // caching is required to allow cache invalidation without changing the old
- // cache key names.
- relResolveCacheIdentifier = `${parent.path}\x00${request}`;
- const filename = relativeResolveCache[relResolveCacheIdentifier];
- reportModuleToWatchMode(filename);
- reportModuleToWatchModeFromWorker(filename);
+ // Fast path for (lazy loaded) modules in the same directory. Keyed by
+ // parent directory and then request, so no concatenated cache key
+ // string is allocated per require() call.
+ parentPath = parent.path;
+ relResolveCacheByDir = relativeResolveCache.get(parentPath);
+ const filename = relResolveCacheByDir?.[request];
if (filename !== undefined) {
+ reportModuleToWatchMode(filename);
+ reportModuleToWatchModeFromWorker(filename);
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
@@ -1358,7 +1360,7 @@ Module._load = function(request, parent, isMain, internalOptions = kEmptyObject)
}
return cachedModule.exports;
}
- delete relativeResolveCache[relResolveCacheIdentifier];
+ delete relResolveCacheByDir[request];
}
}
@@ -1461,8 +1463,16 @@ Module._load = function(request, parent, isMain, internalOptions = kEmptyObject)
module[kFormat] ??= format;
}
- if (parent !== undefined) {
- relativeResolveCache[relResolveCacheIdentifier] = filename;
+ if (parent) {
+ // Only create the per-directory bucket once there is an entry to store,
+ // so builtins and failed resolutions don't leave empty buckets behind.
+ if (relResolveCacheByDir === undefined) {
+ // A plain object handles dynamically built specifier strings
+ // better than a Map here.
+ relResolveCacheByDir = { __proto__: null };
+ relativeResolveCache.set(parentPath, relResolveCacheByDir);
+ }
+ relResolveCacheByDir[request] = filename;
}
let threw = true;
@@ -1473,7 +1483,9 @@ Module._load = function(request, parent, isMain, internalOptions = kEmptyObject)
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
- delete relativeResolveCache[relResolveCacheIdentifier];
+ if (relResolveCacheByDir !== undefined) {
+ delete relResolveCacheByDir[request];
+ }
const children = parent?.children;
if (ArrayIsArray(children)) {
const index = ArrayPrototypeIndexOf(children, module);
diff --git a/test/parallel/test-module-relative-resolve-cache.js b/test/parallel/test-module-relative-resolve-cache.js
new file mode 100644
index 00000000000..b9868a3a3c9
--- /dev/null
+++ b/test/parallel/test-module-relative-resolve-cache.js
@@ -0,0 +1,58 @@
+'use strict';
+
+// Checks that Module._load() only creates a relative resolve cache entry for
+// the parent directory when there is a resolved filename to store in it.
+
+require('../common');
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const Module = require('module');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+
+let uniqueId = 0;
+
+function createParent() {
+ const dir = tmpdir.resolve(`dir-${uniqueId++}`);
+ fs.mkdirSync(dir);
+ const parent = new Module(path.join(dir, 'parent.js'));
+ parent.filename = parent.id;
+ parent.paths = Module._nodeModulePaths(dir);
+ let pathReads = 0;
+ Object.defineProperty(parent, 'path', {
+ __proto__: null,
+ get() {
+ pathReads++;
+ return dir;
+ },
+ });
+ return { dir, parent, getPathReads: () => pathReads };
+}
+
+// Builtins return before the relative resolve cache is populated, so the
+// parent directory should only be looked up, not also used to create a bucket.
+for (const request of ['node:path', 'path']) {
+ const { parent, getPathReads } = createParent();
+ assert.strictEqual(Module._load(request, parent, false), path);
+ assert.strictEqual(getPathReads(), 1);
+}
+
+// Same for resolutions that throw.
+{
+ const { parent, getPathReads } = createParent();
+ assert.throws(() => Module._load('./missing', parent, false),
+ { code: 'MODULE_NOT_FOUND' });
+ assert.strictEqual(getPathReads(), 1);
+}
+
+// A successful load populates the cache, and the second load hits it.
+{
+ const { dir, parent, getPathReads } = createParent();
+ fs.writeFileSync(path.join(dir, 'child.js'), 'module.exports = {};');
+ const first = Module._load('./child', parent, false);
+ assert.strictEqual(getPathReads(), 1);
+ assert.strictEqual(Module._load('./child', parent, false), first);
+ assert.strictEqual(getPathReads(), 2);
+}