DSH Plugins Marketplace

DSH Plugins

Plugins

/

dsh-coldread-cache

d

dsh-coldread-cache

Manifest valid

DSH plugin: keep a session's parsed event log resident so a local append extends the resident parse instead of invalidating it - a long session is parsed once per host process, not once per read.

hasBundlePatch

dsh-coldread-cache

English | 简体中文

dsh-plugin

把正在写入的会话日志常驻在内存里解析,长会话不再越用越卡。

这是一个 DeepSeek Harness(DSH)插件。 它不修改 DSH 安装目录里的任何文件:挂载时在运行中的持久化后端实例上写入自有属性, 卸载时还原。


症状与根因

会话开得越久、轮次越多,界面就越卡:切换会话变慢、发一条消息要等、磁盘一直有读写。 关掉重开就好了——过一会儿又慢慢卡起来。

根因不是内存泄漏,是整份冷读:

  1. DSH 把每个会话存成 session.v4.jsonl.zstd:可独立解码的 zstd 帧拼接,每帧一个 JSONL 批次,首帧是头部。
  2. JSONL 后端会缓存已解析的日志,但缓存上限只有两条,且以 stat 推导出的 revision 作为键。
  3. 每次追加都会改变这个 revision,所以正在写入的会话几乎每次读都缓存未命中。
  4. 未命中就是一次整份冷读:整个文件 readFile → 逐帧 zstd 解码 → 逐行 JSON.parse。

本机真实日志实测:

文件事件数整份冷读
16 MB15 958576 ms
73 MB约 39 700约 2 400 ms

开销几乎全在 JSON 层,不在解压也不在磁盘:73 MB 的文件里 JSON.parse 约 1 750 ms、JSON.stringify 约 2 470 ms。

抓到的现场:一个开着长会话的宿主进程 30 秒读了 846 MB,峰值 61.6 MB/s, 30 秒内 37 477 次读操作;而刚启动的宿主基本是 0 MB/s。

这个插件做了什么

为每个会话保留一个常驻解码器,用的是后端同一套已发布的格式解码器,只做增量扩展、 不重建:

  • 文件没变 → 一次 stat 之后直接从常驻结果返回;
  • 文件变长了 → 只读新增的那段字节,解码后喂给常驻解码器追加;
  • 其它任何情况 → 原样交给后端自身的读取逻辑,不猜、不凑。

实测:把真实追加产生的帧喂给一个已有的 16k 事件解码器,耗时不到 1 ms, 而同一份日志整份冷读是 576 ms,且扩展结果与整份解析深度相等。 已经发出去的事件对象在增长后仍是同一批对象,调用方不会看到抖动。

一个踩过的坑

后端在返回结果前会调 freezeStoredEvents,它原地冻死解码器持有的那个事件数组。 所以插件必须让常驻解码器保持未冻结,每次读发一份冻结副本出去。 否则第二次追加会直接炸:

Cannot add property 15953, object is not extensible

正确性

只有在能够证明「复用等价于整份解析」时才复用常驻结果:

  1. 同一个文件——device + inode 相同,且路径相同;
  2. 文件只增长,且头部帧的字节未变;
  3. 常驻长度之后的每个字节都是完整、可独立解码的 zstd 帧(尾部撕裂就回退);
  4. 把这些帧喂给常驻解码器只会新增行,且格式解码器自身的关系约束仍然成立;
  5. 新增行通过与后端读取逻辑相同的 v4 行准入、存储事件校验与关系断言, 并且上报的是实际观测到的 revision。

任何一条无法证明就回退。所以最坏情况只是「和没装一样慢」,不会更慢,也不会给出 一份和整份解析不同的答案。

安装

插件由 profile 加载,并自带插入行:

// <profile>/cordis.patch.yml
- insert:
    - id: coldread-cache
      name: 'dsh-coldread-cache'

推荐用 dsh 自带的插件命令装:

dsh plugin --profile <name> add dsh-coldread-cache

手工挂载需要同时改三处,缺一处启动就会失败:

文件需要什么
<profile>/package.jsondependencies 里有本包
<profile>/package.jsondsh.profile.bundles 里有 dsh-coldread-cache
<profile>/cordis.patch.yml上面那条 insert 行

挂载顺序不用管:插件先试 ctx.get("sessionPersistence"),服务还没起来就注册 ctx.on("internal/service", …) 等它出现。所以宿主启动时自动生效,不需要人工干预。

配置

键默认值含义
enabledtrue允许从常驻结果直接返回
maxEntries8本进程常驻的会话数
maxResidentBytes134217728常驻源字节总量
statusFile""可选的实时诊断文件路径

前两项是进程的内存策略。后端自己那份两条的缓存原样不动。

这个预算是按压缩后的源字节算的,但你付的是堆。实测一份解析出来的日志约等于 源字节的 8 倍(202 MB 的会话在堆上约 1207 MB):所以默认 128 MB 预算大约值 1 GB 堆。 statusFile 里的 resident.heapEstimateBytes / heapRatio 就是这份实时估算——比例由真实 冷读采样得到,不是写死的常数。内存宽裕的机器可以调大它。

**这个默认值试过调小,实测后撤回了。**直觉是「预算小一点省内存、行为不变」,但用真实会话 量了一遍(1 个会话在写、4 个在读):

预算常驻常驻命中率
16 MB35 MB14%
32 MB35 MB14%
64 MB35 MB14%
128 MB99 MB53%
256 MB167 MB93%

原因:真正解决卡顿的那条路径(把「追加后立刻读」交给常驻解析扩展)16 MB 就够——因为 被写入的会话排在只被读的会话前面。预算再往上买的是纯读会话的常驻:翻历史、会话列表、 来回切会话。所以 128 MB 是「能服务一个真实工作集」的最小值;调小是拿一个实测到的功能退化 去换一个不是瓶颈的内存节省。

什么时候扩展次数才会涨

extensions 这个计数器只在一种情况下会涨:宿主对某个会话追加之后,又去读同一个会话。 把三种流量形状都量了一遍(各 8 轮,真实会话,fixture 共 254 MB):

流量形状扩展冷读
追加后读同一个会话(32 MB 预算)80
追加后读同一个会话(128 MB 预算)80
追加后先读 4 个别的会话(32 MB 预算)832
追加后先读 4 个别的会话(128 MB 预算)832
只读、不追加015

所以「extensions: 0」本身不是故障:如果宿主那段时间只是在翻会话、没有在某个会话里连续 跑轮次,扩展就该是 0,而 coldParses 会涨。判断插件有没有在工作,看的是追加之后那次读 有没有走扩展,而不是看扩展的绝对次数。另外两条也顺手排除了:写句柄那条分支(宿主自己的 「追加→读回」循环会走快速路径)和预算大小(32 MB 下带旁观流量照样 8/8 扩展)。

为什么会在长时间使用后变卡(已修)

扩展路径每轮都会深冻结整条会话的事件图:Object.freeze 要递归走到每个对象。 这本身是对的(冻结是为了和后端行为一致),但它在扩展时被套在了全部事件上,而不是 只套在新追加的那几个上。于是每轮都要重新走一遍整条会话——成本随会话变长而线性增长。 在 6 万事件的日志上实测:

每轮扩展的动作耗时
冻结结果里的每一个事件(改前)191.7 / 199.5 ms(两次)
只冻结新追加的事件(改后)0.4 / 1.8 ms(两次)
concat 本身(无法省)0.3 ms

一个 100 轮的会话大约要为此花 20 秒纯冻结——这就是「用得越久越卡」的一个真实来源, 而且是插件自己引入的。修法有两条,互为保险:① 扩展时只冻结新事件;② freezeStoredEvent 遇到已经冻结的根就直接返回(已冻结的根意味着整棵图都冻结了,因为冻结永远是整图一次 完成的)。第 ② 条让第 ① 条即使漏掉某条路径也不会退化。门禁里有一条断言遍历扩展后的真实 结果、确认每个事件都深冻结,并且用一个故意留开口的对象证明这条断言能真的报出问题。

修完后的 A/B 中位数:131 ms(改前约 500 ms)。

淘汰顺序

常驻满了先淘汰谁,是按读的最近性吗?不是——那样在真实宿主上会失效。旁观会话的读取 本身就是一次冷读,冷读会让那条会话变成「最近使用」,于是正在被写入的那条会话反而被挤 出去,而「追加后立刻读」恰恰是本插件唯一的目标场景。实测:宿主连续跑 85 分钟、405 次读由 常驻服务、扩展次数 0,回退原因全是 no-entry。

所以规则改成:被写入过的会话优先于只被读过的会话。同级之间按最近读取淘汰,最后一条永 不淘汰。门禁里有一条专门的回归断言,并且用故障注入证明它能变红(把写入排名关掉,同一条 断言必须失败)。resident.hot 是当前被标记为「写入中」的会话数。

statusFile 是排查用的:写一份实时文档(心跳刷新),说明这份进程实际装上了哪些钩子、 命中了多少次、回退的原因分布。一个静默失效的插件比一个从没装上的插件更糟,所以留了这个 口子。

验证

pnpm run gate          # 验收门禁,跑真实 vendored 后端
pnpm run fault         # 故障注入:证明上面的门禁真的能变红
pnpm run perf          # 真实会话日志上的「追加后读取」A/B
pnpm test              # 依赖与安装机制探针

pnpm run compat        # 对每一棵装了后端的 DSH 树跑完整回路(多版本兼容)
pnpm run probe:contract # 契约能变红:摘掉一个 helper 必须被拒绝

pnpm run probe:freeze  # 每轮全量冻结 vs 只冻结新事件(回归断言)
pnpm run probe:evict   # 旁观流量下常驻能否存活(回归断言)
pnpm run probe:budget  # 各预算档的常驻命中率(默认值就是靠它定的)
pnpm run probe:heap    # 常驻的堆开销(源字节 → 堆的换算比例)
pnpm run probe:segments # 校验能不能分段(证明剩下的开销是下限)
pnpm run probe:breakdown # 一次扩展的逐阶段耗时
pnpm run probe:artifact  # reader.artifact() 的代价(证明显式校验不是多余的)

probe:* 这几条是把 README 里的数字量出来的脚本,不是装饰:probe:freeze 和 probe:evict 都带断言,改坏了会 exit 1。它们需要真实会话:默认读 $DSH_HOME\sessions (未设则 $DSH_HOME 必填),也可以用 DSH_SESSIONS 直接指到会话目录。

门禁

GATE_GREEN checks=93 failures=0(约 3.5 分钟)。门禁把真实的 vendored 后端跑两遍同一批 真实会话副本(一遍原始、一遍打上插件),深度比对结果,覆盖:语料、追加路径、尾部撕裂文件、 同长度改写、卸载还原、关闭常驻的对照组、旁观流量下常驻的存活,并包含一次故意注入 错误答案的故障注入——用来证明这个比对本身能失败。没有故障注入的绿色不算绿色。

pnpm run fault 单独跑 7 项故障注入,证明门禁的关键断言都能真的变红: 翻转一个字节让 vendored 摘要检查失败、少一个事件 / 多一个字段让读取比对失败、 关掉常驻后命中数必须为 0(否则性能断言就是自证)。同时门禁脚本自带互斥锁—— 两实例同跑会污染同一份 fixture,锁让第二次运行直接拒绝而不是悄悄给出错误结论。

两棵 vendored 目录始终保持与出厂 SHA-256 字节一致 (027803DDE72AAC7038253F0A8FF9FDCDBF0EB8CAD53ED26EFA3EC610F01587E7), 门禁在每次运行的开头和结尾都断言这一点。

性能 A/B

在真实的约 90 MB 会话日志上,用后端自己的写入路径追加一个真实批次,再计量紧随其后 的那次读(也就是宿主每轮都要做的那次读),各跑 5 轮取中位数:

三次独立运行的中位数(同一份 ~90 MB 真实会话,每次 5 轮):

追加后的一次读运行 1运行 2运行 3运行 4
原始后端2 928 ms4 232 ms5 972 ms2 930 ms
挂插件但关闭常驻(对照组)5 244 ms6 436 ms6 493 ms5 744 ms
挂插件474 ms410 ms477 ms509 ms

即 5.8×–12.5×。对照组就是这个断言自己的证明:同一段循环、同一个 后端类、同一个文件,只关掉常驻——它一点加速都没有,说明这个断言能真的变红。

去掉每轮全量冻结之后

上面那批数字是在扩展路径还每轮深冻结整条会话时测的。修掉之后(见「为什么会在 长时间使用后变卡」),同一份 fixture 又跑了两次:

追加后的一次读运行 A运行 B
原始后端8 417 ms2 893 ms
挂插件但关闭常驻(对照组)6 959 ms4 682 ms
挂插件131 ms88 ms

即 22×–64×。绝对值随机器负载浮动(原始后端自己两次就差 3 倍),所以结论看的是 倍数,而对照组是让这个倍数可信的东西。

注意:这里比的不是「同一个文件读两遍」。后端自己那份两条缓存已经能让重复读很快, 那样比什么都证明不了。真正会卡的是读一份刚刚变长的日志,所以 A/B 测的就是这个。

实机

单独冷启一份 profile 到另一个端口(不动正在用的宿主),什么参数都不给:

{
  "backend": "session-persistence-jsonl",
  "installed": ["readStoredLog","decodeStoredLog","persistBatch","persistHeader","truncateTornTail"],
  "failures": {}
}

启动日志里零条插件错误。然后在界面上纯 UI 操作来回切换真实会话:

hits=4   ext=0  cold=1     ← 第一次打开
hits=5   ext=0  cold=2     ← 打开另一个会话
hits=9   ext=1  cold=3     ← 回到已打开的会话:靠「扩展常驻解析」服务,没有重新冷读
hits=11  ext=2  cold=4     ← 再一轮:又两次扩展

关键在最后两行:extensions 涨了,coldParses 没跟着涨。

真实宿主上发现的缺陷(已修)

上面这段是短时间、来回切同一个会话的情形,它掩盖了一个只在长时间使用下才暴露的问题。 在一台连续跑了 85 分钟、真实使用中的宿主上,状态文件是这样:

"stats": { "hits": 405, "extensions": 0, "coldParses": 369,
           "fallbacks": 369, "reasons": { "no-entry": 369 } }

405 次读由常驻服务,扩展 0 次,369 次回退全部是 no-entry。注意:extensions: 0 本身不足以判定故障(见上面「什么时候扩展次数才会涨」)。把这条观测钉成缺陷的是一次 受控实验:固定 2 条常驻、让旁观会话读 64 次,然后看「追加后读同一会话」还能不能扩展—— 保留写入排名时扩展(1→2),把写入排名去掉后不扩展(1→1)。同一段代码、同一个文件, 唯一的差别就是排名,所以机制成立。

结论:淘汰顺序确实是一个真实缺陷(按读的最近性淘汰,会被旁观会话的冷读把正在写入的会话 挤出去),实机那 85 分钟的计数器与它一致。这条是看实机计数器发现的,不是靠测试发现的; 修完才有「淘汰顺序」那节、以及门禁里那条带故障注入的回归断言。

多版本兼容

插件不是改源码,而是挂在一个已发布的后端上:它静态 import 七个 @deepseek-ai/* 包, 并在 dsh-session-persistence-jsonl 的实例上替换五个方法。所以「兼容」必须意味着 换一棵我没在上面开发的 DSH 树也能跑,而不是「在我这台机器上能跑」。

声明的接口面

插件对后端的依赖分成两类,分开列是因为失败方式不一样:

类别成员缺了会怎样
被替换的readStoredLog、decodeStoredLog、persistBatch、persistHeader、truncateTornTail不安装——一个都不写
被调用的assertStoredIdentity、locate、memoizeStoredLog不安装,并给出可读原因

第一类缺了不能安装;第二类缺了更麻烦——替换进去的代码会在别人的读路径里面抛异常, 那时候已经退无可退。两类都在写任何东西之前探测,所以版本搬家只会退化成原始后端, 不会装一半。

怎么测的

pnpm run compat          # 对机器上每一棵装了后端的 DSH 树跑一遍完整回路
pnpm run probe:contract  # 契约本身能变红:摘掉一个 helper 必须被拒绝

compat 会自己在标准位置找树(也可以用 DSH_TREES 指定),对每棵树:建一份用那棵树自己写的 会话日志,挂上插件,真追加一个批次,再真读回来,断言这次读是扩展常驻解析而不是重新冷读。 本机 3 棵树、2 个版本(0.2.0-rc.2 与 0.2.1-alpha.2)全部 COMPAT_GREEN checks=16。

测试还用一个 loader hook 把插件自己的 @deepseek-ai import 也重定向到被测的那棵树上。 没有这一步,测的其实是「A 树的后端 + B 树的校验模块」——两棵树一样时看不出来, 而那种情况恰恰最不容易被发现。

COMPAT_ALL_GREEN trees=3 failed=0
  exit=0  COMPAT_GREEN checks=16 failures=0   0.2.0-rc.2
  exit=0  COMPAT_GREEN checks=16 failures=0   0.2.1-alpha.2 (nightly)
  exit=0  COMPAT_GREEN checks=16 failures=0   0.2.1-alpha.2 (stable)

顺带发现的两件事

  • 两个版本的差异只有 17 行旧/21 行新,全在迁移校验的退出处理与 readGenerationHeader 改名(header → metadata),读写路径一行没动。
  • 老树(0.2.0-rc.2)读不了新 harness 写的会话:它把 working-directory/change 当成未知且不可忽略的事件类型直接拒绝。这是数据层的不兼容,跟插件无关, 但足以说明为什么测试 fixture 必须由被测的那棵树自己生成。

历史加载:两个真实的回归与修复

上面那些优化都是针对「追加后读取」。历史加载是另一条路径,之前从没量过,一量就发现 插件在它上面比原始后端更慢——这正是「越用越卡、历史加载也慢」的机械原因。

先量清楚两条路径

路径耗时说明
list()(会话列表)839 ms遍历 548 个会话,每个都要开文件读头部
冷读一个 62 MB 会话3892 ms用户从历史里点开一个会话

冷读拆开看:解压 16665 个帧 3006 ms、JSON.parse 40478 条 2007 ms。而帧非常小 (中位数 1298 B),单帧解压 0.195 ms——几乎全是每次调用的固定开销,不是按字节的工作量。

回归 1:插件用了慢的解码器

上游的后端不用一次性 API 逐帧解码:它的 createZstdFrameDecoder() 是

return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();

即优先用一个复用同一份原生上下文的私有解码器,探针失败才退回公开 API。插件的移植 只抄了公开那一半,于是冷读每一帧都新建一次原生上下文。

受控测量(交替轮次、每轮之间强制 gc、取中位数):

中位数
公开一次性 API 逐帧906 ms
复用上下文439 ms — 2.07x

这里差点犯错:另一次测量说复用上下文是 0.72x(更慢)。两者不可能都对。差别在于那次 测量之前已经分配了 277 MB 明文,GC 状态被污染了。加上「每轮强制 gc、交替执行」之后结论 稳定为 2.07x。一个测量与另一个测量冲突时,先查环境差异,别挑顺眼的那个信。

回归 2:一次把所有明文都展开

插件原先先把 16665 个帧全部解压收集(约 278 MB 明文同时存活),再交给 reader。 而 reader 本来就能处理跨帧的记录,先收集毫无收益,只是白付分配与 GC。

修复后的效果

修复前修复后
冷读 62 MB 会话(对比原始后端)慢 1.48x(4227 ms vs 2855 ms)快 1.11x(2667 ms vs 2974 ms)

会话列表:头部缓存

list() 会为每个会话开一次文件、解码第一帧来拿头部,即使那个文件的头部根本没变。 这占了枚举历史的大部分时间。

插件新增第 6 个钩子 readGenerationHeader,把后端自己的返回值按文件 identity 缓存 (和它自己那份 coldLogMemo 用同一个 stat 派生键)。缓存与常驻是两回事,必须分开:

常驻(residency)头部缓存
存什么解析后的事件解析后的头部
大小几百 MB每个约 500 B
预算方式按字节,128 MB按条数,4096 条(约 2 MB)

分开才可能「历史枚举变快」而不动那个精心调过的内存预算。

阶段原始挂插件
listGenerations(目录遍历)786 ms556 ms没被缓存覆盖
listArtifacts(逐个头读取)2964 ms701 ms4.23x
list() 合计1499 ms762 ms1.97x

计数器实测:548 条缓存、1092 次命中、552 次未命中——命中是主要来源,不是靠运气。

两个假绿门禁(都已被抓住并修掉)

这一轮写门禁时,连续两次写出了「看着绿、其实没验到」的检查。两次都是靠故障注入 发现的,不是靠读代码:

  1. 第一版比对「选中的解码器」和「公开 API」的速度。把选择强制成公开路径后, 两边是同一份代码,自然一样快——检查照样绿。而且 400 帧的样本里,同一份代码两次 测量就差了 20%(66 vs 55 ms),这个检查本身就在噪声带里。
  2. 改成直接报告「选中了哪个解码器」后,注入依然绿——因为那个报告函数绕过了 被测的 createZstdFrameDecoder(),直接调底层构造函数,报的是「有没有」而不是「选了哪个」。

修法是让选择可观测:每个解码器自带 kind,报告函数必须穿过选择逻辑。 现在注入会红,而且理由是可诊断的:

FAIL the decoder selection matches what this runtime supports
  :: runtime v26.0.0 privateUsable=true selected=public

注意同一轮里那条速度检查仍然是绿的——这正好说明:只看时间差不等于验到了机制。

第三个门禁缺陷:一次「假红」

三轮复跑里有一次撕裂尾部检查变红:the recovered event sets differ at 1 positions。五次里有 一次失败是最难处理的一种——不可按需复现,而且消息无法诊断:它不说是有事件丢了、多了, 还是某个字段不同。

所以先做了三件事,而不是先去改插件:

  1. 让它可诊断:把「差 N 处」换成多元集差,直接打印多出来/少掉的那个事件。
  2. 隔离复现:把第三节的场景按原样重跑(含它前面那次真实 append),交替 10–12 轮。 结果每轮都完全相同,而且可重复。
  3. 排除文件本身在动:在比较的每个步骤给文件做哈希。两侧读到的是同一批字节, 谁都没有改写它。

于是怀疑转向检查本身。门禁用的是 JSON.stringify 逐事件比对,而字符串对键的顺序敏感, 键顺序却不是会话事件语义的一部分——后端自己的判据是 isDeepStrictEqual,它不看键顺序。 probe:keyorder 把这个机制证出来了:两个只差键顺序的事件,deep 判等说相同、stringify 说不同。

改法是把比较收紧而不是放宽:撕裂尾部时插件根本不做恢复,它拒绝快路、把同一批字节交给 同一个原始读取器,所以两侧应当逐字节相同,不只是「作为集合相同」。现在用的是严格判等, 并额外固定住输入哈希——这样将来再红,一定是因为答案不同,而不是因为文件动了。

检查改前改后
比较方式JSON.stringify 多元集(对键顺序敏感)严格深判等(与后端同一判据)
输入固定无两侧读取前后各一次 SHA256
失败信息「差 1 处」多出/少掉的具体事件
复现性不检查同一批字节读两次,答案必须一致

诚实的边界:我没有捕获到那次失败当场的内存状态,所以不能证明键顺序就是它的原因。 能证明的是:那个检查会因为与被测对象无关的理由变红,而收紧之后连跑 8 次全绿、 12 轮受控复现逐字节相同。这是证据支持的解释,不是已证实的结论。

顺带记下这条的通用形态:门禁有三种坏法——假绿(看着通过其实没验到)、假红 (因为无关理由失败,让人去改本来没坏的代码)、无法诊断(红了也查不动)。这一轮三种都遇到过了。

还能不能再优化:剩下的时间不归插件管

头部缓存之后,list() 里剩下的大头是 listGenerations——目录遍历。插件没有替换它: 六个钩子(readStoredLog / decodeStoredLog / persistBatch / persistHeader / truncateTornTail / readGenerationHeader)里没有它。它是后端自己的代码。

那能不能缓存整份目录列表?probe:walk 量了这件事的两面:

content touched in place   -> dir mtime UNCHANGED   (合法:内容变了,路径列表没变)
file created               -> dir mtime CHANGED
file removed               -> dir mtime CHANGED

=> 但省不下东西:校验需要 575 次 stat(135 ms)来跳过 575 次 readdir(168 ms)

也就是说,按目录 mtime 做的列表缓存是安全的(内容变化不影响路径列表),但判断它是否还有效 要花的代价和它想省掉的代价是同一量级——都是每个会话一次系统调用。这不是「不容易做」,是不划算。

一个必须写下来的测量失败:我一度想算出遍历的「系统调用底价」,结果量出 listGenerations 比它自己的底价还快(152 ms vs 254 ms)。这不可能,说明那个「底价」的算法错了——逐个 await 计时 的开销本身污染了单次操作成本。同一函数在不同轮次里也从 152 ms 变到 1692 ms(一个数量级)。

所以这里不给精确数字。能站住的只有两条:目录遍历是剩余大头(它在 list() 中占比最高), 而它不在插件手里。不做没有依据的优化,也不把测不准的数字包装成结论。

综合起来,这一轮的停手理由是分层的:

层结论依据
冷读解码器已修(2.07x 的私有多路复用器)受控 A/B,且故障注入会变红
冷读明文展开已修(流式喂帧替代 278 MB 全量展开)冷读从慢 1.48x 到快 1.11x
会话列表头部已修(4.23x on listArtifacts)计数器 548 条 / 1092 命中
目录遍历不归插件管,且缓存不划算probe:walk 两面测量
已发布校验器占了扩展的 90%,且不能分段probe:segments 状态机拒绝

优化到哪里为止

这一轮的目标是「在确认无法继续优化的时候停下」。结论是:剩下的 57 ms 是已发布校验器的价钱, 插件不该也不能替它省。

一条 55 MB / 58523 事件的会话,一次扩展共 63.2 ms,拆开看:

阶段耗时占比
assertReleasedV4Relationships(遍历全部事件)56.91 ms90%
整表扫描(O(n) 对照)2.01 ms3%
concat(O(n) 数组拷贝)0.31 ms0.5%
其余~3.9 ms6%

想过把它分段(只校验新追加的事件),probe:segments 直接否掉了这个方向:

全量遍历 58523 个事件     80.6 ms   通过
只校验最后 4 个事件        0.07 ms
  只校验尾部的结果:tool/ptc-dispatch is outside an open turn
  从 turn/end 之后切一段:turn/start does not open the expected turn

**校验是一个顺序状态机,一个事件能不能被接受取决于它前面的事件。**尾部单独校验会被拒绝, 所以「从检查点续跑」在这里不成立。要做的话得重新实现 Relationships 那套状态机 (dsh-session-format-v3-to-v4 里 539–857 行),而它是模块私有的、没有导出—— 那等于在已发布的校验逻辑上另起一套,比之前的冻结修复风险大得多,不值得。

另外两点也确认过了:

  • entry.reader.artifact() 是 0.0 ms(它只检查版本号就返回),所以插件显式调用校验器 不是多余的——它是这条路径上唯一的全量校验。
  • CPU profile 显示开销主要来自冷解析的第一遍(#consume 22.5%、processChunkSync 13.2%、 GC 9.0%),不是校验循环本身。

限制

  • 快速路径只覆盖「按整帧增长」和「完全没变」。尾部撕裂、改写、截断、帧表重建都会回退。
  • 常驻是进程级的,宿主重启后失效;插件会在挂载时重新安装。
  • 第一次读永远是冷的(那一刻还没有常驻结果可用)。
  • 后端自己那份两条的缓存不受影响,插件另有自己的字节数与条目数预算。

许可

MIT

Comments

Loading…