DSH / Atlas
2026-09-04implementedtesting

Required CI performance gate for opening large Sessions

打开大型 Session 的必需 CI 性能 gate

The Session format v2 rollout changed two paths whose cost scales with model output: the JSONL backend migrates and publishes a released-v0 log, and the Client folds each settled reply's embedded compact stream. Neither path had an executable performance check, so first open grew from about 35 ms to about 5 s on a 127,400-event synthetic log (and from about 0.3 s to 26 s on a 575,000-chunk real log, with 2.7 GB peak

English

Problem

The Session format v2 rollout changed two paths whose cost scales with model output: the JSONL backend migrates and publishes a released-v0 log, and the Client folds each settled reply's embedded compact stream. Neither path had an executable performance check, so first open grew from about 35 ms to about 5 s on a 127,400-event synthetic log (and from about 0.3 s to 26 s on a 575,000-chunk real log, with 2.7 GB peak RSS and heap exhaustion under a 512 MB limit), while Client fold grew linearly with streamed deltas instead of compact records; these regressions reached master unnoticed.

Measuring only SessionPersistence.open() does not stably describe the result for which a user or Host waits. Work can move among open(), SessionHandle.read(), Session restoration, and projection, while the first history page and cold Agent resume add separate orchestration above those operations. A single heapUsed sample without prior GC also cannot distinguish data still retained by the Session from reclaimable migration temporaries.

Decision

Linux pull requests run a required node 24 / benchmarks job that executes pnpm run check:ci:bench → scripts/run-ci-bench.ts. Its disposable library build omits repeated TypeScript diagnostics under the PR artifact build policy; required full builds retain them. The private @deepseek-ai/dsh-benchmarks workspace owns benchmark-only dependencies. The command first builds workspace libraries and dedicated workers under benchmarks/.dsh-build/, then invokes vitest.bench.config.ts. The standard hosted runner reference owns runner selection and the outer job timeout. The job runs the benchmark lane alone; Vitest runs one file at a time and only prepares input, starts measurement children, aggregates results, and enforces budgets. Every timed Node CPU path executes compiled JavaScript under plain Node with NODE_OPTIONS removed and no TypeScript loader; bare workspace imports therefore resolve from benchmarks/node_modules through package exports to built lib/ entries.

Required performance gates live under top-level benchmarks/, grouped by measured user path rather than package ownership. Host files use *.bench.ts, Client-face files use *.bench.client.ts, and scenario-specific workers and fixtures stay beside their benchmark without a benchmark suffix. Package-local .perf.ts files remain non-gating diagnostics; scripts/ owns orchestration rather than benchmark cases.

The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. Each synthetic turn starts its step before appending user surface input, allowing the V2-to-V3 migration to reserve a protected system head without reordering history. The fixture constructs released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions.

Every Session endpoint runs at two user-lifecycle points. first-open starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces post-upgrade-reopen once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published current-generation successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.

Each access-kind and endpoint sample runs in a fresh compiled Node child process. Module imports, Host service initialization, and fixture preparation finish before measurement; the measured process performs no extra parse warm-up. Normal-heap mode runs five independent samples, reports every sample plus minimum, median, and maximum, and enforces access-specific fixed budgets against the median. Another child runs the same path under a fixed 128 MB old-space limit and checks only that it completes; extra GC caused by the constrained heap does not enter the normal timing baseline.

The lane contains three independent Session-opening benchmarks and retains the Client-fold benchmark:

BenchmarkMeasured pathTiming metrics
Phase profileExecutes the real persistence open, handle read, Session restore, and projection for both first open and post-upgrade reopenopenMs, readMs, sessionRestoreMs, and projectionMs each have a fixed budget; read-only migration belongs to first-open openMs; successor encoding, verification, and publication belong to writable Agent resume
First historyReads each access kind through the Host Session history controller until it produces the first paginated snapshotSeparate first-open and reopen end-to-end budgets; each includes source stat, reading, restoration, projection, pagination, and snapshot construction, while first open additionally includes migration; both exclude Gateway network transport, Client fold, and browser paint
Agent resumeCalls ctx.agents.resume() for each access kind until Agent creation, setup, publication, and loop startup finishSeparate first-open and reopen end-to-end budgets; neither path runs after first-history or reuses that benchmark's cache
Client foldFolds small and large v2 history windows through the real ConversationNodeAssembler and every Chat DefinitionThe large window's absolute time and scaling relative to the small window each have a fixed budget

The phase profile invokes each layer's production entry point explicitly and does not copy any decode, migration, restore, or projection algorithm. First-history and Agent-resume each run their real higher-level entry point against fresh first-open and reopen roots, so component measurements do not stand in for end-to-end results and one scenario cannot warm another's process or Session cache. The sum of the four phases is diagnostic only; an outer clock independently measures each end-to-end result.

Normal-heap mode performs a fixed pair of explicit garbage collections after Host initialization and before the cold Session is touched, then records starting memory. It stops operation timing before performing the same garbage-collection sequence while the scenario's intended long-lived objects remain explicitly reachable, then records ending memory. The Agent-resume endpoint retains the Agent, Session, complete events, and normal service caches; its heapUsed delta is the primary resident-Session memory budget. Every scenario also reports external, arrayBuffers, post-GC RSS, and process.resourceUsage().maxRSS; the 128 MB mode prevents transient allocation peaks from being hidden by endpoint collection. Explicit garbage-collection time is excluded from operation timing.

A small untimed fixture prerequisite verifies current migration, message preservation, immutable V0 bytes, and successor reopen. Worker failures retain the first and last ten stderr lines, or fatal heap diagnostics, so setup rejection remains distinguishable from a budget breach. The timed performance cases do not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real ConversationNodeAssembler and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets.

Budgets are calibrated per measured endpoint. Two repeated Node 24.19 x64 CI runs differ by at most 5.2% in their medians; their CPU-heavy wall times are 1.95–2.06× the Node 24.18 arm64 reference run. Except for current-generation open and first-open Agent resume, source constants record expected reference-machine durations; ciTimeBudget() multiplies them by the measured 2× CI time scale and 1.25× variance headroom. Current-generation open uses a directly measured standard-runner expectation of 50 ms with only the 1.25× headroom, rounded up to a 63 ms budget. First-open Agent resume uses a reviewed 562 ms hosted limit. The retained-heap and Client-fold scaling budgets use only the 1.25× headroom because neither is a wall-clock duration. The 128 MB completion check remains an independent transient-allocation limit. The resulting first-open time limits, constrained-heap checks, and Client-fold limits all reject the known regressions. The PR #3533 merge is the fixed calibration and review reference; CI does not check out or execute the historical repository. Budgets are reviewed source constants and have no environment-variable override.

Calibration evidence

The comparison is orthogonal by user lifecycle, not by artifact representation. Both implementations receive the same fixed V0 bytes for first open. For reopen, each implementation reads the format it considers current in a fresh process: the pre-stack reference remains on V0, while the V2 implementation reads its published V2 successor. This intentionally compares the same user's later-open experience rather than two codecs over one data structure.

Five-sample medians on the same Node 24 reference machine establish the positive and negative controls:

Access kindImplementationFour-phase totalFirst historyAgent resumeAgent retained heap128 MB old space
First openPre-stack reference249.0 ms253.8 ms100.7 ms26.1 MBCompletes
First openRepeated-snapshot regression4,197.5 ms4,284.8 ms4,197.9 ms4.4 MBExhausts heap
Post-upgrade reopenPre-stack reference251.1 ms253.8 ms100.7 ms26.1 MBCompletes
Post-upgrade reopenRepeated-snapshot regression49.2 ms50.4 ms43.8 ms4.5 MBCompletes

The pre-stack implementation keeps V0 as its current format, so first open does not change its on-disk representation; its native V0 first-history and Agent-resume measurements therefore apply to both lifecycle rows.

The standard two-CPU run (run 34023970384, job 101461539961) for PR #3640 uses Node 24.20.0 x64 and Ubuntu image 20260831.293.1. Its five current-generation open samples are 49.2, 47.4, 49.1, 48.6, and 48.1 ms: median 48.6 ms, maximum 49.2 ms. The rounded 50 ms CI expectation gives a 63 ms limit without reapplying the 2× machine scale. The log identifies two available CPUs but not their model; it does not isolate hardware from the Node-version change. This is endpoint-specific runner calibration, not evidence of an application optimization or a new reference-machine measurement. Every other benchmark passes its existing budget. Deterministic controls reject the observed median at the historical 30 ms limit, accept it at 63 ms, reject a synthetic 75 ms reopen median, and reject a synthetic 4,000 ms first-open duration at its unchanged 550 ms limit. These controls verify budget enforcement, not a measured new regression.

A cold-verifier packaging change removes runtime workspace-module loading without changing these budgets or the measured endpoint. On macOS arm64, Node 24.18.0, the same 127,400-event fixture before verifier bundling takes 164.2, 162.4, 159.7, 149.3, and 167.7 ms for first writable resume (median 162.4 ms). Bundling the verifier through the workspace build gives 119.8, 120.9, 121.9, 122.3, and 121.8 ms (median 121.8 ms, 25% lower). Retained heap stays at 5.4 MB; median peak RSS changes from 144.9 to 143.7 MB. Reopen medians are 27.5 and 27.1 ms, and all 16 Session cases, including the 128 MB completion checks, pass. A CPU profile attributes part of the old verifier cost to module resolution and compilation. The isolated-package built-worker test fails on the original worker because its workspace imports cannot resolve, and passes with the bundled worker, including rejection of an incorrect event count. These local results do not establish Linux runner timing; those cases use the 450 ms CI limit.

The hosted run (run 34265057987, job 102192211510) includes the bundled verifier and reports first-open Agent-resume samples of 454.2, 454.8, 455.4, 457.8, and 459.8 ms: median 455.4 ms against 450 ms. The code-equivalent preceding run (run 34263062688, job 102185561214) reports a 436.2 ms median; only the bilingual request-history README and its pairing record differ between those heads. The reviewed ceiling is 562 ms, floor(450 × 1.25), a 24.89% increase that leaves 23.4% above the observed 455.4 ms median. Five fresh M4 Pro / Node 24.19 samples span 150.07–158.22 ms with a 152.57 ms median. A bounded main-thread profile identifies no obvious small optimization; it excludes verifier-thread CPU and does not establish the cause of hosted variation. Controls reject the recorded median at 450 ms, accept it at 562 ms, and reject 600 ms. The bundled-verifier improvement, workload, other time budgets, shared scaling, and memory limits remain intact.

The calibrated source budgets are:

MeasurementReference expectationCI budget
First-open open220 ms550 ms
Current-generation open12 ms (historical reference; CI expectation: 50 ms)63 ms
Complete read8 ms20 ms
Session restore24 ms60 ms
Projection14 ms35 ms
First-open first history220 ms550 ms
Current-generation first history48 ms120 ms
First-open Agent resume180 ms (historical reference)562 ms
Current-generation Agent resume40 ms100 ms
Agent retained heap26.1 MB33 MB
Client-fold absolute time16 ms40 ms
Client-fold delta scaling2.5×3.125×
Constrained old space—128 MB

Indexed traversal of stored JSON arrays

The JSONL backend freezes decoded event graphs before memoization. Its native decoder and fixed migration catalog produce acyclic JSON graphs whose arrays contain only indexed values. Numeric array traversal preserves freezing of every nested object and array while avoiding string-key enumeration across the compact stream's 125,000 text entries and 124,600 time deltas. Object traversal still preserves own __proto__ and constructor data. No admission, relationship validation, decoding, or freezing moves beyond the measured open endpoint, and a shallow Object.isFrozen() result never skips descendant traversal.

On an Apple M4 Pro with Node 24.19.0, an open-only CPU profile attributes about 4.2 ms to complete event freezing. Separate fresh-process V4 runs give these results; the final column restores and rebuilds the exact original traversal as a negative control:

Reopen measurementOriginal traversalIndexed traversalOriginal restored
open samples (ms)22.2, 22.4, 22.8, 22.6, 22.917.7, 18.4, 17.4, 17.8, 17.621.7, 22.4, 22.2, 21.7, 21.6
Median open (ms)22.617.721.7
Median first history (ms)35.030.633.6
Median Agent resume (ms)30.526.829.8
Agent retained heap (MB)4.54.54.5

Each run passes all 17 Session cases, including the 128 MB completion checks. The original implementation remains below 63 ms on this local machine; the restored traversal demonstrates the removed cost, while the hosted benchmark remains the required budget verdict. The workload, time limits, memory limits, cold-process setup, and measurement endpoints are unchanged. The JSONL owner tests cover nested opaque JSON, rejected mutation, and immutable event sharing through historical V3 and current reads. These measurements establish a reopen improvement; they do not establish a consistent first-open improvement or hosted-runner timing.

Alternatives considered

Check out the historical commit and compare it on every CI run. Rejected because a historical checkout requires a separate install, and old and current revisions can assign work to different API phases, adding runtime, dependency, and interface drift. A fixed workload with static budgets calibrated against positive and negative controls is easier to reproduce and review.

Measure only first open from V0. Rejected because migration is a one-time upgrade cost and cannot protect later opens of the settled current generation from regressions. The two access kinds need separate measurements and budgets.

Measure only the four component phases. Rejected because component measurements locate cost but omit source stat, orchestration, pagination, and snapshot construction, and cannot prove that the complete first-history path remains usable and fast enough.

Measure only first-history or Agent-resume total time. Rejected because an end-to-end number protects the result but cannot identify whether storage, reading, Session restoration, or projection regressed; four phase budgets retain actionable attribution.

Add fine-grained timing instrumentation inside production implementations. Rejected because those probes would expand production APIs and couple the benchmark to implementation details. Tests use existing service and object boundaries; costs that those boundaries cannot attribute remain part of the end-to-end result.

Run measured workers from TypeScript source. Rejected because a source loader changes module resolution and startup behavior, and causes nested workers to select source-only bootstrap paths. Vitest remains an unmeasured orchestrator; every timed worker executes the build output exactly as plain Node consumers do.

Use only time budgets or only post-GC memory. Rejected because time does not reveal memory regressions, while endpoint live memory cannot expose transient migration spikes. Normal-heap post-GC deltas and constrained-heap completion cover the two risks separately.

Benchmark the real recorded corpus. Rejected because corpus fixtures stay small by policy, recorded material must not become benchmark input, and re-recording would silently move the workload.

Run benchmarks inside an existing gate aggregate. Rejected because aggregate gates run concurrently on one runner, so wall-clock measurements inherit neighbouring CPU load.

Keep each cross-package gate under one participating product package. Rejected because Session opening spans persistence, migration, projection, Host history, and Agent resume; choosing one participant creates misleading ownership and benchmark-only package dependencies. The repository-level tree owns the integrated user path, while package-local diagnostics remain with their implementation.

Put benchmark cases under scripts/. Rejected because scripts own commands, generators, and orchestration, while a benchmark case owns typed test files, workers, fixtures, budgets, and lifecycle cleanup. A future reporting or calibration command may consume benchmarks/ without moving the cases there.

Consequences

Every pull request pays for one required Linux job; its Session portion runs several short-lived child processes in exchange for cold caches, isolated V8 heaps, explicit GC state, and attributable failures. The repository-level benchmark tree accepts deliberate cross-package test dependencies without changing product package manifests. The fixed Zstandard workload covers both event volume and frame topology; first-open measurements protect the one-time upgrade experience, reopen measurements prevent regressions in later opens, phase budgets locate cost, first-history budgets protect user-visible waiting, Agent-resume budgets and post-GC deltas protect complete cold activation and resident memory, and the 128 MB mode protects the transient allocation ceiling.

The Session and Node-fold scenarios do not measure network transfer, browser rendering, or recorded Sessions, and they are not a continuous performance-trend system. Frontend performance budgets own browser workflow measurements. A Node or runner change requires resampling the same workload and reviewing the budgets; a business-implementation change must not relax a budget without new positive and negative control data.

中文

问题

Session format v2 的推出改变了两条成本随模型输出增长的路径:JSONL backend 迁移并发布 released-v0 log,Client fold 每个已结算回复中嵌入的紧凑 stream。两条路径都没有可执行的性能检查,因此首次打开在 127,400 事件的合成 log 上从约 35 ms 增长到约 5 s(在 575,000 chunk 的真实 log 上从约 0.3 s 增长到 26 s,峰值 RSS 2.7 GB,并在 512 MB 堆限制下耗尽堆),Client fold 也随流式 delta 数而不是紧凑记录数线性增长,这些退化未被察觉地进入了 master。

只测 SessionPersistence.open() 不能稳定表达用户或 Host 等待的结果。工作可以在 open()、SessionHandle.read()、Session restore 与 projection 之间移动,而首次历史页和冷 Agent 恢复还包含这些操作之上的独立编排。操作结束时未经 GC 的一次 heapUsed 采样也不能区分仍被 Session 持有的数据与可回收的迁移临时对象。

决定

Linux pull request 运行必需的 node 24 / benchmarks job,执行 pnpm run check:ci:bench → scripts/run-ci-bench.ts。其临时 library 构建按 PR 产物构建政策省略重复的 TypeScript 诊断;必需的完整构建保留这些诊断。私有 @deepseek-ai/dsh-benchmarks workspace 拥有 benchmark 专属依赖。该命令先构建 workspace library 和 benchmarks/.dsh-build/ 下的专用 worker,再调用 vitest.bench.config.ts。标准托管运行器说明拥有运行器选择及外层 job 超时。该 job 单独运行 benchmark lane;Vitest 逐文件运行,只负责准备输入、启动测量子进程、汇总结果和执行预算断言。每条被计时的 Node CPU 路径都以纯 Node 执行编译后的 JavaScript,并移除 NODE_OPTIONS 且不加载 TypeScript runtime;workspace 裸导入因此从 benchmarks/node_modules 通过 package exports 解析到构建后的 lib/ 入口。

必需性能 gate 位于顶层 benchmarks/,按被测用户路径而非 package 归属组织。Host 文件使用 *.bench.ts,Client 面文件使用 *.bench.client.ts,场景专属 worker 与 fixture 留在对应 benchmark 旁且不带 benchmark 后缀。包内 .perf.ts 文件仍是非门禁诊断;scripts/ 负责编排而不承载 benchmark case。

Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。每个合成 turn 先开始 step,再追加 user surface 输入,使 V2-to-V3 migration 能预留受保护的 system head 而不重排历史。fixture 直接构造 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。

每个 Session endpoint 都针对用户生命周期中的两个时点运行。first-open 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 post-upgrade-reopen,再把未改动的 V0 前代和已发布的当前 generation 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。

每个 access kind 与 endpoint 的样本都在全新、已编译的 Node 子进程中运行。模块加载、Host 服务初始化和 fixture 准备在测量开始前完成;测量进程不执行额外的预热解析。正常堆模式运行五个独立样本,报告全部样本及最小值、中位数和最大值,并以中位数执行各访问状态独立的固定预算。另一个子进程使用固定 128 MB old-space 上限运行同一路径,只判断能否完成;低堆限制引起的额外 GC 不进入正常时间基线。

该 lane 包含三个独立的 Session 打开 benchmark,并保留 Client fold benchmark:

Benchmark被测路径时间指标
阶段剖面分别为 first open 与 post-upgrade reopen 执行真实 persistence open、handle read、Session restore 与 projectionopenMs、readMs、sessionRestoreMs、projectionMs 各自使用固定预算;只读 migration 归入 first-open openMs;后继编码、verify 与 publish 属于可写 Agent resume
首屏历史两种 access kind 分别经 Host Session history controller 读取到首个分页 snapshotFirst open 与 reopen 各有一个端到端预算;均包含 source stat、读取、Session restore、projection、分页与 snapshot 构造,first open 还包含 migration;两者都不包含 Gateway 网络传输、Client fold 或浏览器 paint
Agent resume对两种 access kind 分别调用 ctx.agents.resume(),直到 Agent 创建、setup、发布与 loop 启动完成First open 与 reopen 各有一个端到端预算;两条路径都不与首屏历史串行,也不依赖它留下的 cache
Client fold大小两个 v2 history window 经真实 ConversationNodeAssembler 与全部 Chat Definition fold大窗口的绝对时间与相对小窗口的缩放比各自使用固定预算

阶段剖面显式调用各层正式入口,不复制 decode、migration、restore 或 projection 算法。首屏历史和 Agent resume 分别以新的 first-open 与 reopen 根目录运行真实上层入口,因此组件数据不冒充端到端结果,一个场景也不会给另一个场景预热进程或 Session cache。四阶段之和仅用于解释成本;首屏与 Agent resume 的端到端时间各自由外层时钟直接测量。

正常堆模式在 Host 初始化完成且 Session 尚未访问时执行固定的两轮显式 GC,记录起点内存;操作计时结束后,在该场景要求的长期对象仍明确可达时再次执行同样的 GC,再记录终点内存。Agent resume 场景在终点保留 Agent、Session、完整 events 与正常服务 cache,它的 heapUsed 增量是常驻 Session 内存预算的主指标。每个场景同时报告 external、arrayBuffers、GC 后 RSS 和 process.resourceUsage().maxRSS;128 MB 模式继续防止瞬时分配峰值被终点 GC 隐藏。显式 GC 时间不计入操作时间。

一个不计时的小型 fixture 前置用例验证当前 migration、消息保留、V0 字节不变及后继再次打开。Worker 失败时保留 stderr 首尾各十行或致命堆错误,使准备阶段拒绝与预算超限可区分。计时性能用例不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 ConversationNodeAssembler 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。

预算按各测量终点分别校准。两次 Node 24.19 x64 CI 运行的中位数最大相差 5.2%;其 CPU 密集型壁钟时间是 Node 24.18 arm64 参考运行的 1.95–2.06 倍。除当前 generation open 和 first-open Agent resume 外,源码常量记录参考机器上的预期耗时;ciTimeBudget() 将其乘以实测的 2 倍 CI 时间系数和 1.25 倍波动余量。当前 generation open 使用标准运行器直接测得的 50 ms 预期值,仅乘以 1.25 倍余量,向上取整得到 63 ms 预算。First-open Agent resume 使用经审查的 562 ms 托管上限。GC 后增量堆与 Client fold 缩放预算不属于壁钟时间,因此只使用 1.25 倍余量。128 MB 完成性检查仍是独立的瞬时分配限制。由此得到的 first-open 时间上限、受限堆检查与 Client fold 上限都会拒绝已知退化。参考固定为 PR #3533 的合并结果,只用于校准和评审预算;CI 不 checkout 或执行历史仓库。预算是源码中的受评审常量,不由环境变量覆盖。

校准证据

比较按用户生命周期正交,而不是按产物表示正交。两种实现的 first open 都接收完全相同的固定 V0 字节。Reopen 时,每种实现都在全新进程中读取自己认定的当前格式:栈前参考版本仍读取 V0,V2 实现则读取它已发布的 V2 后继。这里有意比较同一用户后续打开的体验,而不是让两个 codec 处理同一种数据结构。

同一台 Node 24 参考机器上的五次样本中位数构成正反例:

Access kind实现四阶段总时间首屏历史Agent resumeAgent GC 后增量堆128 MB old space
First open栈前参考版本249.0 ms253.8 ms100.7 ms26.1 MB完成
First open重复 snapshot 退化实现4,197.5 ms4,284.8 ms4,197.9 ms4.4 MB堆耗尽
Post-upgrade reopen栈前参考版本251.1 ms253.8 ms100.7 ms26.1 MB完成
Post-upgrade reopen重复 snapshot 退化实现49.2 ms50.4 ms43.8 ms4.5 MB完成

栈前实现以 V0 作为当前格式,因此 first open 不改变磁盘表示;它的原生 V0 首屏历史与 Agent resume 测量同时适用于两个生命周期行。

PR #3640 的标准双 CPU 运行 (run 34023970384, job 101461539961)使用 Node 24.20.0 x64 和 Ubuntu 镜像 20260831.293.1。当前 generation open 的五次样本为 49.2、47.4、49.1、48.6 和 48.1 ms:中位数 48.6 ms,最大值 49.2 ms。取整后的 50 ms CI 预期值给出 63 ms 上限,不重复乘以 2 倍机器系数。日志标明两个可用 CPU,但未记录型号;它无法区分硬件变化与 Node 版本变化的影响。这是端点专属的运行器校准,不是应用优化或参考机器新测量的证据。其他每项 benchmark 均通过既有预算。确定性正反例在历史 30 ms 上限下拒绝实测中位数,在 63 ms 下接受它,拒绝合成的 75 ms reopen 中位数,并以未改变的 550 ms 上限拒绝合成的 4,000 ms 首次打开耗时。这些正反例验证预算执行,不代表测得新的退化。

一次冷 verifier 打包调整移除了运行时 workspace 模块加载,未改变这些预算或测量终点。在 macOS arm64、Node 24.18.0 上,verifier 打包前对同一份 127,400-event fixture 的首次 writable resume 耗时为 164.2、162.4、159.7、149.3、167.7 ms(中位数 162.4 ms)。通过 workspace build 打包 verifier 后为 119.8、120.9、121.9、122.3、121.8 ms(中位数 121.8 ms,降低 25%)。Retained heap 保持 5.4 MB;peak RSS 中位数从 144.9 变为 143.7 MB。Reopen 中位数为 27.5 和 27.1 ms,包含 128 MB completion check 的全部 16 项 Session 用例通过。CPU profile 将旧 verifier 的部分成本归因于模块解析和编译。隔离 package 的 built-worker 测试在旧 worker 上因无法解析 workspace import 而失败,在打包后的 worker 上通过,同时验证错误的 event count 会被拒绝。这些本地结果不能证明 Linux runner 耗时;这些用例使用 450 ms CI 上限。

托管运行 (run 34265057987, job 102192211510)包含已打包的 verifier,first-open Agent-resume 样本为 454.2、454.8、455.4、457.8 和 459.8 ms:中位数 455.4 ms,超过 450 ms。代码等价的前一次运行 (run 34263062688, job 102185561214)报告 436.2 ms 中位数;两个 head 之间只有请求历史双语 README 及其配对记录不同。经审查的上限为 562 ms,即 floor(450 × 1.25),增加 24.89%,比观测到的 455.4 ms 中位数高 23.4%。五次新进程 M4 Pro / Node 24.19 样本范围为 150.07–158.22 ms,中位数为 152.57 ms。一次有界的主线程 profile 未发现明显的小型优化;它不包含 verifier 线程 CPU,也不能证明托管耗时变化的原因。对照在 450 ms 下拒绝已记录中位数,在 562 ms 下接受该值,并拒绝 600 ms。Verifier 打包优化、工作负载、其他时间预算、共享缩放和内存限制均保持不变。

校准后的源码预算如下:

测量项参考机预期CI 预算
First-open open220 ms550 ms
当前 generation open12 ms(历史参考值;CI 预期值:50 ms)63 ms
完整 read8 ms20 ms
Session restore24 ms60 ms
Projection14 ms35 ms
First-open 首屏历史220 ms550 ms
当前 generation 首屏历史48 ms120 ms
First-open Agent resume180 ms(历史参考)562 ms
当前 generation Agent resume40 ms100 ms
Agent GC 后增量堆26.1 MB33 MB
Client fold 绝对时间16 ms40 ms
Client fold delta 缩放比2.5×3.125×
受限 old space—128 MB

按索引遍历持久化 JSON 数组

JSONL backend 在 memo 化前冻结解码后的 event graph。原生 decoder 与固定 migration catalog 产出无环 JSON graph,其中数组只包含索引值。按数字索引遍历数组仍会冻结每个嵌套对象和数组,同时避免枚举 compact stream 中 125,000 个文本条目和 124,600 个时间差值的字符串键。对象遍历继续保留自有的 __proto__ 和 constructor 数据。Admission、关系校验、解码与冻结都仍在被测 open 终点前完成;浅层 Object.isFrozen() 结果不会跳过后代遍历。

在 Apple M4 Pro、Node 24.19.0 上,仅覆盖 open 的 CPU profile 将约 4.2 ms 归因于完整 event 冻结。独立的全新进程 V4 运行得到以下结果;末列恢复并重新构建完全相同的原始遍历,作为负对照:

Reopen 测量项原始遍历索引遍历恢复原始遍历
open 样本(ms)22.2, 22.4, 22.8, 22.6, 22.917.7, 18.4, 17.4, 17.8, 17.621.7, 22.4, 22.2, 21.7, 21.6
open 中位数(ms)22.617.721.7
首屏历史中位数(ms)35.030.633.6
Agent resume 中位数(ms)30.526.829.8
Agent 保留堆(MB)4.54.54.5

每次运行的全部 17 个 Session 用例均通过,包括 128 MB 完成性检查。原始实现在本机仍低于 63 ms;恢复原始遍历展示了被移除的成本,而托管 benchmark 仍负责给出必须满足的预算结果。工作负载、时间上限、内存上限、冷进程准备与测量终点保持不变。JSONL owner 测试覆盖历史 V3 和当前读取中的嵌套不透明 JSON、修改拒绝及不可变 event 共享。这些测量确认了 reopen 改善,但没有确认一致的 first-open 改善或托管运行器耗时。

考虑过的替代方案

每次 CI checkout 历史提交并做相对比较。 拒绝:历史 checkout 需要独立安装,旧版与当前版还可能把工作放在不同 API 阶段,增加时间、依赖和接口漂移。固定 workload 与经正反例校准的静态预算更容易复现和评审。

只测从 V0 first open。 拒绝:migration 是一次性升级成本,不能防止进入稳定当前 generation 后的后续打开发生退化。两种 access kind 需要独立的测量与预算。

只测四个组件阶段。 拒绝:组件测量便于定位,但会遗漏 source stat、编排、分页和 snapshot 构造,也不能证明首屏路径整体仍然可用且足够快。

只测首屏或 Agent resume 总时间。 拒绝:端到端数字能保护结果,却不能指出退化来自存储、读取、Session restore 还是 projection;四阶段预算保留可操作的归因。

在生产实现内部添加细粒度计时桩。 拒绝:这些桩会扩大生产接口并让 benchmark 与实现细节耦合。测试只使用既有服务和对象边界;无法由这些边界解释的成本保留在端到端结果中。

从 TypeScript 源码运行被测 worker。 拒绝:源码 loader 会改变模块解析与启动行为,并使嵌套 worker 选择仅适用于源码的启动路径。Vitest 仍可作为不计时的编排层;每个被计时的 worker 都像纯 Node 消费方一样执行构建产物。

只用时间预算或只看 GC 后内存。 拒绝:时间无法发现内存退化,终点存活内存也看不到迁移期间的瞬时爆发。正常堆的 GC 后增量与受限堆的完成性分别覆盖两类风险。

用真实录制语料做 benchmark。 拒绝:语料 fixture 按策略保持小体量,录制材料不得成为 benchmark 输入,且其重新录制会静默移动 workload。

把 benchmark 放进现有 gate 聚合中运行。 拒绝:聚合在一个 runner 上并发运行各 gate,壁钟测量会继承邻居的 CPU 负载。

把每个跨包 gate 放在一个参与的产品 package 下。 拒绝:Session 打开跨越 persistence、migration、projection、Host history 与 Agent resume;任选一个参与方都会形成误导性的归属和仅为 benchmark 增加的 package 依赖。仓库级目录拥有集成用户路径,包内诊断仍留在对应实现旁。

把 benchmark case 放在 scripts/ 下。 拒绝:scripts 拥有命令、生成器与编排,而 benchmark case 拥有带类型的测试文件、worker、fixture、预算和生命周期清理。未来的报告或校准命令可以消费 benchmarks/,不需要把 case 移入其中。

后果

每个 pull request 多付出一个必需 Linux job;该 job 的 Session 部分运行多个短生命周期子进程,以换取冷 cache、独立 V8 heap、明确 GC 状态和可归因的失败。仓库级 benchmark 目录接受有意的跨包测试依赖,而不修改产品 package manifest。固定 Zstandard workload 同时覆盖事件规模与 frame 拓扑;first-open 测量保护一次性升级体验,reopen 测量防止后续打开退化,四阶段预算定位成本归属,首屏预算保护用户可见等待,Agent resume 预算与 GC 后增量保护完整冷恢复及常驻内存,128 MB 模式保护瞬时分配上限。

Session 与 Node-fold 场景不测量网络传输、浏览器渲染或真实录制 Session,也不是持续性能趋势系统。前端性能预算拥有浏览器工作流测量。Node 或 runner 变化需要用同一 workload 重新采样并评审预算;修改业务实现时不得顺带放宽预算而不提供新的正反例数据。