跳过主要内容
B

dsh-aot-runner (plugin)

boyingliu01/dsh-aot-runner/plugin

子进程 runner 的 NativeAOT 替代实现:每次调用开销降低 59~67%(p50 156ms → 63ms),28 个用例与 Node 版字节级一致;仅限 Windows x64,当前需要一个上游补丁,且装上后默认关闭,需在 profile 里设 enabled: true 才生效。

安装

dsh plugin --profile web add github:boyingliu01/dsh-aot-runner

该插件位于仓库的 plugin 子路径下。

README

dsh-aot-runner

npm license platform

DSH 子进程 runner 的 NativeAOT 高性能实现。用原生可执行文件替换 Node.js 版 runner, 每次子进程调用省 77197ms(约 5967% 开销),行为与 Node 版在 28 个用例上字节级一致。

DSH 每跑一条命令都要先起一个 runner.js 子进程 —— 光加载 Node 运行时就是 200ms 起步, 比你要跑的命令本身还慢。换成原生 exe 后这段降到 69~76ms。

node runner(现状)AOT runner
p50156~158ms63~64ms
p99249~279ms173~220ms
抖动(IQR/中位数)11.7%~20.5%1.9%~4.1%

抖动收窄同样重要:node runner 本身就不稳定,AOT 稳定在 2%~4%。 交互式场景里,可预测往往比更快更值钱。 完整数据、多轮口径与方法见 docs/性能对比测试报告.md —— 收益比例在不同测量场景下为 59~67%,本文不挑最大的那个说。


📌 装之前先看这两步

这是 Windows x64 专用插件,且当前需要一个上游补丁。 已在 DSH 0.2.0-rc.2 和 0.2.2-rc.1 上确认 —— 上游两个 launch* 函数漏传 internals 参数,注入被静默忽略。 插件会自动检测这个情况:命中时明确告警并停用自己,而不是假装在工作。 上游修好后,删掉 vendor/ 目录、按普通插件安装即可。

📮 想催上游修?只能去 discussion,没有 issue 通道。 deepseek-ai/deepseek-harness 已关闭 issues/PR 功能(has_issues=false), 提交 PR 会 404 —— 但 discussion 区仍在活跃维护。 请到 discussion #11893 留言。

⚠️ 安装前必读(两步,缺一不可)

1. 需要一行上游补丁。 在 DSH 0.2.0-rc.2 上,本插件必须配合一个 1 行的上游补丁才能工作。

原因是 DSH 的一个传参缺陷:LocalSubprocessRuntime.spawn() 调用 launchWindowsJob() / launchLinuxScope() 时漏传 internals 参数, 导致所有基于 internals 的注入被静默忽略。

  • 缺陷报告:deepseek-ai/deepseek-harness#11893
  • 补丁与说明:vendor/README.md(node vendor/patch-dsh.mjs apply)
  • 插件会自动检测这个缺陷:命中时明确告警并停用自己,而不是假装在工作
  • 上游修复后,删掉整个 vendor/ 目录即可

2. 从源码构建需要 C++ 工具链。 NativeAOT 在 Windows 上强制要求 MSVC 链接器:

  • ✅ .NET 8+ SDK
  • ✅ Visual Studio Build Tools 的 C++ 工作负载(或 VS 的「使用 C++ 的桌面开发」)

只装 .NET SDK 会报 error : Platform linker not found(C++ 工具链约 8GB)。 不想装工具链就直接用预编译产物 —— 见下方「下载」。

协议状态:AOT 版实现的是与 Node 版相同的 Node IPC 协议(fd 3), 可被真实 DSH host 直接调用。

下载

三种获取方式,按需要选:

方式适合谁得到什么
GitHub Releases只想拿原生 exe,不想装工具链dsh-aot-runner.exe + .zip + SHA256SUMS
npm @boyingliu01/dsh-aot-runner要接入 DSH(推荐)插件 + 包内自带的 exe
从源码构建要改代码 / 审计构建过程自己编译的 exe(需 C++ 工具链)

Release 里的二进制由 CI 从 tag 对应的 commit 构建,无 VC++ 运行时依赖、无 .NET 运行时依赖 (CI 中已验证)。下载后建议核对校验和:

# Windows PowerShell
Get-FileHash dsh-aot-runner.exe -Algorithm SHA256   # 对照 SHA256SUMS
# Linux / macOS / Git Bash
sha256sum -c SHA256SUMS

SHA256SUMS 用 LF 行尾,上面这条命令在三个平台都能直接用。 (0.2.1 及更早的 Release 里这份文件是 CRLF,Linux 上 sha256sum -c 会报 'dsh-aot-runner.exe'$'\r': No such file;遇到就用 tr -d '\r' < SHA256SUMS | sha256sum -c -。 该问题已在发布流程中修复。)

📋 计划看 ROADMAP.md | 性能数据见 docs/性能对比测试报告.md

当前状态:已可通过插件接入(plugin/), 支持配置开关、熔断降级与启动自检。生产路径实测见下方「实测收益」。


为什么做这个

DSH 每启动一个子进程,链路是:

node.exe (dsh host) → node.exe runner.js -- <target> → target

中间那个 runner.js 每次都要重新加载 Node 运行时、解析模块、走一遍 IPC 握手。 profile 显示它单独贡献 200–220ms 的固定延迟 —— 比真正要跑的命令还慢。

把它换成 NativeAOT 编译的原生 exe 后,这部分降到 69–76ms。

实测收益

在真实 DSH 的 launchWindowsJob 路径上交替采样(3 轮 × 100 次):

实现p50p99离散度
node runner(现状)156~158ms249~279ms11.7%~20.5%
AOT runner63~64ms142~206ms1.9%~4.1%
  • 收益比例稳定在 63%~67%(多次独立测量收敛,0 失败、0 输出不一致)
  • 每次净省 92~197ms(随系统负载变化,见下方口径说明)
  • p99 长尾改善 73~124ms
  • 真实命令(hostname / whoami / cmd /c echo)每次省 77~155ms
  • 稳定性:300 次连续调用 0 失败,RSS 53~61MB 有涨有落、非单调增长
  • 熔断兜底:注入损坏 runner 时,连续 3 次失败后自动降级,第 4 次恢复正常

波动收窄也是收益:node runner 的 IQR/中位数离散度是 11.7%~20.5%, AOT 是 1.9%~4.1%。对交互式场景来说,减少抖动比降低均值更重要。

关于口径(重要):绝对值随系统负载波动很大 —— 同一台机器不同时段实测 得到过 92ms / 116ms / 168ms / 197ms 的净差值。 收益比例才是稳定指标:独立测量结果为 64.1%(5 轮可信观测)、 66.6%(3 轮跨轮一致)、63.5%(单次 30 样本)、59.1~59.6%(3 轮 × 100 次交替采样)。

因此本文统一表述为「约 59~67%」,并取区间而非上限,也不再引用某个具体毫秒数。 基准已内置自动可信度判定(离散度 > 10% 直接标 UNRELIABLE,不给出结论)。 完整数据与方法见 docs/性能对比测试报告.md。


设计要点

调用契约严格对齐 @deepseek-ai/dsh-subprocess-local

Windows 下真实的 runner 调用是:

spawn(command, [...prefix, "--", ...spec.argv], {
  cwd: process.cwd(),
  windowsHide: true,
  env: runnerEnvironment("windows", invocation),
  stdio: ["ignore", "ignore", "ignore", "ipc", stdinCarrier, stdout, stderr, control],
})

关键点:stdio[3]=ipc、stdio[4]=target stdin、stdio[5]=target stdout、 stdio[6]=target stderr、stdio[7]=control。

AOT 版实现的是同一个 Node IPC 协议(stdio[3]),不是自定义协议。 帧格式(实测确认):

[8 字节 type (LE,恒为 1)][8 字节 payload 长度 (LE)][JSON payload + '\n']

type 不是序号 —— Windows 上 Node 走 uv_pipe + 阻塞 I/O,没有 sequence number; 1 = NODE_HANDLE_NORMAL,负值是内部帧(载荷不是 JSON,必须拒绝)。

start 请求字段被 hasExactKeys 严格校验为 {type, cwd, env, control?} —— 多一个字段都会被拒绝,这一点必须照抄。

历史注记:Issue #11 之前 AOT 用的是 NDJSON over stdin,与 host 完全不兼容。 现在 --ndjson 仅作为手工调试模式保留,生产路径只走 IPC。

进程创建(CreateProcessW)

项目值说明
lpApplicationName解析后的绝对路径裸名会让 CreateProcessW 用可疑的搜索顺序
lpCommandLinemsvcrt 兼容加引号见 AppendQuoted
lpEnvironment手工构造 UTF-16LE 块CREATE_UNICODE_ENVIRONMENT 要求双 \0 终止
lpCurrentDirectory反斜杠规范化的路径传 "C:/" 会得到 ERROR_INVALID_PARAMETER(87)
dwCreationFlagsCREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT先挂起,入 Job 后再 resume

句柄必须用 _get_osfhandle(4/5/6) 取 —— GetStdHandle(4) 只会返回 INVALID_HANDLE_VALUE,因为它查的是标准句柄表(-10/-11/-12),不是 CRT fd 表。

Job Object

用 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (0x2000),JobObjectExtendedLimitInformation, 结构体 144 字节。runner 退出时整棵进程树一并回收。

这个机制的价值是实测出来的,不只是「设计上应该有用」( 完整报告,脚本 bench/isolation-value.js):

场景内联方案走 runner隔离有价值
target 崩溃host 正常完成host 正常完成❌ 无差异
target 吐 200MB 输出(host 不读)卡住卡住❌ 无差异
target 挂起不退出host 保持响应host 保持响应❌ 无差异
target 崩溃 + 遗留 detached 子孙5 个孤儿全存活5 个全部回收✅

结论值得单独强调,因为它推翻了常见直觉:

这一层进程边界的价值不是"防崩溃",而是"防进程泄漏"。

target 崩溃时,内联方案里 host 同样存活 —— Windows 的进程隔离本来就够用。 真正需要 Job Object 的是孤儿进程:target 派生并 detach 的子进程在 target 死后会一直存活占资源,实测内联方案残留 5 个、runner 方案 0 个。

同理,「多一层转发更安全」的直觉在背压上不成立:两组都卡住 —— 隔离不改变管道语义,谁在最下游不读,整条链都会阻塞。

可执行文件解析必须与 node 逐项对齐

node 的 resolveWindowsExecutable 规则(runner-launch-*.js):

  1. 候选扩展名只有 .com 和 .exe,且 .com 优先 —— 不含 .bat/.cmd
  2. 自带扩展名(点号后至少 1 字符)时,只检查原样文件名一次
  3. 不含路径分隔符时先搜 cwd(受 NODEFAULTCURRENTDIRECTORYINEXEPATH 控制),再按 PATH
  4. 全部候选不存在 → ENOENT,而不是把裸名丢给 CreateProcessW

第 1 条很反直觉但必须照做:npm 只有 npm.cmd,如果 AOT 版自作主张补 .cmd,就会出现「node 报 ENOENT、AOT 能跑」的分歧。行为一致优先于看起来更聪明。

错误对象同样逐项对齐:

// WINDOWS_SPAWN_ERROR_CODES
2 → ENOENT, 3 → ENOENT, 267 → ENOENT, 5 → EPERM, 193 → EFTYPE, 740 → EACCES
// NODE_SPAWN_DETAIL_CODES: 只有 EACCES/ENOENT 把程序名写进 syscall 和 path

快速开始

前置

  • .NET 8 SDK(含 NativeAOT 工具链,即 MSVC 链接器)
  • Node.js 22+
  • 本机安装 DSH(A/B 验证需要 @deepseek-ai/dsh-subprocess-local 作为基线)

详细的工具链版本、验证命令与故障排查见 docs/开发环境.md。

构建

./build.sh                 # 增量构建
./build.sh --check         # 只跑质量门禁(8 道,见「质量门禁」章节)
./build.sh --clean         # 清理后重建
./build.sh --test          # 构建 + 跑回归(JS 77 项 + C# 319 项)
./build.sh --all           # 质量门禁 + 清理重建 + 测试 + 基准
./build.sh --out <目录>    # 指定产物目录

装到 DSH(插件方式)

前提:先按 vendor/README.md 打上游补丁 —— 不打补丁的话,插件启动自检会发现并自动停用(这是刻意设计,避免「装了却没效果」)。

# 1. 构建产物(会自动同步到 plugin/bin/)
./build.sh

# 2. 检查补丁状态
node vendor/patch-dsh.mjs status

# 3. 在 DSH profile 里注册插件(示例:web profile)
#    ~/.dsh/profiles/web/package.json 的 dependencies 加:
#      "@boyingliu01/dsh-aot-runner": "link:<仓库绝对路径>/plugin"

# 4. 在 profile 的 cordis.patch.yml 里启用
#    - id: aot-runner
#      config:
#        enabled: true

注意:覆盖已有条目只需写 id + 要改的字段。 用 insert: 会让同一个 id 出现两条 —— DSH 不报错但配置不生效(静默失败)。

详细配置项见 plugin/README.md。

产物默认落在仓库内 dist/dsh-aot-runner.exe。构建前会自动做 Low Integrity 目录自检(见下文「重要陷阱」),命中 Low IL 会直接中止而不是产出一个跑不起来的 exe。

手动构建

dotnet publish -c Release -r win-x64 -o dist

测试与基准

./build.sh --test          # 构建 + A/B(推荐)
npm test                   # 只跑 A/B,需先构建
node bench/d1-ab-bench.js  # 性能对比(单次测量)
长期观测:判断优化值不值得继续投入

单次基准会被机器负载带偏——同一台机器连跑三次,收益比例 58.9% / 65.1% / 67.1%, 但离散度 48.9% / 22.5% / 25.4%,三次全部不可信。 bench/aot-observe.js 跑多轮、只采信可信轮次、把结果落盘成历史趋势:

npm run observe:check                    # 先看环境能不能测(CPU 高就别测)
npm run observe -- --rounds 5 --n 50     # 跑 5 轮 × 50 次,落盘
npm run observe:report                   # 看历史趋势与汇总

它拒绝把不可信的数字平均一下当结论:全部轮次离散度超限时, 输出 UNRELIABLE 且收益字段为 null,不会给出一个漂亮但虚假的比例。

判据分两层(2026-11-04 补充):

判定条件给出什么
MEASURED至少一轮单轮离散度 ≤ 10%绝对值 + 比例
GAIN_CONSISTENT单轮全超限,但 ≥3 轮收益跨度 ≤ 5pp只有比例,绝对值为 null
UNRELIABLE上述都不满足什么都不给

第二层存在的理由:本机常态负载 50~75%(火绒 HipsDaemon + 常驻办公软件), 单轮离散度基本压不到 10% 以下。但收益比例对负载不敏感—— 负载同时抬高 node 和 AOT 的绝对延迟,比值却保持稳定。 实测 6 轮为 60.7 / 63.2 / 62.9 / 64.1 / 61.7 / 65.0%(跨度 4.3pp)。

这一层刻意不输出 netMs / nodeP50 —— 那些绝对值确实被污染了。 阈值也没有放水:实测有一轮跨度 8pp,被正确判为 UNRELIABLE。

观测时的一个反直觉现象:系统负载升高时,node runner 的 p50 会明显漂移 (221→249→263ms),而 AOT 的 p50 几乎不动(107→108→108ms)。 也就是说 AOT 的价值不只是均值更快,更是尾延迟和稳定性—— 这也解释了为什么收益比例比绝对值更适合作为判断指标。

⚠️ 上面那条「AOT p50 几乎不动」只在 runner 跳这一层成立,不能外推到完整调用。 2026-11-10 复核 .aot-observation/matrix-*.json(bench/load-matrix.cjs, 测的是真实 bash 完整生命周期):

实际 CPUnode p50/p90AOT p50/p90node 尾抖AOT 尾抖
37%276 / 331ms114 / 146ms55ms32ms
76%286 / 320ms126 / 147ms34ms21ms
100%448 / 715ms257 / 297ms267ms40ms

完整调用口径下 p50 确实会动:37%→100% 时 node 涨 172ms、AOT 涨 143ms。 原因不难理解 —— load-matrix 测的是 runner + bash,负载升高时 bash 自己也在变慢,这份成本两个 runner 都要付。 所以「AOT 的 p50 不随负载变化」在该口径下不成立。

但「AOT 更稳」这个结论仍然成立,只是要换指标。 换指标时要注意一个陷阱: 若比相对涨幅(AOT +125% vs node +62%),会得出「AOT 更差」—— 那是分母假象:AOT 基准是 114ms、node 是 276ms,加同样大小的共享成本, 基准小的那个相对涨幅天然更大。正确的比法是三项:

指标数据结论
绝对增量node +172ms vs AOT +143msAOT 涨得更少
净省(node−AOT)162 → 160 → 191ms没有塌掉,高负载下反而略增
尾抖(p90−p50)node 55→34→267ms,AOT 32→21→40ms100% 负载下 AOT 少抖 227ms

结论:AOT 的优势是「绝对延迟更低 + 尾延迟不随负载爆炸」, 不是「p50 不受负载影响」。 后者的观测口径(runner 跳、减掉 hostname 地板) 与用户实际感受到的(runner + target 完整生命周期)不是一回事,不能混着引用。

另外提醒:上面 221→249→263 / 107→108→108 那组数字在仓库里找不到对应的数据文件 (引入它们的提交只改了 README),无法追溯测量条件。需要引用时以 bench/load-matrix.cjs 的完整调用数据为准。

一次真实的 MEASURED 记录(2026-11-04 11:12,CPU 36%):

第 1 轮: node 209ms → aot 93ms  净省 116ms (64.1%)  离散度 7.2/1.3  可信
可信轮次: 1/5
净收益中位数: 116ms/次  (64.1%)
node p50 209ms → AOT p50 93ms,地板 28ms

A/B 需要两个 runner:AOT 产物与 node runner。两者路径自动解析:

runner查找顺序
AOTAOT_RUNNER → 仓库内 dist/dsh-aot-runner.exe
nodeDSH_RUNNER → NODE_RUNNER → 仓库 node_modules → DSH_HOME / %USERPROFILE%/.workbuddy/binaries/node/workspace

找不到时测试会明确报错并给出修复建议,而不是静默跑空或拿一个过期二进制去比对。


质量门禁

./build.sh --check 跑八道检查,任何一道失败即非零退出:

门禁实现说明
C# 代码格式dotnet format --verify-no-changes规则来自 .editorconfig + .csharp-formatting.json
C# 静态检查编译期 analyzercsproj 里 TreatWarningsAsErrors=true,0 警告是硬约束
版本一致性scripts/check-version.mjsVERSION / package.json / plugin/package.json 三处对齐
C# 单元测试dotnet test319 个测试
覆盖率门槛coverlet + .runsettings对应 xp-gate Gate 5
ESLinteslint .对应 xp-gate Gate 1
JS 语法scripts/check-js-syntax.mjs口径唯一定义处,CI 与 pre-commit 共用
上游补丁留档vendor/patch-dsh.mjs verify校验 index.js.orig 与原始版本一致

一键修复格式:dotnet format。

npm run test:all 会把 --check 作为第一道,再跑完整测试链 —— 「跑了测试」不等于「跑了门禁」,两者都过才算绿。

这些门禁与 xp-gate 的全局 hook 是双保险,不是互相替代:--check 不依赖 全局安装状态、CI 里也跑,是最小可复现的那一层;xp-gate 提供 Gate 0~13 的 完整覆盖。历史上 xp-gate 曾「看不见 C#」(#717), 那段时期确实只能靠 --check;该缺口已修复,见 ROADMAP.md 的复核记录。


⚠️ 重要陷阱:Low Integrity 目录

如果构建产物放在被标记为 Mandatory Label\Low Mandatory Level 的目录下, 产出的 exe 会跑不起来。

症状:

bash: fatal error - NtCreateDirectoryObject(\BaseNamedObjects\msys-2.0S5-...): 0xC0000022
exit code = -1073741502

同时伴随:写 TEMP 被拒绝(Access is denied),但 cmd/python/pwsh 正常。

原因:DSH 的 Windows 沙箱(@deepseek-ai/dsh-sandbox-windows-acl)在 workspace-write 模式下会给工作目录打 Low IL 标签,且退出时有意保留 (源码注释:"workspace ACEs stay standing as the reuse cache")。 从该目录启动的进程继承 Low IL 后:

  • 无法创建 BaseNamedObjects 下的对象 → MSYS/Cygwin 程序(bash/ls/sh)崩溃
  • 无法写 C 盘 TEMP(跨完整性级别写入被拒)

诊断:

icacls "D:\your\workspace" | grep -i "mandatory\|label"
# 出现 "Mandatory Label\Low Mandatory Level:(OI)(CI)(NW)" 就是它

# 或者直接用项目自带的自检脚本
./scripts/check-integrity.sh dist

确认是路径问题而非代码问题的判据:把同一个 exe(md5 不变)复制到 %TEMP% 下再跑,若恢复正常则确认。

规避:构建输出放到 Medium IL 目录。build.sh 在构建前会调用 scripts/check-integrity.sh 自检产物目录,命中 Low IL 直接中止并提示改用 --out 换目录,而不是产出一个跑不起来的 exe。

想彻底清除标签需要 WRITE_DAC 权限,而沙箱留下的 ACL 里 Authenticated Users 只有 (M),通常不够 —— 所以规避比清除更实际。


⚠️ 另一个陷阱:exe 被占用时无法覆盖(改名可绕过)

Windows 不允许覆盖一个正被映射为可执行映像的文件,但允许改名。

场景:DSH 会话本身就在通过 plugin/bin/dsh-aot-runner.exe 跑子进程, 此时重新构建会撞上:

cp: cannot create regular file 'plugin/bin/dsh-aot-runner.exe': Device or resource busy

build.sh 的处理是改名换位:把占用中的文件 mv 成 .old, 再把新文件放到原路径。已运行的进程继续持有旧 inode 的句柄(照常工作), 下次启动就用新版 —— 不需要关掉 DSH。

.old 后缀不是随便选的:plugin/package.json 的 files 里有 !bin/*.old, 即使清理漏了也不会被打进发布包。

这个陷阱有历史:早期版本在这种情况下只告警、仍以 0 退出, 结果插件包里躺着上一版 exe 而构建日志一切正常。 现在除了改名换位,发布前还有 scripts/check-release.mjs 兜底 —— 它已接进 prepublishOnly,产物不一致时 npm publish 会直接失败。 两道防线各司其职:构建期尽量成功,发布期必须正确。


项目结构

.
├── DshAotRunner.csproj        # NativeAOT 配置 + 严格静态检查
├── build.sh                   # 构建 / 质量门禁 / 测试 / 基准 一键脚本
├── .editorconfig              # C# 格式规则
├── .csharp-formatting.json    # dotnet format 规则
├── src/
│   ├── Program.cs             # 入口 + 顶层异常兜底
│   ├── JobRunner.cs           # 核心:启动/监控/终止 target
│   ├── JobHandle.cs           # Job Object 封装
│   ├── Native.cs              # P/Invoke 声明
│   ├── Protocol.cs            # 协议 POCO
│   ├── ProtocolJson.cs        # 手写序列化(零反射)
│   └── Trace.cs               # 诊断日志(唯一写入口)
├── scripts/
│   └── check-integrity.sh     # Low IL 目录自检
├── test/
│   ├── runner-client.js       # 共享测试工具(真实 DSH 调用契约)
│   ├── ab-verify.js           # 28 项 A/B 字节级一致性验证
│   ├── ipc-verify.js          # Node IPC 协议回归
│   ├── host-path-verify.js    # 真实 host 调用路径
│   ├── e2e-plugin-verify.js   # 插件端到端
│   ├── leak-verify.js         # 零泄漏 / 混沌 / 探针自校验
│   ├── race-verify.js         # terminate 竞态
│   └── aot-observe-verify.js  # 观测器单元测试
├── plugin/
│   ├── lib/index.js           # 覆盖 internals.runnerInvocation
│   ├── lib/circuit-breaker.js # 熔断器(失败自动降级)
│   ├── bin/                   # 构建产物同步到这里
│   └── cordis.patch.yml       # 插件自注册
├── bench/
│   ├── d1-ab-bench.js         # 交错采样性能基准
│   └── aot-observe.js         # 多轮观测 + 双层可信度判据
└── docs/
    ├── 开发环境.md             # 搭环境、跑验证、故障排查
    ├── 测量方法论-当工具比被测对象更容易出错.md  # ⭐ 踩坑总结
    ├── 依赖清单.md
    ├── 方案-AOT接入DSH.md
    ├── 性能对比测试计划.md
    ├── 性能对比测试报告.md
    ├── 调研报告-JSON序列化与启动优化.md
    ├── S2b-性能实测报告.md
    └── S2-进展与阻塞.md

⭐ 先读这篇:测量方法论

docs/测量方法论-当工具比被测对象更容易出错.md

这个项目最贵的两次误判,都不是代码写错了,而是测量工具的隐含假设错了:

#假象真实情况发现方式
1JsonSerializer 慢 34×慢 2.4×严格同源 A/B,只改测量方式
2零泄漏测试偶发失败 = runner 泄漏基线竞态误报单跑 vs 串行对比
36 轮全不可信 = 优化无效比例稳定 64%,判据太窄换指标维度
4bash 崩溃 = runner 缺陷目录 Low Integrity 标签同 md5 exe 换盘符测试
5npm 解析差异 = AOT 更聪明与 node 行为不一致逐行对照源码

5 条里有 4 条,第一直觉都指向了错误的方向。

元规则:当测量结果反复"不合理"时,把怀疑顺序倒过来 —— 先怀疑测量工具,再怀疑环境,最后才怀疑被测代码。

这篇文档还给出了 6 条可复用规则(自校验、拒绝平均不可信的数、 差值比绝对值更稳、警惕未初始化基线等)和一份反模式清单。


技术决策记录

为什么手写 JSON 而不用 JsonSerializer

方案体积启动
JsonSerializer + JsonSerializerContext1.74MB251ms
手写 JsonDocument(当前)1.20MB105ms
Utf8JsonReader(官方底层 API)1.36MB~90ms

同为严格同源 A/B,差值 146ms(2.38×)。

补充修正:早期版本曾记录「JsonSerializer 导致 3609ms」,那是 stdio:'ignore' 造成 Console.OpenStandardOutput() 阻塞的测量假象, 真实值是 251ms。详见 docs/调研报告-JSON序列化与启动优化.md §2.3。

硬约束:IlcDisableReflection=true + JsonSerializer 会在运行时崩溃 MT140696497762168: Reflection_Disabled。既然要极致体积就必须手写序列化。

为什么不用现成的 JSON 库

  • Utf8Json —— 最后发布 2018-01-30,已无维护
  • MessagePack-CSharp / MemoryPack —— 二进制格式,与 NDJSON 协议不兼容
  • SpanJson —— 无维护信号

Utf8JsonReader(官方底层 API)是唯一可用且更快的选项,尚未迁移到生产路径, 在 docs/调研报告 §5 有 12 个用例的逐字节迁移矩阵。


当前状态

阶段状态说明
S0 可行性✅CreateProcessW + Job Object 跑通
S0b 协议对齐✅Node IPC(fd 3),首次可被真实 host 调用(#11)
S1 NativeAOT 最小实现✅零反射,1.2MB
S1b 依赖检查✅无 VC++ / .NET 运行时依赖(#5)
S1c 签名分发⏸ 推迟自用无证书(#8)
S2 功能对齐✅协议/stdio/exit code
S2b 性能实测✅见上表
S3 语义完备✅terminate 竞态(#4)、control pipe(#2)
S4 A/B 验证✅28/28 通过
S5 泄漏/混沌✅100 次零泄漏 + 混沌(#3)
S6 插件接入✅插件 + 熔断器(#11 阶段 B)
S7 性能对比验证🔄第一层 + 第二层 ✅,第三层进行中

全套件 408 项通过:npm run test:all(89 项 JS)+ dotnet test(319 项 C#)。

层套件项数
C# 单元tests/DshAotRunner.Tests319
A/B 一致性test/ab-verify.js28
观测器逻辑test/aot-observe-verify.js15
插件单元plugin/test/index.test.js11
泄漏/混沌test/leak-verify.js7
pre-commit 短路test/pre-commit-shortcircuit-verify.js7
JS 语法检查器test/lint-syntax-verify.js7
IPC 协议test/ipc-verify.js6
terminate 竞态test/race-verify.js5
插件端到端test/e2e-plugin-verify.js2
真实 host 路径test/host-path-verify.js1
合计408

已知待办

按线上 Issue 排列(Issue 是唯一事实来源):

Issue状态内容
#8⏸ 推迟S1c 签名与分发布置(自用无证书)
#1🔄 跟踪路线图与验收标准

非 Issue 记录的技术债:

  • JSON 迁移 JsonDocument → Utf8JsonReader(可再省 ~15ms)
  • runner 开销仍有压缩空间:当前 6976ms,地板 3236ms,理论上还能再省 40ms+
  • 两个测试缺口(详见 性能对比测试报告): 熔断器的 e2e 验证(node:test 环境下行为不确定)、 切帧丢数据类缺陷(需要 runner 侧测试接缝)

关于 resolveWindowsExecutable:已核对一致

曾一度怀疑 AOT 缺「applicationName 所在目录作为额外搜索根」的回退。2026-11-01 复核 runner-launch-*.js:1485 源码确认:DSH 本身就没有这条回退,搜索根只有 [""](cwd)+ PATH 两处。

实测裸名命令在两种 runner 下(含 cwd 不在 PATH、找不到可执行文件等场景) ENOENT 行为与错误文本完全一致:

node → {"message":"spawn definitely-not-real-xyz ENOENT","code":"ENOENT",
        "syscall":"spawn definitely-not-real-xyz","path":"definitely-not-real-xyz"}
aot  → 同上,逐字节相同

早先观察到的差异是探测脚本把 cwd 传成 glob 模式、导致 runner 自身启动失败造成的 测量伪迹,不是真实缺口。这条记录保留在此,避免后来者重复走一遍弯路。


复用前提

本 runner 直接复用 DSH 内部契约(@deepseek-ai/dsh-subprocess-local 的 runner 协议与 resolveWindowsExecutable 语义)。DSH 升级时需要重新核对:

  • runner.js 的 start 请求字段集
  • WINDOWS_SPAWN_ERROR_CODES 映射表
  • resolveWindowsExecutable 的候选扩展名与搜索顺序
  • runnerStdio 的 fd 布局

test/ab-verify.js 会捕获这些偏差 —— 它是回归闸门,不要删。


License

MIT © boyingliu01

可以自由使用、修改、分发(含商业用途),唯一要求是保留版权声明与许可声明。 本软件按「原样」提供,不附带任何明示或暗示的担保。

相关插件