1
0
Эх сурвалжийг харах

test(history): measure packed heap usage

kingwl 1 сар өмнө
parent
commit
ea282f5710

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
-2026-08-15-packed-session-history-transport.md: 7f85bfedde5d3789beb19708c5b5e5690947ae5e
-2026-08-15-packed-session-history-transport.zh.md: 8d4b37b1cdc35e260f8bb5f997ca0e76f651b447
+2026-08-15-packed-session-history-transport.md: 3766c6454a65f2350832e873b59ee8afd6984c4a
+2026-08-15-packed-session-history-transport.zh.md: 9ef5ea8caeb4f952c9b69473ddb21d6ee18f1c57

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md

@@ -32,7 +32,7 @@ A production-sized private session sample was measured without retaining or comm
 
 Packing reduced uncompressed JSON by 90.8% relative to raw logical events and by 83.4% relative to the lossy completed-step projection candidate. Brotli output was 73.2% smaller than raw and 44.8% smaller than that projection candidate. These figures describe this sample rather than a protocol guarantee; savings scale with the length and regularity of delta runs.
 
-The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` reports wire sizes and Host/client timing under `HISTORY_TRANSPORT_PERF_RESULT`. The manual performance inventory does not run in CI and carries no machine-dependent timing assertions; structural assertions pin the fixture cardinalities, compact input count, and identical final state from its two-consumer Assistant fold fixture.
+The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/preparation/fold stage, relative to the same initialized benchmark state. They do not measure process RSS and can miss transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, compact input count, and identical final state from its two-consumer Assistant fold fixture.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md

@@ -32,7 +32,7 @@ Status: implemented
 
 与原始逻辑事件相比,打包使未压缩 JSON 减少 90.8%;与有损的已完成步骤投影候选相比减少 83.4%。Brotli 输出相对原始形式减少 73.2%,相对该投影候选减少 44.8%。这些数字描述该样本,并非协议保证;收益随 delta run 的长度与规律性变化。
 
-可选运行的 `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积与 Host/client 计时。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时断言;结构断言固定 fixture 的事件规模、紧凑输入数,以及双消费方 Assistant 折叠 fixture 的一致最终状态。
+可选运行的 `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时与采样的额外 V8 堆峰值。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/准备/折叠各主要阶段之后所观察峰值的中位数。该指标不测量进程 RSS,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、紧凑输入数,以及双消费方 Assistant 折叠 fixture 的一致最终状态。
 
 ## 曾考虑的替代方案
 

+ 101 - 0
packages/api/session-controller/tests/history-transport.perf.client.ts

@@ -32,6 +32,12 @@ interface Timed<T> {
   readonly ms: number
 }
 
+interface HeapPeaks<T> {
+  readonly value: T
+  readonly medianPeakBytes: number
+  readonly peakBytes: readonly number[]
+}
+
 interface FoldState {
   readonly blocks: readonly string[]
   readonly firstTokenTime?: number
@@ -70,6 +76,36 @@ function reduction(before: number, after: number): number {
   return rounded((1 - after / before) * 100)
 }
 
+function median(values: readonly number[]): number {
+  const ordered = [...values].sort((left, right) => left - right)
+  return ordered[Math.floor(ordered.length / 2)]!
+}
+
+/** Measure caller-sampled additional V8 heap from forced-GC baselines. */
+function sampledPeakHeap<T>(run: (sample: () => void) => T): HeapPeaks<T> {
+  const forceGc = globalThis.gc
+  if (forceGc === undefined) {
+    throw new Error('history transport memory benchmark requires Vitest worker --expose-gc')
+  }
+  const samples = Array.from({ length: 3 }, () => {
+    forceGc()
+    forceGc()
+    const baseline = process.memoryUsage().heapUsed
+    let peak = baseline
+    const sample = (): void => {
+      peak = Math.max(peak, process.memoryUsage().heapUsed)
+    }
+    const value = run(sample)
+    sample()
+    return { value, peakBytes: peak - baseline }
+  })
+  return {
+    value: samples[0]!.value,
+    medianPeakBytes: median(samples.map(sample => sample.peakBytes)),
+    peakBytes: samples.map(sample => sample.peakBytes),
+  }
+}
+
 function append<Type extends keyof SessionEventMap>(
   events: SessionEvent[],
   type: Type,
@@ -208,6 +244,31 @@ function digest(value: unknown): string {
 
 it('reports packed history transport and compact fold costs', () => {
   const fixture = timed(buildEvents)
+
+  assemble(conversationInputs(fixture.value.slice(0, 1_000).map(event => ({ event }))))
+  const rawHostHeap = sampledPeakHeap((sample) => {
+    const entries = fixture.value.map(event => ({ event }))
+    sample()
+    const json = JSON.stringify({ events: entries, hasMore: false } satisfies RawHistoryValue)
+    sample()
+    return Buffer.byteLength(json)
+  })
+  const packedHostHeap = sampledPeakHeap((sample) => {
+    const packedEvents = packChunkRuns(fixture.value)
+    sample()
+    const records = packedEvents.map((record): HistoryRecord =>
+      isChunkRow(record) ? { chunks: record } : { event: record })
+    sample()
+    const json = JSON.stringify({
+      records,
+      hasMore: false,
+      fromSeq: 0,
+      toSeq: fixture.value.length,
+    } satisfies PackedHistoryValue)
+    sample()
+    return Buffer.byteLength(json)
+  })
+
   const rawEntries = timed(() => fixture.value.map(event => ({ event })))
   const packed = timed(() => packChunkRuns(fixture.value))
   const packedRecords = timed(() => packed.value.map((record): HistoryRecord =>
@@ -226,6 +287,30 @@ it('reports packed history transport and compact fold costs', () => {
   const packedGzip = timed(() => gzipSync(packedJson.value).byteLength)
   const rawBrotli = timed(() => brotliCompressSync(rawJson.value).byteLength)
   const packedBrotli = timed(() => brotliCompressSync(packedJson.value).byteLength)
+
+  const rawClientHeap = sampledPeakHeap((sample) => {
+    const parsed = JSON.parse(rawJson.value) as RawHistoryValue
+    sample()
+    for (const entry of parsed.events) historyEntrySchema.parse(entry)
+    sample()
+    const prepared = conversationInputs(parsed.events)
+    sample()
+    const folded = assemble(prepared)
+    sample()
+    return digest(folded)
+  })
+  const packedClientHeap = sampledPeakHeap((sample) => {
+    const parsed = JSON.parse(packedJson.value) as PackedHistoryValue
+    sample()
+    sessionHistoryValueSchema.parse(parsed)
+    sample()
+    const prepared = conversationInputs(historyEntries(parsed.records))
+    sample()
+    const folded = assemble(prepared)
+    sample()
+    return digest(folded)
+  })
+
   const parsedRaw = timed(() => JSON.parse(rawJson.value) as RawHistoryValue)
   const parsedPacked = timed(() => JSON.parse(packedJson.value) as PackedHistoryValue)
   const rawValidation = timed(() => {
@@ -249,6 +334,9 @@ it('reports packed history transport and compact fold costs', () => {
   expect(packed.value).toHaveLength(696)
   expect(packedPreparation.value).toHaveLength(696)
   expect(digest(packedFold.value)).toBe(digest(rawFold.value))
+  expect(packedClientHeap.value).toBe(rawClientHeap.value)
+  expect(rawHostHeap.value).toBe(rawBytes)
+  expect(packedHostHeap.value).toBe(packedBytes)
   expect(packedBytes).toBeLessThan(rawBytes)
 
   const rawResponseMs = rawEntries.ms + rawJson.ms
@@ -276,6 +364,19 @@ it('reports packed history transport and compact fold costs', () => {
       packedBrotli: packedBrotli.value,
       brotliReductionPct: reduction(rawBrotli.value, packedBrotli.value),
     },
+    memory: {
+      samples: 3,
+      rawHostAdditionalHeapPeakBytes: rawHostHeap.medianPeakBytes,
+      packedHostAdditionalHeapPeakBytes: packedHostHeap.medianPeakBytes,
+      hostReductionPct: reduction(rawHostHeap.medianPeakBytes, packedHostHeap.medianPeakBytes),
+      rawClientAdditionalHeapPeakBytes: rawClientHeap.medianPeakBytes,
+      packedClientAdditionalHeapPeakBytes: packedClientHeap.medianPeakBytes,
+      clientReductionPct: reduction(rawClientHeap.medianPeakBytes, packedClientHeap.medianPeakBytes),
+      rawHostPeakSamples: rawHostHeap.peakBytes,
+      packedHostPeakSamples: packedHostHeap.peakBytes,
+      rawClientPeakSamples: rawClientHeap.peakBytes,
+      packedClientPeakSamples: packedClientHeap.peakBytes,
+    },
     host: {
       rawEntryWrapMs: rounded(rawEntries.ms),
       packMs: rounded(packed.ms),

+ 3 - 0
vitest.web.perf.config.ts

@@ -1,5 +1,6 @@
 import { defineConfig } from 'vitest/config'
 import webConfig from './vitest.web.config.ts'
+import { vitestExecArgv } from './vitest.shared.ts'
 
 // Manual high-cardinality diagnostics stay outside every default Vitest
 // inventory and therefore outside CI's executed test lanes.
@@ -7,6 +8,8 @@ export default defineConfig({
   ...webConfig,
   test: {
     ...webConfig.test,
+    // Memory diagnostics use forced-GC baselines only in this manual inventory.
+    execArgv: [...vitestExecArgv, '--expose-gc'],
     include: [
       'apps/web/tests/**/*.perf.ts',
       'packages/api/session-controller/tests/**/*.perf.client.ts',