test(bench): calibrate Session corpus budgets on hosted CI

Standard two-CPU hosted runs are 2.4-3.4 times the reference machine, above
the shared reference scale, so the budgets use rounded hosted medians with
the shared 1.25 headroom. The benchmark job took 14.4 minutes with these
cases; its timeout becomes 25 minutes.
This commit is contained in:
turtle1999 committed 2026-09-28 22:18:58 +08:00
1 parent 7deaab676f
commit e806d9cb97
6 files changed
+78 -66

No files matched your search

@@ -15,17 +15,17 @@
en: ae4682fdb06d3192
zh: 6a48d412f15bedfa
/agent-note-performance-baselines-for-large-session-corpora/decision/corpus-construction:
en: b10d48e91e1ec022
zh: e47854e79e3fb630
en: d350a1fbedc2e3a7
zh: 9f4024144beeea10
/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints:
en: e2854f9bdf00a586
zh: b2cc87dd1d50444e
en: c25cc40af5d6bca5
zh: ad4ad41323816ea6
/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence:
en: d18a054dd2d204da
zh: 054c48ee9fe17c8b
en: 92d9f4b25d028690
zh: 08ec11c2d9cba4d7
/agent-note-performance-baselines-for-large-session-corpora/alternatives-considered:
en: a17b4375a6350360
zh: 4d2b9b9e691940a9
en: 6cb37cadd5533349
zh: fa4415f7c407de89
/agent-note-performance-baselines-for-large-session-corpora/consequences:
en: b82f7c00d2271186
zh: 70a0da23fe292a27
en: 1918124e4bae0f76
zh: abd8d168700b4c08
@@ -29,7 +29,7 @@ Other measured aggregates also set the fixture: 19 Sessions per project director
### Corpus construction
The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`.
The seed worker authors one event body per anchor, compresses it once, and writes each Session as its own header frame followed by the anchor's body. Frame compression and file writes run eight at a time: each pending compression holds a native Zstandard context, and unbounded compression peaked at 7.7 GB RSS, more than a standard hosted runner provides. Sessions sharing an anchor differ only in header identity, creation time, project directory, and parent link. Creation time is a fixed permutation of rank, so list order does not follow length. Events carry the fields that `Session.append` records, compact streams come from the production `AssistantStreamAccumulator`, and rows are encoded with the persistence package's `eventLines`. A verification worker replays each distinct body through `sessionQuery.readSession`.
The projection cache is part of the list endpoint: each cold row carries cached projection values. The seed folds each anchor once through the public `sessionProjectionCache.coldSnapshot` write-back. It then writes per-record documents for every Session with `serializeRecord`, rebinding identity to each header. Writing through the domain API costs one fsync per record, which took 89 s for 10,000 Sessions. The list worker requires every returned row to carry projections, so a storage layout change fails the benchmark instead of silently removing that work.
@@ -43,30 +43,30 @@ Workers are compiled plain-Node processes, each started fresh against a seeded r
| Content search | 1,000 | 1 | First `session.search`, including the index build; a second query with a different term |
| Fork | 1,000 | 3 | `session.fork` return for rank midpoints of ten equal length strata, rank 990, and rank 999 |
Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes about a minute on the reference machine. Reports include peak RSS and heap after the endpoint, but no memory budget applies.
Budgets enforce medians: list boot, first, and repeat; search first and repeat; fork median over strata, rank 990, and rank 999. One search sample is enforced because a cold index build takes over two minutes on standard hosted CI. Reports include peak RSS and heap after the endpoint, but no memory budget applies.
## Calibration evidence
Reference expectations come from Apple M5 Pro, macOS arm64, Node 26.5, measured in isolated worker runs. They are scaled through the shared CI factor and headroom.
Budgets use standard two-CPU hosted CI expectations (AMD EPYC 7763, Linux x64, Node 24.21, PR #5403), rounded above the recorded medians and multiplied by the shared 1.25 headroom. Hosted samples varied by less than 4% within each case. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs.
| Endpoint | Observed | Expectation |
|---|---|---:|
| List 1,000: boot / first / repeat | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms |
| List 5,000: boot / first / repeat | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms |
| Search 1,000: first / repeat | 57,006 / 1,046 ms | 60,000 / 1,100 ms |
| Fork: strata median / p99 / longest | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms |
| Endpoint | Hosted medians | Expectation | Budget |
|---|---|---:|---:|
| List 1,000: boot / first / repeat | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms |
| List 5,000: boot / first / repeat | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms |
| Search 1,000: first / repeat (one sample) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms |
| Fork: strata median / p99 / longest | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms |
A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS; the extreme list case uses 5,000 Sessions to bound seeding time and disk use.
On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS; the extreme list case uses 5,000 Sessions to bound seeding time and disk use.
## Alternatives considered
- **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material.
- **Authoring every Session through `Session.append`.** Rejected: per-append validation and freezing, repeated per Session, made seeding take minutes.
- **Omitting the projection cache.** Rejected: rows would lack the values the Web list shows, which understates list work.
- **A separate CI job.** Rejected: benchmarks share one idle standard runner; the job timeout rises to 30 minutes instead.
- **A separate CI job.** Rejected: benchmarks share one idle standard runner. The file adds 6.6 minutes to that job, including 68 s of seeding, and the job timeout rises from 15 to 25 minutes.
## Consequences
The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. It already shows that content search spends about a minute building its index on first use, that list cost grows linearly to about 1.5 s at 5,000 Sessions, and that forking the longest Session takes about 6 s. Seeding writes about 1.9 GB and adds roughly half a minute locally.
The gate covers corpus-scale list, search, and fork costs that single-Session gates cannot see. On standard hosted CI it already shows that content search spends 139 s building its index on first use, that the first list takes 5.1 s at 5,000 Sessions, and that forking the longest Session takes 17.5 s. Seeding writes about 1.9 GB.
Exclusions: the corpus is current-generation only, although most real Sessions are stored in older generations, which list revision hashing and migration-on-read would add. Only 22 distinct bodies exist, so search indexes repeated text. Fork runs without Agent presets or Workspace attachment. Calls are in-process, without Typert transport or a browser. Filesystem caches are not evicted, so cold means a fresh process, not cold storage.
@@ -29,7 +29,7 @@ Status: implemented
### 语料构建
播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。
播种 worker 为每个锚点生成一份事件主体,只压缩一次,然后把每个 Session 写成独立的 header 帧加上该锚点的主体。帧压缩和文件写入每次并发八个:每个待完成的压缩都持有一个原生 Zstandard 上下文,不加限制的压缩峰值 RSS 达到 7.7 GB,超过标准托管运行器的内存。共享锚点的 Session 只在 header 身份、创建时间、项目目录和父链接上不同。创建时间是排名的固定置换,因此列表顺序不跟随长度。事件携带 `Session.append` 记录的字段,紧凑流来自生产 `AssistantStreamAccumulator`,行通过持久化包的 `eventLines` 编码。验证 worker 通过 `sessionQuery.readSession` 回放每份不同的主体。
投影缓存属于列表端点:每个冷行都携带缓存的投影值。播种过程通过公开的 `sessionProjectionCache.coldSnapshot` 回写,为每个锚点折叠一次。之后用 `serializeRecord` 为每个 Session 写入逐记录文档,并把身份重新绑定到各自的 header。通过 domain API 写入时每条记录一次 fsync,10,000 个 Session 耗时 89 s。列表 worker 要求返回的每一行都携带投影,因此存储布局变化会使基准失败,而不是静默去掉这部分工作。
@@ -43,30 +43,30 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新
| 内容搜索 | 1,000 | 1 | 首次 `session.search`,包括索引构建;使用不同词项的第二次查询 |
| Fork | 1,000 | 3 | 十个等长度分层的中点排名、排名 990 和排名 999 的 `session.fork` 返回 |
预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在参考机器上一次冷索引构建约需一分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。
预算约束中位数:列表的启动、首次和重复;搜索的首次和重复;fork 的分层中位数、排名 990 和排名 999。搜索只约束一个样本,因为在标准托管 CI 上一次冷索引构建超过两分钟。报告包含端点之后的峰值 RSS 和堆,但不设内存预算。
## 校准证据
参考期望值来自 Apple M5 Pro、macOS arm64、Node 26.5 上隔离的 worker 运行,并通过共享 CI 系数和余量缩放。
预算使用标准双 CPU 托管 CI 的期望值(AMD EPYC 7763、Linux x64、Node 24.21、PR #5403),取高于记录中位数的整数,再乘以共享的 1.25 余量。每个用例内的托管样本差异小于 4%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。
| 端点 | 观测值 | 期望值 |
|---|---|---:|
| 列表 1,000:启动 / 首次 / 重复 | 237–271 / 311–358 / 254–293 ms | 300 / 400 / 300 ms |
| 列表 5,000:启动 / 首次 / 重复 | 1,182–1,261 / 1,364–1,603 / 1,287–1,588 ms | 1,300 / 1,700 / 1,600 ms |
| 搜索 1,000:首次 / 重复 | 57,006 / 1,046 ms | 60,000 / 1,100 ms |
| Fork:分层中位数 / p99 / 最长 | 29–32 / 608–672 / 6,000–6,365 ms | 40 / 700 / 6,500 ms |
| 端点 | 托管中位数 | 期望值 | 预算 |
|---|---|---:|---:|
| 列表 1,000:启动 / 首次 / 重复 | 830.3 / 1,155.3 / 876.7 ms | 850 / 1,200 / 900 ms | 1,063 / 1,500 / 1,125 ms |
| 列表 5,000:启动 / 首次 / 重复 | 3,569.5 / 5,143.5 / 4,290.5 ms | 3,600 / 5,200 / 4,300 ms | 4,500 / 6,500 / 5,375 ms |
| 搜索 1,000:首次 / 重复(一个样本) | 139,470 / 3,369 ms | 140,000 / 3,400 ms | 175,000 / 4,250 ms |
| Fork:分层中位数 / p99 / 最长 | 89.6 / 1,485.7 / 17,538.1 ms | 90 / 1,500 / 17,600 ms | 113 / 1,875 / 22,000 ms |
10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB;极端列表用例使用 5,000 个 Session,以限制播种时间和磁盘占用。
在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB;极端列表用例使用 5,000 个 Session,以限制播种时间和磁盘占用。
## 考虑过的替代方案
- **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。
- **通过 `Session.append` 生成每个 Session。** 已否决:逐次追加的验证和冻结在每个 Session 上重复,使播种耗时数分钟。
- **省略投影缓存。** 已否决:行会缺少 Web 列表显示的值,从而低估列表工作量。
- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器;改为把任务超时提高到 30 分钟。
- **单独的 CI 任务。** 已否决:基准共享一台空闲的标准运行器。此文件让该任务增加 6.6 分钟,其中播种 68 s,任务超时从 15 分钟提高到 25 分钟。
## 后果
该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。它已经显示:内容搜索在首次使用时约花一分钟构建索引,列表成本线性增长,在 5,000 个 Session 时约 1.5 s,fork 最长 Session 约需 6 s。播种写入约 1.9 GB,本地约增加半分钟。
该门禁覆盖单 Session 门禁看不到的语料规模列表、搜索和 fork 成本。在标准托管 CI 上,它已经显示:内容搜索在首次使用时花 139 s 构建索引,5,000 个 Session 的首次列表耗时 5.1 s,fork 最长 Session 耗时 17.5 s。播种写入约 1.9 GB。
排除项:语料只包含当前代,而大多数真实 Session 以旧代存储,这会增加列表的修订哈希和读取时迁移。只有 22 份不同主体,因此搜索索引的是重复文本。Fork 不挂载 Agent preset,也不附加 Workspace。调用在进程内完成,不经过 Typert 传输或浏览器。不驱逐文件系统缓存,因此“冷”指全新进程,而不是冷存储。
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
name: node 24 / benchmarks
# Wall-clock budgets need an otherwise idle runner, so this job runs the
# benchmark lane alone instead of joining a concurrent gate aggregate.
timeout-minutes: 30
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
@@ -5,7 +5,7 @@ import { availableParallelism, cpus, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget } from '../support/calibration.ts'
import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts'
import type {
ForkReport,
@@ -20,7 +20,7 @@ import type {
const CORPUS = { standard: 1_000, extreme: 5_000 } as const
/** Fresh processes per list and fork scenario; the median enforces each budget. */
const ATTEMPTS = { list: 5, fork: 3 } as const
/** One cold content-search sample: the index build takes about a minute on the reference machine. */
/** One cold content-search sample: the index build takes over two minutes on standard hosted CI. */
const SEARCH_ATTEMPTS = 1
/** Midpoints of ten equal length strata of the standard corpus, then its p99 and longest Session. */
const FORK_STRATA = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950] as const
@@ -30,18 +30,26 @@ const SEED_TIMEOUT_MS = 600_000
const WORKER_TIMEOUT_MS = 300_000
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'session-corpus.worker.js')
/** Apple M5 Pro / Node 26.5 expectations, before shared CI scaling and variance headroom. */
const EXPECTED_MS = {
listBoot: { standard: 300, extreme: 1_300 },
listFirst: { standard: 400, extreme: 1_700 },
listRepeat: { standard: 300, extreme: 1_600 },
searchFirst: 60_000,
searchRepeat: 1_100,
forkStratumMedian: 40,
forkP99: 700,
forkLongest: 6_500,
/**
* Standard two-CPU hosted CI expectations, rounded above the recorded medians before variance headroom.
* Recorded medians: list 1,000 830.3 / 1,155.3 / 876.7 ms; list 5,000 3,569.5 / 5,143.5 / 4,290.5 ms;
* search 139,470 / 3,369 ms; fork 89.6 / 1,485.7 / 17,538.1 ms.
*/
const EXPECTED_CI_MS = {
listBoot: { standard: 850, extreme: 3_600 },
listFirst: { standard: 1_200, extreme: 5_200 },
listRepeat: { standard: 900, extreme: 4_300 },
searchFirst: 140_000,
searchRepeat: 3_400,
forkStratumMedian: 90,
forkP99: 1_500,
forkLongest: 17_600,
} as const
function budget(expectedCiMs: number): number {
return Math.ceil(expectedCiMs * PERFORMANCE_BUDGET_HEADROOM)
}
type CorpusSize = keyof typeof CORPUS
async function run<Report extends SessionCorpusReport>(args: readonly string[], timeoutMs = WORKER_TIMEOUT_MS): Promise<Report> {
@@ -62,8 +70,8 @@ function rounded(values: readonly number[]): number[] {
return values.map(value => Math.round(value * 10) / 10)
}
function expectWithinBudget(value: number, budget: number): void {
expect(value).toBeLessThanOrEqual(budget)
function expectWithinBudget(value: number, limit: number): void {
expect(value).toBeLessThanOrEqual(limit)
}
function environment() {
@@ -91,15 +99,19 @@ describe('Session corpus workload', () => {
expect(() => sessionShape(CORPUS.standard, CORPUS.standard)).toThrow('outside')
})
it('rejects endpoint medians more than a quarter above their CI expectation', () => {
const expectations = [
EXPECTED_MS.listFirst.extreme, EXPECTED_MS.searchFirst, EXPECTED_MS.forkLongest, EXPECTED_MS.forkStratumMedian,
]
for (const expected of expectations) {
const budget = ciTimeBudget(expected)
expectWithinBudget(expected * 2, budget)
expect(() => expectWithinBudget(Math.ceil(expected * 2 * 1.26), budget)).toThrow()
it('accepts recorded hosted medians and rejects medians a quarter above their expectation', () => {
const recorded = [
[median([3_569.4, 3_634.7, 3_569.5, 3_536, 3_624.8]), EXPECTED_CI_MS.listBoot.extreme],
[median([5_206.2, 5_043, 5_143.5, 5_167.9, 5_047.4]), EXPECTED_CI_MS.listFirst.extreme],
[139_470, EXPECTED_CI_MS.searchFirst],
[median([17_573.2, 17_532.9, 17_538.1]), EXPECTED_CI_MS.forkLongest],
[median([88.2, 89.6, 91.8]), EXPECTED_CI_MS.forkStratumMedian],
] as const
for (const [value, expected] of recorded) {
expectWithinBudget(value, budget(expected))
expect(() => expectWithinBudget(Math.ceil(expected * 1.26), budget(expected))).toThrow()
}
expect(budget(EXPECTED_CI_MS.listFirst.extreme)).toBe(6_500)
})
})
@@ -127,9 +139,9 @@ describe('Session corpus operations', () => {
for (const size of ['standard', 'extreme'] as const) {
const budgets = {
bootMs: ciTimeBudget(EXPECTED_MS.listBoot[size]),
firstMs: ciTimeBudget(EXPECTED_MS.listFirst[size]),
repeatMs: ciTimeBudget(EXPECTED_MS.listRepeat[size]),
bootMs: budget(EXPECTED_CI_MS.listBoot[size]),
firstMs: budget(EXPECTED_CI_MS.listFirst[size]),
repeatMs: budget(EXPECTED_CI_MS.listRepeat[size]),
}
it(`lists ${String(CORPUS[size])} Sessions after a cold Host boot`, async () => {
@@ -153,7 +165,7 @@ describe('Session corpus operations', () => {
}
it(`searches ${String(CORPUS.standard)} Sessions with a cold and then a built content index`, async () => {
const budgets = { firstMs: ciTimeBudget(EXPECTED_MS.searchFirst), repeatMs: ciTimeBudget(EXPECTED_MS.searchRepeat) }
const budgets = { firstMs: budget(EXPECTED_CI_MS.searchFirst), repeatMs: budget(EXPECTED_CI_MS.searchRepeat) }
const reports: SearchReport[] = []
for (let attempt = 0; attempt < SEARCH_ATTEMPTS; attempt++) {
reports.push(await run<SearchReport>([root('standard'), 'search']))
@@ -165,9 +177,9 @@ describe('Session corpus operations', () => {
it(`forks Sessions across the length distribution of ${String(CORPUS.standard)} Sessions`, async () => {
const budgets = {
stratumMedianMs: ciTimeBudget(EXPECTED_MS.forkStratumMedian),
p99Ms: ciTimeBudget(EXPECTED_MS.forkP99),
longestMs: ciTimeBudget(EXPECTED_MS.forkLongest),
stratumMedianMs: budget(EXPECTED_CI_MS.forkStratumMedian),
p99Ms: budget(EXPECTED_CI_MS.forkP99),
longestMs: budget(EXPECTED_CI_MS.forkLongest),
}
const ranks = [...FORK_STRATA, FORK_P99_RANK, FORK_LONGEST_RANK].map(String)
const reports: ForkReport[] = []
+2 -2
View File
@@ -464,10 +464,10 @@ describe('CI workflow', () => {
})
})
it('bounds the complete benchmark job to thirty minutes', () => {
it('bounds the complete benchmark job to twenty-five minutes', () => {
const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench')
expect(benchmark['timeout-minutes']).toBe(30)
expect(benchmark['timeout-minutes']).toBe(25)
expect(benchmark.steps).toContainEqual({
name: 'Run performance benchmarks',
env: { DSH_GATE_VERBOSE: '1' },