AI建议用sorted vector替换unordered_map以提升性能,但benchmark显示实际慢4.1%,门控机制成功拦截了回归。
一个免费模型提议用有序 vector 替换 std::unordered_map。基准测试门禁拒绝了这个 patch。候选方案慢了 4.1%,而非更快。
这就是一句话结论。接下来的案例研究将解释:为何要有这个门禁、门禁如何运作,以及这次拒绝让我对接受 AI 生成的 C++ 改动有了哪些认识。
我维护着一个小型 C++17 工具,负责将日志行解析为键值桶。性能分析显示一个热点:std::unordered_map<std::string, int> 的查找大约占用了总运行时的 18%。
这个 map 持有短键,大多数不到八个字符。查找在一个紧循环中对 20 万条记录执行。
我将 profile 输出粘贴到 MonkeyCode 免费模型接入的一个免费模型端点。给出的建议乍看合理:用排序后的 std::vector<std::pair<std::string, int>> 替换哈希 map,并使用 std::lower_bound。更好的缓存局部性、更少的内存分配、更简洁的代码。
披露:本文作为 MonkeyCode 产品推广内容的一部分撰写。
看似合理的推理不是证据。我之前曾被模型的 patch 坑过——看起来正确,运行时却发生了退化。所以我建了一个门禁。
验收标准在任何代码改动之前就已确定:
中位查找延迟必须改善至少 5%。
改善必须具有统计显著性(p < 0.05,Welch's t 检验)。
结果必须在 15 次交叉 A/B 运行中保持一致。
没有主观评审。没有"感觉更快"。一个数字,或者拒绝。
门禁由三部分组成:基准测试工具、统计比较器和 CI 步骤。
一个小型 bench.cpp,对每个变体编译一次,使用相同的编译标志:
// bench.cpp — compile once per variant, same flags
#include <chrono>
#include <iostream>
#include <string>
#include <vector>
extern int lookup(const std::string& key); // variant-specific
int main(int argc, char** argv) {
const int rounds = argc > 1 ? std::stoi(argv[1]) : 20;
std::vector<std::string> keys = load_keys("keys.txt"); // 200k entries
auto start = std::chrono::steady_clock::now();
volatile int sink = 0;
for (int r = 0; r < rounds; ++r)
for (const auto& k : keys)
sink += lookup(k);
auto end = std::chrono::steady_clock::now();
double total_ns = std::chrono::duration<double, std::nano>(end - start).count();
std::cout << total_ns / (rounds * keys.size()) << '\n';
return sink == 0 ? 0 : 1;
}
load_keys 为简洁起见省略;它为两个变体从文件读取相同的 20 万个键。
关键设计决策是交叉执行。基线和候选版本交替运行,固定在同一核心上,这样热漂移和 CI 噪声对双方的影响是均等的:
for i in $(seq 1 15); do
taskset -c 2 ./baseline_bench 20 >> baseline.txt
taskset -c 2 ./candidate_bench 20 >> candidate.txt
done
python3 bench_gate.py baseline.txt candidate.txt --min-effect 0.05
比较器是一个小脚本。简化版如下:
# bench_gate.py (simplified) — Welch's t-test + effect-size check
import math, sys
def stats(path):
xs = [float(x) for x in open(path)]
n = len(xs)
m = sum(xs) / n
var = sum((x - m) ** 2 for x in xs) / (n - 1)
return m, var, n
b_m, b_v, b_n = stats(sys.argv[1])
c_m, c_v, c_n = stats(sys.argv[2])
se = math.sqrt(b_v / b_n + c_v / c_n)
t = (c_m - b_m) / se
delta = (c_m - b_m) / b_m
print(f"baseline={b_m:.2f} candidate={c_m:.2f} delta={delta:+.2%} t={t:.2f}")
真实版本从 t 统计量计算 p 值,并在候选版本未达到效果阈值时以非零退出。打印的判决结果是 CI 任务所需的唯一信号。
门禁作为独立 job 运行,不在构建 job 内部。它产生一行输出:
baseline: 41.2 ns/lookup (sd 0.8)
candidate: 42.9 ns/lookup (sd 0.9)
delta: +4.1% p=0.003 verdict: REJECT
排序后的 vector 更慢了。不是噪声——慢了 4.1%,p = 0.003。
为什么?键很短,所以哈希计算很廉价。vector 的二分查找在每次查找时都引入了分支预测失败。模型的缓存局部性论点在理论上合理,但对这种输入分布实证上是错误的。
我将拒绝输出反馈给模型。它的第二个提议不同了:保留哈希 map,用预期的桶数量添加 reserve(),并使用透明哈希进行 std::string_view 查找。同一个门禁测量到 7.2% 的改善(p = 0.001),patch 合并了。
MonkeyCode 中的免费服务器选项足以支撑这个循环。一台临时机器运行 15 分钟的 A/B 序列,然后关闭。没有持久基础设施,没有共享 CI runner 噪声。
门禁将观点变成可证伪的声明。第一个 patch 在任何静态分析意义上都不是错的。它对这个工作负载是错的,只有测量才能说明这一点。
交叉,否则测量的只是热漂移而非代码。将基线先跑、候选后跑,在独立 job 中分别运行,测量的更多是热漂移而非代码。交叉执行才是使 p = 0.003 具有意义的原因。
将拒绝数据反馈回去。第二个提议更好,因为它看到了第一个的数值。提议、测量、拒绝、重新提议——这个循环才是免费模型接入真正发挥作用的地方。
统计显著性不等于实际显著性。p = 0.0001 的 1% 胜利仍然是 1% 的胜利。5% 效果阈值阻止了一次本会徒增复杂度却毫无收益的合并。
这个门禁在设计上很窄。它只在一台机器上、用一个编译器、针对一个函数进行验证。它对内存使用、代码大小或可维护性没有任何说明。
以下情况不要使用这种方法:
热路径是 I/O 绑定的。基准测试将测量磁盘,而非你的代码。
你缺乏稳定的机器。有噪声的共享 runner 产生的 p 值无法信赖。
改动是一行代码。搭建成本超过收益。
你需要几分钟内做出决定。十五次交叉运行至少需要 15 分钟。
门禁存在一个特定场景:模型提议了一个性能改动,团队需要在合并前有证据。这种场景足够常见,自动化成本也足够低,以至于我现在对每个 AI 提议的优化都运行它。
第一次拒绝是最好的可能结果。它证明了门禁可以说"不"。