dsh-aotjit-compare:把 AOT 与 JIT 的对拍做成一次调用

前言

MLIR/LLVM 的 AOT 后端(toyc --emit=exe 之类)和 JIT 路径(toyc --emit=run)本该给出同样的运行结果。后端改动之后,差异往往只出现在某一类输入上,而且可能是 stdout 的第 3 行、退出码,或者某个写入文件的内容。手工对拍要两边各起一个进程、抄输出、对日志,慢且容易漏。

dsh-aotjit-compare 就是为此做的 DSH 插件:同一个用例分别交给 AOT 可执行文件和 JIT 启动命令跑一遍,逐字段比较 stdout / exit code / stderr / 文件副作用,直接给出「哪一行、哪一侧、期望什么、实际什么」。下面介绍它的能力、安装方式和用法。

这是什么

插件 id aotjit-compare,工具名 aotjit_compare,版本 0.1.0,维护者 Rcyc-1025,分类属于工作流,许可证 MIT。

它做一件事:跑两侧、比对、定位、留日志。对拍发现差异是正常返回,不是错误抛出——工具把差异结构化给你,让你自己决定下一步改哪侧。

核心功能

1、同一组用例分别跑 AOT 可执行文件与 JIT 启动命令,支持逐用例覆盖参数、stdin、env、cwd。
2、逐字段比较,默认比 stdout 和 exit,stderr 默认只采集不比较,文件副作用默认关闭。
3、差异定位到字段、行号、期望值、实际值;diffs[].expected 永远是 AOT 侧,actual 是 JIT 侧。
4、归一化策略可配:CRLF、ANSI 颜色、末尾换行、路径前缀替换。
5、文件副作用支持 hash / content / tree 三种模式,并提供隔离目录,让两侧副作用不互相污染。
6、每次调用写日志,含两侧 argv、退出码、耗时、截断与编码情况、每处差异;日志失败只进 warnings[],不影响结论。
7、非法配置不阻止加载,退回默认值并记入 warnings[];调用参数优先于配置,环境变量可做进程级覆盖。

安装与启用

插件目录需要被 desktop profile 引用,README 给了两种方式。第一种用 DSH CLI 把本地目录装进 profile:

D:\exe\DSH\resources\runtime\cli\bin\dsh.cmd plugin --profile desktop add D:\workplace\aotjit-compare

第二种手工在 <profile>/package.json 里登记,profile 目录通常为 %DSH_HOME%\data\profiles\desktop:

{
  "dependencies": { "dsh-aotjit-compare": "file:D:/workplace/aotjit-compare" },
  "dsh": { "profile": { "bundles": ["...", "dsh-aotjit-compare"] } }
}

cordis.patch.yml 会以 insert 方式注册本插件,name: 'dsh-aotjit-compare'。包声明 dsh: { bundle: { patch: "./cordis.patch.yml" } },Node 环境要求 ^22.19.0 || >=24.0.0,DSH 版本要求 >=0.2.1-rc.5 <0.3.0-0。

典型用法

最小调用只需要 jitSpec 与 cases。下面这个例子让 AOT 可执行文件和 JIT 命令跑同一个用例:

aotjit_compare({
  aotExe: "build/s01/add.exe",
  jitSpec: { cmd: "build/toyc.exe", args: ["examples/add.mul.mlir", "--emit=run"] },
  cases: [{ name: "add.mul" }]
})

两侧输出都是 7\n、退出码都是 9 时,返回 allMatch: true。

argv 的拼装约定要记清楚,这是最容易配错的地方:

AOT: [aotExe, ...aotArgs, ...case.args]
JIT: [cmd,    ...(case.jitArgs ?? jitSpec.args), ...case.args]

case.jitArgs 是覆盖 jitSpec.args,不是追加。这样 10 个用例可以共用同一个 JIT 入口、只换输入文件。case.args 追加到两侧,用于同一份 IR 的运行时开关(如 --seed=1)。argv 直传,不经过 shell,所以不需要引号转义,也不能写管道或重定向。jitSpec.cmd 若指向 .cmd / .bat 会被拒绝,返回 JIT_CMD_SHELL_WRAPPER。

输出与判定

返回结构里最容易混淆的是 ok 与 allMatch:ok 表示执行完成,不代表一致;allMatch 才是全部用例一致。差异被发现时 ok 仍为 true。

{
  "ok": true,
  "allMatch": false,
  "firstMismatchCase": "case-07",
  "firstMismatchField": "stdout",
  "total": 10, "matched": 9, "mismatched": 1,
  "rows": [
    {
      "index": 6, "case": "case-07", "match": false, "firstMismatch": "stdout",
      "diffs": [
        { "field": "stdout", "kind": "text", "aotLine": 1, "jitLine": 1,
          "expected": "7", "actual": "8", "expectedLength": 2, "actualLength": 2 }
      ],
      "aot": { "cmd": ["D:/p/build/parity/case-07.exe"], "cwd": "D:/p", "exitCode": 0,
               "exitCodeHex": "0x0", "signal": null, "timedOut": false, "durationMs": 12,
               "stdout": "7\n", "stderr": "", "stdoutTruncated": false, "stderrTruncated": false,
               "spawnError": null },
      "jit": { "...": "同上" }
    }
  ],
  "logPath": "D:/p/.aotjit-logs/20261106-103000-a1b2-case07.log",
  "durationMs": 183, "warnings": [], "hint": null
}

字段优先级是 spawnError > timedOut > exit > stdout > stderr > sideEffects。几条边界行为值得注意:任一侧输出被截断时不比较截断前缀,直接 firstMismatch: "truncated",避免拿残缺输出得出假结论;输出不是合法 UTF-8 时改比字节 SHA-256;Windows 负退出码保留原始数值,另给 exitCodeHex(如 0xc0000005)。

配置

配置写在 cordis.patch.yml 或宿主 config 里,README 列出的键与默认值如下:

键 默认 说明
timeoutMs 120000 单个进程超时;超时后先 SIGTERM,graceMs 后 SIGKILL
maxOutputBytes 1048576 每条 stdout / stderr 流保留的字节上限
maxCases 100 单次调用允许的用例数上限
maxSideEffectBytes 16777216 副作用快照允许的总字节数
logDir .aotjit-logs 日志目录(相对会话 cwd)
compare {stdout:true, exit:true, stderr:"off", sideEffects:false} 默认比较项
normalize {crlf:true, ansi:false, trailingNewline:"exact", replacePaths:[]} 默认归一化
saveArtifacts false 是否把每侧完整 stdout/stderr 另存到 artifacts/

归一化规则:crlf 把 CRLF 与孤立 CR 统一成 LF;ansi 置 true 才剥颜色序列;trailingNewline 为 "exact" 保留末尾换行、为 "ignore" 去掉末尾所有换行;replacePaths 把指定前缀替换成 <path>,stderr 里构建目录差异靠它抹平。compare.stderr 除了 "off" 还有 "normalized"(按 normalize 比)与 "strict"(只统一 CRLF)。

环境变量 AOTJIT_TIMEOUT_MS / AOTJIT_MAX_OUTPUT_BYTES 可做进程级覆盖,调用参数优先级最高。

文件副作用

开启 sideEffects 后,插件会对指定路径做快照与比对:

compare: {
  sideEffects: { paths: ["out/result.txt"], mode: "content", isolate: true, seed: ["input.bin"] }
}

paths 必填,相对运行目录,禁止绝对路径与 .. 逃逸,否则返回 SIDE_EFFECT_ESCAPE。mode 三种:hash 比 SHA-256、content 比内容并给首个不同行、tree 递归目录树。

isolate: true 时 AOT 与 JIT 各跑在 <workDir>/.aotjit/<runId>/<caseIndex>-aot|jit,seed 里的文件或目录从 workDir 复制进两侧,确保 AOT 写的文件 JIT 看不到。一次调用结束后(成功、失败或中途 break)整棵 <workDir>/.aotjit/<runId>/ 会被清掉,清理失败只进 warnings[]。seed 的字节数按展开后统计,AOT/JIT 两侧合计受 maxSideEffectBytes 约束:超出返回 SIDE_EFFECT_TOO_LARGE,复制失败返回 SIDE_EFFECT_COPY_FAILED。

错误码

插件给每类失败定义了 code,并带中文 hint 说明下一步该改什么:

code 含义
AOT_EXE_NOT_FOUND aotExe 缺失或指向的文件不存在
JIT_CMD_NOT_FOUND jitSpec.cmd 既不是存在的路径,也不在宿主 PATH 中
JIT_CMD_SHELL_WRAPPER cmd 是 .cmd/.bat;本插件不走 shell
NO_CASES / TOO_MANY_CASES cases 为空 / 超过 maxCases
INVALID_CASE 字段类型错误(name/args/stdin/jitArgs/env/cwd/aotExe)
WORKDIR_NOT_FOUND workDir 或 jitSpec.cwd 不存在
SPAWN_FAILED 所有行都启动失败(如 EPERM)
TIMEOUT 超时(同时 timedOut: true,ok 仍为 true)
OUTPUT_TRUNCATED 输出被截断(对应 firstMismatch: "truncated")
SIDE_EFFECT_ESCAPE / SIDE_EFFECT_TOO_LARGE / SIDE_EFFECT_COPY_FAILED 副作用路径越界 / 超限 / seed 复制失败

验证

仓库自带四类校验命令:

npm test                 # 85 个单测(node --test,无真实进程)
npm run smoke            # 假 subprocess 走通「加载 → 调用 → 日志 → 渲染」
npm run integration      # 8 条真机用例(真实 exe + cmd.exe,需要能 spawn 的宿主环境)
npm run integration:parity # 10 用例夹具 + 注入错误(需要 toyc 与 clang++)

npm run integration 用 node:child_process 冒充宿主 subprocess,覆盖单用例全一致、stdout 差异定位、超时、截断、文件副作用与隔离、JIT 命令缺失、.cmd 包装器拒绝。DSH 的 workspace-write 沙箱里 Node 自己 spawn 会 EPERM,所以这一步要在宿主侧运行;插件本身在宿主里走 ctx.subprocess,不受影响。

npm run integration:parity 用仓库自带的 test/fixtures/parity/case-01..10.mlir,现场用 toyc --emit=exe 生成 AOT 可执行文件、用 --emit=run 跑 JIT 侧,先断言 10/10 全一致;随后只改 case-05 的打印值(退出码不变)当作注入错误,断言 firstMismatchCase:"case-05"、firstMismatchField:"stdout"、100 vs 101,其余 9 行仍 match:true。工具链默认读 mlir-lang 的 build/toyc.exe 与 LLVM 23.1.4 的 clang++.exe,可用 AOTJIT_TOYC / AOTJIT_CLANG 覆盖;缺工具时打印 SKIP 并以 0 退出。

这里有个容易踩的坑:注入必须只改 stdout。字段优先级是 exit 早于 stdout,若同时改掉退出码,正确结果就是 firstMismatchField:"exit"。

适用场景与注意

适合场景:AOT 后端与 JIT 路径需要长期保持行为一致,后端改动后需要对同一组用例做回归对拍的场景。它的价值在于省掉手工起双进程、抄输出、逐行比对的工作,并把差异结构化输出,便于直接定位。

几点注意:

1、插件以当前 dsh 进程的权限运行,会 spawn 指定的可执行文件并读取指定路径的输出,安装前应检查源码与许可证。
2、本插件不走 shell,argv 直传。需要管道或重定向时,应自己写包装脚本,但 .cmd / .bat 会被拒绝。
3、workDir 必须存在,相对路径与默认 cwd 都按它解析。
4、日志与 artifacts/ 写在会话 cwd 下,注意不要把它们纳入版本控制或当成构建输入。
5、副作用隔离目录在调用结束后统一清理,不要在用例里依赖它持久存在。

结尾

这个插件定位很窄:不做模糊匹配,不做多场景编排,只把「跑两侧 + 比对 + 定位 + 留日志」做成一次调用,字段优先级和边界行为都写在 README 里。如果你的工作涉及 MLIR/LLVM 的 AOT 与 JIT 一致性回归,可以先从 npm run integration:parity 那条链路验证工具链,再用真实用例跑一次。

目录页:https://www.skillhub.cn/plugins/Rcyc-1025/dsh-aotjit-compare
GitHub:https://github.com/Rcyc-1025/dsh-aotjit-compare

羽毛球分组比赛记分
小程序二维码

欢迎使用《羽毛球分组比赛记分》微信小程序

小夜