Repository navigation
[Bug][Desktop/Win] 启动后约 80 秒单核满载 + 约 900 MB 读取:dsh-settings describe() 每次调用都重算整个 profile 组合 #9710
Replies: 4 comments
|
Thanks for the measurement — the mechanism reproduces in the source, and your numbers cross-check against what a single call actually touches. I read this against What one
|
|
Follow-up: I shipped the cache this path was missing, as a plugin — dsh plugin --profile <profile> add @argszero/dsh-profile-composition-cacheOne line of usage, then restart: the first What it changes, and what it deliberately does notIt shadows Validity is the file identity ( Two inputs are not files, and both are in the identity: the entry list the call is about to describe (a Loader reconciliation can hand back an identically named but different The cost, measured rather than asserted, on a fixture profile with one bundle:
Two loads on a miss, not one: the inputs have to be recorded before the composition runs (see below), and recording them means reading the profile once to find out which files the composition is about to read. So the first call inside a window is somewhat more expensive than the shipped path, and the ~1,500 calls that follow it in your measurement cost one Three details worth naming, because each is a way a cache on this path could be wrong:
On your numbers, and one correction to mineYour table holds, and it is the arithmetic that made this worth doing. One thing I got wrong in my earlier reply and want to keep visible rather than quietly fix: I wrote that a cache key must watch the profile manifest, the profile patch, each bundle's What is covered is the repeated read of the same unchanged profile, which is what your trace shows. Where the durable fix belongsThis is a seal applied from outside, and I would rather say so than imply the plugin is the end of it. The durable fix is upstream, in one of two places:
What the plugin cannot do is make one composition cheap. If a profile is slow for a reason outside the composition — a bundle whose patch file is huge, a js-yaml parse that dominates — the plugin still pays that cost once per window, and the profile itself is where the fix is. Tests67 tests, and One limit I have not measured: I could not reproduce the 80 s on this machine, so the plugin is verified to stop the repeated composition (that is directly counted in the tests) but not to have moved your wall clock. If you try it, the number worth reporting is the |
|
另一台机器的独立复现(Windows 11 / DSH 0.2.1-rc.2 / 71 个 bundle),确认同一条链路,并补三点上面数据里没有的东西。 1. 放大器:这条链路被「每个活跃会话 × 2 次」调用在 也就是说单次 2. 为什么「闭包级缓存」走不通:
|
| 指标 | 修前 | 修后 |
|---|---|---|
| 单次动作主线程冻结 | 7.18 / 4.82 / 6.08 / 6.77 / 14.93 s | 1.28 / 1.04 / 1.08 / 0.90 s |
| 窗口无响应累计 | 34.8 s | 约 5.2 s |
| 16 min 窗口主线程停顿占比 | 27 % | 1.9 % |
| 最大事件循环间隔 | 14,928 ms | 1,275 ms |
需要说明:这次是「按调用方逐个打补丁」(能力面板模块级缓存 + 一处插件侧漏缓存),不是在 configuration() 上统一做缓存,所以还剩约 1 s 的残余尖峰。
另外两处同类写法(一个每 10 s 重算 peer 依赖、一个冷启动同步遍历 657 个 skill 目录)分别贡献了 12 s 节律冻结和 13 s 冷启动 —— 「无缓存的全量扫描」在这个生态里是会传染的写法,建议文档层面点出来。
关于 @argszero 的 @argszero/dsh-profile-composition-cache@0.1.0
正在 0.2.1-rc.2 上装它做对照。装完会把同样的 A/B 贴回来,重点看两个数:describe() 实际执行次数是否从 349 降到个位数,以及第 3 点里 listBundles() 那条路径是否仍拖慢启动。
|
装 环境Windows 11 / DSH 0.2.1-rc.2 打包版(bundle 在 同进程 A/B(同一
|
| 路径 | 耗时 |
|---|---|
instance.configuration()(插件影子) |
115 / 118 / 119 ms |
ConfigEditor.prototype.configuration.call(instance)(原始实现) |
54 / 53 / 65 ms |
分解:observe() 里那次 loadProfileDirectory 单独 51 ms,原始实现 62 ms,合计 113 ms —— 与影子路径的 131 ms 吻合。也就是说它每次都在 MISS,而 MISS 的代价是「原始实现 + 一次额外的完整组合」。
为什么永远 MISS
把 stampAll() 的 135 个路径连续采集两次并逐条比较:26 条不一致,全部是 app.asar 内的路径,非 asar 路径零条。不一致的字段只有 ino,mtimeMs 与 size 完全相同:
@deepseek-ai/dsh-base/package.json ino 5885 → 5911
@deepseek-ai/dsh-base/cordis.patch.yml ino 5886 → 5912
@deepseek-ai/dsh-web-app/package.json ino 5887 → 5913
Electron 的 asar 虚拟 fs 每次 stat 给出的 ino 是合成值,不跨调用保持。stampsHold() 在 ino 不等时返回 false,于是命中条件恒不成立 —— 而这个 profile 有 26 个 bundle 位于 archive 内,必然触发。
补充证据:loadProfileDirectory() 返回 skippedBundles = [](64 层全覆盖),entryKey() 两次调用完全一致,所以不是「跳过 bundle 不缓存」或「entry 身份漂移」这两条路径。
建议
不建议简单地「ino 变化就当作未变」——那会把 atomic-rename 的检测一并丢掉。更稳的做法是识别 archive 内的路径(如 .asar 段,或对 Electron 虚拟 fs 做能力探测)时只比 mtimeMs + size:archive 是只读的,其中的文件不可能被 rename 替换,ino 在这类路径上不携带任何信息。
另外,即使 ino 修好,MISS 路径的常量开销也值得压掉:现在一次 MISS 是「原始组合 + 另一次 loadProfileDirectory」,在冷启动 1500 次调用的场景里,只要命中率不是从第一次就接近 100%,这条额外开销会直接叠加。
测完已卸载,等 ino 处理修好后可以立刻重测并回帖。
Uh oh!
There was an error while loading. Please reload this page.
Summary
Windows 桌面端每次启动后,宿主进程会持续约 80 秒跑满一个 CPU 核心,并读取约 900 MB 数据;这段时间里插件树 / 设置页 / 新建会话 / 历史列表都明显卡顿。根因是配置描述路径每次调用都会重新组合整个 profile:
dsh-settings的describe()→dsh-config-editor的configuration()→loadProfileDirectory()全量重读 + 重解析 profile patch 与全部 bundle manifest,启动期约 1,500 次。Reproduction
不需要桌面端,用同一份 profile 内容在隔离的
DSH_HOME里跑 CLI 即可复现(profile 名避开保留名desktop):再用一个 fs 级 spy(
--require注入,包裹fs.readFileSync/fs.promises.readFile/createReadStream/FileHandle.read等)统计路径级读取次数,或直接采样宿主进程的 CPU 与 IO。最小形态:profile 约 35 个 bundle、
cordis.patch.yml约 300 KB。Current behavior
dsh-settings的SettingsForms#describe()(dsh-settings/lib/index.js:413)在第 415 行调用this.ownerContext.configEditor.configuration()。dsh-config-editor的ConfigEditor#configuration()(dsh-config-editor/lib/index.js:39-53)每次都执行loadProfileDirectory("dsh", profile.dir, profile.installAnchor),即重新读取并解析 profile patch 与每个 bundle 的package.json/cordis.patch.yml。describe()被调用约 1,500 次 ⇒ 整个 profile 组合被重算约 1,500 次。spy 抓到的调用栈(采样文件 = profile 的
cordis.patch.yml,217.5 MB / 845 次):spy 逐路径计数(一次冷启动,95 秒窗口内):
profiles/<p>/cordis.patch.yml@deepseek-ai/dsh-app-boot/package.json@deepseek-ai/dsh-web-app/cordis.patch.yml@deepseek-ai/dsh-base/cordis.patch.yml(其余为 client bundle 的一次性读取,属正常开销。)
CDP CPU profile(隔离实例,140 s / 88,064 样本,非 idle 主体):
structuredClone(13.3%)、existsSync(12.1%)、readFileUtf8(10.2%),其后 js-yaml 4.25 s、schemastery 3.75 s、readProfileManifest/getDshRuntimeVersion/packageDirFromAnchor/applyEntryPatches等。实测对照(同一 profile 内容、同一份框架代码,隔离启动):
configuration()后describe()实际执行另一个观察:
SettingsForms#invalidate()(dsh-settings/lib/index.js:382)用queueMicrotask合并失效通知,实测整轮启动只被调用 18 次,而describe()约 1,500 次 —— 这个合并并未限制重算次数(跨await边界合不上),所以"失效通知"不是节流点。Expected behavior
组合结果应按 profile 文件的版本缓存,只在真正变更时重算;启动期的批量
describe()不应触发上千次全量组合。建议(任一即可):ConfigEditor#configuration()缓存组合结果,键 = profilecordis.patch.yml/package.json/ home patch 的(mtime, size),并在 loader reload 事件里显式失效;loadProfileDirectory()自身加 manifest 级缓存(同一个package.json被读 3 万次本身也不合理);describe()结果按 entry + 组合版本缓存;invalidate()换成真正的节流/去抖(覆盖await边界)。Environment
@deepseek-ai/dsh-*0.2.0-rc.2(asar 内与 npm 全局安装两份dsh-config-editor/dsh-settings字节一致)cordis.patch.yml304 KB(内含!!js自定义标签,插件较多)dsh --profile <p> --no-open --port 19488同样复现本地规避(供参考,不改任何 DSH 文件)
以用户插件(
preset-code/*.mjs+cordis.patch.yml的insert行)在运行期给ConfigEditor.prototype.configuration加(mtime,size)+ TTL 缓存、给SettingsForms.prototype.invalidate加节流,即可完全消除启动风暴(数据见上表)。相关
这两条描述的是 renderer 常驻占用;本条是宿主冷启动 80 秒的 CPU/IO 风暴,机制不同(配置组合重算),可独立修复。
All reactions