Skip to content

Commit e75eaf0

Browse files
scotthovestadtthymikee
authored andcommitted
Dramatically improve watch mode performance. (#8201)
## Summary Resolves #7341 This PR dramatically improves watch mode performance, bringing it in line with single run mode performance. It accomplishes that by: - Workers previously initialized a new `ModuleMap` and `Resolver` for every test in watch mode. Now, those objects are only initialized once when the worker is setup. - In the main thread, caching the conversion of `ModuleMap` to a JSON-friendly object. - Allowing watch mode to use the same number of CPUs as single run mode. ## Benchmarks I benchmarked against Jest's own test suite, excluding e2e tests which don't provide good signal because they individually take a long time (so startup time for the test is marginalized). The numbers show that running in Watch mode previously added an extra 35%~ of runtime to the tests but that has now been reduced to almost nothing. Watch mode should now just be paying a one-time initial cost for each worker when the haste map changes instead of paying that same cost for _every_ test run. ### branch: master `yarn jest ./packages` Run time: 15.091s `yarn jest ./packages --watch` Run time: 23.234s ### branch: watch-performance `yarn jest ./packages` Run time: 14.973s `yarn jest ./packages --watch` Run time: 15.196s ## Test plan - All tests pass. - Benchmarked to verify the performance wins. - Verified that when the haste map is updated, the update is propagated out to all workers.
1 parent 13b0ca3 commit e75eaf0

7 files changed

Lines changed: 71 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
- `[jest-haste-map]` Avoid persisting haste map or processing files when not changed ([#8153](https://github.com/facebook/jest/pull/8153))
3737
- `[jest-core]` Improve performance of SearchSource.findMatchingTests by 15% ([#8184](https://github.com/facebook/jest/pull/8184))
3838
- `[jest-resolve]` Optimize internal cache lookup performance ([#8183](https://github.com/facebook/jest/pull/8183))
39+
- `[jest-core]` Dramatically improve watch mode performance ([#8201](https://github.com/facebook/jest/pull/8201))
3940

4041
## 24.5.0
4142

packages/jest-config/src/__tests__/getMaxWorkers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('getMaxWorkers', () => {
3232

3333
it('Returns based on the number of cpus', () => {
3434
expect(getMaxWorkers({})).toBe(3);
35-
expect(getMaxWorkers({watch: true})).toBe(2);
35+
expect(getMaxWorkers({watch: true})).toBe(3);
3636
});
3737

3838
describe('% based', () => {

packages/jest-config/src/getMaxWorkers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,6 @@ export default function getMaxWorkers(
3232
return parsed > 0 ? parsed : 1;
3333
} else {
3434
const cpus = os.cpus() ? os.cpus().length : 1;
35-
return Math.max(argv.watch ? Math.floor(cpus / 2) : cpus - 1, 1);
35+
return Math.max(cpus - 1, 1);
3636
}
3737
}

packages/jest-haste-map/src/ModuleMap.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ export type SerializableModuleMap = {
3232
};
3333

3434
export default class ModuleMap {
35-
private readonly _raw: RawModuleMap;
3635
static DuplicateHasteCandidatesError: typeof DuplicateHasteCandidatesError;
36+
private readonly _raw: RawModuleMap;
37+
private json: SerializableModuleMap | undefined;
3738

3839
constructor(raw: RawModuleMap) {
3940
this._raw = raw;
@@ -84,12 +85,15 @@ export default class ModuleMap {
8485
}
8586

8687
toJSON(): SerializableModuleMap {
87-
return {
88-
duplicates: Array.from(this._raw.duplicates),
89-
map: Array.from(this._raw.map),
90-
mocks: Array.from(this._raw.mocks),
91-
rootDir: this._raw.rootDir,
92-
};
88+
if (!this.json) {
89+
this.json = {
90+
duplicates: Array.from(this._raw.duplicates),
91+
map: Array.from(this._raw.map),
92+
mocks: Array.from(this._raw.mocks),
93+
rootDir: this._raw.rootDir,
94+
};
95+
}
96+
return this.json;
9397
}
9498

9599
static fromJSON(serializableModuleMap: SerializableModuleMap) {

packages/jest-runner/src/__tests__/testRunner.test.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ test('injects the serializable module map into each worker in watch mode', () =>
5151
context: runContext,
5252
globalConfig,
5353
path: './file.test.js',
54-
serializableModuleMap,
5554
},
5655
],
5756
[
@@ -60,7 +59,6 @@ test('injects the serializable module map into each worker in watch mode', () =>
6059
context: runContext,
6160
globalConfig,
6261
path: './file2.test.js',
63-
serializableModuleMap,
6462
},
6563
],
6664
]);
@@ -90,7 +88,6 @@ test('does not inject the serializable module map in serial mode', () => {
9088
context: runContext,
9189
globalConfig,
9290
path: './file.test.js',
93-
serializableModuleMap: null,
9491
},
9592
],
9693
[
@@ -99,7 +96,6 @@ test('does not inject the serializable module map in serial mode', () => {
9996
context: runContext,
10097
globalConfig,
10198
path: './file2.test.js',
102-
serializableModuleMap: null,
10399
},
104100
],
105101
]);

packages/jest-runner/src/index.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import exit from 'exit';
1111
import throat from 'throat';
1212
import Worker from 'jest-worker';
1313
import runTest from './runTest';
14-
import {worker} from './testWorker';
14+
import {worker, SerializableResolver} from './testWorker';
1515
import {
1616
OnTestFailure,
1717
OnTestStart,
@@ -103,11 +103,31 @@ class TestRunner {
103103
onResult: OnTestSuccess,
104104
onFailure: OnTestFailure,
105105
) {
106+
let resolvers: Map<string, SerializableResolver> | undefined = undefined;
107+
if (watcher.isWatchMode()) {
108+
resolvers = new Map();
109+
for (const test of tests) {
110+
if (!resolvers.has(test.context.config.name)) {
111+
resolvers.set(test.context.config.name, {
112+
config: test.context.config,
113+
serializableModuleMap: test.context.moduleMap.toJSON(),
114+
});
115+
}
116+
}
117+
}
118+
106119
const worker = new Worker(TEST_WORKER_PATH, {
107120
exposedMethods: ['worker'],
108121
forkOptions: {stdio: 'pipe'},
109122
maxRetries: 3,
110123
numWorkers: this._globalConfig.maxWorkers,
124+
setupArgs: resolvers
125+
? [
126+
{
127+
serializableResolvers: Array.from(resolvers.values()),
128+
},
129+
]
130+
: undefined,
111131
}) as WorkerInterface;
112132

113133
if (worker.getStdout()) worker.getStdout().pipe(process.stdout);
@@ -135,9 +155,6 @@ class TestRunner {
135155
},
136156
globalConfig: this._globalConfig,
137157
path: test.path,
138-
serializableModuleMap: watcher.isWatchMode()
139-
? test.context.moduleMap.toJSON()
140-
: null,
141158
});
142159
});
143160

packages/jest-runner/src/testWorker.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@
88

99
import {Config} from '@jest/types';
1010
import {SerializableError, TestResult} from '@jest/test-result';
11-
import HasteMap, {SerializableModuleMap, ModuleMap} from 'jest-haste-map';
11+
import HasteMap, {ModuleMap, SerializableModuleMap} from 'jest-haste-map';
1212
import exit from 'exit';
1313
import {separateMessageFromStack} from 'jest-message-util';
1414
import Runtime from 'jest-runtime';
15+
import Resolver from 'jest-resolve';
1516
import {ErrorWithCode, TestRunnerSerializedContext} from './types';
1617
import runTest from './runTest';
1718

19+
export type SerializableResolver = {
20+
config: Config.ProjectConfig;
21+
serializableModuleMap: SerializableModuleMap;
22+
};
23+
1824
type WorkerData = {
1925
config: Config.ProjectConfig;
2026
globalConfig: Config.GlobalConfig;
2127
path: Config.Path;
22-
serializableModuleMap: SerializableModuleMap | null;
2328
context?: TestRunnerSerializedContext;
2429
};
2530

@@ -47,45 +52,50 @@ const formatError = (error: string | ErrorWithCode): SerializableError => {
4752
};
4853
};
4954

50-
const resolvers = Object.create(null);
51-
const getResolver = (
52-
config: Config.ProjectConfig,
53-
moduleMap: ModuleMap | null,
54-
) => {
55-
// In watch mode, the raw module map with all haste modules is passed from
56-
// the test runner to the watch command. This is because jest-haste-map's
57-
// watch mode does not persist the haste map on disk after every file change.
58-
// To make this fast and consistent, we pass it from the TestRunner.
59-
if (moduleMap) {
60-
return Runtime.createResolver(config, moduleMap);
61-
} else {
62-
const name = config.name;
63-
if (!resolvers[name]) {
64-
resolvers[name] = Runtime.createResolver(
55+
const resolvers = new Map<string, Resolver>();
56+
const getResolver = (config: Config.ProjectConfig, moduleMap?: ModuleMap) => {
57+
const name = config.name;
58+
if (moduleMap || !resolvers.has(name)) {
59+
resolvers.set(
60+
name,
61+
Runtime.createResolver(
6562
config,
66-
Runtime.createHasteMap(config).readModuleMap(),
67-
);
68-
}
69-
return resolvers[name];
63+
moduleMap || Runtime.createHasteMap(config).readModuleMap(),
64+
),
65+
);
7066
}
67+
return resolvers.get(name)!;
7168
};
7269

70+
export function setup(setupData?: {
71+
serializableResolvers: Array<SerializableResolver>;
72+
}) {
73+
// Setup data is only used in watch mode to pass the latest version of all
74+
// module maps that will be used during the test runs. Otherwise, module maps
75+
// are loaded from disk as needed.
76+
if (setupData) {
77+
for (const {
78+
config,
79+
serializableModuleMap,
80+
} of setupData.serializableResolvers) {
81+
const moduleMap = HasteMap.ModuleMap.fromJSON(serializableModuleMap);
82+
getResolver(config, moduleMap);
83+
}
84+
}
85+
}
86+
7387
export async function worker({
7488
config,
7589
globalConfig,
7690
path,
77-
serializableModuleMap,
7891
context,
7992
}: WorkerData): Promise<TestResult> {
8093
try {
81-
const moduleMap = serializableModuleMap
82-
? HasteMap.ModuleMap.fromJSON(serializableModuleMap)
83-
: null;
8494
return await runTest(
8595
path,
8696
globalConfig,
8797
config,
88-
getResolver(config, moduleMap),
98+
getResolver(config),
8999
context && {
90100
...context,
91101
changedFiles: context.changedFiles && new Set(context.changedFiles),

0 commit comments

Comments
 (0)