From ea282f571012e74d29e02375f49affc0803d875b Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 18 Aug 2026 12:46:45 +0800 Subject: [PATCH] test(history): measure packed heap usage --- ...packed-session-history-transport.i18n.yaml | 4 +- ...-08-15-packed-session-history-transport.md | 2 +- ...-15-packed-session-history-transport.zh.md | 2 +- .../tests/history-transport.perf.client.ts | 101 ++++++++++++++++++ vitest.web.perf.config.ts | 3 + 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml index f5380cbb5b..6a5aa7ecfc 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md index 7f85bfedde..3766c6454a 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md index 8d4b37b1cd..9ef5ea8cae 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md +++ b/.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 的一致最终状态。 ## 曾考虑的替代方案 diff --git a/packages/api/session-controller/tests/history-transport.perf.client.ts b/packages/api/session-controller/tests/history-transport.perf.client.ts index 3520fefbf2..c6546f414f 100644 --- a/packages/api/session-controller/tests/history-transport.perf.client.ts +++ b/packages/api/session-controller/tests/history-transport.perf.client.ts @@ -32,6 +32,12 @@ interface Timed { readonly ms: number } +interface HeapPeaks { + 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(run: (sample: () => void) => T): HeapPeaks { + 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( 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), diff --git a/vitest.web.perf.config.ts b/vitest.web.perf.config.ts index 103279236a..f427dad1ad 100644 --- a/vitest.web.perf.config.ts +++ b/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',