session-open.bench.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. /** Required performance budgets for cold Session preparation, first history, and Agent resume. */
  2. import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import {
  7. runBuiltBenchmarkWorker,
  8. type BuiltBenchmarkWorkerRun,
  9. } from '../support/built-worker.ts'
  10. import {
  11. ciTimeBudget,
  12. PERFORMANCE_BUDGET_HEADROOM,
  13. } from '../support/calibration.ts'
  14. import type {
  15. SessionOpenBenchmarkScenario,
  16. SessionOpenWorkerReport,
  17. } from './session-open.worker.ts'
  18. import {
  19. SYNTHETIC_CURRENT_GENERATION,
  20. SYNTHETIC_SESSION_DIRECTORY,
  21. SYNTHETIC_CURRENT_FILENAME,
  22. SYNTHETIC_V0_FILENAME,
  23. writeSyntheticReleasedV0Session,
  24. type SyntheticV0SessionWrite,
  25. } from './synthetic-released-v0-session.ts'
  26. /** 200 turns × (500 text + 125 reasoning deltas): 127,400 released-v0 events. */
  27. const SHAPE = { turns: 200, textDeltas: 500 } as const
  28. /** Fresh processes per normal-heap scenario; the median enforces each timing budget. */
  29. const ATTEMPTS = 5
  30. /** A stuck child is reaped well before the outer test and hook deadlines. */
  31. const WORKER_TIMEOUT_MS = 60_000
  32. /** Old-space pressure check, kept independent from normal-heap timing samples. */
  33. const CONSTRAINED_HEAP_MB = 128
  34. type SessionAccessKind = 'first-open' | 'post-upgrade-reopen'
  35. type SessionBenchmarkEndpoint = 'phases' | 'first-history' | 'agent-resume'
  36. const SOURCE_GENERATION_BY_ACCESS = {
  37. 'first-open': 'released-v0',
  38. 'post-upgrade-reopen': SYNTHETIC_CURRENT_GENERATION,
  39. } as const satisfies Record<SessionAccessKind, string>
  40. /** Expected durations on the reference machine before CI scaling and variance headroom. */
  41. const EXPECTED_MS = {
  42. migrationOpen: 220,
  43. read: 8,
  44. sessionRestore: 24,
  45. projection: 14,
  46. firstOpenFirstHistory: 220,
  47. reopenFirstHistory: 48,
  48. firstOpenAgentResume: 180,
  49. reopenAgentResume: 40,
  50. } as const
  51. const MIGRATION_OPEN_BUDGET_MS = ciTimeBudget(EXPECTED_MS.migrationOpen)
  52. /** Standard two-CPU CI reopen samples span 47.4–49.2 ms; 50 ms is the rounded expectation. */
  53. const EXPECTED_REOPEN_CI_MS = 50
  54. const REOPEN_OPEN_BUDGET_MS = Math.ceil(EXPECTED_REOPEN_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  55. const READ_BUDGET_MS = ciTimeBudget(EXPECTED_MS.read)
  56. const SESSION_RESTORE_BUDGET_MS = ciTimeBudget(EXPECTED_MS.sessionRestore)
  57. const PROJECTION_BUDGET_MS = ciTimeBudget(EXPECTED_MS.projection)
  58. const FIRST_OPEN_FIRST_HISTORY_BUDGET_MS = ciTimeBudget(EXPECTED_MS.firstOpenFirstHistory)
  59. const REOPEN_FIRST_HISTORY_BUDGET_MS = ciTimeBudget(EXPECTED_MS.reopenFirstHistory)
  60. const FIRST_OPEN_AGENT_RESUME_BUDGET_MS = ciTimeBudget(EXPECTED_MS.firstOpenAgentResume)
  61. const REOPEN_AGENT_RESUME_BUDGET_MS = ciTimeBudget(EXPECTED_MS.reopenAgentResume)
  62. /** Historical-reference retained heap before variance headroom. */
  63. const EXPECTED_AGENT_RETAINED_HEAP_MB = 26.1
  64. const AGENT_RETAINED_HEAP_BUDGET_MB = Math.ceil(
  65. EXPECTED_AGENT_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM,
  66. )
  67. const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-open', 'session-open.worker.js')
  68. type WorkerRun = BuiltBenchmarkWorkerRun<SessionOpenWorkerReport>
  69. function rounded(value: number): number {
  70. return Math.round(value * 10) / 10
  71. }
  72. function median(values: readonly number[]): number {
  73. const sorted = [...values].sort((left, right) => left - right)
  74. return sorted[Math.floor(sorted.length / 2)] as number
  75. }
  76. function metric(
  77. reports: readonly SessionOpenWorkerReport[],
  78. read: (report: SessionOpenWorkerReport) => number,
  79. ): { readonly min: number; readonly median: number; readonly max: number; readonly samples: readonly number[] } {
  80. const samples = reports.map(read)
  81. return {
  82. min: rounded(Math.min(...samples)),
  83. median: rounded(median(samples)),
  84. max: rounded(Math.max(...samples)),
  85. samples: samples.map(rounded),
  86. }
  87. }
  88. function phaseMetric(
  89. reports: readonly SessionOpenWorkerReport[],
  90. key: keyof NonNullable<SessionOpenWorkerReport['phases']>,
  91. ): ReturnType<typeof metric> {
  92. return metric(reports, (report) => {
  93. if (report.phases === undefined) throw new Error(`${report.scenario} did not report phase timings`)
  94. return report.phases[key]
  95. })
  96. }
  97. function summarize(reports: readonly SessionOpenWorkerReport[]) {
  98. return {
  99. totalMs: metric(reports, report => report.totalMs),
  100. cpuUserMs: metric(reports, report => report.cpuUserMs),
  101. cpuSystemMs: metric(reports, report => report.cpuSystemMs),
  102. retainedHeapMb: metric(reports, report => report.retained.heapUsedMb),
  103. retainedExternalMb: metric(reports, report => report.retained.externalMb),
  104. retainedArrayBuffersMb: metric(reports, report => report.retained.arrayBuffersMb),
  105. retainedRssMb: metric(reports, report => report.retained.rssMb),
  106. peakRssMb: metric(reports, report => report.afterGc.peakRssMb),
  107. }
  108. }
  109. function summarizePhases(reports: readonly SessionOpenWorkerReport[]) {
  110. return {
  111. ...summarize(reports),
  112. openMs: phaseMetric(reports, 'openMs'),
  113. readMs: phaseMetric(reports, 'readMs'),
  114. sessionRestoreMs: phaseMetric(reports, 'sessionRestoreMs'),
  115. projectionMs: phaseMetric(reports, 'projectionMs'),
  116. }
  117. }
  118. function runWorker(
  119. root: string,
  120. scenario: SessionOpenBenchmarkScenario,
  121. heapLimitMb?: number,
  122. ): Promise<WorkerRun> {
  123. return runBuiltBenchmarkWorker({
  124. worker: WORKER,
  125. args: [root, scenario],
  126. timeoutMs: WORKER_TIMEOUT_MS,
  127. exposeGc: true,
  128. ...(heapLimitMb === undefined ? {} : { heapLimitMb }),
  129. })
  130. }
  131. function requireReport(
  132. run: WorkerRun,
  133. scenario: SessionOpenBenchmarkScenario,
  134. heapLimitMb?: number,
  135. ): SessionOpenWorkerReport {
  136. if (run.report !== undefined) return run.report
  137. const stderrLines = run.stderr.trim().split('\n')
  138. const fatal = stderrLines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line))
  139. const detail = (fatal.length > 0 ? fatal : stderrLines.slice(-10)).join('\n')
  140. const limit = heapLimitMb === undefined ? 'normal heap' : `${String(heapLimitMb)} MB old space`
  141. throw new Error(
  142. `${scenario} failed under ${limit}: exit=${String(run.exitCode)}, signal=${String(run.signal)}, `
  143. + `timedOut=${String(run.timedOut)}\n${detail}`,
  144. )
  145. }
  146. /** Owns deterministic first-open/reopen sources and private roots created for one benchmark file. */
  147. class SessionOpenBenchmarkSuite {
  148. private legacySourcePath = ''
  149. private currentSourcePath = ''
  150. private scratch = ''
  151. private facts: SyntheticV0SessionWrite | undefined
  152. private rootIndex = 0
  153. async prepare(): Promise<void> {
  154. this.scratch = await mkdtemp(join(tmpdir(), 'dsh-session-open-bench-'))
  155. this.facts = await writeSyntheticReleasedV0Session(join(this.scratch, 'source'), SHAPE)
  156. this.legacySourcePath = this.facts.path
  157. // Produce one real post-upgrade directory outside every measured interval.
  158. const templateRoot = await this.createRoot('first-open', 'post-upgrade-template')
  159. requireReport(await runWorker(templateRoot, 'agent-resume'), 'agent-resume')
  160. this.currentSourcePath = join(
  161. templateRoot,
  162. SYNTHETIC_SESSION_DIRECTORY,
  163. SYNTHETIC_CURRENT_FILENAME,
  164. )
  165. }
  166. async dispose(): Promise<void> {
  167. await rm(this.scratch, { recursive: true, force: true })
  168. }
  169. workload(accessKind: SessionAccessKind) {
  170. if (this.facts === undefined) throw new Error('Session opening benchmark source is not prepared')
  171. return {
  172. accessKind,
  173. sourceGeneration: SOURCE_GENERATION_BY_ACCESS[accessKind],
  174. logicalInputEvents: this.facts.events,
  175. legacyInputRows: this.facts.rows,
  176. legacyInputFrames: this.facts.frames,
  177. legacyInputLogicalBytes: this.facts.logicalBytes,
  178. legacyInputCompressedBytes: this.facts.compressedBytes,
  179. }
  180. }
  181. async sample(
  182. accessKind: SessionAccessKind,
  183. endpoint: SessionBenchmarkEndpoint,
  184. ): Promise<SessionOpenWorkerReport[]> {
  185. const reports: SessionOpenWorkerReport[] = []
  186. for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
  187. reports.push(await this.run(accessKind, endpoint))
  188. }
  189. return reports
  190. }
  191. async run(
  192. accessKind: SessionAccessKind,
  193. endpoint: SessionBenchmarkEndpoint,
  194. heapLimitMb?: number,
  195. ): Promise<SessionOpenWorkerReport> {
  196. const scenario = this.workerScenario(accessKind, endpoint)
  197. const root = await this.createRoot(
  198. accessKind,
  199. `${accessKind}-${endpoint}-${String(this.rootIndex++)}`,
  200. )
  201. const report = requireReport(await runWorker(root, scenario, heapLimitMb), scenario, heapLimitMb)
  202. return report
  203. }
  204. private workerScenario(
  205. accessKind: SessionAccessKind,
  206. endpoint: SessionBenchmarkEndpoint,
  207. ): SessionOpenBenchmarkScenario {
  208. if (endpoint !== 'phases') return endpoint
  209. return accessKind === 'first-open' ? 'phase-migrate' : 'phase-steady'
  210. }
  211. private async createRoot(accessKind: SessionAccessKind, label: string): Promise<string> {
  212. const root = join(this.scratch, label)
  213. const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
  214. await mkdir(directory, { recursive: true })
  215. await copyFile(this.legacySourcePath, join(directory, SYNTHETIC_V0_FILENAME))
  216. if (accessKind === 'post-upgrade-reopen') {
  217. if (this.currentSourcePath === '') throw new Error('current V2 benchmark source is not prepared')
  218. // Released generations remain adjacent after migration, so V2 samples retain their V0 predecessor.
  219. await copyFile(this.currentSourcePath, join(directory, SYNTHETIC_CURRENT_FILENAME))
  220. }
  221. return root
  222. }
  223. }
  224. interface AccessBenchmarkSpec {
  225. readonly accessKind: SessionAccessKind
  226. readonly label: string
  227. readonly openBudgetMs: number
  228. readonly firstHistoryBudgetMs: number
  229. readonly agentResumeBudgetMs: number
  230. }
  231. const ACCESS_BENCHMARKS: readonly AccessBenchmarkSpec[] = [
  232. {
  233. accessKind: 'first-open',
  234. label: 'first open from released V0',
  235. openBudgetMs: MIGRATION_OPEN_BUDGET_MS,
  236. firstHistoryBudgetMs: FIRST_OPEN_FIRST_HISTORY_BUDGET_MS,
  237. agentResumeBudgetMs: FIRST_OPEN_AGENT_RESUME_BUDGET_MS,
  238. },
  239. {
  240. accessKind: 'post-upgrade-reopen',
  241. label: 'fresh-process reopen after upgrade',
  242. openBudgetMs: REOPEN_OPEN_BUDGET_MS,
  243. firstHistoryBudgetMs: REOPEN_FIRST_HISTORY_BUDGET_MS,
  244. agentResumeBudgetMs: REOPEN_AGENT_RESUME_BUDGET_MS,
  245. },
  246. ]
  247. function expectOpenWithinBudget(value: number, budget: number): void {
  248. expect(value).toBeLessThanOrEqual(budget)
  249. }
  250. describe('standard hosted reopen calibration', () => {
  251. it('accepts the recorded two-CPU samples that exceed the historical budget', () => {
  252. const recordedMedian = median([49.2, 47.4, 49.1, 48.6, 48.1])
  253. expect(recordedMedian).toBe(48.6)
  254. expect(() => expectOpenWithinBudget(recordedMedian, ciTimeBudget(12))).toThrow()
  255. expectOpenWithinBudget(recordedMedian, REOPEN_OPEN_BUDGET_MS)
  256. expect(REOPEN_OPEN_BUDGET_MS).toBe(63)
  257. })
  258. it('rejects synthetic reopen and multi-second first-open regressions', () => {
  259. const regressionMedian = median([74, 75, 76, 75, 74])
  260. expect(() => expectOpenWithinBudget(regressionMedian, REOPEN_OPEN_BUDGET_MS)).toThrow()
  261. expect(MIGRATION_OPEN_BUDGET_MS).toBe(550)
  262. expect(() => expectOpenWithinBudget(4_000, MIGRATION_OPEN_BUDGET_MS)).toThrow()
  263. })
  264. })
  265. describe('opening a large Session for first open and post-upgrade reopen', () => {
  266. const suite = new SessionOpenBenchmarkSuite()
  267. beforeAll(async () => { await suite.prepare() })
  268. afterAll(async () => { await suite.dispose() })
  269. for (const access of ACCESS_BENCHMARKS) {
  270. describe(access.label, () => {
  271. it('profiles all four phases under normal heap', async () => {
  272. const result = summarizePhases(await suite.sample(access.accessKind, 'phases'))
  273. console.log(JSON.stringify({
  274. benchmark: `session-open/${access.accessKind}/phases`,
  275. ...suite.workload(access.accessKind),
  276. result,
  277. budgetsMs: {
  278. open: access.openBudgetMs,
  279. read: READ_BUDGET_MS,
  280. sessionRestore: SESSION_RESTORE_BUDGET_MS,
  281. projection: PROJECTION_BUDGET_MS,
  282. },
  283. }))
  284. expectOpenWithinBudget(result.openMs.median, access.openBudgetMs)
  285. expect(result.readMs.median).toBeLessThanOrEqual(READ_BUDGET_MS)
  286. expect(result.sessionRestoreMs.median).toBeLessThanOrEqual(SESSION_RESTORE_BUDGET_MS)
  287. expect(result.projectionMs.median).toBeLessThanOrEqual(PROJECTION_BUDGET_MS)
  288. })
  289. it(`completes all four phases under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  290. const report = await suite.run(access.accessKind, 'phases', CONSTRAINED_HEAP_MB)
  291. console.log(JSON.stringify({
  292. benchmark: `session-open/${access.accessKind}/phases-constrained`,
  293. ...suite.workload(access.accessKind),
  294. heapLimitMb: CONSTRAINED_HEAP_MB,
  295. report,
  296. }))
  297. })
  298. it(`produces first Host history within ${String(access.firstHistoryBudgetMs)} ms`, async () => {
  299. const result = summarize(await suite.sample(access.accessKind, 'first-history'))
  300. console.log(JSON.stringify({
  301. benchmark: `session-open/${access.accessKind}/first-history`,
  302. ...suite.workload(access.accessKind),
  303. result,
  304. budgetMs: access.firstHistoryBudgetMs,
  305. }))
  306. expect(result.totalMs.median).toBeLessThanOrEqual(access.firstHistoryBudgetMs)
  307. })
  308. it(`produces first Host history under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  309. const report = await suite.run(access.accessKind, 'first-history', CONSTRAINED_HEAP_MB)
  310. console.log(JSON.stringify({
  311. benchmark: `session-open/${access.accessKind}/first-history-constrained`,
  312. ...suite.workload(access.accessKind),
  313. heapLimitMb: CONSTRAINED_HEAP_MB,
  314. report,
  315. }))
  316. })
  317. it(`resumes a cold Agent within ${String(access.agentResumeBudgetMs)} ms`, async () => {
  318. const result = summarize(await suite.sample(access.accessKind, 'agent-resume'))
  319. console.log(JSON.stringify({
  320. benchmark: `session-open/${access.accessKind}/agent-resume`,
  321. ...suite.workload(access.accessKind),
  322. result,
  323. budgetMs: access.agentResumeBudgetMs,
  324. retainedHeapBudgetMb: AGENT_RETAINED_HEAP_BUDGET_MB,
  325. }))
  326. expect(result.totalMs.median).toBeLessThanOrEqual(access.agentResumeBudgetMs)
  327. expect(result.retainedHeapMb.median).toBeLessThanOrEqual(AGENT_RETAINED_HEAP_BUDGET_MB)
  328. })
  329. it(`resumes a cold Agent under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  330. const report = await suite.run(access.accessKind, 'agent-resume', CONSTRAINED_HEAP_MB)
  331. console.log(JSON.stringify({
  332. benchmark: `session-open/${access.accessKind}/agent-resume-constrained`,
  333. ...suite.workload(access.accessKind),
  334. heapLimitMb: CONSTRAINED_HEAP_MB,
  335. report,
  336. }))
  337. })
  338. })
  339. }
  340. })