Skip to content

Commit 9f23f8e

Browse files
authored
perf: lowers peak memory usage when using --changed on a large graph (#10866)
1 parent 5eb3570 commit 9f23f8e

1 file changed

Lines changed: 92 additions & 24 deletions

File tree

‎packages/vitest/src/node/specifications.ts‎

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Vitest } from './core'
22
import type { TestProject } from './project'
33
import type { TestSpecification } from './test-specification'
44
import { existsSync } from 'node:fs'
5+
import os from 'node:os'
56
import { join, relative, resolve } from 'pathe'
67
import pm from 'picomatch'
78
import { isWindows } from '../utils/env'
@@ -145,34 +146,81 @@ export class VitestSpecifications {
145146
return []
146147
}
147148

148-
const testGraphs = await Promise.all(
149-
specs.map(async (spec) => {
150-
const deps = await this.getTestDependencies(spec)
151-
return [spec, deps] as const
152-
}),
153-
)
149+
// The module graph, and so the dependency edges, are per project.
150+
const specsByProject = new Map<TestProject, TestSpecification[]>()
151+
for (const spec of specs) {
152+
let projectSpecs = specsByProject.get(spec.project)
153+
if (!projectSpecs) {
154+
specsByProject.set(spec.project, projectSpecs = [])
155+
}
156+
projectSpecs.push(spec)
157+
}
158+
159+
const affectedByProject = new Map<TestProject, Set<string>>()
160+
for (const [project, projectSpecs] of specsByProject) {
161+
affectedByProject.set(
162+
project,
163+
await this.getAffectedModules(project, projectSpecs, related),
164+
)
165+
}
166+
167+
return specs.filter(spec => affectedByProject.get(spec.project)!.has(spec.moduleId))
168+
}
154169

155-
const runningTests: TestSpecification[] = []
170+
/**
171+
* Returns every module in `project` that transitively imports one of `related`.
172+
*
173+
* Expands each module's imports at most once into a shared reverse-edge map,
174+
* then walks that map backwards from the changed files.
175+
*/
176+
private async getAffectedModules(
177+
project: TestProject,
178+
specs: TestSpecification[],
179+
related: string[],
180+
): Promise<Set<string>> {
181+
const importers = new Map<string, Set<string>>()
182+
const visited = new Set<string>()
183+
const existsCache = new Map<string, boolean>()
156184

157-
for (const [specification, deps] of testGraphs) {
158-
// if deps or the test itself were changed
159-
if (related.some(path => path === specification.moduleId || deps.has(path))) {
160-
runningTests.push(specification)
185+
// limit concurrency to lower peak memory usage on large graphs
186+
const TRANSFORM_CONCURRENCY = os.availableParallelism?.() ?? os.cpus().length
187+
let active = 0
188+
const waiters: Array<() => void> = []
189+
const withLimit = async <T>(fn: () => Promise<T>): Promise<T> => {
190+
if (active >= TRANSFORM_CONCURRENCY) {
191+
await new Promise<void>(resolve => waiters.push(resolve))
192+
}
193+
active++
194+
try {
195+
return await fn()
196+
}
197+
finally {
198+
active--
199+
waiters.shift()?.()
161200
}
162201
}
163202

164-
return runningTests
165-
}
203+
const cachedExists = (filepath: string): boolean => {
204+
const cached = existsCache.get(filepath)
205+
if (cached !== undefined) {
206+
return cached
207+
}
208+
const result = existsSync(filepath)
209+
existsCache.set(filepath, result)
210+
return result
211+
}
166212

167-
private async getTestDependencies(spec: TestSpecification, deps = new Set<string>()): Promise<Set<string>> {
168-
const addImports = async (project: TestProject, filepath: string) => {
169-
if (deps.has(filepath)) {
213+
const addImports = async (filepath: string) => {
214+
// `visited` is shared by every spec in the project, so a module is
215+
// expanded once per run instead of once per test file that reaches it.
216+
if (visited.has(filepath)) {
170217
return
171218
}
172-
deps.add(filepath)
219+
visited.add(filepath)
173220

174-
const mod = project.vite.environments.ssr.moduleGraph.getModuleById(filepath)
175-
const transformed = mod?.transformResult || await project.vite.environments.ssr.transformRequest(filepath)
221+
const environment = project.vite.environments.ssr
222+
const mod = environment.moduleGraph.getModuleById(filepath)
223+
const transformed = mod?.transformResult || await withLimit(() => environment.transformRequest(filepath))
176224
if (!transformed) {
177225
return
178226
}
@@ -181,15 +229,35 @@ export class VitestSpecifications {
181229
const fsPath = dep.startsWith('/@fs/')
182230
? dep.slice(isWindows ? 5 : 4)
183231
: join(project.config.root, dep)
184-
if (!fsPath.includes('node_modules') && !deps.has(fsPath) && existsSync(fsPath)) {
185-
await addImports(project, fsPath)
232+
if (fsPath.includes('node_modules') || !cachedExists(fsPath)) {
233+
return
186234
}
235+
let importedBy = importers.get(fsPath)
236+
if (!importedBy) {
237+
importers.set(fsPath, importedBy = new Set())
238+
}
239+
importedBy.add(filepath)
240+
await addImports(fsPath)
187241
}))
188242
}
189243

190-
await addImports(spec.project, spec.moduleId)
191-
deps.delete(spec.moduleId)
244+
await Promise.all(specs.map(spec => addImports(spec.moduleId)))
245+
246+
const affected = new Set<string>(related)
247+
const queue = [...related]
248+
while (queue.length) {
249+
const importedBy = importers.get(queue.pop()!)
250+
if (!importedBy) {
251+
continue
252+
}
253+
for (const importer of importedBy) {
254+
if (!affected.has(importer)) {
255+
affected.add(importer)
256+
queue.push(importer)
257+
}
258+
}
259+
}
192260

193-
return deps
261+
return affected
194262
}
195263
}

0 commit comments

Comments
 (0)