test(history): measure packed heap usage

This commit is contained in:
kingwl 2026-08-18 12:46:45 +08:00 • committed by imccyu
parent dec9732d1f
commit ea282f5710
5 changed files with 108 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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 的一致最终状态。
## 曾考虑的替代方案

View file

@ -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),

View file

@ -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',