session-open.worker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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. const agentScenario = scenario === 'agent-resume'
  148. if (agentScenario) await mountAgentLoopTestDependencies(ctx)
  149. else {
  150. await ctx.plugin(SessionProjectionRegistry)
  151. await ctx.plugin(SessionStore)
  152. }
  153. await installProjectionSet(ctx, agentScenario)
  154. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
  155. let history: SessionHistoryController | undefined
  156. if (scenario === 'first-history') {
  157. new BenchmarkSessionQuery(ctx)
  158. history = new SessionHistoryController(ctx, (observation) => {
  159. // First-history ends at snapshot delivery; Agent-resume owns live activation and retention.
  160. observation[Symbol.dispose]()
  161. })
  162. }
  163. if (agentScenario) await ctx.plugin(AgentLoop, { agents: [] })
  164. return new SessionBenchmarkHost(ctx, scenario, history)
  165. }
  166. async measure(): Promise<SessionOpenWorkerReport> {
  167. const beforeGc = await collectGarbage()
  168. const started = performance.now()
  169. const cpuStarted = process.cpuUsage()
  170. const measured = await this.runScenario()
  171. const totalMs = performance.now() - started
  172. const cpu = process.cpuUsage(cpuStarted)
  173. if (this.retained === undefined) throw new Error(`${this.scenario} did not retain its measured endpoint`)
  174. const afterGc = await collectGarbage()
  175. return {
  176. scenario: this.scenario,
  177. totalMs,
  178. ...measured.phases === undefined ? {} : { phases: measured.phases },
  179. cpuUserMs: cpu.user / 1_000,
  180. cpuSystemMs: cpu.system / 1_000,
  181. events: measured.events,
  182. beforeGc,
  183. afterGc,
  184. retained: memoryDelta(beforeGc, afterGc),
  185. }
  186. }
  187. async dispose(): Promise<void> {
  188. this.historyAbort?.abort(new Error('Session opening benchmark complete'))
  189. await this.historyIterator?.return?.()
  190. await this.agentHandle?.dispose()
  191. this.preparation?.[Symbol.dispose]()
  192. this.retained = undefined
  193. await this.ctx.fiber.dispose()
  194. }
  195. private runScenario(): Promise<{
  196. readonly events: number
  197. readonly phases?: SessionOpenWorkerReport['phases']
  198. }> {
  199. switch (this.scenario) {
  200. case 'phase-migrate':
  201. case 'phase-steady':
  202. return this.measurePhases()
  203. case 'first-history':
  204. return this.measureFirstHistory()
  205. case 'agent-resume':
  206. return this.measureAgentResume()
  207. }
  208. }
  209. private async measurePhases(): Promise<{
  210. readonly events: number
  211. readonly phases: NonNullable<SessionOpenWorkerReport['phases']>
  212. }> {
  213. let phaseStarted = performance.now()
  214. const handle = await this.ctx.sessionPersistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
  215. const openMs = performance.now() - phaseStarted
  216. phaseStarted = performance.now()
  217. const read = await handle.read()
  218. await handle.close()
  219. const readMs = performance.now() - phaseStarted
  220. phaseStarted = performance.now()
  221. const repaired = [...read.events, ...interruptedTurnClosers(read.events)]
  222. const seed = repaired
  223. const preparation = SessionPreparation.create(this.ctx.sessions.prepare(SessionId(SYNTHETIC_SESSION_ID), {
  224. seed,
  225. meta: structuredClone(handle.header),
  226. inheritedEventCount: handle.inheritedEventCount,
  227. eventState: read.eventState,
  228. }))
  229. this.preparation = preparation
  230. const sessionRestoreMs = performance.now() - phaseStarted
  231. phaseStarted = performance.now()
  232. const projection = this.ctx.sessionProjections.hydrate(
  233. preparation.session,
  234. {},
  235. seed,
  236. SessionLogOffset(0),
  237. )
  238. const projectionMs = performance.now() - phaseStarted
  239. this.retained = { preparation, projection, seed }
  240. return {
  241. events: preparation.session.seq,
  242. phases: { openMs, readMs, sessionRestoreMs, projectionMs },
  243. }
  244. }
  245. private async measureFirstHistory(): Promise<{ readonly events: number }> {
  246. const abort = new AbortController()
  247. this.historyAbort = abort
  248. const history = this.history
  249. if (history === undefined) throw new Error('first-history benchmark did not initialize its controller')
  250. const iterator = history.follow({
  251. address: { kind: 'session', sessionId: SessionId(SYNTHETIC_SESSION_ID) },
  252. }, abort.signal)[Symbol.asyncIterator]()
  253. this.historyIterator = iterator as AsyncIterator<unknown>
  254. const first = await iterator.next()
  255. if (first.done || first.value.type !== 'snapshot') {
  256. throw new Error('Session history did not produce an opening snapshot')
  257. }
  258. this.retained = { history, iterator, first }
  259. return { events: first.value.records.length }
  260. }
  261. private async measureAgentResume(): Promise<{ readonly events: number }> {
  262. const handle = await this.ctx.agents.resume({
  263. resumeSessionId: SessionId(SYNTHETIC_SESSION_ID),
  264. agentOptions: { provider: 'bench', model: 'bench' },
  265. })
  266. this.agentHandle = handle
  267. this.retained = handle
  268. return { events: handle.agent.session.seq }
  269. }
  270. }
  271. assertBuiltBenchmarkRuntime(import.meta.url, {
  272. '@deepseek-ai/dsh-session-persistence-jsonl': import.meta.resolve('@deepseek-ai/dsh-session-persistence-jsonl'),
  273. })
  274. const [root, scenarioValue] = process.argv.slice(2)
  275. const scenarios: readonly SessionOpenBenchmarkScenario[] = [
  276. 'phase-migrate',
  277. 'phase-steady',
  278. 'first-history',
  279. 'agent-resume',
  280. ]
  281. if (root === undefined || !scenarios.includes(scenarioValue as SessionOpenBenchmarkScenario)) {
  282. throw new Error('usage: session-open.worker.js <root> <phase-migrate|phase-steady|first-history|agent-resume>')
  283. }
  284. const scenario = scenarioValue as SessionOpenBenchmarkScenario
  285. const host = await SessionBenchmarkHost.create(root, scenario)
  286. let report: SessionOpenWorkerReport
  287. try {
  288. report = await host.measure()
  289. } finally {
  290. await host.dispose()
  291. }
  292. process.stdout.write(`${JSON.stringify(report)}\n`)