test(history): measure packed heap usage
This commit is contained in:
parent
dec9732d1f
commit
ea282f5710
5 changed files with 108 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 的一致最终状态。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue