Skip to content

Commit 235140b

Browse files
bjnewmanBenjamin Newman
andauthored
perf(ssr): skip circular import check for already-evaluated modules (#21632)
Co-authored-by: Benjamin Newman <benjaminnewman@Benjamins-Laptop.local>
1 parent 8ce23a3 commit 235140b

2 files changed

Lines changed: 225 additions & 2 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* Benchmark for the ModuleRunner
3+
*
4+
* Creates synthetic module graphs at various scales and measures the
5+
* total time to evaluate all modules.
6+
*
7+
* Usage:
8+
* node packages/vite/scripts/benchCircularImport.ts
9+
*
10+
* With CPU profiling:
11+
* node --cpu-prof --cpu-prof-dir=./profiles packages/vite/scripts/benchCircularImport.ts
12+
*/
13+
14+
import { createServer, createServerModuleRunner } from 'vite'
15+
16+
// ---------------------------------------------------------------------------
17+
// Graph generation
18+
// ---------------------------------------------------------------------------
19+
20+
interface GraphConfig {
21+
/** Number of modules */
22+
moduleCount: number
23+
/** Average imports per module */
24+
avgImports: number
25+
/** Fraction of modules involved in cycles (0..1) */
26+
cycleFraction: number
27+
}
28+
29+
interface SyntheticGraph {
30+
/** Map of module id -> list of dependency ids */
31+
edges: Map<string, string[]>
32+
/** The entry module id */
33+
entry: string
34+
}
35+
36+
function moduleId(i: number): string {
37+
return `/bench/mod_${i}.js`
38+
}
39+
40+
/**
41+
* Generate a synthetic module graph with configurable size, density, and cycles.
42+
*
43+
* Strategy:
44+
* 1. Create a chain: mod_0 -> mod_1 -> ... -> mod_{n-1} (ensures all reachable)
45+
* 2. Add random cross-edges to reach target avg imports
46+
* 3. Add back-edges to create cycles for cycleFraction of modules
47+
*/
48+
function generateGraph(config: GraphConfig): SyntheticGraph {
49+
const { moduleCount, avgImports, cycleFraction } = config
50+
const edges = new Map<string, string[]>()
51+
52+
// Initialize all modules
53+
for (let i = 0; i < moduleCount; i++) {
54+
edges.set(moduleId(i), [])
55+
}
56+
57+
// 1. Chain: each module imports the next (ensures all reachable from entry)
58+
for (let i = 0; i < moduleCount - 1; i++) {
59+
edges.get(moduleId(i))!.push(moduleId(i + 1))
60+
}
61+
62+
// 2. Random edges to reach target density (may include back-edges)
63+
const targetTotalEdges = moduleCount * avgImports
64+
const currentEdges = moduleCount - 1
65+
const extraEdges = Math.max(0, targetTotalEdges - currentEdges)
66+
67+
// Use seeded PRNG for reproducibility
68+
let seed = 42
69+
const rand = () => {
70+
seed = (seed * 1664525 + 1013904223) & 0x7fffffff
71+
return seed / 0x7fffffff
72+
}
73+
74+
for (let e = 0; e < extraEdges; e++) {
75+
const from = Math.floor(rand() * moduleCount)
76+
const to = Math.floor(rand() * moduleCount)
77+
if (from !== to) {
78+
const deps = edges.get(moduleId(from))!
79+
const target = moduleId(to)
80+
if (!deps.includes(target)) {
81+
deps.push(target)
82+
}
83+
}
84+
}
85+
86+
// 3. Add cycles: back-edges from later modules to earlier ones
87+
const cycleModules = Math.floor(moduleCount * cycleFraction)
88+
for (let c = 0; c < cycleModules; c++) {
89+
// Pick a module in the later half, add back-edge to earlier half
90+
const from = Math.floor(moduleCount * 0.5 + rand() * moduleCount * 0.5)
91+
const to = Math.floor(rand() * moduleCount * 0.5)
92+
if (from < moduleCount && to < moduleCount && from !== to) {
93+
const deps = edges.get(moduleId(from))!
94+
const target = moduleId(to)
95+
if (!deps.includes(target)) {
96+
deps.push(target)
97+
}
98+
}
99+
}
100+
101+
return { edges, entry: moduleId(0) }
102+
}
103+
104+
// ---------------------------------------------------------------------------
105+
// Benchmark plugin (resolveId + load)
106+
// ---------------------------------------------------------------------------
107+
108+
const BENCH_PREFIX = '/bench/'
109+
110+
function createBenchPlugin(graph: SyntheticGraph) {
111+
return {
112+
name: 'bench-circular-import',
113+
114+
resolveId(id: string) {
115+
if (id.startsWith(BENCH_PREFIX)) {
116+
// Return a virtual module id (null-byte prefix prevents fs lookup)
117+
return `\0${id}`
118+
}
119+
},
120+
121+
load(id: string) {
122+
if (id.startsWith(`\0${BENCH_PREFIX}`)) {
123+
const realId = id.slice(1)
124+
const deps = graph.edges.get(realId)
125+
if (deps) {
126+
const lines = deps.map(
127+
(dep, i) => `import { value as __dep_${i}__ } from "${dep}";`,
128+
)
129+
lines.push(`export const value = ${deps.length};`)
130+
return lines.join('\n')
131+
}
132+
}
133+
},
134+
}
135+
}
136+
137+
// ---------------------------------------------------------------------------
138+
// Benchmark runner
139+
// ---------------------------------------------------------------------------
140+
141+
interface BenchResult {
142+
moduleCount: number
143+
edgeCount: number
144+
timeMs: number
145+
}
146+
147+
async function runBenchmark(config: GraphConfig): Promise<BenchResult> {
148+
const graph = generateGraph(config)
149+
150+
let edgeCount = 0
151+
for (const deps of graph.edges.values()) {
152+
edgeCount += deps.length
153+
}
154+
155+
const server = await createServer({
156+
configFile: false,
157+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
158+
root: import.meta.dirname,
159+
logLevel: 'error',
160+
server: {
161+
middlewareMode: true,
162+
ws: false,
163+
},
164+
optimizeDeps: {
165+
noDiscovery: true,
166+
include: [],
167+
},
168+
plugins: [createBenchPlugin(graph)],
169+
})
170+
171+
const runner = createServerModuleRunner(server.environments.ssr, {
172+
hmr: false,
173+
sourcemapInterceptor: false,
174+
})
175+
176+
const start = performance.now()
177+
await runner.import(graph.entry)
178+
const timeMs = performance.now() - start
179+
180+
await runner.close()
181+
await server.close()
182+
183+
return {
184+
moduleCount: config.moduleCount,
185+
edgeCount,
186+
timeMs,
187+
}
188+
}
189+
190+
// ---------------------------------------------------------------------------
191+
// Main
192+
// ---------------------------------------------------------------------------
193+
194+
async function main() {
195+
console.log('=== Vite isCircularImport Benchmark ===\n')
196+
197+
const scales = [100, 500, 1000, 2000, 5000]
198+
const results: BenchResult[] = []
199+
200+
// Warmup: single small run to trigger JIT compilation
201+
await runBenchmark({ moduleCount: 100, avgImports: 3, cycleFraction: 0.05 })
202+
203+
for (const n of scales) {
204+
const result = await runBenchmark({
205+
moduleCount: n,
206+
avgImports: 3,
207+
cycleFraction: 0.05,
208+
})
209+
results.push(result)
210+
211+
console.log(`N=${n}:`)
212+
console.log(` modules: ${result.moduleCount}, edges: ${result.edgeCount}`)
213+
console.log(` time: ${result.timeMs.toFixed(1)}ms`)
214+
console.log()
215+
}
216+
}
217+
218+
main().catch(console.error)

packages/vite/src/module-runner/runner.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,12 @@ export class ModuleRunner {
182182

183183
if (importee) importers.add(importee)
184184

185-
// check circular dependency
185+
// fast path: already evaluated modules can't deadlock
186+
if (mod.evaluated && mod.promise) {
187+
return this.processImport(await mod.promise, meta, metadata)
188+
}
189+
190+
// check circular dependency (only for modules still being evaluated)
186191
if (
187192
callstack.includes(moduleId) ||
188193
this.isCircularModule(mod) ||
@@ -207,7 +212,7 @@ export class ModuleRunner {
207212
}
208213

209214
try {
210-
// cached module
215+
// cached module (in-progress, not yet evaluated)
211216
if (mod.promise)
212217
return this.processImport(await mod.promise, meta, metadata)
213218

0 commit comments

Comments
 (0)