揭示基准测试的隐蔽错误:调用过时的生产代码路径导致数据离线 26-31pp。强调同一代码应先跑两次验证一致性。
通过仅复制主逻辑而创建的重复基准测试,一直在测量生产环境已经丢弃的设置。将其重写为直接调用生产函数,恢复了 +26 到 +31pp 的数字。
当我在任何 A/B 测试之前检查"相同代码运行两次是否一致"时,我终于看到了臭名昭著的"±20pp 的噪音"实际上是什么。不是随机噪音:是泄露到 A/B 差值中的非确定性 — 一个混杂因素。
这正是让人感到恐惧的地方,关于 measure-first(以测量优先):你能测量得越快,你就越容易对错误的东西产生信心。
当你以测量优先的方式开发时,在某个时刻你会停止质疑工具本身 — 测试框架、你测量的脚手架。数字会出现。基于这些数字,你采纳一个方案并抛弃另一个。但是 — 如果这些数字从一开始就在说谎呢?
在过去的六个月里,我把测试框架作为开发的默认模式。测量变得快速且廉价,不测量的借口消失了。但是一旦你能快速测量,另一个陷阱就开启了:快速且自信地测量错误的东西。
这篇文章讲述了两次掉进这个陷阱的故事。第一次:一个重复的基准测试在测量生产中不存在的东西。第二次:运行相同的代码两次给了我不匹配的数字。两次,输出的数字看起来都非常合理。
这个测试框架测量的是 Live 翻译(实时翻译视频字幕的功能)的 OCR 区域追踪。它能捕获多少字幕而不遗漏 — 完整性。
这个框架仅从生产追踪逻辑中共享了主要计算,并手工复制了它周围的一切(何时丢弃追踪框、全宽恢复、水平范围、框的生命周期)。"核心是共享的,所以它一定在测量生产所做的相同事物" — 这就是我的想法。
这是一个假设。
在 2026-07-04,生产判断一个名为"水平网格缩小"的优化是负面的,并将其丢弃。但这个重复基准的手工部分仍在测量那个据称已被丢弃的缩小。生产不再做的事情,基准一直在评分。从我复制它的那一刻起,两者就一直在悄悄分开 — 漂移。
我抛弃了这个重复并将其重写,以直接通过生产自己的追踪状态对象,并调用生产的函数。完整性(捕获的字幕份额)像这样恢复(pp = 百分点,整篇如此)。
近三十个百分点。生产实际上交付 82.6% / 81.2%;重复基准将其评分为 56.5% / 50.0%。如果我相信这个重复基准的数字来判断对追踪逻辑的改变是否良好,我就会一直在优化一个在生产中不存在的世界。

图 1:重复基准与调用生产。生产实际上交付 82.6% / 81.2%;重复基准将其评分为 56.5% / 50.0%。它测量的不是"生产的性能",而是"重复的性能"。
教训很简单。一个测试框架应该实例化生产的组件并直接调用它们。不要复制逻辑。你一复制,它就变成了一个不同的东西,测量的是"重复的性能",而不是"生产的性能"。如果生产代码的结构难以调用,不要用重复绕过它 — 重构生产使其能被调用。
难以调用的部分 — 你无法提取的工作,因为它依赖于屏幕 — 应该从生产中提取为纯函数,然后你用类型系统来阻止漂移。整体共享生产的状态对象,并让计算函数要求作为参数"周期顶部设置步骤返回的令牌";那么跳过设置的重复甚至都不会编译。"小心谨慎"不如"让它不编译"可靠。提取过程本身(用黄金特征化测试固定当前行为,然后进行仅移动提取)在附录中。
用它重写为调用生产,基准现在是可信的 — 或者我是这么认为的,我对追踪逻辑运行了一个 A/B(两个选项的比较)。出现了差异。我有解释其后机制的冲动。
但首先有一件事要检查。在相同的数据上运行相同的代码两次 — 你会得到相同的数字吗?
原因是测试框架的时钟。进入决定下一段工作何时可以开始的计算中,我在馈入测量的 OCR 时长。但由于设备的 CPU 调节器和热特性,该时长会波动数十毫秒。这改变了一个视频中有多少个周期,以及下游的一切 — 显示成功率、覆盖率、字幕何时出现 — 都随之移动。这是相同代码在相同数据上运行两次实际测量的内容。
首先,仅运行相同代码两次就使中间量本身也会波动。
这种运行间波动本身仍然只是"测量相同选项两次的内部误差"。问题出现在这种波动混入 A/B 差值时。在不同的记录上比较相同的两个选项(旧选项 vs 新选项),甚至显示成功率的 A/B 差值的符号也会翻转。
相同的两个选项,但在一个记录上"新选项差 6.7pp",在另一个上"新选项好 12.5pp"。这是基准在运行 A/B 并说"有区别,这是机制"时的状态。
然后我恍然大悟。我长期以来以半放弃的态度对待的"这个框架有 ±20pp 的噪音" — 这个范围的来源正是我刚看到的:A/B 差值在各个记录中散布从 −6.7pp 到 +12.5pp(大约 19pp 宽)。而且它不是随机噪音。这是由将测量值馈入时钟所产生的非确定性 — 运行间波动 — 泄露到 A/B 差值中。非确定性本身只是每次运行内的波动。但一旦它混入 A/B 差值,它就决定了差异的符号。只有这样它才能获得混杂因素的名称(当你想比较的效果和你不想要的波动无法分离时)。
修复很矛盾。与其一遍遍地测量噪音并将其与差值进行比较,不如消除噪音源更快更可靠。
我停止了让挂钟时间进入模拟时间轴,而是使用固定值代替(默认值大致是测量的中位数)。测量的延迟仅被记录用于测量目的;它永远不会进入时间轴。我想要实际测量延迟的运行是分开保存的。
这给了我一个确定性模式。相同的输入,相同的周期序列,逐位重现。在确定性模式中重新运行那个 A/B,282 个测量匹配,我一直在称之为"机制"的差异消失了。一个我通过读代码而说服自己的假说,被确定性 A/B 毫不费力地反驳了。
这里有个反直观的结论。"一次确定性运行"是比"三次运行加统计"更强的声明。如果两次运行一致,第三次也会一致。与其用统计平均掉噪音,不如在噪音根本不存在的脚手架上构建 — 那样才能让你说一个差异就是一个差异。
验证确定性的技巧不是聚合值匹配,而是周期序列本身匹配。聚合值可能意外对齐,而通过它们的路径不同。反过来 — 这是美妙的部分 — 测量的延迟是允许改变的。它改变了,而结果没有移动:那正是混杂因素已被分离的证据。
一个基准应该直接调用生产。一个重复总是会漂移。一个仅共享主逻辑的重复一直在测量生产很久以前就丢弃的设置。如果很难调用,重构生产并用类型系统阻止漂移(跳过设置,它就不应该编译)。仅仅将其重写为调用生产就恢复了 +26 到 +31pp 的完整性。
一个基准应该直接调用生产。一个重复总是会漂移。一个仅共享主逻辑的重复一直在测量生产很久以前就丢弃的设置。如果很难调用,重构生产并用类型系统阻止漂移(跳过设置,它就不应该编译)。仅仅将其重写为调用生产就恢复了 +26 到 +31pp 的完整性。
在任何 A/B 之前,运行相同的代码两次,检查它是否一致。如果不一致,你用那个框架测量过的每个差值都可疑。臭名昭著的"±20pp 的噪音"通常是非确定性(运行间波动)混杂到 A/B 差值中,然后被误命名为随机噪音。非确定性和混杂因素是不同的东西 — 波动只有在泄露到 A/B 差异时才成为混杂因素。
在任何 A/B 之前,运行相同的代码两次,检查它是否一致。如果不一致,你用那个框架测量过的每个差值都可疑。臭名昭著的"±20pp 的噪音"通常是非确定性(运行间波动)混杂到 A/B 差值中,然后被误命名为随机噪音。非确定性和混杂因素是不同的东西 — 波动只有在泄露到 A/B 差异时才成为混杂因素。
杀死源头比测量噪音更快。一旦你修复了工具,你过去的每个接受和拒绝都回到了重新审计堆。不要用统计稀释它;移除非确定性本身("一次确定性运行"胜过"三次的统计")。但一旦你修复它,你用那个框架拒绝的每个选项和接受的每个选项都回到了重新评估中 — 当你决定修复事物的顺序时,也估计那个返工。
杀死源头比测量噪音更快。一旦你修复了工具,你过去的每个接受和拒绝都回到了重新审计堆。不要用统计稀释它;移除非确定性本身("一次确定性运行"胜过"三次的统计")。但一旦你修复它,你用那个框架拒绝的每个选项和接受的每个选项都回到了重新评估中 — 当你决定修复事物的顺序时,也估计那个返工。
这就是让人感到恐惧的地方,关于 measure-first:你能测量得越快,你就越容易对错误的东西产生信心。所以在你测量主题之前,对工具怀疑一次。相同的输入返回相同的数字 — 这个显而易见的小检查,在一个去年的正确答案变成今年的错误答案的世界中,是你能有的相信这些数字的唯一基础。
最初发表于 LYR Performance Note #024 — "测量和调整 — 正确测量,然后调整设置"系列的一部分。完整集合见 lyr.jp/en/research。