Skip to content

pstack 拆解 16 · 验证机械(上):测试与数字 ​

模型的自我报告里,“测试过了”是最会被误读的一句:它可能只是跑了一遍,和“对了”是两回事。要把它变成可信,验证得落到具体的工具上:一个先红后绿的测试,一组用来审数字的问题。

tdd ​

把破坏变成可执行的。在动生产代码之前,先让那个坏行为能在一条命令里重现:修复于是有了一个能过或不过的判据,而不是一句"应该好了";git 历史里也因此留下一条"先红后绿"的论证,别人不用信你,跑一遍就知道。这条原则也有它的代价意识:不是所有 bug 都值得造测试,当测试路径昂贵或脆弱时,用最接近的可执行检查代替,仍然好过一篇无法验证的说明。

tdd 讲的是当 bug 有一条廉价的本地测试路径时,先写最小的、因为正确原因失败的测试,再修,再重跑。

它有一条很明确的适用边界,而且两边都写清楚了。用它,当用户明确要 TDD、要一个失败测试、要一个回归测试,或者这个 bug 明显有一条廉价的本地测试路径。跳过它,当测试路径不清楚、昂贵、集成很重,或者用户根本没要。

它甚至进一步说明:一个测试如果会需要大范围搭 harness、脆弱的 mock、慢的端到端基础设施、只在生产才有的状态、含糊的复现步骤,或者会导致大量无关的 fixture 改动,那就别为它硬造测试,改用最接近的可执行检查。它有一句判据很干脆:宁可不要新测试,也不要一个坏测试。

那什么叫坏测试,它给了定义:一个主要在测 mock 的测试;一个把当前实现细节写死的测试;一个依赖时序或无关全局状态的测试;一个为一个小修复却需要昂贵基础设施的测试;一个证明完这次修复就会被立刻删掉的测试。

满足条件时,它的流程是六步。

  1. 理解 bug。 弄清预期行为、当前行为、受影响的路径,以及最小的可观察复现。
  2. 选最窄的可执行检查。 优先用这条代码路径已经在用的、最接近的单元、组件、集成或回归测试。如果没有明显可行的测试路径,不要为了走流程而从零造一个。
  3. 先写失败测试。 加最小的、能抓住这个 bug 的那个测试。它要编码预期行为,不是复刻当前实现。
  4. 修之前先跑它。 确认它是因为正确的原因失败。如果它通过了,或者因为一个无关原因失败,先纠正测试或复现,再动实现。
  5. 修 bug。 做最小的、能满足预期行为、同时保住周围契约的实现改动。
  6. 重跑回归测试。 确认它现在通过。

它的护栏也值得记:不要为了迁就一个错误的实现去改测试;不要削弱已有的断言,除非预期行为真的变了而且理由清楚;回归测试聚焦在这个 bug 上,避免大范围 fixture 改动;如果是 flaky 的 bug,尽量让测试确定化,并写下被锁住的信号;如果这个 bug 暴露出更大的一类问题,先把聚焦的回归路径落地,再考虑加兄弟覆盖。

最后一条回复纪律也很有意思:报告证据,不是报告结果。 它要求点名"修之前失败的那个测试或可执行检查"以及它产生的失败,点名"修之后通过的那次运行"以及周边的验证,如果无法演示失败在前,就说明为什么,并描述替代的最接近的回归检查。

benchmark-checklist ​

一个数字是一个关于系统的声明,而声明可以是真的,也可以只是看起来是真的:一次坏掉的运行照样会打印一个合理的数字。所以它把"这个数字到底测了什么"变成七个必须用运行证据回答的问题,而且明确了什么时候"没有可测量的区别"或"不确定"才是唯一诚实的结论。

benchmark-checklist 把 explain-the-number 变成了七个问题,每个问题都要一个来自真实运行的答案,不是从代码里猜的答案。

动手之前先做三件事。把你打算写进结论的那句话写下来,七个问题是用来检验这句话的。读一遍测量脚本,弄清它计的是什么、忽略的是什么。看一眼机器的负载和核数,如果机器正忙,找出在跑什么,停不掉就让两侧交替跑,让它们看见同样的噪声。

然后是那七个问题。

  1. 为什么不是翻倍? 说出那个限制。profiler 会拖慢被测量的东西,所以另做一次不加 profiler 的跑来取限制:用 top、pidstat 看每个进程的 CPU,用 node --cpu-prof、py-spy、perf 看运行时热点,看 I/O 等待,看 syscall 数。然后把热点映射回源码。也要盯着 load generator,如果它先饱和,你测的是它。如果一个改动没有让数字动,限制能解释为什么,所以在断定这个改动没用之前,先找到限制。
  2. 调优了吗? 每一侧都按生产的方式跑:release 构建、生产的 flag 和环境、批处理和事务设置、连接池、和生产一样冷热程度的缓存、相同的版本和数据。如果有一侧还在默认设置上,你比的是配置,不是实现。如果那个限制本身就是一个设置(每行一次提交、一个 debug 构建、少一个索引),那一侧就是没调优,调好再测,测不了就不要从这个运行里选赢家。
  3. 突破极限了吗? 做算术。把每秒字节数对硬盘和网络带宽,把每秒操作数乘单次成本对核数。再看时间账:去掉一个只占 10% 的环节,整个流程最多快约 11%。一个越过极限的结果,说明这次运行测的是别的东西,比如缓存、空操作,或者一个 bug。
  4. 报错了吗? 数失败和异常响应,确认输出是对的,不是"存在"。失败和成功的行为不一样:被拒绝的请求往往很快,超时和重试很慢。如果脚本没数错误,把计数加上。
  5. 复现吗? 每一侧至少跑五次,并且交替跑(A、B、A、B),让预热、懒初始化、缓存和漂移不会偏向某一侧。报告中位数和范围。比噪声还小的差距,算没有可测量的区别。差距很接近时,用秩和检验或 harness 自带的统计。
  6. 重要吗? 在任何微观结果旁边,测一次用户真正要等的端到端路径,用真实的数据量和并发。把微观结果报成整体的一部分。一个只占一次请求 1% 的 helper,最快的优化也只能让请求快 1%。
  7. 真的发生了吗? 确认工作发生在计时区间里。请求到了服务器,行被写进去了,字节被读了,代码用了那个结果。没有被迭代的生成器、没有被 await 的 promise、被 JIT 丢掉的结果、超时,都会为一件根本没发生的工作打印一个数字。

有一个例外。用户只要一个粗略的感觉时,跑一次就够,但仍然要检查第 4 和第 7 条,并且说明这是单次运行。而在两个选项之间做选择,永远不是"粗略的感觉",必须走完整套。

它的结论是四种之一:更快、更慢、没有可测量的区别、不确定。每种都带上 run count、范围、限制。三种情况会给"不确定":你声称有差别却说不清限制,有一侧没调优,或者第 4、7 条没法检查。

它还把报告的形状定死了:先给判决,再给数字、run count、范围和限制。一个 PR 的描述里只放一个主数字,其余运行细节放进一个链接的产物或笔记文件。

谁在用它。poteto-mode 在 perf-issue 和 hillclimb 两个 playbook 里已经自动跑了它:前者在据此规划之前先审基线,以及之后每一个数字;后者在冻结 harness 之前先审它。

参考 ​

原文都在 pstack 上游仓库(cursor/plugins):