Skip to content

perf: 发布体积 -33%,长会话报告 -28%,图片编码按变体去重 - #95

Merged
Mars-Sea merged 6 commits into
Mars-Sea:mainfrom
GT-610:perf/reduce-footprint-and-hot-paths
Oct 19, 2026
Merged

Mars-Sea merged 6 commits into
Mars-Sea:mainfrom
GT-610:perf/reduce-footprint-and-hot-paths

Conversation

@GT-610

@GT-610 GT-610 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

本次改动集中在性能:发布包体积、长会话报告聚合、以及图片请求路径上的重复编码。全部改动都保持用户侧兼容性不变(详见「兼容性」一节),并包含重建后的 lib/ 产物。

性能提升总览

位置 指标 前 后 变化
发布包(解包) 5,188,371 B 3,480,662 B −32.9%
发布包(tarball) 2,948,915 B 2,479,266 B −15.9%
历史分析报告 5,000 条事实 × 96 目录模型 42.17 ms 30.36 ms −28.0%
图片 base64 1 MiB 载荷 0.738 ms/次出现 每个变体一次 后续为 ~21 ns 查找

一、发布体积:sourcemap 不再随包发布

tsdown.config.ts 的 sourcemap: true 会产出 lib/index.js.map 与 lib/client.js.map,而 files 直接收整个 lib,于是两份 map 进了 tarball:1.63 MiB 原始字节,占解包体积的 32.9%。

它们没有任何读取方:

  • lib/client.js 是 WebView 直接执行的 CJS 包,//# sourceMappingURL 只是尾部注释;
  • scripts/verify-engine-load.mjs 的审计用正则匹配 import/require 语句,不匹配注释,少一份 map 照样通过;
  • scripts/verify-package-exports.mjs 只校验声明入口在包内存在,不断言 .map。

改动:files 增加 "!lib/**/*.map",两份 map 同时从 git 取消跟踪。本地 npm run build 照旧产出 map,调试能力不受影响。

取消跟踪还有第二个理由:它们是 tsdown 产物里唯一在本机无法逐字节重建的。同一棵源码树上,lib/index.js、lib/client.js、lib/index.d.ts 都能逐字节重现,两份 map 却不能(本机 919,315/783,625,而提交里是 890,731/760,161)。把它们留在版本控制里,等于让 CI 的 git diff --exit-code -- lib/(注释里写明是「stale bundle」守卫)依赖平台相关的字节。取消跟踪并移出 tarball 后,该守卫在本机稳定退出 0。

npm pack --dry-run:18 → 16 个文件。

二、报告聚合:从两趟扫描降到一趟

buildAnalytics 里有两处对「事实 × 模型」的重复扫描:

  1. 每模型小计:[...new Set(selected.map(f => f.model))].map(id => aggregate(selected.filter(f => f.model === id), ...))。5,000 条事实、96 个模型时要整表过滤 96 次,只为得到一次分桶就能拿到的结果。
  2. 场景矩阵:对每个(事实 × 目录模型)都用 Map 查一次候选模型的价格行——96 个模型共 480,000 次查找,查的是同一批行。

改动:

  • 先把选中的事实按模型分桶一次;
  • 把定价算式从 analytics.ts 搬到 cost-facts.requestCost(tokens, price, ...),接收已解析好的价格行,于是场景循环把查价提到内层循环之外,算式仍只存在一处;
  • requestRates 增加可选的高峰时段 memo:peakHour() 每次调用都要 new Date() 并读三个字段,而答案只是 at 与该表的纯函数,报告因此可以复用同一份 memo(memo 由调用方持有,不可能活得比它的表更久)。

测量(node 22,96 个目录模型,一次性脚本,未入库):

 500 条   4.65 →  3.84 ms
2,000 条  18.19 → 12.35 ms
5,000 条  42.17 → 30.36 ms

输出逐位不变:分桶顺序与原 Set 顺序一致,排序本来就是按唯一 id 的全序。

三、图片:同一变体只编码一次

readImageRequest() 对每个(附件,编码目标)返回同一个缓存对象,requestImages() 在一次生成内也复用这个对象——但一条请求里同一张图会出现在多条历史消息中。三个 wire 编解码器原先各自按「出现次数」执行 Buffer.from(version.data).toString('base64'):

1 MiB  → 0.738 ms / 次
24 MiB → 18.66 ms / 次

改动:三个编解码器共用一个以该缓存对象为键的 WeakMap,保存原始 base64 与 data URL(拼接同样要整份拷贝)。副本生命周期跟随宿主自己的版本缓存,而不是插件。命中约 21 ns。

兼容性

  • 公开导出面零变化。lib/index.d.ts 的 export { ... } 一行未改;去导出前后的 git diff 里没有一行含 export(见下节的清理说明)。
  • dsh.bundle 清单形状、exports["./client"]、dsh.client.inject、dsh.client.platform 均未动。
  • engines.dsh、dsh.compatibility.dshReleases、peer 范围均未动。
  • API key 解析顺序未改;未引入任何 pi/OMP auth 文件扫描;字面密钥仍不进设置文档与诊断。
  • 三条 wire 契约未改:CLI /alpha/generate、Provider API /provider/v1/chat/completions、Messages 的请求形状与内容完全一致(测试覆盖)。
  • 部署到真实引擎 peer 树后导入正常:name = llm-commandcode、inject = ["llm"]、Config/apply 齐全,导出清单与改动前一致。

验证

  • npm run typecheck:退出 0。
  • npm test:1040/1056。失败的 16 条全部是本机既有的 web-search/settings 环境性失败;我把失败测试名去掉耗时后缀后与 main 基线做了集合比对,逐条相同(基线、性能改动后、清理后三次运行一致)。
  • npm run build:两段 Build complete。
  • git diff --exit-code -- lib/:退出 0(产物与源码一致,lib/ 已随每个提交重建)。
  • 每个提交各自含与其源码对应的 lib/ 产物,逐提交 checkout 也能过 CI 守卫。
  • 本机无法运行的两项(诚实说明):npm run test:engine 与 npm run test:pack 都以 spawnSync('npm.cmd') 且未开 shell 启动,在 Windows/Node 24 上直接 EINVAL(scripts/verify-engine-load.mjs:124、scripts/verify-package-exports.mjs:28),与本次改动无关。作为替代,我把打包后的插件放进真实引擎(@deepseek-ai/dsh 0.2.1-rc.2)的 peer 树中导入验证,如上节所述。

附带的代码清理(3 个提交,不属于性能)

在同一批路径内顺手做了非性能清理,均可单独审视:

  1. refactor(adapter):6 个 export 没有任何可达的消费者(AccountRotationContext、CommandCodeAccount、CommandCodeUsage、CommandCodePlan、MODELS_TIMEOUT_MS、PROTOCOL_CACHE_TTL_MS)——src/index.ts 未转出,exports 表也不给深导入路径,引用只在本文件内。产物其实早已「不同意」它们:没有一个是 lib/index.d.ts 的导出,其中 5 个只作为公共形状所需的非导出声明存在,两个常量完全不出现。刻意保留 SurfaceImagePolicy、headersTimeoutAdvice、AnalyticsEvent:它们既被生产代码使用也被测试具名引用,是真实接缝。
  2. test(analytics):删掉一条挂在控制器测试末尾的 assert.equal(emptyMetrics().requests, 0)——它拿零初始化字面量断言自己,与被测对象无关,且是该工厂在模块外唯一的消费者。工厂保留(aggregate 每次都用),收掉 export。
  3. refactor(analytics):把我这次新增的高峰时段 memo 从 peakHours 改名为 peakHourCache。原名字与 CommandCodePriceTable.peakHours(窗口列表)同名,而两者在 requestRates 里只隔两行。

顺带发现(未改动,供参考)

  • src/client/panel.ts 的 resetPanelAutoRefresh() 其 JSDoc 自述「Exported for tests only」,生产侧零调用,仅 tests/panel.test.ts 调用 6 次;生产通过 startPanelAutoRefresh 返回的 disposer 释放。按要求本次未越出改动范围,如需处理可以另开一轮。
  • src/analytics.ts 与 src/cost-projection.ts 有一段逐字重复的守卫代码(record、tokensOf,以及只差名字的数值守卫 number/finite),合理归属是 src/cost-facts.ts。
  • src/login.ts 的 buildCommandAuthUrl 注释写着「exported for tests」,但它其实被生产调用并公开导出,注释已过时。

如何复核

npm ci
npm run typecheck
npm test                      # 本机基线即有 16 条 web-search 失败
npm run build
git diff --exit-code -- lib/   # 应为 0
npm pack --dry-run             # 应为 16 个文件,不含 *.map

The tarball carried lib/index.js.map and lib/client.js.map: 1.63 MiB raw,
32.9% of the unpacked payload. Nothing reads them: lib/client.js is a CJS
bundle the webview executes, and scripts/verify-engine-load.mjs audits
import/require statements rather than comments, so the `//# sourceMappingURL`
trailer is inert.

`files` now excludes them, and they are untracked as well. They are the one
tsdown output that does not rebuild byte-identically on this checkout --
lib/index.js, lib/client.js and lib/index.d.ts all reproduce exactly -- so
tracking them only gives the CI `git diff --exit-code -- lib/` guard a
platform-dependent failure. Local builds still emit them, so debugging keeps
its maps.

npm pack --dry-run: tarball 2,948,915 -> 2,479,266 B (-15.9%), unpacked
5,188,371 -> 3,480,662 B (-32.9%), 18 -> 16 files.
Two shapes re-scanned the whole selection per model:

* the per-model totals filtered `selected` once per distinct model, so a
  5,000-fact report made 96 full passes to build subtotals that a single
  bucketing pass produces;
* the scenario matrix resolved the candidate's price row with a Map lookup for
  every (fact, catalog model) pair — 480,000 lookups for the same 96 rows.

The selection is now bucketed by model once, and `priceRequest`'s arithmetic
moved into `cost-facts.requestCost`, which takes an already-resolved row so the
scenario loop can hoist the lookup out of its inner loop. The arithmetic still
lives in exactly one place. `requestRates` gained an optional peak-hour memo:
`peakHour` constructs a Date per call and the answer is a pure function of `at`
and this one table, so a report shares one memo across all of its pricing
questions.

Measured on node 22 with 96 catalog models (throwaway harness, not committed):

  500 facts    4.65 -> 3.84 ms
  2,000 facts 18.19 -> 12.35 ms
  5,000 facts 42.17 -> 30.36 ms

Output is unchanged: the bucket order matches the previous `Set` order and the
sort was already a total order over unique ids.
`readImageRequest()` returns one cached codec object per (attachment, encoder
target) and `requestImages()` reuses that object for every occurrence, but a
history that carries an image also replays it in every later message. Each of
the three wire codecs base64'd the buffer per occurrence, so the same encoded
bytes were re-derived once per replay — `Buffer.from(...).toString('base64')`
measured 0.738 ms for a 1 MiB payload and 18.66 ms for 24 MiB, against ~21 ns
for the memoised lookup that now replaces it, and an image can legitimately
appear several times in one message.

The three codecs share one WeakMap keyed on the codec object, so the copy's
lifetime follows the harness's own version cache instead of this plugin's, and
the data URL is memoised alongside the raw base64 because building it also
copies the whole payload.
Six declarations carried `export` with no reachable consumer:

  AccountRotationContext, CommandCodeAccount, CommandCodeUsage,
  CommandCodePlan, MODELS_TIMEOUT_MS, PROTOCOL_CACHE_TTL_MS

None is re-exported by src/index.ts, and the package's `exports` map offers no
deep-import path, so a module was the only place they could be named from — and
in every case the only reference is in that same file. The emitted bundle
already agreed: none of the six is an export in lib/index.d.ts, five survive
there only as non-exported declarations that the public `CommandCodeUsageReport`
and `CommandCodeAdapterDeps` shapes need, and the two constants do not appear at
all.

`git diff` over the rebuilt lib/index.d.ts adds and removes no `export` line, so
the published type surface is unchanged. The JSDoc now says which public shape
each internal type is reached through, which is what a reader needs instead of
an `export` that promises more than it delivers.

Three siblings were left exported on purpose: `SurfaceImagePolicy`,
`headersTimeoutAdvice` and `AnalyticsEvent` are all used by production code
*and* named by tests, so their export is a real seam rather than dead weight.
`tests/analytics.test.ts` ended its browser-controller test with
`assert.equal(emptyMetrics().requests, 0)`. That checks a literal initializer
against itself and says nothing about the controller the test is named for. It
was also the only consumer of that factory outside `src/analytics.ts` — which is
the only reason the factory was exported.

`emptyMetrics` itself stays: `aggregate` builds every accumulator with it. It
loses the `export`, so the module surface now matches its real audience. The
rebuilt lib/index.js gains exactly one line for this change — the doc comment;
the name was never in the bundle's export list.

That closes the in-scope inventory for "code only tests use": nothing in
src/analytics.ts, src/cost-facts.ts or src/adapter.ts has a consumer that is
exclusively a test.
…indows

The memo added with the single-pass report was called `peakHours`, which is also
the name of `CommandCodePriceTable.peakHours` — the `[start, end]` window list.
In `requestRates` the two sit two lines apart:

    let peak = peakHours?.get(at)
    peak = peakHour(at, table.peakHours)

so the collision is read before it is understood. `peakHourCache` says which one
is the memo. No behaviour change: same values, same lookups.
@Mars-Sea
Mars-Sea merged commit 40a43d8 into Mars-Sea:main Oct 19, 2026
5 checks passed
Mars-Sea added a commit that referenced this pull request Oct 19, 2026
PR #95 把两份 sourcemap 移出发布包并取消跟踪,这部分独立成立:没有任何读取方,
本地构建照旧产出。但它给出的第二条理由——「map 是 tsdown 产物里唯一无法逐字节
重建的」——把 CRLF 污染误归为平台固有。实测:在 /tmp 干净目录用 git archive main
展开后重建,两份 map 与提交里的逐字节相同(SHA-256 8f94557d… / 5ca1b0aa…),
作者本机 28,584 / 23,464 字节的差值约等于内嵌源码行数的 2 倍,根因是仓库从未有
.gitattributes。

补上 * text=auto eol=lf(图片与压缩包按 binary 排除),红绿对照成立:无该文件时
core.autocrlf=true 检出后 src/adapter.ts 有 3,482 行 CRLF、lib/index.js 有 11,817
行,重建后 git diff --exit-code -- lib/ 报出 lib/client.js.map 差异;加上后两者均
为 0,守卫退出码 0。加入时已跟踪文件 100% 为 LF,git add --renormalize . 未改写
任何文件。

合并后验证:typecheck 退出码 0、npm test 1056/1056、build 两段、git diff
--exit-code -- lib/ 退出码 0、test:engine 2/2、test:pack 16 文件、npm pack
--dry-run 2.5 MB / 解包 3.5 MB(与文档所述一致)。

文档:CHANGELOG 新建 [Unreleased] 段;《决策记录》第八章第 15 条记录更正后的判据;
《对接说明》第四章第 4 条并标注 rc.2 轮次的「18 个文件」为历史值;《提交历史》总览;
仓库地图补 .gitattributes。
@Mars-Sea

Copy link
Copy Markdown
Owner

已合并(40a43d8),并在 main 上补了一个收尾提交(7fa4ce1)。感谢 @GT-610 这份实现——三项改动的结论都成立,我逐项复现过。有一处归因需要更正,记录在这里,因为那句判断会留在合并历史里。

更正:map 不可逐字节重建不是平台固有,是 CRLF 污染

PR 描述里写「它们是 tsdown 产物里唯一在本机无法逐字节重建的」,并据此把不可重建归为跨平台差异。我在 macOS 上做了对照:

  • 在 /tmp 干净目录用 git archive main 展开后 npm run build,lib/index.js.map 得到 SHA-256 8f94557d…、lib/client.js.map 得到 5ca1b0aa…,与 main 提交里的逐字节相同;lib/index.js、lib/client.js、lib/index.d.ts 同样一致。
  • 作者本机与提交的差值(index.js.map 919,315 vs 890,731 差 28,584 字节;client.js.map 783,625 vs 760,161 差 23,464 字节)约等于两份 map 内嵌源码总行数(14,292 行 / 11,732 行)的 2 倍。

这个倍数关系指向 CRLF:sourcesContent 内嵌了全部源码,Windows 工作树按 CRLF 检出后每行多 1 字节,两份 map 各多约一倍行数的字节数。根因是仓库此前从未有 .gitattributes,不是构建不可重现。

已补的修复

新增 .gitattributes:* text=auto eol=lf,图片与压缩包按 binary 排除。红绿对照:

场景 检出后 CRLF 行数 重建后 git diff --exit-code -- lib/
无 .gitattributes + core.autocrlf=true src/adapter.ts 3,482 行、lib/index.js 11,817 行 退出码 1,报出 lib/client.js.map 差异
有 .gitattributes + 同一条件 0 退出码 0

也就是说,PR 描述里那个「Windows 上 map 重建不一致」的症状在本机完整复现了,加上属性文件后消失。加入时已跟踪文件 100% 是 LF,git add --renormalize . 未改写任何文件(零内容变更)。

复核记录

合并前我在本地 worktree 独立跑了全套,不是照抄描述:

检查 结果
npm run typecheck 退出码 0
npm test 1056/1056(你报告的 16 条失败是 Windows / Node 24 环境性失败,macOS / Node 22.22.0 上不复现)
npm run build 两段 Build complete
git diff --exit-code -- lib/ 退出码 0
npm run test:engine 2/2(0.2.0-rc.2 与 0.2.1-rc.2)
npm run test:install 2/2(pnpm 10.34.5)
npm run test:pack 16 个文件
npm pack --dry-run 16 文件,2.5 MB / 解包 3.5 MB
npm pack --dry-run(npm 10.9.9) 同样是 16 文件——files 的否定模式 !lib/**/*.map 跨版本可靠

另外两项独立验证:

  • 输出逐位不变:用 3,000 条确定性事实 × 96 个目录模型分别在 main 与 PR 源码上跑 buildAnalytics,两份 JSON 输出 147,944 字节完全一致。
  • 逐提交产物可重建:6 个提交各自 checkout 后单独 npm run build,lib/index.js、lib/client.js、lib/index.d.ts 均与提交里的逐字节一致。
  • 性能复测:另用一套合成数据,500/2000/5000 条事实为 1.72→1.13 / 6.02→3.69 / 14.87→9.90 ms(约 −33%),比你报告的 −28% 略好。

test:engine 与 test:pack 在你那边以 spawnSync('npm.cmd') 未开 shell 报 EINVAL,属 Windows / Node 24 环境问题,与改动无关——这两个在本机都跑通了。

关于「顺带发现」

三条我都核实了,结论与你一致,本轮未越界处理:resetPanelAutoRefresh() 确实只在 tests/panel.test.ts 被调用 6 次、生产侧零调用;buildCommandAuthUrl 的注释确实过时(它被 src/login.ts:304 生产调用并从 src/index.ts:145 导出);analytics.ts 与 cost-projection.ts 的守卫重复属实。这些留给后续单独一轮。

map 保持不跟踪(用户选定):它们不参与运行时、也不参与「源码改了产物没重建」的守卫,跟踪只会给每次源码改动加一份 1.6 MB 的 diff 噪声。

@GT-610
GT-610 deleted the perf/reduce-footprint-and-hot-paths branch October 19, 2026 08:34
Mars-Sea added a commit that referenced this pull request Oct 19, 2026
为什么:用户要求「提交并发布新版本」。版本号取 patch——三批改动均为非破坏性的
性能与界面调整,无新功能、公开导出面零变化(lib/index.d.ts 的 export 列表逐字
未改),沿 0.13.4「设置页信息架构调整随 patch 发布」先例。

- 语义化版本 0.14.2 → 0.14.3(package.json 与 package-lock.json 两处同步)
- CHANGELOG:[Unreleased] 定版为 [0.14.3] - 2026-10-19,含 PR #95 的三条性能
  改动、.gitattributes、以及本轮两处界面调整
- lib/ 重建:lib/client.js 内嵌版本常量 0.14.3,分析入口已移入「账户用量」标题行
- 发布前检查全通过:锁文件隔离校验(npm ci --dry-run)、typecheck、测试 1056/1056、
  构建两段、test:engine 2/2(0.2.0-rc.2 与 0.2.1-rc.2)、test:install 2/2
  (pnpm 10.34.5)、test:pack(16 文件,版本 0.14.3)、npm pack --dry-run
  (16 文件,2.5 MB / 解包 3.5 MB)
- 环境问题一处:npm 因 ~/.npm 的 root 属主文件报 EPERM(与 10-04、10-08、10-14、
  10-18 同因),全程用 npm_config_cache=$PWD/.npm-cache 绕过,未做权限根治
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants