From 191760a75b093ee6c10cc826c9de5dec5b574889 Mon Sep 17 00:00:00 2001 From: arlo Date: Fri, 6 Nov 2026 17:54:01 +0800 Subject: [PATCH 1/2] perf(rolldown): reduce stalls on cold session open --- .../__tests__/cold-open-cache.test.ts | 64 ++++++++ .../src/node/rolldown/events-manager.ts | 71 ++++++++ .../rolldown/src/node/rolldown/log-cache.ts | 153 ++++++++++-------- 3 files changed, 220 insertions(+), 68 deletions(-) create mode 100644 packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts diff --git a/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts b/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts new file mode 100644 index 00000000..877db8fb --- /dev/null +++ b/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts @@ -0,0 +1,64 @@ +import type { Event } from '@rolldown/debug' +import { describe, expect, it } from 'vitest' +import { createPluginBuildMetricsSummary, RolldownEventsManager } from '../events-manager' +import { stringifyInSlices } from '../log-cache' + +const base = { build_id: 'bid_1', session_id: 'sid_1' } + +function hookCalls(callId: string, pluginId: number, module: string, time: number): Record[] { + const plugin = { plugin_name: `plugin-${pluginId}`, plugin_id: pluginId, trigger: 'automatic', ...base } + return [ + { timestamp: time, action: 'HookResolveIdCallStart', importer: null, module_request: module, import_kind: 'import-statement', call_id: `${callId}-r`, ...plugin }, + { timestamp: time + 1, action: 'HookResolveIdCallEnd', resolved_id: module, is_external: false, call_id: `${callId}-r`, ...plugin }, + { timestamp: time + 2, action: 'HookLoadCallStart', module_id: module, call_id: `${callId}-l`, ...plugin }, + { timestamp: time + 5, action: 'HookLoadCallEnd', module_id: module, content: null, call_id: `${callId}-l`, ...plugin }, + { timestamp: time + 6, action: 'HookTransformCallStart', module_id: module, content: 'a', call_id: `${callId}-t`, ...plugin }, + { timestamp: time + 9, action: 'HookTransformCallEnd', module_id: module, content: 'bb', call_id: `${callId}-t`, ...plugin }, + ] +} + +function expectSummariesMatchCalls(manager: RolldownEventsManager) { + expect(manager.plugin_build_metrics.size).toBeGreaterThan(0) + const expected = new Map(Array.from(manager.plugin_build_metrics, ([id, metrics]) => [id, createPluginBuildMetricsSummary(metrics)])) + expect(manager.plugin_build_summaries).toEqual(expected) +} + +describe('plugin summaries accumulated while parsing', () => { + it('match summaries computed from the recorded calls, also after a restore', () => { + const manager = new RolldownEventsManager() + const events = [ + { timestamp: 1, action: 'BuildStart', ...base }, + ...hookCalls('a', 1, '/src/a.ts', 10), + ...hookCalls('b', 2, '/src/b.ts', 20), + ...hookCalls('c', 1, '/src/c.ts', 40), + // An end without a matching start isn't recorded, so it must not be counted either. + { timestamp: 60, action: 'HookTransformCallEnd', module_id: '/src/a.ts', content: 'x', call_id: 'missing', plugin_name: 'plugin-1', plugin_id: 1, trigger: 'automatic', ...base }, + ] + for (const event of events) + manager.handleEvent(event as unknown as Event) + + expectSummariesMatchCalls(manager) + expect(manager.plugin_build_summaries.get(1)!.total.count).toBe(6) + + // Restoring a snapshot (package summary cache) rebuilds the summaries. + const restored = new RolldownEventsManager() + restored.restore(manager.snapshot()) + expect(restored.plugin_build_summaries).toEqual(manager.plugin_build_summaries) + }) +}) + +describe('sliced cache serialization', () => { + it('produces the same JSON as JSON.stringify', async () => { + const large = Array.from({ length: 1100 }, (_, index) => index % 7 === 0 + ? undefined + : { + id: `m${index} "quoted" \u2028 รค`, + skipped: undefined, + nested: index % 5 ? [index, null, undefined, { deep: [1, 2] }] : Array.from({ length: 1100 }, (_, i) => i), + fn: () => index, + }) + const value = { version: 3, log: { size: 1, mtime: 2 }, data: { metrics: large, empty: [], text: 'x' }, missing: undefined } + + expect((await stringifyInSlices(value)).join('')).toBe(JSON.stringify(value)) + }) +}) diff --git a/packages/rolldown/src/node/rolldown/events-manager.ts b/packages/rolldown/src/node/rolldown/events-manager.ts index b3736801..c3abe53a 100644 --- a/packages/rolldown/src/node/rolldown/events-manager.ts +++ b/packages/rolldown/src/node/rolldown/events-manager.ts @@ -63,6 +63,62 @@ function getContentInfo(content: string | null | undefined): ContentInfo { } } +export interface PluginMetricSummary { + count: number + duration: number +} + +export interface PluginBuildMetricsSummary { + plugin_id: number + plugin_name: string + total: PluginMetricSummary + resolve: PluginMetricSummary + load: PluginMetricSummary + transform: PluginMetricSummary +} + +function createMetricSummary(): PluginMetricSummary { + return { + count: 0, + duration: 0, + } +} + +function addMetricSummary(summary: PluginMetricSummary, duration: number) { + summary.count += 1 + summary.duration += duration +} + +function createEmptyPluginBuildMetricsSummary(pluginId: number, pluginName: string): PluginBuildMetricsSummary { + return { + plugin_id: pluginId, + plugin_name: pluginName, + total: createMetricSummary(), + resolve: createMetricSummary(), + load: createMetricSummary(), + transform: createMetricSummary(), + } +} + +function addPluginCallToSummary(summary: PluginBuildMetricsSummary, call: Pick) { + addMetricSummary(summary.total, call.duration) + if (call.type === 'resolve') + addMetricSummary(summary.resolve, call.duration) + else if (call.type === 'load') + addMetricSummary(summary.load, call.duration) + else if (call.type === 'transform') + addMetricSummary(summary.transform, call.duration) +} + +export function createPluginBuildMetricsSummary(metrics: PluginBuildMetrics): PluginBuildMetricsSummary { + const summary = createEmptyPluginBuildMetricsSummary(metrics.plugin_id, metrics.plugin_name) + + for (const call of metrics.calls) + addPluginCallToSummary(summary, call) + + return summary +} + export function getContentRef(content: unknown) { return typeof content === 'string' && content.startsWith('$ref:') ? content.slice(5) @@ -106,6 +162,8 @@ export class RolldownEventsManager { module_build_hook_events: Map = new Map() module_build_metrics: Map = new Map() plugin_build_metrics: Map = new Map() + // Kept in step with `plugin_build_metrics`, so writing the plugin summary cache doesn't rescan every call. + plugin_build_summaries: Map = new Map() build_start_time: number = 0 build_end_time: number = 0 @@ -124,6 +182,15 @@ export class RolldownEventsManager { : getContentInfo(undefined) } + private addPluginCallSummary(pluginId: number, pluginName: string, duration: number, type: PluginBuildMetrics['calls'][number]['type']) { + let summary = this.plugin_build_summaries.get(pluginId) + if (!summary) { + summary = createEmptyPluginBuildMetricsSummary(pluginId, pluginName) + this.plugin_build_summaries.set(pluginId, summary) + } + addPluginCallToSummary(summary, { type, duration }) + } + recordBuildMetrics(event: ModuleBuildHookEvents) { if (MODULE_BUILD_START_HOOKS.includes(event.action)) { if (event.action === 'HookResolveIdCallStart') { @@ -225,6 +292,8 @@ export class RolldownEventsManager { }) } this.plugin_build_metrics.set(pluginId, plugin_build_metrics) + // Every branch above either returned or recorded a call. + this.addPluginCallSummary(pluginId, event.plugin_name, info.duration, plugin_build_metrics.calls.at(-1)!.type) this.module_build_metrics.set(module_id, module_build_metrics) } } @@ -320,6 +389,7 @@ export class RolldownEventsManager { this.module_build_hook_events.clear() this.module_build_metrics.clear() this.plugin_build_metrics.clear() + this.plugin_build_summaries.clear() this.build_start_time = 0 this.build_end_time = 0 } @@ -360,6 +430,7 @@ export class RolldownEventsManager { build_metrics: this.module_build_metrics.get(id), }])) this.plugin_build_metrics = new Map(snapshot.plugin_build_metrics) + this.plugin_build_summaries = new Map(snapshot.plugin_build_metrics.map(([pluginId, metrics]) => [pluginId, createPluginBuildMetricsSummary(metrics)])) this.build_start_time = snapshot.build_start_time this.build_end_time = snapshot.build_end_time } diff --git a/packages/rolldown/src/node/rolldown/log-cache.ts b/packages/rolldown/src/node/rolldown/log-cache.ts index e91b4e7e..430a0e80 100644 --- a/packages/rolldown/src/node/rolldown/log-cache.ts +++ b/packages/rolldown/src/node/rolldown/log-cache.ts @@ -1,9 +1,10 @@ import type { SessionMeta } from '@rolldown/debug' import type { Stats } from 'node:fs' -import type { ModuleBuildMetrics, PluginBuildMetrics } from '../../shared/types' -import type { RolldownEventsManager } from './events-manager' +import type { ModuleBuildMetrics } from '../../shared/types' +import type { PluginBuildMetricsSummary, RolldownEventsManager } from './events-manager' import fs from 'node:fs/promises' import { basename, dirname, join } from 'pathe' +import { createPluginBuildMetricsSummary } from './events-manager' import { decodeReaderIndex, encodeReaderIndex } from './reader-index-codec' export const ROLLDOWN_LOG_CACHE_DIR = '.cache' @@ -14,6 +15,8 @@ export const ROLLDOWN_LOG_PLUGIN_SUMMARY_CACHE_FILENAME = 'plugin-summary.json' export const ROLLDOWN_LOG_MODULE_METRICS_SUMMARY_CACHE_FILENAME = 'module-metrics-summary.json' const ROLLDOWN_LOG_CACHE_VERSION = 3 +export type { PluginBuildMetricsSummary, PluginMetricSummary } from './events-manager' + type ManagerSnapshot = ReturnType export interface LineLocation { @@ -74,20 +77,6 @@ interface RolldownModuleMetricsSummaryCacheSnapshot { metrics: Array<[string, ModuleBuildMetrics]> } -export interface PluginMetricSummary { - count: number - duration: number -} - -export interface PluginBuildMetricsSummary { - plugin_id: number - plugin_name: string - total: PluginMetricSummary - resolve: PluginMetricSummary - load: PluginMetricSummary - transform: PluginMetricSummary -} - interface RolldownPluginSummaryCacheSnapshot { plugins: Array<[number, PluginBuildMetricsSummary]> } @@ -127,7 +116,7 @@ class RolldownLogCacheFile { try { const temporaryFilepath = `${this.filepath}.tmp` await fs.mkdir(dirname(this.filepath), { recursive: true }) - await fs.writeFile(temporaryFilepath, JSON.stringify(this.createFile(stat, data)), 'utf8') + await fs.writeFile(temporaryFilepath, await stringifyInSlices(this.createFile(stat, data)), 'utf8') await fs.rename(temporaryFilepath, this.filepath) } catch { @@ -189,20 +178,61 @@ class RolldownReaderIndexCacheFile { } } -function toLogStat(stat: Stats) { - return { size: stat.size, mtime: stat.mtime.getTime() } +const SNAPSHOT_SLICE_MS = 8 + +function createYielder() { + let sliceStart = performance.now() + return async () => { + if (performance.now() - sliceStart <= SNAPSHOT_SLICE_MS) + return + await new Promise(resolve => setImmediate(resolve)) + sliceStart = performance.now() + } } -function createMetricSummary(): PluginMetricSummary { - return { - count: 0, - duration: 0, +const SLICED_ARRAY_MIN_LENGTH = 1024 + +/** + * Same output as `JSON.stringify(value)`, but large arrays are serialized item by item with + * yields in between, so a cache of tens of megabytes doesn't block the event loop at once. + */ +export async function stringifyInSlices(value: unknown): Promise { + const parts: string[] = [] + const yieldIfBusy = createYielder() + const visit = async (item: unknown, inArray: boolean): Promise => { + if (Array.isArray(item) && item.length >= SLICED_ARRAY_MIN_LENGTH) { + parts.push('[') + for (let index = 0; index < item.length; index++) { + if (index) + parts.push(',') + await visit(item[index], true) + await yieldIfBusy() + } + parts.push(']') + return + } + if (item && typeof item === 'object' && !Array.isArray(item) && typeof (item as { toJSON?: unknown }).toJSON !== 'function') { + parts.push('{') + let first = true + for (const [key, child] of Object.entries(item)) { + if (child === undefined || typeof child === 'function' || typeof child === 'symbol') + continue + parts.push(`${first ? '' : ','}${JSON.stringify(key)}:`) + first = false + await visit(child, false) + } + parts.push('}') + return + } + const json = JSON.stringify(item) + parts.push(json ?? (inArray ? 'null' : '')) } + await visit(value, false) + return parts } -function addMetricSummary(summary: PluginMetricSummary, duration: number) { - summary.count += 1 - summary.duration += duration +function toLogStat(stat: Stats) { + return { size: stat.size, mtime: stat.mtime.getTime() } } function compactModuleBuildMetrics(metrics: ModuleBuildMetrics): ModuleBuildMetrics { @@ -296,29 +326,6 @@ function compactModuleBuildMetrics(metrics: ModuleBuildMetrics): ModuleBuildMetr } } -function createPluginBuildMetricsSummary(metrics: PluginBuildMetrics): PluginBuildMetricsSummary { - const summary = { - plugin_id: metrics.plugin_id, - plugin_name: metrics.plugin_name, - total: createMetricSummary(), - resolve: createMetricSummary(), - load: createMetricSummary(), - transform: createMetricSummary(), - } - - for (const call of metrics.calls) { - addMetricSummary(summary.total, call.duration) - if (call.type === 'resolve') - addMetricSummary(summary.resolve, call.duration) - else if (call.type === 'load') - addMetricSummary(summary.load, call.duration) - else if (call.type === 'transform') - addMetricSummary(summary.transform, call.duration) - } - - return summary -} - export class RolldownLogCache { private sessionSummaryCache: RolldownLogCacheFile | undefined private moduleMetricsSummaryCache: RolldownLogCacheFile | undefined @@ -432,22 +439,26 @@ export class RolldownLogCache { async writeCompleteSession(stat: Stats, state: RolldownLogCacheState) { this.completeSessionWriteAttempted = true this.summaryWriteAttempted = true - await Promise.all([ - this.sessionSummaryCache?.write(stat, this.createSessionSummarySnapshot(state)), - this.moduleMetricsSummaryCache?.write(stat, this.createModuleMetricsSummarySnapshot(state)), - this.pluginSummaryCache?.write(stat, this.createPluginSummarySnapshot(state)), - this.readerIndexCache?.write(stat, state), - ]) + await this.writeSidecars(stat, state) } async writeSummary(stat: Stats, state: RolldownLogCacheState) { this.summaryWriteAttempted = true // Writes the same sidecars as a complete session, so don't write them again for the full read. this.completeSessionWriteAttempted = true + await this.writeSidecars(stat, state) + } + + private async writeSidecars(stat: Stats, state: RolldownLogCacheState) { + // Large sessions have tens of thousands of modules, so build the snapshots in slices first. + const manager = state.manager.snapshot() + const sessionSummary = this.createSessionSummarySnapshot(state, manager) + const moduleMetrics = await this.createModuleMetricsSummarySnapshot(manager) + const pluginSummary = await this.createPluginSummarySnapshot(state) await Promise.all([ - this.sessionSummaryCache?.write(stat, this.createSessionSummarySnapshot(state)), - this.moduleMetricsSummaryCache?.write(stat, this.createModuleMetricsSummarySnapshot(state)), - this.pluginSummaryCache?.write(stat, this.createPluginSummarySnapshot(state)), + this.sessionSummaryCache?.write(stat, sessionSummary), + this.moduleMetricsSummaryCache?.write(stat, moduleMetrics), + this.pluginSummaryCache?.write(stat, pluginSummary), this.readerIndexCache?.write(stat, state), ]) } @@ -457,8 +468,7 @@ export class RolldownLogCache { await this.packageSummaryCache?.write(stat, this.createPackageSummarySnapshot(state)) } - private createSessionSummarySnapshot(state: RolldownLogCacheState): RolldownSessionSummaryCacheSnapshot { - const manager = state.manager.snapshot() + private createSessionSummarySnapshot(state: RolldownLogCacheState, manager: ManagerSnapshot): RolldownSessionSummaryCacheSnapshot { return { reader: { lastBytes: state.lastBytes, @@ -479,18 +489,25 @@ export class RolldownLogCache { } } - private createModuleMetricsSummarySnapshot(state: RolldownLogCacheState): RolldownModuleMetricsSummaryCacheSnapshot { - const manager = state.manager.snapshot() - return { - metrics: manager.module_build_metrics.map(([module, metrics]) => [module, compactModuleBuildMetrics(metrics)]), + private async createModuleMetricsSummarySnapshot(manager: ManagerSnapshot): Promise { + const yieldIfBusy = createYielder() + const metrics: RolldownModuleMetricsSummaryCacheSnapshot['metrics'] = [] + for (const [module, moduleMetrics] of manager.module_build_metrics) { + metrics.push([module, compactModuleBuildMetrics(moduleMetrics)]) + await yieldIfBusy() } + return { metrics } } - private createPluginSummarySnapshot(state: RolldownLogCacheState): RolldownPluginSummaryCacheSnapshot { - const manager = state.manager.snapshot() - return { - plugins: manager.plugin_build_metrics.map(([pluginId, metrics]) => [pluginId, createPluginBuildMetricsSummary(metrics)]), + private async createPluginSummarySnapshot(state: RolldownLogCacheState): Promise { + const yieldIfBusy = createYielder() + const plugins: RolldownPluginSummaryCacheSnapshot['plugins'] = [] + for (const [pluginId, metrics] of state.manager.plugin_build_metrics) { + // Summaries are accumulated while parsing; metrics hydrated from the index don't have one yet. + plugins.push([pluginId, state.manager.plugin_build_summaries.get(pluginId) ?? createPluginBuildMetricsSummary(metrics)]) + await yieldIfBusy() } + return { plugins } } private createPackageSummarySnapshot(state: RolldownLogCacheState): RolldownPackageSummaryCacheSnapshot { From 7c6cd8b280f11ba3d6b355fd3da2f30f2849909f Mon Sep 17 00:00:00 2001 From: arlo Date: Fri, 6 Nov 2026 17:58:03 +0800 Subject: [PATCH 2/2] chore: update Co-Authored-By: Claude Opus 5.5 --- .../node/rolldown/__tests__/cold-open-cache.test.ts | 3 +++ packages/rolldown/src/node/rolldown/log-cache.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts b/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts index 877db8fb..42abe75e 100644 --- a/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts +++ b/packages/rolldown/src/node/rolldown/__tests__/cold-open-cache.test.ts @@ -56,6 +56,9 @@ describe('sliced cache serialization', () => { skipped: undefined, nested: index % 5 ? [index, null, undefined, { deep: [1, 2] }] : Array.from({ length: 1100 }, (_, i) => i), fn: () => index, + // eslint-disable-next-line no-new-wrappers, unicorn/new-for-builtins + boxed: new String(`s${index}`), + date: new Date(index), }) const value = { version: 3, log: { size: 1, mtime: 2 }, data: { metrics: large, empty: [], text: 'x' }, missing: undefined } diff --git a/packages/rolldown/src/node/rolldown/log-cache.ts b/packages/rolldown/src/node/rolldown/log-cache.ts index 430a0e80..622274a3 100644 --- a/packages/rolldown/src/node/rolldown/log-cache.ts +++ b/packages/rolldown/src/node/rolldown/log-cache.ts @@ -196,6 +196,14 @@ const SLICED_ARRAY_MIN_LENGTH = 1024 * Same output as `JSON.stringify(value)`, but large arrays are serialized item by item with * yields in between, so a cache of tens of megabytes doesn't block the event loop at once. */ +// Anything else (boxed primitives, dates, class instances) is left to `JSON.stringify`. +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') + return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + export async function stringifyInSlices(value: unknown): Promise { const parts: string[] = [] const yieldIfBusy = createYielder() @@ -211,7 +219,7 @@ export async function stringifyInSlices(value: unknown): Promise { parts.push(']') return } - if (item && typeof item === 'object' && !Array.isArray(item) && typeof (item as { toJSON?: unknown }).toJSON !== 'function') { + if (isPlainObject(item) && typeof item.toJSON !== 'function') { parts.push('{') let first = true for (const [key, child] of Object.entries(item)) {