Files
Unlucky-Launcher/packaging/docs/gpu-evidence.md
T
AskaEth 2e9702790d fix(ci)+test(packaging): 接线自检不再给假安全证书:19 条变异重放全红,dev 线接上
r1 复审 P1-1:上一版自检对外宣称「不可能被改成总是绿」,实测 19 条变异漏 11 条
(最要命的是把 `available` 写成恒 false —— 门禁永久关闭而每次运行都绿)。根因是三条
判据只是字符串存在性检查、自测步与自检步被整条豁免、`is True` 挡不住 `'true'`。

`packaging/tests/gpu_shot_ci_selftest.py` 重写为 6 条判据:
① 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`,
   `exitcodes` 还得真断言 `expect 0/1/2`);
② 解析 `if:` 表达式后**求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''`
   必须真/假/假,认不出一律红)—— `|| always()` 这类文本层面的门现在真红了;
③ 两种调用形态都必须在场且产物来自 `needs.probe.outputs.*`;
④ 跳过路径**实跑 7 种输入**(新增 `packaging/tests/gpu_shot_ci_probe.py`),
   断言 `$GITHUB_OUTPUT` 的 `available`、summary 文案、`::warning::` 与三条红线的 exit≠0;
⑤ `continue-on-error` 按 YAML 布尔语义判(`'true'` / `${{ true }}` 也算开启);
⑥ 扫所有 `python`+`packaging/` 的 `run`(自测步、自检步都在内),`|| true` / `; true` /
   `|| exit 0` / 裸 `exit 0` 全算吞。
变异向量搬到 `packaging/tests/gpu_shot_ci_vectors.json`:21 条,按解析后的结构注入。

P2-1:`gpu-evidence.yml` 加 `workflow_call:`,`dev-guards.yml` 里 `uses:` 同一份 ——
直推 dev 时 selftest 那一半现在会跑(evidence 在 dev 上照样显式跳过)。
P2-2:跳过摘要与 `gpu-evidence.md §8` 改成唯一可行的补救路径(把产物提交进被 dispatch
的 ref,`git add -f`),并写明「就地拷文件在 CI 上做不到」。
措辞:三处「不可能被改成总是绿」改成能复跑的覆盖面 + 边界。
2026-11-11 18:08:31 +08:00

21 KiB
Raw Blame History

GPU 截图证据账(M249)

判据的唯一实现是 packaging/check_gpu_shot.py(零第三方依赖,19 条自测向量)。 样本是 M246 在本机的实测产物 DevDocs/artifacts/gpu-probe/(DevDocs/ 被 .gitignore:6 忽略, 图不进仓库,所以这里只引它的读数,不引它的字节)。

一句话:这套判据把「这张图是 GPU 档出的」从人读日志变成可复跑的退出码,但它证明的是 「像素由哪条光栅化路径产出」,不是「用户看到的窗口长这样」。第 4/5 节逐条划线。

CI 接线(M253)在第 8 节:--self-test 无条件跑,真实产物判据只在有可用产物时跑; 无 GPU 时显式跳过并写明原因,跳过与通过分开写(跳过 ≠ 通过)。

1. 怎么跑

python packaging/check_gpu_shot.py <stderr 日志> <PNG>              # 正例:六条判据,0/1 退出
python packaging/check_gpu_shot.py <stderr 日志> --expect-refuse [PNG]  # 负例:拒绝降级出图是否成立
python packaging/check_gpu_shot.py --self-test                     # 19 条向量(数据 tests/gpu_shot_vectors.json,脚手架 tests/gpu_shot_selftest.py)

退出码:0 = 判据全过;1 = 有判据没过(逐条点名,不只印 FAILED);2 = 用法错(少参数 / 认不出的参数)。 判据不看日志的请求值([probe] renderer = wgpu 只是意图),只看结果行([shot] … renderer=)。

取日志必须带 ULL_GPU_LOG=info:Selected: AdapterInfo { … } 那一行是 wgpu 的 info 日志, 默认级别下根本不落盘(实测:a1b 那份 24 行 stderr 里 ^\[gpu\] 0 条,见 §5 第 2 条)。

2. 本机实跑退出码(M246 产物,逐条)

用例 命令(节选) 退出码 判据怎么说话
A1 正例 GPU a1-gpu.stderr.log + a1-gpu-light.png 0 6/6 过:renderer=wgpu、device_type=DiscreteGpu(RX 6600 XT / Vulkan)、1180x760、final=wgpu result=started、唯一色 1856 / σ 33.21 / 强边缘 0.05778
A2 正例 GPU(preflight) a2-preflight.stderr.log + a2-preflight-gpu.png 0 6/6 过(与 A1 字节相同的图,另带 [gpu-preflight] ok type=DiscreteGpu)
A4 正例 GPU(auto→shell) a4-auto.stderr.log + a4-auto-shell.png 0 6/6 过:reason=auto-hardware-adapter 也真的落到 wgpu
A3 CPU 档 a3-cpu.stderr.log + a3-cpu-light.png 1 3 条红:renderer=tiny-skia、无 Selected 行、final=tiny-skia(PNG 本身没问题:唯一色 2519 / σ 35.43)
A3b CPU 档重复 a3b-cpu-repeat.stderr.log + a3b-cpu-light.png 1 同上(重复性对照,结论一致)
A5 CPU 档 shell a5-cpu-shell.stderr.log + a5-cpu-shell.png 1 同上
A6 负例,按正例口径判 a6-negative-refuse.stderr.log + a6-negative-refuse.png 1 6 条全红(其中 3 条是「PNG 不存在」——负例本来就没图)
A6 负例,--expect-refuse(传 PNG 路径) a6-negative-refuse.stderr.log --expect-refuse a6-negative-refuse.png 0 3/3 过:via=refuse、无出图行、目标 PNG 不存在
A6 负例,--expect-refuse(不传 PNG) a6-negative-refuse.stderr.log --expect-refuse 0 2/2 过(判据 9 不登记:--expect-refuse 的 PNG 参数是可选的)
A1b 日志 + A1b 图 a1b-gpu-repeat.stderr.log + a1b-gpu-light.png 1 见 §5 第 2 条:这份日志没开 ULL_GPU_LOG=info,没有 Selected 行 ⇒ 单独看它无法自证适配器
串味:A1 日志 + A3 的 CPU 图 a1-gpu.stderr.log + a3-cpu-light.png 0 这是已知盲区,见 §5 第 1 条(两张图同为 1180x760)

A6 的负例形状(WGPU_BACKEND=metal):Available adapters: [] → wgpu 初始化失败(具名) → 拒绝降级出图 → [backend] final=none result=failed … via=refuse,退出码 1,没有 PNG。

3. 六条判据:每条抓得住什么、抓不住什么

判据 1–3 来自 M246 的实测口径,4–6 是为「日志与图对不上号」补的钉子。每条都能单独判红、单独打印。

# 判据 抓得住 抓不住
1 全部 [shot] … renderer= 行都是 wgpu(set(renderers) == {"wgpu"}) 「其实走了 CPU 档」——CPU 档在这行写 renderer=tiny-skia(M97 起这行打的是实际后端,不是请求值,见 crates/app-iced/src/renderer/mod.rs:52) 只证明这些行被打印过;日志被手工拼装、或从别的进程拷来的行,它分不清(判据只读文本);「一次运行里混了两种 renderer」(>> 拼接、via=respawn 降级)一律判红,但同种 renderer 的多次运行拼在一起看不出来(§5 第 7 条)
2 Selected: AdapterInfo { device_type: X } 且 X ∉ {Cpu, Other};取不到 X 一律判红 「wgpu 真建成了设备,而且选中的不是 WARP(Cpu) / Gl 兼容层(Other)」;与 crates/app-iced/src/probe.rs:165-167 的硬件判据同口径 选中的是哪一块卡不判(独显 vs 集显都算硬件);不看 Available adapters 那 12 条,只看 Selected 那条 —— 也就抓不住「枚举里有独显、实际选了集显」之外的更细的驱动行为;多行 Debug / 字段缺失时不知道真值是啥,只按「取不到即判红」处理(宁可假红:iced 换成 pretty-print Debug 时真 GPU 图会被判红,得回来改正则)
3 PNG 能解码且像素数 > 0 0 字节 / 半截写入 / 根本不是 PNG(签名、IHDR、IDAT 逐层查) 只管「能解出来」,不管内容对不对(那是判据 6 的事);不解调色板与隔行 PNG(沿用 check_screenshot.py 的同一条限制)
4 PNG 宽高 = 日志 [shot] 声明的宽高 拿错文件的第一步:日志说 1180x760、图是别的尺寸 同尺寸互换抓不住(见 §5 第 1 条);分辨率和内容是否同一帧也抓不住
5 [backend] final=wgpu result=started 后端结论行与判据 1/2 自相矛盾(例如 final=none … via=refuse) 需要 ULL_ACCEPTANCE=1 才有这行;只看这一行的话,A6 那种「一行都没出图」和「出了图但日志没写」分不清
6 PNG 过 check_screenshot.py 的非空白阈值(唯一色 ≥64 / 亮度σ ≥3.0 / 强边缘 ≥0.001) 「进程起来了、PNG 合法、但一个像素都没画」的空帧 阈值很低(这是它的设计:跨主题/字体不误报),不判视觉正确性,也不判元素齐全

--expect-refuse 的三条(负例口径):via=refuse 存在、没有任何 [shot] 出图行、目标 PNG 不存在。 它证明「该拒绝时真拒绝了」,不证明「GPU 能出图」——两者互为对照,缺一个都能蒙混过关。

3.1 判据对「行为被改坏」敏感(变异自证)

--self-test 的向量在 packaging/tests/gpu_shot_vectors.json,每条的 expect_fail 是期望的失败判据 清单,逐条对。三个变异都能被抓住(本轮 r1 又补了判据 2 的「取不到即红」):

变异 抓它的向量 结果
DISALLOWED_DEVICE_TYPES 里删掉 Cpu(= 允许 WARP 当 GPU) 「renderer=wgpu 但 Selected 是 Cpu」 由红转绿 ⇒ 清单对不上 ⇒ --self-test 退出 1
删掉判据 1(不再要求 renderer=wgpu) 「删掉 [shot] renderer 行」、「日志里混入第二种 renderer」 由红转绿 ⇒ 清单对不上 ⇒ --self-test 退出 1
判据 2 不再把「取不到 device_type」当失败(恢复旧写法) 「Selected 换成多行 Debug」、「Selected: AdapterInfo { 空体」 由红转绿 ⇒ 清单对不上 ⇒ --self-test 退出 1

4. 这份证据能证明什么

逐条都能指到「哪个文件 + 哪条判据过了」:

  1. 字形栅格与文字边缘的抗锯齿是 GPU 路径给的。 a1-gpu-light.png 的 1180×760 像素由 wgpu/Vulkan 在 RX 6600 XT 上光栅化(判据 1+2+5 全过,Selected 是 DiscreteGpu),而同一屏的 CPU 档(a3) 被同一套判据判红。⇒ 「12px 规格文本的覆盖率采样来自 MSAAx4 的那条路」这件事,是图+日志一起证的。
  2. 面板 α 与玻璃只有 GPU 档能出。 CPU 档在代码层就把它们强制掉了:crates/app-iced/src/pages/ appearance/alpha.rs 的 panel_is_forced_opaque(Backend::Cpu) == true ⇒ effective_panel_alpha 恒 1.0、effective_glass 恒 false。所以只要是真的 GPU 档出的图,它拍到的面板 α/玻璃是产品 在 GPU 档下的真实合成结果,而不是「CPU 兜底也能喂出来」的近似。
  3. 两个后端在同一屏上的一致性是可复核的。 --diff 的读数(M246):gallery differing=51055/896800 (5.69%) max_delta=200 best_shift=(0,0)、shell 19088/896800 (2.13%) max_delta=75 best_shift=(0,0); 差异 99.3% 落在亮度边缘上、delta>32 的 9301 像素连成 3002 块(最大 83 px);两个后端各自连跑 两次 PNG 字节相同(52d4c1d6e464da16 ×2 / 10696a043bd9e7b9 ×2)。⇒ 「几何没有平移、差的是 边缘覆盖率」这个结论成立,5.69% 不是抖动。
  4. --renderer=auto 不是「悄悄走 CPU」。 A4 的 reason=auto-hardware-adapter 与 Selected: DiscreteGpu 同时成立,判据 0 退出。
  5. 断开的 RDP 会话也能出 GPU 图(M246 的现场条件:quser 显示 2 断开)。⇒ 「断连 ⇒ GPU 截不了图」 这个说法不成立;成立的是 M245 §4 的「断连打折首帧耗时与整窗 α,不打折像素内容」。
  6. 「该拒绝时真拒绝」有机器判据。 A6 的 via=refuse + 无 PNG + 退出码 1 由 --expect-refuse 判 0。

5. 这份证据不能证明什么(逐条,别把「没证」读成「没问题」)

  1. 图与日志的绑定只到「尺寸」一级。 同一尺寸的两张正经图互换,本脚本判绿(实测:A1 日志 + A3 的 CPU 图 = 退出码 0)。要堵这个口得让产品把 PNG 的 sha256 打进日志(改 Rust,不在本 mission)。 ⇒ 现有用法是「一次运行、当场取日志与图」,不是「从一堆历史产物里随便挑两个配对」。
  2. 日志级别不够时判据 2 无从判定。 Selected: AdapterInfo 是 wgpu 的 info 级日志:a1b / a5 那两轮 没带 ULL_GPU_LOG=info,两份 stderr 里 ^\[gpu\] 各 0 条。a1b 的「合格」来自它与 a1 字节相同 (52d4c1d6e464da16),不是来自它自己的日志 —— 所以本脚本对 a1b 判红是如实(这份日志单独 确实证不了适配器),不是误报。
  3. 首帧 / 帧率不能证。 全部 [perf] 都是墙钟,且冷热差距极大(A1 首次 probe=1281ms、font=749ms; 后续进程 584/587ms、55~57ms)。A1 3661ms vs A3 3146ms 不能读成「GPU 比 CPU 慢」。
  4. 整窗 α 与圆角观感不能证,任何截图都不行。 window::screenshot 读的是渲染目标,而整窗 α (LWA_ALPHA 字节)与圆角(DWMWA_WINDOW_CORNER_PREFERENCE)由 DWM 在合成时施加,不在渲染 目标里 —— 同一份判据在 Windows 上拍到的是「DWM 之前」的像素。观感只能人眼看真显示器。
  5. 「窗口被 DWM 合成过」这件事没证。 图是渲染目标,不是屏幕。DWM 是否真把这帧合成上屏、有没有掉帧、 有没有被别的窗口遮挡,这张图一概不知道。
  6. 换机器不成立。 全部证据来自一台机器(RX 6600 XT / Raphael iGPU、12 个 adapter、RDP 断开会话)。 VirtualGpu 会不会被判成硬件只在代码层确认(probe.rs:165-167 会放行),没在真 VirtualGpu 适配器上验过(本机 12 条里一条都没有)。
  7. 同种 renderer 的多次运行拼在一起,无法分辨图属于哪一次。 判据 1 只挡住「混入异种 renderer」 (wgpu + tiny-skia 拼一起);两次都是 renderer=wgpu 的日志 >> 到同一个文件时所有行都自洽, 判据 4 又只比尺寸 ⇒ 两张同为 1180×760 的 GPU 图互换仍然判绿。要堵它得把 PNG 的 sha256 / 运行 id 打进日志(改 Rust)。⇒ 一次取证 = 一份干净的 stderr,别往同一个文件里续写。
  8. 交互启动那一支没验。 A6 验的是「取证启动(--screenshot)下拒绝降级出图」;交互启动的 via=respawn(换进程降级)要求真窗口,本轮没开。
  9. dark 主题、其它屏、ULL_WINDOW_SIZE 变尺寸、macOS / Linux 全部没跑:只有 gallery + shell 的 light。

6. 历史 --diff 基线:永久不可复核

DevDocs/00-债务台账-p1.md:29 记着一条 differing=8134/896800 (0.91%) max_delta=60 best_shift=(0,0), M246 本机实测是 5.69% / 200(gallery)与 2.13% / 75(shell)。两个数字不可比,原因是记录本身 缺项:那条没有 commit、没有屏幕、对应图(DevDocs/artifacts/m187/)已丢失。

⇒ 这条基线在任何将来都无法复核:没有图就没有分母,没有 commit 就没有树状态,重跑也只会得到新读数。 唯一还能比的是几何判据(best_shift=(0,0)),它一致。别把 0.91% 当「回归阈值」用;本账不承认它是基线。

7. 没验的部分(M249 自己)

  • M249 当时没接进 CI:那一轮 .github/workflows/** 一行未动(接 CI 是策略决定:runner 上有真 GPU, 还是接受「跑不起来就跳过」)。M253 已接上,见 §8;本节其余条目原样保留。
  • 没在任何非本机环境跑过:Windows + 上述单卡 + RDP 断开会话之外,Linux/macOS/其它 GPU 全没跑。
  • 没改 Rust:判据只读现有日志格式。若产品将来改了 [shot] / Selected: 的行格式,这里的正则 会同时失效(--self-test 用的是按现格式手拼的合成日志,抓不到格式漂移——它守判据,不守格式)。
  • 没验 --diff 阈值化:本文第 4 节引的是 M246 的读数,本 mission 没有重跑 --diff。

8. CI 接线(M253):无 GPU 时显式跳过,跳过 ≠ 通过

判据靠 .github/workflows/gpu-evidence.yml 跑(push: main / pull_request / workflow_dispatch, 以及 workflow_call:日常线 dev 经 dev-guards.yml 复用同一份,见下)。 为什么新开一个文件而不是并进 guards.yml:guards.yml 已是 399 行、红线 400(file-size 那道门 就扫 *.yml),挂一个 job 必越线;release-build.yml 正好 400 行、动它得先拆。与 release-guard.yml 当初单独成文的理由逐字相同。

日常线 dev 也跑(M253 r1 P2-1):dev-guards.yml 里多一个 gpu-evidence: uses: ./.github/workflows/gpu-evidence.yml。dev-guards.yml 文件头自己写着 「门禁存在但日常线上永不运行 = 空跑的门禁,长期等于没有」,而这条 workflow 原先只在 main/PR 触发, 直推 dev 时一次都不跑。dev 上 evidence 照样显式跳过(无 GPU、无产物),所以 dev 拿到的是 selftest 那一半:19 条向量 + 三个退出码 + 接线自检。刻意不加「只跑自测」的开关 —— 那会给这道 门禁多开一个「可以合法关掉真判据」的旋钮。

三个 job,各管一件事:

job 什么时候跑 它的结论代表什么
selftest 无条件(不需要 GPU、不需要真产物) 「尺子合格」:19 条向量 + 三个退出码 0/1/2 用真 CLI 走一遍
probe 无条件,只探测、不判定 工作区里有没有可判的产物;没有就把原因写进 job summary 并打 ::warning::
evidence if: needs.probe.outputs.available == 'true' 「这张图真是 GPU 档出的」:check_gpu_shot.py 的退出码(0 过 / 1 判据不过 / 2 用法错)即结论

跳过与通过分开写:没有可用产物时 evidence job 的 if: 不成立 ⇒ 它在 Actions 里是灰色 skipped,不是一个绿色 ✓;同时 probe 打出(本地手模拟的原样输出,见 §8.1):

GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi),且工作区里没有可用的样例产物
(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=push),
跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)

外加一条 ::warning::GPU 证据:跳过(…)—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」。 available 同时作为 job output 交给 evidence,所以「跳过」与「通过」在数据流上也是两条路。

四条路径的口径:

情形 结果 为什么这么定
工作区里有 DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log + gpu-shot.png(约定成对) available=true ⇒ evidence 真判 一次取证 = 一份干净的 stderr + 同次运行的那张图(§5 第 1/7 条);只给一半仍按跳过(半套产物不算可用)
workflow_dispatch 填了 shot_log / shot_png 同上 显式点名 ⇒ 真判
点了名但文件不存在(或正例没给 PNG) 红(::error:: + exit 1) 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip
托管 runner 常态(无 GPU + 无产物) 跳过 + 原因 + 黄条 见上;托管 runner 本来就没有硬件适配器

在 CI 里怎么让它真判(r1 复审 P2-2:原先 §8 说的「把产物放到 DevDocs/」在 CI 里没人做得到 —— checkout 只拿已跟踪文件,而 /DevDocs/ 在 .gitignore:6 里,也没有 upload/download-artifact 环节):

  1. 把一次取证的那一对产物提交进你打算 dispatch 的那个 ref:git add -f DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log DevDocs/artifacts/gpu-probe/gpu-shot.png(-f 是必需的, 否则被 ignore);
  2. 对该 ref 跑 workflow_dispatch,或干脆让它走 push / PR —— probe 会认这对约定路径,available=true;
  3. 不想提交到 DevDocs/ 的话,把两个文件放到任一已跟踪目录,再用 dispatch 的 shot_log / shot_png 指过去(那两个输入只认「checkout 后确实存在的路径」)。

就地往 workspace 里拷文件(不提交、靠上一步的产物)在这个设计里做不到:没有 artifact 传递环节。

接线自检保证什么:packaging/tests/gpu_shot_ci_selftest.py 用 6 条判据 + 21 条变异向量把上面这些 口径钉住 —— 判据 1 按 id: 认「五步必须在场」(vectors / exitcodes / wiring / detect / judge, 删步或加 if: 即红);判据 2 解析 if: 表达式后求值(available 取 'true'/'false'/'' 分别 必须真/假/假,认不出的表达式一律判红);判据 3 要求两种调用形态都在场;判据 4 把 probe 的 run 原文 实跑 7 种输入;判据 5 按 YAML 布尔语义判 continue-on-error('true' / ${{ true }} 都算开启); 判据 6 扫所有 python+packaging/ 的 run(含自测步与自检步自己)。 它保证不了:连判据一起改(它不自守)、if: 的 GitHub 语义(那是本脚本的小求值器模拟的)、以及 真 Actions 里的 job skip / required check 行为。r1 那一版把「不可能被改成总是绿」写进本文件,而实测 19 条变异漏 11 条 —— 所以这里只写能复跑的覆盖面(21 条向量,逐条见 packaging/tests/gpu_shot_ci_vectors.json),不再给绝对化的安全证书。

8.1 这一节没验的部分

  • CI 上从没真跑过:本 workflow 是本地写的,一次 GitHub Actions 运行都没有(未 push)。 本地验的是两件事:① 把三个 step 的 run: 原文抽出来用 bash 跑(跳过 / 约定成对 / 约定半套 / dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 这 7 条已由判据 4 每次自检都重跑); ② evidence 的判据命令本地实跑过 0 与 1。这不是 CI 实测:if: 求值、job skip 在 UI 上的呈现、 needs 的输出传递在真 Actions 里仍未跑过。
  • 没在真 GPU runner 上跑过:evidence 的真判定只在本地用合成向量走过(0 与 1 都到达过), 「真产物 + 真 GPU runner」这个组合没实测。
  • 没验「跳过是否满足 required status check」:本 workflow 没挂成必需检查;GitHub 对 skipped job 与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。
  • 自检脚本自身的边界在它自己的 docstring 里(不防「连判据一起改」;! 不在支持的表达式语法里, 出现即红;只有 probe 那一步是功能断言,vectors/exitcodes 两步只做结构断言)。