Skip to content

Commit 6637632

Browse files
test: add node-compare benchmark + perf: Promise.withResolvers in resolvePromise (#525)
1 parent 7e502fc commit 6637632

6 files changed

Lines changed: 417 additions & 9 deletions

File tree

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"abcxyz",
55
"addrs",
66
"codspeed",
7+
"megapkg",
78
"stabilise",
89
"tinybench",
910
"walltime",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/*
2+
* node-compare
3+
*
4+
* Head-to-head comparison between enhanced-resolve and Node.js's built-in
5+
* resolvers (CJS `require.resolve` via `createRequire`, ESM
6+
* `import.meta.resolve`) over a large, fixed request list (10000 resolves
7+
* per iteration). This is the case people most often ask about when
8+
* deciding whether enhanced-resolve's feature set is "worth it" over the
9+
* built-in resolvers — so it lives as its own bench for easy
10+
* cross-reference.
11+
*
12+
* Everything is held equal across tasks:
13+
* - Same fixture tree
14+
* - Same 10000-entry request list (one entry per unique target, see below)
15+
* - Same starting directory
16+
*
17+
* The enhanced-resolve side is benched in three variants per API flavour
18+
* so the contribution of each caching layer is visible:
19+
* - `(no cache)` — `CachedInputFileSystem` constructed with
20+
* `duration: 0`, which routes through `OperationMergerBackend`
21+
* instead of `CacheBackend` and does no stat/readdir/readJson
22+
* memoization (see `lib/CachedInputFileSystem.js:527-532`). No
23+
* `unsafeCache` either. Real pipeline work AND real fs work on every
24+
* call.
25+
* - `(fs cache)` — `CachedInputFileSystem` with a 30s TTL so stat /
26+
* readdir / readJson are memoized in memory. Mirrors the OS-level fs
27+
* cache Node itself relies on, but does NOT cache the
28+
* specifier → path mapping.
29+
* - `(fs + unsafeCache)` — adds `unsafeCache: true`, the closest
30+
* analogue to Node's per-specifier memoization (Module._pathCache for
31+
* CJS, the ESM loader cache for `import.meta.resolve`).
32+
*
33+
* The request list is *unique-per-entry* — every specifier in the 10000-
34+
* request batch points at a distinct resolvable target. This defeats the
35+
* per-specifier result caches within a single batch, so even with
36+
* `unsafeCache` on the first pass through the list must run the full
37+
* resolver pipeline for every entry. Subsequent tinybench iterations
38+
* replay the list, at which point Node's internal caches and
39+
* enhanced-resolve's `unsafeCache` / fs cache start hitting — which is
40+
* exactly what the cached variants are there to illustrate.
41+
*
42+
* Cross-iteration caching is unavoidable: tinybench replays the same
43+
* request list across 2 warmup + 10 measurement iterations, and the
44+
* first run through populates Node's internal caches for the whole
45+
* list. Read the numbers as "steady-state warm, cache cannot help"
46+
* rather than truly cold.
47+
*
48+
* Pool composition (all 10000 entries unique, ESM-compatible so they
49+
* resolve in every task):
50+
* - 5000 bare specifiers into a single package with wildcard subpath
51+
* exports (`megapkg/fileNNNN` → `megapkg/lib/fileNNNN.js`). A single
52+
* package with wildcard exports keeps the fixture size small while
53+
* still exercising the `exports` field / `node_modules` lookup path.
54+
* - 5000 relative specifiers with explicit `.js` extensions (`./fileNNNN.js`)
55+
* so Node's ESM resolver accepts them; enhanced-resolve is happy with
56+
* them too.
57+
*
58+
* The fixture is generated at registration time (one `megapkg` with 5000
59+
* lib files + 5000 src files + one ESM helper for the bound
60+
* `import.meta.resolve`) and gitignored. A sentinel file skips
61+
* regeneration when counts haven't changed, so only the first run pays
62+
* the setup cost.
63+
*/
64+
65+
import fs from "fs";
66+
import path from "path";
67+
import { createRequire } from "module";
68+
import { pathToFileURL } from "url";
69+
import enhanced from "../../../lib/index.js";
70+
71+
const { ResolverFactory, CachedInputFileSystem } = enhanced;
72+
73+
const PKG_SUBPATH_COUNT = 500;
74+
const FILE_COUNT = 500;
75+
const BATCH_SIZE = PKG_SUBPATH_COUNT + FILE_COUNT;
76+
77+
/**
78+
* Build a deterministic fixture tree on disk. Skipped entirely when a
79+
* sentinel file records that the current (PKG_SUBPATH_COUNT, FILE_COUNT)
80+
* combination has already been materialized — creating 10k tiny files is
81+
* a ~5s cost we don't want to pay every `npm run benchmark`.
82+
*
83+
* Layout:
84+
* fixture/
85+
* package.json // type: commonjs
86+
* src/
87+
* resolver.mjs // exports `import.meta.resolve`
88+
* file0000.js .. file4999.js // relative-resolve targets
89+
* node_modules/megapkg/
90+
* package.json // wildcard subpath exports
91+
* lib/
92+
* file0000.js .. file4999.js // bare-specifier targets
93+
*/
94+
function ensureFixture(fixtureDir) {
95+
const sentinel = path.join(fixtureDir, ".ok");
96+
const expected = `${PKG_SUBPATH_COUNT}:${FILE_COUNT}`;
97+
try {
98+
if (fs.readFileSync(sentinel, "utf8") === expected) return;
99+
} catch {
100+
// sentinel missing or unreadable — regenerate
101+
}
102+
103+
fs.mkdirSync(fixtureDir, { recursive: true });
104+
fs.writeFileSync(
105+
path.join(fixtureDir, "package.json"),
106+
JSON.stringify({
107+
name: "node-compare-fixture",
108+
version: "1.0.0",
109+
type: "commonjs",
110+
}),
111+
);
112+
113+
const srcDir = path.join(fixtureDir, "src");
114+
fs.mkdirSync(srcDir, { recursive: true });
115+
// ESM helper: `import.meta.resolve` is a bound method that closes over
116+
// its owning module's URL, so we need a module that actually lives in
117+
// the fixture tree. Exporting the bound function lets the bench body
118+
// call it and have relative + bare specifiers resolved against this
119+
// module's location.
120+
fs.writeFileSync(
121+
path.join(srcDir, "resolver.mjs"),
122+
"export const resolve = import.meta.resolve;\n",
123+
);
124+
for (let i = 0; i < FILE_COUNT; i++) {
125+
const name = `file${String(i).padStart(4, "0")}`;
126+
fs.writeFileSync(
127+
path.join(srcDir, `${name}.js`),
128+
`module.exports = ${i};\n`,
129+
);
130+
}
131+
132+
const megapkgDir = path.join(fixtureDir, "node_modules", "megapkg");
133+
const megapkgLibDir = path.join(megapkgDir, "lib");
134+
fs.mkdirSync(megapkgLibDir, { recursive: true });
135+
// Wildcard subpath exports: `megapkg/fileNNNN` → `./lib/fileNNNN.js`.
136+
// Supported by both Node's CJS + ESM resolvers and enhanced-resolve.
137+
fs.writeFileSync(
138+
path.join(megapkgDir, "package.json"),
139+
JSON.stringify({
140+
name: "megapkg",
141+
version: "1.0.0",
142+
exports: {
143+
"./*": "./lib/*.js",
144+
},
145+
}),
146+
);
147+
for (let i = 0; i < PKG_SUBPATH_COUNT; i++) {
148+
const name = `file${String(i).padStart(4, "0")}`;
149+
fs.writeFileSync(
150+
path.join(megapkgLibDir, `${name}.js`),
151+
`module.exports = ${JSON.stringify(name)};\n`,
152+
);
153+
}
154+
155+
fs.writeFileSync(sentinel, expected);
156+
}
157+
158+
/**
159+
* @param {import('tinybench').Bench} bench
160+
* @param {{ fixtureDir: string }} ctx
161+
*/
162+
export default async function register(bench, { fixtureDir }) {
163+
ensureFixture(fixtureDir);
164+
const srcDir = path.join(fixtureDir, "src");
165+
166+
// Build the 10000-entry request list. All specifiers are unique so the
167+
// per-specifier result caches in Node's CJS and ESM resolvers (and
168+
// enhanced-resolve's `unsafeCache` if we'd enabled it) cannot
169+
// short-circuit anything within a single batch.
170+
const requests = new Array(BATCH_SIZE);
171+
for (let i = 0; i < PKG_SUBPATH_COUNT; i++) {
172+
requests[i] = `megapkg/file${String(i).padStart(4, "0")}`;
173+
}
174+
for (let i = 0; i < FILE_COUNT; i++) {
175+
requests[PKG_SUBPATH_COUNT + i] = `./file${String(i).padStart(4, "0")}.js`;
176+
}
177+
178+
// Node's `require.resolve` is anchored to a real file inside `src/` so
179+
// relative specifiers resolve against that directory, and bare
180+
// specifiers walk up the `node_modules` lookup chain.
181+
const requireAnchor = createRequire(path.join(srcDir, "index.js"));
182+
183+
// `import.meta.resolve` is bound to its module's URL, so import the
184+
// helper we wrote into `fixture/src/resolver.mjs` and use its exported
185+
// `resolve`. Relative specifiers will resolve against `fixture/src/`,
186+
// matching the CJS anchor above.
187+
const { resolve: importMetaResolve } = await import(
188+
pathToFileURL(path.join(srcDir, "resolver.mjs")).href
189+
);
190+
191+
// Two filesystem wrappers:
192+
// - raw: duration 0 → OperationMergerBackend, no stat/readdir cache
193+
// - cached: 30s TTL is long enough that entries never expire during
194+
// a bench run
195+
const rawFileSystem = new CachedInputFileSystem(fs, 0);
196+
const fileSystem = new CachedInputFileSystem(fs, 30 * 1000);
197+
198+
const baseOptions = {
199+
extensions: [".js"],
200+
conditionNames: ["node", "require", "import"],
201+
mainFields: ["main"],
202+
};
203+
204+
// Three resolver configs per API flavour: no cache / fs cache / fs +
205+
// unsafeCache. Each flavour gets its own factory call so tinybench
206+
// measures independent V8 inline-cache state per task.
207+
const asyncResolverNone = ResolverFactory.createResolver({
208+
...baseOptions,
209+
fileSystem: rawFileSystem,
210+
});
211+
const asyncResolverFs = ResolverFactory.createResolver({
212+
...baseOptions,
213+
fileSystem,
214+
});
215+
const asyncResolverFsUnsafe = ResolverFactory.createResolver({
216+
...baseOptions,
217+
fileSystem,
218+
unsafeCache: true,
219+
});
220+
const syncResolverNone = ResolverFactory.createResolver({
221+
...baseOptions,
222+
fileSystem: rawFileSystem,
223+
useSyncFileSystemCalls: true,
224+
});
225+
const syncResolverFs = ResolverFactory.createResolver({
226+
...baseOptions,
227+
fileSystem,
228+
useSyncFileSystemCalls: true,
229+
});
230+
const syncResolverFsUnsafe = ResolverFactory.createResolver({
231+
...baseOptions,
232+
fileSystem,
233+
useSyncFileSystemCalls: true,
234+
unsafeCache: true,
235+
});
236+
237+
const resolveAsync = (resolver, req) =>
238+
new Promise((resolve, reject) => {
239+
resolver.resolve({}, srcDir, req, {}, (err, result) => {
240+
if (err) return reject(err);
241+
if (!result) return reject(new Error(`no result for ${req}`));
242+
resolve(result);
243+
});
244+
});
245+
246+
// --- async (callback API, wrapped in a Promise) ---
247+
bench.add(
248+
`node-compare: enhanced-resolve async x ${BATCH_SIZE} (no cache)`,
249+
async () => {
250+
for (let i = 0; i < requests.length; i++) {
251+
await resolveAsync(asyncResolverNone, requests[i]);
252+
}
253+
},
254+
);
255+
256+
bench.add(
257+
`node-compare: enhanced-resolve async x ${BATCH_SIZE} (fs cache)`,
258+
async () => {
259+
for (let i = 0; i < requests.length; i++) {
260+
await resolveAsync(asyncResolverFs, requests[i]);
261+
}
262+
},
263+
);
264+
265+
bench.add(
266+
`node-compare: enhanced-resolve async x ${BATCH_SIZE} (fs + unsafeCache)`,
267+
async () => {
268+
for (let i = 0; i < requests.length; i++) {
269+
await resolveAsync(asyncResolverFsUnsafe, requests[i]);
270+
}
271+
},
272+
);
273+
274+
// --- promise API (resolver.resolvePromise) ---
275+
// Functionally equivalent to the callback task wrapped in a new
276+
// Promise — benched separately so the overhead (or lack thereof) of
277+
// the built-in promise wrapper vs a hand-rolled one is visible.
278+
bench.add(
279+
`node-compare: enhanced-resolve promise x ${BATCH_SIZE} (no cache)`,
280+
async () => {
281+
for (let i = 0; i < requests.length; i++) {
282+
const r = await asyncResolverNone.resolvePromise(
283+
{},
284+
srcDir,
285+
requests[i],
286+
);
287+
if (!r) throw new Error(`no result for ${requests[i]}`);
288+
}
289+
},
290+
);
291+
292+
bench.add(
293+
`node-compare: enhanced-resolve promise x ${BATCH_SIZE} (fs cache)`,
294+
async () => {
295+
for (let i = 0; i < requests.length; i++) {
296+
const r = await asyncResolverFs.resolvePromise({}, srcDir, requests[i]);
297+
if (!r) throw new Error(`no result for ${requests[i]}`);
298+
}
299+
},
300+
);
301+
302+
bench.add(
303+
`node-compare: enhanced-resolve promise x ${BATCH_SIZE} (fs + unsafeCache)`,
304+
async () => {
305+
for (let i = 0; i < requests.length; i++) {
306+
const r = await asyncResolverFsUnsafe.resolvePromise(
307+
{},
308+
srcDir,
309+
requests[i],
310+
);
311+
if (!r) throw new Error(`no result for ${requests[i]}`);
312+
}
313+
},
314+
);
315+
316+
// --- sync API (resolveSync, useSyncFileSystemCalls: true) ---
317+
bench.add(
318+
`node-compare: enhanced-resolve sync x ${BATCH_SIZE} (no cache)`,
319+
() => {
320+
for (let i = 0; i < requests.length; i++) {
321+
const r = syncResolverNone.resolveSync({}, srcDir, requests[i]);
322+
if (!r) throw new Error(`no result for ${requests[i]}`);
323+
}
324+
},
325+
);
326+
327+
bench.add(
328+
`node-compare: enhanced-resolve sync x ${BATCH_SIZE} (fs cache)`,
329+
() => {
330+
for (let i = 0; i < requests.length; i++) {
331+
const r = syncResolverFs.resolveSync({}, srcDir, requests[i]);
332+
if (!r) throw new Error(`no result for ${requests[i]}`);
333+
}
334+
},
335+
);
336+
337+
bench.add(
338+
`node-compare: enhanced-resolve sync x ${BATCH_SIZE} (fs + unsafeCache)`,
339+
() => {
340+
for (let i = 0; i < requests.length; i++) {
341+
const r = syncResolverFsUnsafe.resolveSync({}, srcDir, requests[i]);
342+
if (!r) throw new Error(`no result for ${requests[i]}`);
343+
}
344+
},
345+
);
346+
347+
bench.add(`node-compare: node require.resolve x ${BATCH_SIZE}`, () => {
348+
for (let i = 0; i < requests.length; i++) {
349+
requireAnchor.resolve(requests[i]);
350+
}
351+
});
352+
353+
// `import.meta.resolve` is sync in modern Node (>= 20.6 / 18.19). The
354+
// ESM loader keeps its own specifier cache; using unique specifiers
355+
// (see comment on `requests` above) keeps that cache from hiding the
356+
// real resolve cost on every call within a batch.
357+
bench.add(`node-compare: node import.meta.resolve x ${BATCH_SIZE}`, () => {
358+
for (let i = 0; i < requests.length; i++) {
359+
importMetaResolve(requests[i]);
360+
}
361+
});
362+
}

0 commit comments

Comments
 (0)