session-open.worker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. /** Isolated worker for cold Session phase, first-history, and Agent-resume benchmarks. */
  2. import { performance } from 'node:perf_hooks'
  3. import { scheduler } from 'node:timers/promises'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
  6. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  7. import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
  8. import SessionStore, {
  9. interruptedTurnClosers,
  10. SessionId,
  11. SessionLogOffset,
  12. SessionPreparation,
  13. } from '@deepseek-ai/dsh-session'
  14. import type { AgentHandle } from '@deepseek-ai/dsh-agent'
  15. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  16. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  17. import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
  18. import type {
  19. SessionEventSearchPage,
  20. SessionEventSearchRequest,
  21. SessionSearchExecContext,
  22. SessionSearchHit,
  23. SessionSearchPage,
  24. SessionSearchRequest,
  25. } from '@deepseek-ai/dsh-session-query'
  26. import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
  27. import SessionTitleService from '@deepseek-ai/dsh-session-title'
  28. import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
  29. import TokenMeter from '@deepseek-ai/dsh-token-meter'
  30. // These Host-only adapters have no public Node export and are compiled into the benchmark worker.
  31. import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts'
  32. import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
  33. import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
  34. import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts'
  35. /** Worker scenario selected by the parent benchmark. */
  36. export type SessionOpenBenchmarkScenario =
  37. | 'phase-migrate'
  38. | 'phase-steady'
  39. | 'first-history'
  40. | 'agent-resume'
  41. /** One post-GC process memory observation. */
  42. export interface BenchmarkMemorySnapshot {
  43. readonly heapUsedMb: number
  44. readonly externalMb: number
  45. readonly arrayBuffersMb: number
  46. readonly rssMb: number
  47. readonly peakRssMb: number
  48. }
  49. /** Memory retained by one benchmark endpoint relative to its initialized Host. */
  50. export interface BenchmarkMemoryDelta {
  51. readonly heapUsedMb: number
  52. readonly externalMb: number
  53. readonly arrayBuffersMb: number
  54. readonly rssMb: number
  55. }
  56. /** Timings and memory emitted by one isolated scenario. */
  57. export interface SessionOpenWorkerReport {
  58. readonly scenario: SessionOpenBenchmarkScenario
  59. readonly totalMs: number
  60. readonly phases?: {
  61. readonly openMs: number
  62. readonly readMs: number
  63. readonly sessionRestoreMs: number
  64. readonly projectionMs: number
  65. }
  66. readonly cpuUserMs: number
  67. readonly cpuSystemMs: number
  68. readonly events: number
  69. readonly beforeGc: BenchmarkMemorySnapshot
  70. readonly afterGc: BenchmarkMemorySnapshot
  71. readonly retained: BenchmarkMemoryDelta
  72. }
  73. class BenchmarkSessionQuery extends SessionQueryEngine {
  74. override searchSessions(
  75. _request: SessionSearchRequest,
  76. _exec?: SessionSearchExecContext,
  77. ): Promise<SessionSearchPage<SessionSearchHit>> {
  78. return Promise.reject(new Error('search is outside the Session opening benchmark'))
  79. }
  80. override searchEvents(
  81. _request: SessionEventSearchRequest,
  82. _exec?: SessionSearchExecContext,
  83. ): Promise<SessionEventSearchPage> {
  84. return Promise.reject(new Error('search is outside the Session opening benchmark'))
  85. }
  86. }
  87. function megabytes(bytes: number): number {
  88. return Math.round(bytes / 104_857.6) / 10
  89. }
  90. function memorySnapshot(): BenchmarkMemorySnapshot {
  91. const memory = process.memoryUsage()
  92. return {
  93. heapUsedMb: megabytes(memory.heapUsed),
  94. externalMb: megabytes(memory.external),
  95. arrayBuffersMb: megabytes(memory.arrayBuffers),
  96. rssMb: megabytes(memory.rss),
  97. peakRssMb: Math.round(process.resourceUsage().maxRSS / 102.4) / 10,
  98. }
  99. }
  100. async function collectGarbage(): Promise<BenchmarkMemorySnapshot> {
  101. const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc
  102. if (gc === undefined) throw new Error('Session opening benchmark requires --expose-gc')
  103. gc()
  104. await scheduler.yield()
  105. gc()
  106. return memorySnapshot()
  107. }
  108. function memoryDelta(
  109. before: BenchmarkMemorySnapshot,
  110. after: BenchmarkMemorySnapshot,
  111. ): BenchmarkMemoryDelta {
  112. return {
  113. heapUsedMb: Math.round((after.heapUsedMb - before.heapUsedMb) * 10) / 10,
  114. externalMb: Math.round((after.externalMb - before.externalMb) * 10) / 10,
  115. arrayBuffersMb: Math.round((after.arrayBuffersMb - before.arrayBuffersMb) * 10) / 10,
  116. rssMb: Math.round((after.rssMb - before.rssMb) * 10) / 10,
  117. }
  118. }
  119. async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise<void> {
  120. // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot.
  121. if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
  122. ctx.sessionProjections.register(agentPresetProjectionDefinition)
  123. installModelSelectionProjection(ctx)
  124. await ctx.plugin(SessionTitleService, {
  125. fallbackMaxWords: 5,
  126. fallbackMaxBytes: 40,
  127. maxTitleBytes: 80,
  128. })
  129. await ctx.plugin(SessionStatsPlugin)
  130. await ctx.plugin(SessionTurnOutlinePlugin)
  131. await ctx.plugin(TokenMeter)
  132. }
  133. /** Owns one initialized Host and the live endpoint retained through its final GC sample. */
  134. class SessionBenchmarkHost {
  135. private preparation: SessionPreparation | undefined
  136. private agentHandle: AgentHandle | undefined
  137. private historyAbort: AbortController | undefined
  138. private historyIterator: AsyncIterator<unknown> | undefined
  139. private retained: unknown
  140. private constructor(
  141. private readonly ctx: Context,
  142. private readonly scenario: SessionOpenBenchmarkScenario,
  143. private readonly history: SessionHistoryController | undefined,
  144. ) {}
  145. static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise<SessionBenchmarkHost> {
  146. const ctx = new Context()
  147. await ctx.plugin(SessionProjectionRegistry)
  148. const agentScenario = scenario === 'agent-resume'
  149. if (agentScenario) await mountAgentLoopTestDependencies(ctx)
  150. else await ctx.plugin(SessionStore)
  151. await installProjectionSet(ctx, agentScenario)
  152. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
  153. let history: SessionHistoryController | undefined
  154. if (scenario === 'first-history') {
  155. new BenchmarkSessionQuery(ctx)
  156. history = new SessionHistoryController(ctx, (observation) => {
  157. // First-history ends at snapshot delivery; Agent-resume owns live activation and retention.
  158. observation[Symbol.dispose]()
  159. })
  160. }
  161. if (agentScenario) await ctx.plugin(AgentLoop, { agents: [] })
  162. return new SessionBenchmarkHost(ctx, scenario, history)
  163. }
  164. async measure(): Promise<SessionOpenWorkerReport> {
  165. const beforeGc = await collectGarbage()
  166. const started = performance.now()
  167. const cpuStarted = process.cpuUsage()
  168. const measured = await this.runScenario()
  169. const totalMs = performance.now() - started
  170. const cpu = process.cpuUsage(cpuStarted)
  171. if (this.retained === undefined) throw new Error(`${this.scenario} did not retain its measured endpoint`)
  172. const afterGc = await collectGarbage()
  173. return {
  174. scenario: this.scenario,
  175. totalMs,
  176. ...measured.phases === undefined ? {} : { phases: measured.phases },
  177. cpuUserMs: cpu.user / 1_000,
  178. cpuSystemMs: cpu.system / 1_000,
  179. events: measured.events,
  180. beforeGc,
  181. afterGc,
  182. retained: memoryDelta(beforeGc, afterGc),
  183. }
  184. }
  185. async dispose(): Promise<void> {
  186. this.historyAbort?.abort(new Error('Session opening benchmark complete'))
  187. await this.historyIterator?.return?.()
  188. await this.agentHandle?.dispose()
  189. this.preparation?.[Symbol.dispose]()
  190. this.retained = undefined
  191. await this.ctx.fiber.dispose()
  192. }
  193. private runScenario(): Promise<{
  194. readonly events: number
  195. readonly phases?: SessionOpenWorkerReport['phases']
  196. }> {
  197. switch (this.scenario) {
  198. case 'phase-migrate':
  199. case 'phase-steady':
  200. return this.measurePhases()
  201. case 'first-history':
  202. return this.measureFirstHistory()
  203. case 'agent-resume':
  204. return this.measureAgentResume()
  205. }
  206. }
  207. private async measurePhases(): Promise<{
  208. readonly events: number
  209. readonly phases: NonNullable<SessionOpenWorkerReport['phases']>
  210. }> {
  211. let phaseStarted = performance.now()
  212. const handle = await this.ctx.sessionPersistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
  213. const openMs = performance.now() - phaseStarted
  214. phaseStarted = performance.now()
  215. const read = await handle.read()
  216. await handle.close()
  217. const readMs = performance.now() - phaseStarted
  218. phaseStarted = performance.now()
  219. const repaired = [...read.events, ...interruptedTurnClosers(read.events)]
  220. const seed = repaired
  221. const preparation = SessionPreparation.create(this.ctx.sessions.prepare(SessionId(SYNTHETIC_SESSION_ID), {
  222. seed,
  223. meta: structuredClone(handle.header),
  224. inheritedEventCount: handle.inheritedEventCount,
  225. eventState: read.eventState,
  226. }))
  227. this.preparation = preparation
  228. const sessionRestoreMs = performance.now() - phaseStarted
  229. phaseStarted = performance.now()
  230. const projection = this.ctx.sessionProjections.hydrate(
  231. preparation.session,
  232. {},
  233. seed,
  234. SessionLogOffset(0),
  235. )
  236. const projectionMs = performance.now() - phaseStarted
  237. this.retained = { preparation, projection, seed }
  238. return {
  239. events: preparation.session.seq,
  240. phases: { openMs, readMs, sessionRestoreMs, projectionMs },
  241. }
  242. }
  243. private async measureFirstHistory(): Promise<{ readonly events: number }> {
  244. const abort = new AbortController()
  245. this.historyAbort = abort
  246. const history = this.history
  247. if (history === undefined) throw new Error('first-history benchmark did not initialize its controller')
  248. const iterator = history.follow({
  249. address: { kind: 'session', sessionId: SessionId(SYNTHETIC_SESSION_ID) },
  250. }, abort.signal)[Symbol.asyncIterator]()
  251. this.historyIterator = iterator as AsyncIterator<unknown>
  252. const first = await iterator.next()
  253. if (first.done || first.value.type !== 'snapshot') {
  254. throw new Error('Session history did not produce an opening snapshot')
  255. }
  256. this.retained = { history, iterator, first }
  257. return { events: first.value.records.length }
  258. }
  259. private async measureAgentResume(): Promise<{ readonly events: number }> {
  260. const handle = await this.ctx.agents.resume({
  261. resumeSessionId: SessionId(SYNTHETIC_SESSION_ID),
  262. agentOptions: { provider: 'bench', model: 'bench' },
  263. })
  264. this.agentHandle = handle
  265. this.retained = handle
  266. return { events: handle.agent.session.seq }
  267. }
  268. }
  269. assertBuiltBenchmarkRuntime(import.meta.url, {
  270. '@deepseek-ai/dsh-session-persistence-jsonl': import.meta.resolve('@deepseek-ai/dsh-session-persistence-jsonl'),
  271. })
  272. const [root, scenarioValue] = process.argv.slice(2)
  273. const scenarios: readonly SessionOpenBenchmarkScenario[] = [
  274. 'phase-migrate',
  275. 'phase-steady',
  276. 'first-history',
  277. 'agent-resume',
  278. ]
  279. if (root === undefined || !scenarios.includes(scenarioValue as SessionOpenBenchmarkScenario)) {
  280. throw new Error('usage: session-open.worker.js <root> <phase-migrate|phase-steady|first-history|agent-resume>')
  281. }
  282. const scenario = scenarioValue as SessionOpenBenchmarkScenario
  283. const host = await SessionBenchmarkHost.create(root, scenario)
  284. let report: SessionOpenWorkerReport
  285. try {
  286. report = await host.measure()
  287. } finally {
  288. await host.dispose()
  289. }
  290. process.stdout.write(`${JSON.stringify(report)}\n`)