session-open.bench.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. /** Required performance budgets for cold Session preparation, first history, and Agent resume. */
  2. import { copyFile, mkdir, mkdtemp, readFile, readdir, 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 { Context } from '@deepseek-ai/cordis'
  7. import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
  8. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  9. import {
  10. runBuiltBenchmarkWorker,
  11. type BuiltBenchmarkWorkerRun,
  12. } from '../support/built-worker.ts'
  13. import {
  14. ciTimeBudget,
  15. PERFORMANCE_BUDGET_HEADROOM,
  16. } from '../support/calibration.ts'
  17. import type {
  18. SessionOpenBenchmarkScenario,
  19. SessionOpenWorkerReport,
  20. } from './session-open.worker.ts'
  21. import {
  22. SYNTHETIC_CURRENT_GENERATION,
  23. SYNTHETIC_SESSION_ID,
  24. SYNTHETIC_SESSION_DIRECTORY,
  25. SYNTHETIC_CURRENT_FILENAME,
  26. SYNTHETIC_V0_FILENAME,
  27. writeSyntheticReleasedV0Session,
  28. type SyntheticV0SessionWrite,
  29. } from './synthetic-released-v0-session.ts'
  30. /** 200 turns × (500 text + 125 reasoning deltas): 127,400 released-v0 events. */
  31. const SHAPE = { turns: 200, textDeltas: 500 } as const
  32. /** Fresh processes per normal-heap scenario; the median enforces each timing budget. */
  33. const ATTEMPTS = 5
  34. /** A stuck child is reaped well before the outer test and hook deadlines. */
  35. const WORKER_TIMEOUT_MS = 60_000
  36. /** Old-space pressure check, kept independent from normal-heap timing samples. */
  37. const CONSTRAINED_HEAP_MB = 128
  38. type SessionAccessKind = 'first-open' | 'post-upgrade-reopen'
  39. type SessionBenchmarkEndpoint = 'phases' | 'first-history' | 'agent-resume'
  40. const SOURCE_GENERATION_BY_ACCESS = {
  41. 'first-open': 'released-v0',
  42. 'post-upgrade-reopen': SYNTHETIC_CURRENT_GENERATION,
  43. } as const satisfies Record<SessionAccessKind, string>
  44. /** Expected durations on the reference machine before CI scaling and variance headroom. */
  45. const EXPECTED_MS = {
  46. migrationOpen: 220,
  47. read: 8,
  48. sessionRestore: 24,
  49. projection: 14,
  50. firstOpenFirstHistory: 220,
  51. reopenFirstHistory: 48,
  52. reopenAgentResume: 40,
  53. } as const
  54. const MIGRATION_OPEN_BUDGET_MS = ciTimeBudget(EXPECTED_MS.migrationOpen)
  55. /** Standard two-CPU CI reopen samples span 47.4–49.2 ms; 50 ms is the rounded expectation. */
  56. const EXPECTED_REOPEN_CI_MS = 50
  57. const REOPEN_OPEN_BUDGET_MS = Math.ceil(EXPECTED_REOPEN_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  58. const READ_BUDGET_MS = ciTimeBudget(EXPECTED_MS.read)
  59. const SESSION_RESTORE_BUDGET_MS = ciTimeBudget(EXPECTED_MS.sessionRestore)
  60. const PROJECTION_BUDGET_MS = ciTimeBudget(EXPECTED_MS.projection)
  61. const FIRST_OPEN_FIRST_HISTORY_BUDGET_MS = ciTimeBudget(EXPECTED_MS.firstOpenFirstHistory)
  62. const REOPEN_FIRST_HISTORY_BUDGET_MS = ciTimeBudget(EXPECTED_MS.reopenFirstHistory)
  63. /** Reviewed hosted limit: floor(450 × 1.25); calibration records the original reference. */
  64. const FIRST_OPEN_AGENT_RESUME_BUDGET_MS = 562
  65. const REOPEN_AGENT_RESUME_BUDGET_MS = ciTimeBudget(EXPECTED_MS.reopenAgentResume)
  66. /** Historical-reference retained heap before variance headroom. */
  67. const EXPECTED_AGENT_RETAINED_HEAP_MB = 26.1
  68. const AGENT_RETAINED_HEAP_BUDGET_MB = Math.ceil(
  69. EXPECTED_AGENT_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM,
  70. )
  71. const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-open', 'session-open.worker.js')
  72. type WorkerRun = BuiltBenchmarkWorkerRun<SessionOpenWorkerReport>
  73. function rounded(value: number): number {
  74. return Math.round(value * 10) / 10
  75. }
  76. function median(values: readonly number[]): number {
  77. const sorted = [...values].sort((left, right) => left - right)
  78. return sorted[Math.floor(sorted.length / 2)] as number
  79. }
  80. function metric(
  81. reports: readonly SessionOpenWorkerReport[],
  82. read: (report: SessionOpenWorkerReport) => number,
  83. ): { readonly min: number; readonly median: number; readonly max: number; readonly samples: readonly number[] } {
  84. const samples = reports.map(read)
  85. return {
  86. min: rounded(Math.min(...samples)),
  87. median: rounded(median(samples)),
  88. max: rounded(Math.max(...samples)),
  89. samples: samples.map(rounded),
  90. }
  91. }
  92. function phaseMetric(
  93. reports: readonly SessionOpenWorkerReport[],
  94. key: keyof NonNullable<SessionOpenWorkerReport['phases']>,
  95. ): ReturnType<typeof metric> {
  96. return metric(reports, (report) => {
  97. if (report.phases === undefined) throw new Error(`${report.scenario} did not report phase timings`)
  98. return report.phases[key]
  99. })
  100. }
  101. function summarize(reports: readonly SessionOpenWorkerReport[]) {
  102. return {
  103. totalMs: metric(reports, report => report.totalMs),
  104. cpuUserMs: metric(reports, report => report.cpuUserMs),
  105. cpuSystemMs: metric(reports, report => report.cpuSystemMs),
  106. retainedHeapMb: metric(reports, report => report.retained.heapUsedMb),
  107. retainedExternalMb: metric(reports, report => report.retained.externalMb),
  108. retainedArrayBuffersMb: metric(reports, report => report.retained.arrayBuffersMb),
  109. retainedRssMb: metric(reports, report => report.retained.rssMb),
  110. peakRssMb: metric(reports, report => report.afterGc.peakRssMb),
  111. }
  112. }
  113. function summarizePhases(reports: readonly SessionOpenWorkerReport[]) {
  114. return {
  115. ...summarize(reports),
  116. openMs: phaseMetric(reports, 'openMs'),
  117. readMs: phaseMetric(reports, 'readMs'),
  118. sessionRestoreMs: phaseMetric(reports, 'sessionRestoreMs'),
  119. projectionMs: phaseMetric(reports, 'projectionMs'),
  120. }
  121. }
  122. function runWorker(
  123. root: string,
  124. scenario: SessionOpenBenchmarkScenario,
  125. heapLimitMb?: number,
  126. ): Promise<WorkerRun> {
  127. return runBuiltBenchmarkWorker({
  128. worker: WORKER,
  129. args: [root, scenario],
  130. timeoutMs: WORKER_TIMEOUT_MS,
  131. exposeGc: true,
  132. ...(heapLimitMb === undefined ? {} : { heapLimitMb }),
  133. })
  134. }
  135. function requireReport(
  136. run: WorkerRun,
  137. scenario: SessionOpenBenchmarkScenario,
  138. heapLimitMb?: number,
  139. ): SessionOpenWorkerReport {
  140. if (run.report !== undefined) return run.report
  141. const stderrLines = run.stderr.trim().split('\n')
  142. const fatal = stderrLines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line))
  143. const context = stderrLines.length <= 20
  144. ? stderrLines
  145. : [...stderrLines.slice(0, 10), '... stderr middle omitted ...', ...stderrLines.slice(-10)]
  146. const detail = (fatal.length > 0 ? fatal : context).join('\n')
  147. const limit = heapLimitMb === undefined ? 'normal heap' : `${String(heapLimitMb)} MB old space`
  148. throw new Error(
  149. `${scenario} failed under ${limit}: exit=${String(run.exitCode)}, signal=${String(run.signal)}, `
  150. + `timedOut=${String(run.timedOut)}\n${detail}`,
  151. )
  152. }
  153. /** Owns deterministic first-open/reopen sources and private roots created for one benchmark file. */
  154. class SessionOpenBenchmarkSuite {
  155. private legacySourcePath = ''
  156. private currentSourcePath = ''
  157. private scratch = ''
  158. private facts: SyntheticV0SessionWrite | undefined
  159. private rootIndex = 0
  160. async prepare(): Promise<void> {
  161. this.scratch = await mkdtemp(join(tmpdir(), 'dsh-session-open-bench-'))
  162. this.facts = await writeSyntheticReleasedV0Session(join(this.scratch, 'source'), SHAPE)
  163. this.legacySourcePath = this.facts.path
  164. // Produce one real post-upgrade directory outside every measured interval.
  165. const templateRoot = await this.createRoot('first-open', 'post-upgrade-template')
  166. requireReport(await runWorker(templateRoot, 'agent-resume'), 'agent-resume')
  167. this.currentSourcePath = join(
  168. templateRoot,
  169. SYNTHETIC_SESSION_DIRECTORY,
  170. SYNTHETIC_CURRENT_FILENAME,
  171. )
  172. }
  173. async dispose(): Promise<void> {
  174. await rm(this.scratch, { recursive: true, force: true })
  175. }
  176. workload(accessKind: SessionAccessKind) {
  177. if (this.facts === undefined) throw new Error('Session opening benchmark source is not prepared')
  178. return {
  179. accessKind,
  180. sourceGeneration: SOURCE_GENERATION_BY_ACCESS[accessKind],
  181. logicalInputEvents: this.facts.events,
  182. legacyInputRows: this.facts.rows,
  183. legacyInputFrames: this.facts.frames,
  184. legacyInputLogicalBytes: this.facts.logicalBytes,
  185. legacyInputCompressedBytes: this.facts.compressedBytes,
  186. }
  187. }
  188. async sample(
  189. accessKind: SessionAccessKind,
  190. endpoint: SessionBenchmarkEndpoint,
  191. ): Promise<SessionOpenWorkerReport[]> {
  192. const reports: SessionOpenWorkerReport[] = []
  193. for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
  194. reports.push(await this.run(accessKind, endpoint))
  195. }
  196. return reports
  197. }
  198. async run(
  199. accessKind: SessionAccessKind,
  200. endpoint: SessionBenchmarkEndpoint,
  201. heapLimitMb?: number,
  202. ): Promise<SessionOpenWorkerReport> {
  203. const scenario = this.workerScenario(accessKind, endpoint)
  204. const root = await this.createRoot(
  205. accessKind,
  206. `${accessKind}-${endpoint}-${String(this.rootIndex++)}`,
  207. )
  208. const report = requireReport(await runWorker(root, scenario, heapLimitMb), scenario, heapLimitMb)
  209. return report
  210. }
  211. private workerScenario(
  212. accessKind: SessionAccessKind,
  213. endpoint: SessionBenchmarkEndpoint,
  214. ): SessionOpenBenchmarkScenario {
  215. if (endpoint !== 'phases') return endpoint
  216. return accessKind === 'first-open' ? 'phase-migrate' : 'phase-steady'
  217. }
  218. private async createRoot(accessKind: SessionAccessKind, label: string): Promise<string> {
  219. const root = join(this.scratch, label)
  220. const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
  221. await mkdir(directory, { recursive: true })
  222. await copyFile(this.legacySourcePath, join(directory, SYNTHETIC_V0_FILENAME))
  223. if (accessKind === 'post-upgrade-reopen') {
  224. if (this.currentSourcePath === '') throw new Error('current V2 benchmark source is not prepared')
  225. // Released generations remain adjacent after migration, so V2 samples retain their V0 predecessor.
  226. await copyFile(this.currentSourcePath, join(directory, SYNTHETIC_CURRENT_FILENAME))
  227. }
  228. return root
  229. }
  230. }
  231. interface AccessBenchmarkSpec {
  232. readonly accessKind: SessionAccessKind
  233. readonly label: string
  234. readonly openBudgetMs: number
  235. readonly firstHistoryBudgetMs: number
  236. readonly agentResumeBudgetMs: number
  237. }
  238. const ACCESS_BENCHMARKS: readonly AccessBenchmarkSpec[] = [
  239. {
  240. accessKind: 'first-open',
  241. label: 'first open from released V0',
  242. openBudgetMs: MIGRATION_OPEN_BUDGET_MS,
  243. firstHistoryBudgetMs: FIRST_OPEN_FIRST_HISTORY_BUDGET_MS,
  244. agentResumeBudgetMs: FIRST_OPEN_AGENT_RESUME_BUDGET_MS,
  245. },
  246. {
  247. accessKind: 'post-upgrade-reopen',
  248. label: 'fresh-process reopen after upgrade',
  249. openBudgetMs: REOPEN_OPEN_BUDGET_MS,
  250. firstHistoryBudgetMs: REOPEN_FIRST_HISTORY_BUDGET_MS,
  251. agentResumeBudgetMs: REOPEN_AGENT_RESUME_BUDGET_MS,
  252. },
  253. ]
  254. function expectOpenWithinBudget(value: number, budget: number): void {
  255. expect(value).toBeLessThanOrEqual(budget)
  256. }
  257. describe('standard hosted reopen calibration', () => {
  258. it('accepts the recorded two-CPU samples that exceed the historical budget', () => {
  259. const recordedMedian = median([49.2, 47.4, 49.1, 48.6, 48.1])
  260. expect(recordedMedian).toBe(48.6)
  261. expect(() => expectOpenWithinBudget(recordedMedian, ciTimeBudget(12))).toThrow()
  262. expectOpenWithinBudget(recordedMedian, REOPEN_OPEN_BUDGET_MS)
  263. expect(REOPEN_OPEN_BUDGET_MS).toBe(63)
  264. })
  265. it('rejects synthetic reopen and multi-second first-open regressions', () => {
  266. const regressionMedian = median([74, 75, 76, 75, 74])
  267. expect(() => expectOpenWithinBudget(regressionMedian, REOPEN_OPEN_BUDGET_MS)).toThrow()
  268. expect(MIGRATION_OPEN_BUDGET_MS).toBe(550)
  269. expect(() => expectOpenWithinBudget(4_000, MIGRATION_OPEN_BUDGET_MS)).toThrow()
  270. })
  271. })
  272. describe('standard hosted first-open Agent-resume calibration', () => {
  273. it('accepts recorded hosted samples while rejecting a material regression', () => {
  274. const recordedMedian = median([454.2, 454.8, 455.4, 457.8, 459.8])
  275. expect(recordedMedian).toBe(455.4)
  276. expect(() => expectOpenWithinBudget(recordedMedian, 450)).toThrow()
  277. expectOpenWithinBudget(recordedMedian, FIRST_OPEN_AGENT_RESUME_BUDGET_MS)
  278. expect(() => expectOpenWithinBudget(600, FIRST_OPEN_AGENT_RESUME_BUDGET_MS)).toThrow()
  279. })
  280. })
  281. describe('Session opening benchmark prerequisites', () => {
  282. it('retains the exception headline and bounded stderr tail when a worker fails', () => {
  283. const headline = 'SessionFormatUnsupportedError: source chronology cannot be migrated'
  284. const stderr = [headline, ...Array.from({ length: 30 }, (_, index) => 'stack frame ' + String(index)), 'Node.js test'].join('\n')
  285. const run: WorkerRun = { report: undefined, exitCode: 1, signal: null, timedOut: false, stderr }
  286. expect(() => requireReport(run, 'agent-resume')).toThrow(headline)
  287. expect(() => requireReport(run, 'agent-resume')).toThrow('Node.js test')
  288. expect(() => requireReport(run, 'agent-resume')).not.toThrow('stack frame 15')
  289. expect(() => requireReport({ ...run, stderr: 'FATAL ERROR: heap limit' }, 'agent-resume', 128))
  290. .toThrow('128 MB old space')
  291. })
  292. it('migrates the generated workload and reopens its successor without changing V0', async () => {
  293. const root = await mkdtemp(join(tmpdir(), 'dsh-session-bench-fixture-'))
  294. const contexts: Context[] = []
  295. const mount = async () => {
  296. const ctx = new Context()
  297. contexts.push(ctx)
  298. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
  299. return ctx.sessionPersistence
  300. }
  301. try {
  302. const facts = await writeSyntheticReleasedV0Session(root, { turns: 2, textDeltas: 12 })
  303. expect({ events: facts.events, rows: facts.rows, frames: facts.frames }).toEqual({ events: 54, rows: 28, frames: 29 })
  304. const original = await readFile(facts.path)
  305. const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
  306. const persistence = await mount()
  307. const read = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
  308. const initial = await read.read()
  309. const session = Session.fromRestore(read.header.id, initial.events, read.header, read.inheritedEventCount, initial.eventState)
  310. expect(read.header.version).toBe(SESSION_FORMAT_VERSION)
  311. expect(initial.events.filter(event => event.type === 'system/message')).toHaveLength(1)
  312. expect(session.deriveMessages().map(({ id, role, content }) => ({ id, role, content }))).toEqual(
  313. [1, 2].flatMap(turn => [
  314. { id: 'user-' + String(turn), role: 'user', content: [{ type: 'text', text: 'prompt ' + String(turn) }] },
  315. { id: 'assistant-' + String(turn), role: 'assistant', content: [
  316. { type: 'reasoning', text: 'r0 r1 r2 ' },
  317. { type: 'text', text: Array.from({ length: 12 }, (_, index) => 'w' + String(index) + ' ').join('') },
  318. ] },
  319. ]),
  320. )
  321. await read.close()
  322. expect(await readdir(directory)).toEqual([SYNTHETIC_V0_FILENAME])
  323. const writer = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'write')
  324. expect((await writer.read()).events).toEqual(initial.events)
  325. await writer.close()
  326. expect(await readdir(directory)).toContain(SYNTHETIC_CURRENT_FILENAME)
  327. const reopened = await (await mount()).open(SessionId(SYNTHETIC_SESSION_ID), 'read')
  328. expect((await reopened.read()).events).toEqual(initial.events)
  329. await reopened.close()
  330. expect(await readFile(facts.path)).toEqual(original)
  331. } finally {
  332. try {
  333. for (const ctx of contexts.reverse()) await ctx.fiber.dispose()
  334. } finally {
  335. await rm(root, { recursive: true, force: true })
  336. }
  337. }
  338. })
  339. })
  340. describe('opening a large Session for first open and post-upgrade reopen', () => {
  341. const suite = new SessionOpenBenchmarkSuite()
  342. beforeAll(async () => { await suite.prepare() })
  343. afterAll(async () => { await suite.dispose() })
  344. for (const access of ACCESS_BENCHMARKS) {
  345. describe(access.label, () => {
  346. it('profiles all four phases under normal heap', async () => {
  347. const result = summarizePhases(await suite.sample(access.accessKind, 'phases'))
  348. console.log(JSON.stringify({
  349. benchmark: `session-open/${access.accessKind}/phases`,
  350. ...suite.workload(access.accessKind),
  351. result,
  352. budgetsMs: {
  353. open: access.openBudgetMs,
  354. read: READ_BUDGET_MS,
  355. sessionRestore: SESSION_RESTORE_BUDGET_MS,
  356. projection: PROJECTION_BUDGET_MS,
  357. },
  358. }))
  359. expectOpenWithinBudget(result.openMs.median, access.openBudgetMs)
  360. expect(result.readMs.median).toBeLessThanOrEqual(READ_BUDGET_MS)
  361. expect(result.sessionRestoreMs.median).toBeLessThanOrEqual(SESSION_RESTORE_BUDGET_MS)
  362. expect(result.projectionMs.median).toBeLessThanOrEqual(PROJECTION_BUDGET_MS)
  363. })
  364. it(`completes all four phases under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  365. const report = await suite.run(access.accessKind, 'phases', CONSTRAINED_HEAP_MB)
  366. console.log(JSON.stringify({
  367. benchmark: `session-open/${access.accessKind}/phases-constrained`,
  368. ...suite.workload(access.accessKind),
  369. heapLimitMb: CONSTRAINED_HEAP_MB,
  370. report,
  371. }))
  372. })
  373. it(`produces first Host history within ${String(access.firstHistoryBudgetMs)} ms`, async () => {
  374. const result = summarize(await suite.sample(access.accessKind, 'first-history'))
  375. console.log(JSON.stringify({
  376. benchmark: `session-open/${access.accessKind}/first-history`,
  377. ...suite.workload(access.accessKind),
  378. result,
  379. budgetMs: access.firstHistoryBudgetMs,
  380. }))
  381. expect(result.totalMs.median).toBeLessThanOrEqual(access.firstHistoryBudgetMs)
  382. })
  383. it(`produces first Host history under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  384. const report = await suite.run(access.accessKind, 'first-history', CONSTRAINED_HEAP_MB)
  385. console.log(JSON.stringify({
  386. benchmark: `session-open/${access.accessKind}/first-history-constrained`,
  387. ...suite.workload(access.accessKind),
  388. heapLimitMb: CONSTRAINED_HEAP_MB,
  389. report,
  390. }))
  391. })
  392. it(`resumes a cold Agent within ${String(access.agentResumeBudgetMs)} ms`, async () => {
  393. const result = summarize(await suite.sample(access.accessKind, 'agent-resume'))
  394. console.log(JSON.stringify({
  395. benchmark: `session-open/${access.accessKind}/agent-resume`,
  396. ...suite.workload(access.accessKind),
  397. result,
  398. budgetMs: access.agentResumeBudgetMs,
  399. retainedHeapBudgetMb: AGENT_RETAINED_HEAP_BUDGET_MB,
  400. }))
  401. expect(result.totalMs.median).toBeLessThanOrEqual(access.agentResumeBudgetMs)
  402. expect(result.retainedHeapMb.median).toBeLessThanOrEqual(AGENT_RETAINED_HEAP_BUDGET_MB)
  403. })
  404. it(`resumes a cold Agent under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
  405. const report = await suite.run(access.accessKind, 'agent-resume', CONSTRAINED_HEAP_MB)
  406. console.log(JSON.stringify({
  407. benchmark: `session-open/${access.accessKind}/agent-resume-constrained`,
  408. ...suite.workload(access.accessKind),
  409. heapLimitMb: CONSTRAINED_HEAP_MB,
  410. report,
  411. }))
  412. })
  413. })
  414. }
  415. })