Skip to content

perf(jest-runtime): stop reading a module's source before asking the transformer - #16477

Open
danielnc wants to merge 2 commits into
jestjs:mainfrom
danielnc:perf/runtime-skip-eager-source-read
Open

danielnc wants to merge 2 commits into
jestjs:mainfrom
danielnc:perf/runtime-skip-eager-source-read

Conversation

@danielnc

@danielnc danielnc commented Oct 1, 2026

Copy link
Copy Markdown

Summary

TransformCache#transform (and #transformAsync) reads a module's source through the runtime's FileCache before calling ScriptTransformer#transform:

const source = this.fileCache.readFile(filename);
if (options?.isInternalModule) return source;
const transformedFile = this.scriptTransformer.transform(filename, options, source);

The two caches have different lifetimes:

  • the FileCache wraps the cacheFS map that runTest creates per test file;
  • ScriptTransformer#transform first looks the module up in projectCaches[...].transformedFiles, which is module-level and lives for the whole worker, keyed on path + mtime (+ instrumentation and caller flags). On a hit it returns without looking at fileSource at all. On a miss, _transformAndBuildScript uses fileSource ?? this._cacheFS.get(filename) and otherwise reads the file itself into that same cacheFS.

So in a worker that runs many test files, each test file reads from disk every module it requires, and every one of those reads after the first is thrown away. A module required by 1,000 test files is read about 1,000 times and transformed once.

This PR stops reading up front and leaves the read to the transformer. Internal modules, which skip the transformer, still read through the FileCache. transformJson is unchanged: it has no per-worker memo, so it does need the source.

How this differs from #13419

#13419 (2022) proposed the same deferral and was closed after the review point that readFile and the transformer use the same cacheFS, so it looked like the same read made at a different moment. That holds within one test file. The saving is across test files: cacheFS starts empty for each test file, but the transformer's transformedFiles memo does not, so on a memo hit nothing needs the source. The mtime check behind that memo (getScriptCacheKey, one statSync) is unchanged, so a module edited on disk is still read again and re-transformed.

What still sees the source

  • cacheFS consumers in custom transformers (e.g. ts-jest's language service) are only called on a memo miss, and on that path the transformer has already read the file into cacheFS. They also fall back to disk when an entry is missing.
  • originalCode (used by coverage) comes from the memoized TransformResult, so it is unaffected.

Measurement

Synthetic project: 300 one-function CommonJS modules behind an index.js, and 100 test files that each require the index (default config, so babel-jest). Same checkout and the same warm transform cache; the only difference between runs is packages/jest-runtime/build/index.js built from main or from this branch. Runs were interleaved A/B/B/A, 6 rounds per worker setting, on a shared development machine. A --require hook counted fs.readFileSync calls on the library's files in every Jest process.

Median of 6 runs per variant, with the range in brackets (warm-up runs excluded):

main this PR change
--runInBand
readFileSync calls on the library's files 30,100 301 -99%
Instructions retired (/usr/bin/time -l) 18.03 B [17.73–18.66] 14.67 B [14.47–15.20] -18.7%
CPU, user + sys 3.63 s [3.30–3.74] 2.88 s [2.72–3.15] -20.8%
of which sys 0.92 s [0.81–1.02] 0.46 s [0.42–0.57] -49%
Wall 3.41 s [2.82–3.62] 2.50 s [2.26–2.90] -26.5%
-w 2
readFileSync calls on the library's files 30,100 602 (301 per worker) -98%
CPU, user + sys, including workers 5.32 s [4.84–5.50] 4.45 s [4.27–4.68] -16.4%
of which sys 1.42 s [1.22–1.51] 0.80 s [0.73–0.85] -44%
Wall 2.95 s [2.21–3.64] 2.58 s [2.09–3.53] ranges overlap

(With -w 2, /usr/bin/time counts instructions for the parent process only, so that row is left out.)

Reproduction

Generate the project (./gen.sh /tmp/repro 300 100):

#!/usr/bin/env bash
# Generates a synthetic project: MODULES small CommonJS modules behind one
# index, and TESTS test files that each require the index.
set -euo pipefail
dir=$1; modules=${2:-300}; tests=${3:-100}
rm -rf "$dir"; mkdir -p "$dir/lib" "$dir/__tests__"
echo '{"name":"repro","private":true}' > "$dir/package.json"
echo 'module.exports = {testEnvironment: "node"};' > "$dir/jest.config.js"
: > "$dir/lib/index.js"
for i in $(seq 1 "$modules"); do
  printf 'const k = %d;\nmodule.exports = function m%d(x) {\n  return x + k;\n};\n' "$i" "$i" > "$dir/lib/m$i.js"
  printf 'exports.m%d = require("./m%d");\n' "$i" "$i" >> "$dir/lib/index.js"
done
for i in $(seq 1 "$tests"); do
  printf 'const lib = require("../lib");\ntest("t%d", () => {\n  expect(lib.m1(%d)).toBe(%d);\n});\n' "$i" "$i" "$((i+1))" > "$dir/__tests__/t$i.test.js"
done

Count the reads (READS_DIR=/tmp/repro READS_OUT=/tmp/reads NODE_OPTIONS="--require $PWD/count-reads.cjs" node packages/jest-cli/bin/jest.js --rootDir /tmp/repro -i, then sum the lines of /tmp/reads):

// Loaded with --require in every Jest process: counts readFileSync calls on
// files under READS_DIR/lib and appends the total to READS_OUT at exit.
const fs = require('fs');
const dir = process.env.READS_DIR + '/lib/';
const out = process.env.READS_OUT;
let n = 0;
const original = fs.readFileSync;
fs.readFileSync = function readFileSync(file, ...rest) {
  if (typeof file === 'string' && file.startsWith(dir)) n++;
  return original.call(this, file, ...rest);
};
process.on('exit', () => fs.appendFileSync(out, `${n}\n`));

The read count is exact. The time figures were taken on a loaded machine, so read them as direction and rough size, not as a precise benchmark. The size of the gain depends on how many modules each test file loads compared with how much work the tests do. On a larger private TypeScript suite using @swc/jest, the same change applied as a local patch cut total CPU time at 2 workers by about 11%.

Test plan

New tests:

  • packages/jest-runtime/src/__tests__/runtime_source_reads.test.ts: builds two Runtimes, each with its own cacheFS as in two test files of one worker, and counts graceful-fs.readFileSync calls on a fixture module.
    • reads a module once for two test files that require it: 1 read (2 on main).
    • reads a module again after it changes on disk: a rewrite with a later mtime is read again, and the second runtime gets the new code.
  • TransformCache.test.ts: leaves reading the source to the transformer for transform and transformAsync. forwards options through getFullTransformationOptions no longer expects a source argument. Internal modules still read through the FileCache.

Checking that the tests catch the defect:

  • With TransformCache.ts reset to main, exactly 4 tests fail: the read-once runtime test (Expected: 1, Received: 2), both leaves reading the source to the transformer tests, and forwards options…. The other 9 pass.
  • With the mtime removed from getScriptCacheKey in the built @jest/transform, reads a module again after it changes on disk fails (Expected: "after", Received: "before"). This shows the test does guard against a stale module.

Commands:

yarn jest packages/jest-runtime -i                                                         # 39 suites passed, 2 skipped; 371 passed
NODE_OPTIONS="--experimental-vm-modules --no-warnings" yarn jest packages/jest-runtime -i  # 41 suites, 580 passed
yarn eslint <touched files>
yarn tsc -b packages/jest-runtime packages/jest-runtime/src/__tests__ packages/jest-runtime/src/internals/__tests__

…transformer

TransformCache read every module's source through the runtime's FileCache
before calling ScriptTransformer#transform. The transformer memoizes its
results per worker (projectCaches, keyed on path and mtime) and ignores the
source on a hit, while the FileCache is per test file. So every test file
re-read from disk every module it required, and on every read but the first
in a worker the text was discarded.

On a miss the transformer reads the source itself into the same cacheFS the
runtime shares with it, so leaving the read to it changes nothing but the
redundant reads. Internal modules, which bypass the transformer, still read
through the FileCache.
@netlify

netlify Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for jestjs ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 4a78b00
🔍 Latest deploy log https://app.netlify.com/projects/jestjs/deploys/6abe39a32b3b040008ad9354
😎 Deploy Preview https://deploy-preview-16477--jestjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: danielnc / name: Daniel Naves de Carvalho (3f472a5, 4a78b00)

@github-actions github-actions Bot added the require-changelog If a PR does requires a changelog entry label Oct 1, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

babel-jest

npm i https://pkg.pr.new/babel-jest@16477

babel-plugin-jest-hoist

npm i https://pkg.pr.new/babel-plugin-jest-hoist@16477

babel-preset-jest

npm i https://pkg.pr.new/babel-preset-jest@16477

create-jest

npm i https://pkg.pr.new/create-jest@16477

@jest/diff-sequences

npm i https://pkg.pr.new/@jest/diff-sequences@16477

expect

npm i https://pkg.pr.new/expect@16477

@jest/expect-utils

npm i https://pkg.pr.new/@jest/expect-utils@16477

jest

npm i https://pkg.pr.new/jest@16477

jest-changed-files

npm i https://pkg.pr.new/jest-changed-files@16477

jest-circus

npm i https://pkg.pr.new/jest-circus@16477

jest-cli

npm i https://pkg.pr.new/jest-cli@16477

jest-config

npm i https://pkg.pr.new/jest-config@16477

@jest/console

npm i https://pkg.pr.new/@jest/console@16477

@jest/core

npm i https://pkg.pr.new/@jest/core@16477

@jest/create-cache-key-function

npm i https://pkg.pr.new/@jest/create-cache-key-function@16477

jest-diff

npm i https://pkg.pr.new/jest-diff@16477

jest-docblock

npm i https://pkg.pr.new/jest-docblock@16477

jest-each

npm i https://pkg.pr.new/jest-each@16477

@jest/environment

npm i https://pkg.pr.new/@jest/environment@16477

jest-environment-jsdom

npm i https://pkg.pr.new/jest-environment-jsdom@16477

@jest/environment-jsdom-abstract

npm i https://pkg.pr.new/@jest/environment-jsdom-abstract@16477

jest-environment-node

npm i https://pkg.pr.new/jest-environment-node@16477

@jest/expect

npm i https://pkg.pr.new/@jest/expect@16477

@jest/fake-timers

npm i https://pkg.pr.new/@jest/fake-timers@16477

@jest/get-type

npm i https://pkg.pr.new/@jest/get-type@16477

@jest/globals

npm i https://pkg.pr.new/@jest/globals@16477

jest-haste-map

npm i https://pkg.pr.new/jest-haste-map@16477

jest-jasmine2

npm i https://pkg.pr.new/jest-jasmine2@16477

jest-leak-detector

npm i https://pkg.pr.new/jest-leak-detector@16477

jest-matcher-utils

npm i https://pkg.pr.new/jest-matcher-utils@16477

jest-message-util

npm i https://pkg.pr.new/jest-message-util@16477

jest-mock

npm i https://pkg.pr.new/jest-mock@16477

@jest/pattern

npm i https://pkg.pr.new/@jest/pattern@16477

jest-phabricator

npm i https://pkg.pr.new/jest-phabricator@16477

jest-regex-util

npm i https://pkg.pr.new/jest-regex-util@16477

@jest/reporters

npm i https://pkg.pr.new/@jest/reporters@16477

jest-resolve

npm i https://pkg.pr.new/jest-resolve@16477

jest-resolve-dependencies

npm i https://pkg.pr.new/jest-resolve-dependencies@16477

jest-runner

npm i https://pkg.pr.new/jest-runner@16477

jest-runtime

npm i https://pkg.pr.new/jest-runtime@16477

@jest/schemas

npm i https://pkg.pr.new/@jest/schemas@16477

jest-snapshot

npm i https://pkg.pr.new/jest-snapshot@16477

@jest/snapshot-utils

npm i https://pkg.pr.new/@jest/snapshot-utils@16477

@jest/source-map

npm i https://pkg.pr.new/@jest/source-map@16477

@jest/test-result

npm i https://pkg.pr.new/@jest/test-result@16477

@jest/test-sequencer

npm i https://pkg.pr.new/@jest/test-sequencer@16477

@jest/transform

npm i https://pkg.pr.new/@jest/transform@16477

@jest/types

npm i https://pkg.pr.new/@jest/types@16477

jest-util

npm i https://pkg.pr.new/jest-util@16477

jest-validate

npm i https://pkg.pr.new/jest-validate@16477

jest-watcher

npm i https://pkg.pr.new/jest-watcher@16477

jest-worker

npm i https://pkg.pr.new/jest-worker@16477

pretty-format

npm i https://pkg.pr.new/pretty-format@16477

commit: 4a78b00

This branch has not been deployed

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

Labels

require-changelog If a PR does requires a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant