从计划到产物:九个「看起来没问题」的验证缺口

这个月做了一堆看起来毫不相干的事:给一个模拟器写 AVX-512 优化、给桌面应用补 FreeBSD 安装包、把一个 Node 项目从空库跑起来、修一个 Electron 分支的冷启动白屏、给两本开源书提 PR。

它们的共同点是:每一个都在”看起来已经完成”的地方翻车。

  • CI 全绿,产物装不上;
  • 测试通过,是因为根本没跑到那段代码;
  • 类型检查全绿,打包才炸;
  • 计划里写着”优化这里能涨 20%”,那行代码默认配置下根本不执行。

这篇按”验证”这条线把它们串起来。

一、先审计划:一份优化文档里的四处事实错误

给 86Box(一个 PC 模拟器)的 Voodoo 显卡写 AVX-512 纹理解码之前,我先逐条核对了那份优化计划里的行号、函数名和编译基线。结果四处事实错误,其中两处会直接把方向带偏。

错误一:要优化的代码路径,默认配置根本不执行。

计划里 P3 的目标是把 C 解释器的 span 内层循环向量化,验收标准写”帧率提升 ≥20%”。

但 Voodoo 有两条互斥的渲染路径:默认 recompiler=1 时,vid_voodoo_render.c:1094-1099 直接调用 JIT 生成的 voodoo_draw(),整个 C span 循环被跳过。也就是说,这个改动只在手动关掉 recompiler 时才有意义——那条 20% 的验收标准,测的是默认没人用的配置。

错误二:搞错了要优化的函数。

计划称 voodoo_recalc_tex12() 与 voodoo_recalc_tex3() 做 16 位→RGBA32 解码,并断言”纯查表+移位+或、无分支无跨像素依赖、天然 16-wide”。

实际读代码:这两个函数只算每级 mip 的元数据(tex_base、tex_w_mask、tex_shift、tex_lod、tex_width),循环次数不超过 10、分支密集、每次纹理参数变化才调用一次。它们没有任何 texel 解码。 真正的解码循环在别处。

错误三:codegen 早就有 SIMD 了。

计划说 codegen “运行时发射纯标量 x86-64 指令流……无 SSE/AVX 编码”。

实际 vid_voodoo_codegen_x86-64.h 里有 301 处 XMM 引用,配 __m128i 查表,真实发射 PMULLW、PADDW、PSRLW、PACKUSWB、PUNPCKLBW、PINSRW 这些 128 位打包指令(用于双线性过滤,没有 AVX)。

这个错误的后果是低估了难度:不是”从零新建向量后端”,而是”在现有 SSE2 发射器上加 AVX-512 路径 + CPUID 分派”。

错误四:把默认关闭的可选滤镜当成热点。

voodoo_filterline_v1/v2 只在 scrfilter && scrfilterEnabled 时调用,是默认关闭的功能;内部还是 3 通道交错查表,并带行内前向依赖:

1
fil[x*3] = thefilterb[fil[x*3]][fil[(x-1)*3]];

这不是”无依赖的规则变换”,SIMD 化要专门处理前缀依赖。

这四条都不是笔误,是”没读代码就写计划”。 审计划的成本大约半天,而照着错的计划写一周代码再发现方向不对,成本是这个的十倍。

二、微基准会被编译器骗

计划修完之后开始写 P1(纹理解码)。先做了个微基准,结果 A8/I8 两种格式的数字明显不对。

查下来是 GCC 把重复写入同一个全局数组做了死存储消除——循环里的计算根本没执行。加 -fno-tree-dse 仍然异常,因为循环被整体提到外层、结果恒定。最后改成每次迭代移动源指针,才有可信数字。

修完基准,真数字是:纯位运算格式 2–6 倍,调色板类因为要 vpgatherdd 只有 1.3–1.5 倍(印证了”gather 受内存带宽封顶”的判断)。

另一个收获来自单元测试:RGB332 的蓝通道在原代码里是两步顺序自或,我为了写得漂亮压缩成一次表达式,结果差 1。测了 14 种格式 × 5 种宽度做字节级对比(8 位格式覆盖全部 256 个输入值、16 位覆盖全部 65536 个),才把这处偏差抓住。

结论:优化开始前先确认你的基准本身没有被优化掉。

三、否决一个优化,需要比实现它更硬的依据

计划的最后一项是 P4:把 JIT 生成的代码也向量化。用户也希望做。

侦察完之后我给了”不交付代码”的结论,三个硬阻塞:

1. JIT 是逐像素发射的。 voodoo_generate() 从第 826 行到 3533 行是一整条逐像素流水线,末尾 JNZ 回跳构成 x 循环,中间状态全活在寄存器和栈槽里。要吃 AVX-512,必须让 16 个像素同时在飞——等于把那约 2700 行用向量指令重写,并按状态组合特化(ARM64 后端还有 4807 行要同步改)。现有的 SSE2 只是”像素内 SIMD”(单像素内 4 个纹素),扩不成”跨像素 SIMD”。

2. 最热路径的除法无法精确向量化。 透视校正每个像素每个 TMU 要算 _w = (1ULL << 48) / W,而 tmu0_w 是 int64_t 且除法无符号。按位复现需要无符号 64 位除法和 64×64 精确乘法,AVX-512 两样都没有。任何近似都会破坏 accuracy-first 的前提。

3. 实测余量只有 1.85%,而且可归因于编译选项。

这一条最值得说。端到端跑 3DMark 99 Max:AVX-512 版 1654 分,原版 1624。看起来是 +1.85%,但对比是混的——原版用普通 Release,我这一版用 Optimized preset(-march=native -O3 -flto)。用户关掉多余进程重跑优化版得 1630,差异直接落回噪声。而且 CPU 3DMark(纯 CPU 测试,与纹理解码无关)也涨了 1.11%,说明增益主要来自编译选项对 dynarec 的通用提速。

更根本的是这个基准没有判别力:3DMark 99 Max 跑在模拟的 P2-166 上,场景被 guest CPU 拖住,而不是 Voodoo 光栅化(CPU 子项与总分几乎同步变化就是证据)。

否决一个优化,需要证明”做了也没用”,而不是”我懒得做”。 上面三条中任何一条成立,都足够。

四、只有”装完能跑”才算验证:FreeBSD 二进制包

给一本 FreeBSD 开源书写”如何制作 pkg 包”这一节时,我把每条命令都在真机上跑了一遍。pkg 的本地数据库已经是 SQLite(读元数据要用 pkg query)这点在《跨平台打包与 CI 发布踩坑集》里写过,这里只记”制作”这一侧新踩到的三个事实。

1. find -type f 会漏掉符号链接。

打包时 plist 靠 find stage 生成,而 -type f 只匹配普通文件。含符号链接的目录会少列一条,链接就不会进包:

1
find stage \( -type f -o -type l \) | sed 's|^stage||' > meta/files.plist

实测:改前少一条,改后链接被正常打包、安装后仍是符号链接、卸载无残留。

2. pkg install 接受本地归档路径。

不需要改用 pkg add。pkg install ./hello-1.0.pkg 实测装成功、程序能跑、pkg delete 之后无残留。

3. BSD tar 不剥前导 /。

pkg 归档成员路径本身就是绝对路径,FreeBSD 上 tar --zstd -tf 显示的是 /usr/local/bin/hello。GNU tar 才会剥掉开头的 /——写文档或工具时别弄混。

另外几个参数级的坑:安装/卸载脚本有四个固定文件名(+PRE_INSTALL、+POST_INSTALL、+PRE_DEINSTALL、+POST_DEINSTALL,都需要可执行权限),桌面条目要装到 /usr/local/share/applications/、图标要按标准尺寸装到 hicolor/<尺寸>x<尺寸>/apps/,否则桌面环境不认。

五、overlay 合并:只补一条 CI 才能暴露的回归

overlay 三方合并的语义(merge: true 只增不恢复、未被覆盖的文件会 import 被覆盖文件的新增导出、git merge-file -p 不写文件)已经在《跨平台打包与 CI 发布踩坑集》里写过,这里不重复。补一条只有 CI 才能暴露的:

合并时重建根 package.json,脚本值漏了 package: 前缀——写成 run linux:x64,正确是 run package:linux:x64。本地 typecheck 不会有任何意见,打包 job 直接失败。这类”少一个前缀”的错误没有静态检查兜得住,只能靠”合并后必须完整跑一次打包链路”。

六、空库起不来:迁移脚本不是建库脚本

一个 Node 项目要解决”空库能不能跑起来”。空库能自动创建,但 pnpm db:migrate 在第 1 个迁移就失败:

1
Error: no such table: materials

根因很朴素:migrations/ 是对已有库的增量补丁(加列、补边角表),不是全量建库脚本。运行时那套 ensure 机制把所有函数跑一遍,只能建出 86 / 127 张表——缺 employees、products、distributors 等 41 张,于是大量接口 500。

修法是补一个独立的 db:init:全量 DDL(126 张表 + 索引/视图/触发器,共 212 个对象)+ seed 数据 + 迁移登记。实测从零建库成功、全部接口 200、14/14 款商品可下单。

顺带两个:历史迁移已经手工应用过,重跑会报错,所以加了个 db:baseline(只登记、不执行 SQL);以及 better-sqlite3 的游标迭代中不能写同一连接,批量脱敏要改成一次性 all() 读完再回写。

同一轮依赖升级里还有三个坑:

  • pnpm 12 默认拦截依赖的构建脚本(ERR_PNPM_IGNORED_BUILDS),要在 pnpm-workspace.yaml 里写 allowBuilds 白名单。我第一次漏了 unrs-resolver,ESLint 的原生解析器又被拦。
  • Node 版本要和原生模块 ABI 对齐:生产跑 Node 22,我却在系统 Node 24 下装依赖,better-sqlite3 直接不可用。
  • HOSTNAME 会被 shell 自动设置。process.env.HOSTNAME || '0.0.0.0' 在普通 shell 里拿到的是主机名,服务只绑 LAN IP,而且主机名解析到 IPv6 loopback,导致 next/font/google 加载失败、页面 500。显式 HOSTNAME=0.0.0.0 才对。

七、类型检查全绿,不等于能构建

一个 Electron 分支合并上游之后,冷启动只出托盘、不建主窗口(Windows 与 Linux 都复现)。日志里 [startup] 创建主窗口 出现 0 次,每次启动精确对应一次:

1
unhandledRejection: {}

根因:解决合并冲突时,ZCODE_TELEMETRY_ENABLED 和 ZCODE_ARMS_RUM_ENDPOINT 从 packages/shared/src/index.ts 的导出表里漏掉了,而桌面主进程的启动代码直接 import 它 → ReferenceError → 启动流程中断。

这条 bug 的三个排查障碍都值得记:

1. JSON.stringify(new Error()) 等于 {}。 日志格式化用了 JSON.stringify,而 Error 的 message/stack 是不可枚举属性,于是所有信息都没了。日志里只剩一个空对象。

2. pnpm typecheck 不覆盖 tsconfig.main.json。 只有 tsup 从源码打包时才炸——所以”类型检查全绿”给了完全错误的安全感。

3. 单实例锁在模块加载期就获取。 第二次启动直接退出,无法用双实例复现。最后靠可重定向 userData 的环境变量起了个隔离实例,才拿到真堆栈:

1
2
ReferenceError: ZCODE_TELEMETRY_ENABLED is not defined
at .../out/main/index.js:1398:17232

验证方式也值得一提:修复后解包 app.asar,确认 out/main/index.js 里这两个标识符作为自由变量出现 0 次(故障版本的安装包里是原样保留的)。光看 CI 绿不够。

八、产物核对与升级路径

几条短但实用的:

下载会被截断。 发布一个 7.4MB 的包,curl 只拿到 6.1MB,SHA256 对不上、tar 报损坏。改用 gh release download 后哈希完全吻合——先排除”下载不完整”,再怀疑包本身。

left 是 MySQL 保留字。 写甘特图的查询时直接用了 left,SQL 语法错误;必须反引号包起来。

模块内不能定义 delete($id)。 与父类 model::delete($table, $id) 签名不兼容,直接 Fatal。软删除应该调用父类方法并传表名。

PkgBase 跨版本升级有几个硬前提:先 bectl create 建启动环境(pkgbase 不会自动建)、不要在 pkgbase 机器上跑 freebsd-update、升级前把引导器复制步骤放在重启之前(否则可能用旧引导器引导新系统,且启不来后再也没机会执行命令),以及 Newer FreeBSD version for package 默认选 N 会中止升级。

九、给文档提 PR 时,审查意见也要实测

给两本开源书提 PR 时都碰上了自动审查 bot。做法是逐条核对,成立就改、不成立就给实测反证:

  • 有一次 6 条意见,4 条成立(包括”find -type f 会漏符号链接”这种我确实写错的),2 条不成立——它说”pkg install 不能装本地归档”、”tar 不应显示绝对路径”,我把实测输出贴上去,两条都站不住。
  • 另一次 7 条,5 条成立,其中”ABI 硬编码架构”和”引导器复制顺序”是真的会让人装坏系统的错误;2 条不成立,同样附了 pkg-static -vv 的输出反证。

还有一个纯流程的坑:文档仓库里由 workflow 生成的文件不能只改源头。mu-lu.md 是从 SUMMARY.md 生成的,一级标题也是从 SUMMARY 同步的——改了正文标题却不动 SUMMARY,下一次 CI 会被覆盖回去。

总结

九件事,一条线:“看起来完成了”和”真的完成了”之间,隔着一层必须亲自验的东西。

  • 审计划的成本远低于照着错计划写一周代码;
  • 基准先确认没被编译器优化掉,对比先确认没混入其他变量;
  • 测试通过可能只是因为没跑到那段代码(enabled: false 时函数在顶部就 return);
  • 类型检查、CI 全绿、生成产物,这三件事没有一件能证明”装上去能用”;
  • 否定一个方案和肯定一个方案一样,都需要证据——而且要更硬。

最后一句:错误日志本身也会骗你。 那 4186 条记录、那个 unhandledRejection: {},看起来都在指向别处。