AI在Debug模式(-O0)下生成的bounds check补丁在Release模式(-O2 -DNDEBUG)下消失,导致缓冲区越界。揭示了AI C++补丁三大常见缺陷:assert验证被NDEBUG删除、符号溢出、严格别名优化。
一位维护者合并了一条由 AI 编写的边界检查,注入一个小型 C++ 解析器。Debug CI 以 -O0 编译,运行单元测试,报告成功。那条守卫只是一条 assert。而生产构建使用的是 -O2 -DNDEBUG,所以守卫消失了,一个 short buffer 把 size_t 变成了越界读取。
那次失败不是缺了一个测试用例,而是缺了一个编译 Profile。模型满足了提示词中展示的那一条命令。Release 标志从未成为评分者强制执行的规格的一部分。
关于 AI 编程能力的公开争论仍在按测试是否通过来给补丁打分。编译器标志是那些讨论中跳过的得分项。一份在一个 Profile 下为绿、而在另一个下错误的补丁是静默的回归,即使 Debug 二进制中的每条断言都触发了。
AI 的 C++ 补丁针对提示词中的构建命令进行优化。如果那条命令是 g++ -std=c++17 -O0 -g,模型就永远不会看到 NDEBUG、内联、恒真比较折叠或带符号溢出假设。附加到那条命令的测试成为第二条提示词。它们不会变成一份契约。
在生成的补丁中,有三种分裂反复出现:
标志矩阵测试床把那些 Profile 当作一等公民来对待。同样的源码,同样的测试,四次编译。结果不一致就是一次 eval 失败,不是 flaky。
下面的测试床刻意做得很小。它在一个函数上评分,涵盖 Debug、Release、一个仅 NDEBUG 的构建,以及 ASan/UBSan 构建。候选补丁必须通过项目声称要发货的每个 Profile。先把这些片段标记为本地配方,在团队锁定编译器版本之前不要当作生产 CI 来用。
保持函数足够小,让矩阵保持廉价。第一个 subject 用 assert 作为边界检查。第二个依赖带符号运算,Debug 或许能容忍。
// subject_assert.cpp — assert-only guard (fails under -DNDEBUG)
#include <cassert>
#include <cstddef>
#include <cstdint>
std::uint32_t prefix_sum(const std::uint32_t* p, std::size_t n, std::size_t i) {
assert(p != nullptr && i < n); // gone in Release
std::uint32_t acc = 0;
for (std::size_t k = 0; k <= i; ++k) acc += p[k];
return acc;
}
// subject_overflow.cpp — signed overflow the optimizer may fold
int scale_index(int base, int stride, int i) {
return base + stride * i; // UB if the product overflows
}
第一个函数的 golden test 必须包含一个越界索引。在 Debug 下进程会中止。在 -DNDEBUG 下它可能返回一个值或稍后崩溃。两种结果都是 eval 信号。不要重写测试来跳过那个坏索引。那种重写会隐藏 Profile 分裂。
把矩阵存在测试旁边。README 中的注释会漂移。运行器读取的文件不会。
# profiles.txt — one compile recipe per line: name|cxxflags
debug|-std=c++17 -O0 -g
release|-std=c++17 -O2 -DNDEBUG
ndebug_o0|-std=c++17 -O0 -DNDEBUG
san|-std=c++17 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer
第三行是大多数 AI 评分者忽略的那一行。它保留 -O0,所以二进制仍然易于调试,但仍然会剥离 assert。如果一个补丁只是因为 assert 活着才"能用",这一行会失败而 debug 会通过。那一对就是账本中最有用的分歧。
运行器把每个 Profile 编译成各自的二进制,执行同样的测试驱动,为每个 Profile 写一行。退出码、sanitizer 噪音和稳定的 stdout 摘要都应属于这一行。不要把它们折叠成单一的布尔值直到最后。
#!/usr/bin/env bash
# run_matrix.sh — proposed local harness, not a hosted service
set -euo pipefail
CXX=${CXX:-g++}
SRC=${1:?usage: run_matrix.sh subject.cpp testdriver.cpp}
TEST=${2:?}
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
pass=0
fail=0
while IFS='|' read -r name flags; do
[[ -z "${name:-}" || "$name" == \#* ]] && continue
out="$ROOT/$name"
# shellcheck disable=SC2086
if ! $CXX $flags -o "$out" "$SRC" "$TEST" 2>"$ROOT/$name.err"; then
echo "$name COMPILE_FAIL"
fail=$((fail+1))
continue
fi
set +e
"$out" >"$ROOT/$name.out" 2>"$ROOT/$name.san"
rc=$?
set -e
digest=$(cksum "$ROOT/$name.out" | awk '{print $1}')
san=$(wc -c <"$ROOT/$name.san")
echo "$name rc=$rc digest=$digest san_bytes=$san"
if [[ $rc -ne 0 || $san -ne 0 ]]; then
fail=$((fail+1))
else
pass=$((pass+1))
fi
done < profiles.txt
echo "matrix pass=$pass fail=$fail"
[[ $fail -eq 0 ]]
对基线树和打了补丁的树用同样的方式运行。eval 是两份账本的 diff,而不是只看打了补丁的树。把 Release 崩溃变成 Debug-only assert 的补丁不是改进。它把失败移到了默认 CI 从不运行的 Profile 中。
全票零退出码是必要条件但不是充分条件。在应该功能相同的 Profile 之间摘要必须匹配。debug 和 ndebug_o0 应该在每个有定义的输入上对 stdout 达成一致。对于越界输入,它们应该在且仅在项目将 abort-vs-unchecked 文档化为有意时才不一致。大多数库代码不应该这样做。
# decision table (eval outcome)
# debug release ndebug_o0 san verdict
# pass pass pass pass accept
# pass fail fail pass assert-only guard; reject
# pass pass pass fail sanitizer-only bug; reject
# pass fail pass fail optimizer/UB; reject
# fail fail fail fail tests too weak or patch broken; reject
从两份账本打印这张表。不要把它放在幻灯片里。评分者如果不能发出那一行,就无法捕获分裂。
如果每个测试输入都是良好定义的,矩阵就毫无用处。至少包含一个函数必须拒绝的情况:空指针、i == n、一个使 int 溢出的乘积。在 debug 下进程可能陷入陷阱。在 release 下同样的输入绝不应被当作成功。把每个 Profile 的预期状态记录在侧边文件中,这样运行器就不会自行发明策略。
# expect.txt — input_id|profile|want_rc
overflow_max|debug|1
overflow_max|release|1
overflow_max|ndebug_o0|1
overflow_max|san|1
oob_index|debug|1
oob_index|release|1
oob_index|ndebug_o0|1
oob_index|san|1
如果项目真的想让 Release 跳过检查,在 expect.txt 中说明。沉默不是策略。AI 补丁会用 assert 填满沉默。
生成候选补丁和评分是不同的工作。上面的矩阵需要一个本地编译器、sanitizer 运行时和项目自己的头文件。它不需要 GPU。候选生成需要。
披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 是一个开源项目,提供免费模型访问和免费服务器选项,一些团队在不想自己托管模型时只用它来产生补丁候选。标志矩阵测试床仍然运行在维护者的机器上。在依赖其发布的 token 和硬件声明之前,将其视为需要验证的东西——本文不分配配额、模型名称或正常运行时间。
一个实用的分割是:远程采样几个补丁,把每个 diff 放入 worktree,要求 run_matrix.sh 打印 fail=0 加上匹配的摘要。如果远程端不可用,评分者仍然工作。如果跳过评分者,远程 token 只会买到更多绿色的 Debug 构建。
矩阵不能证明不存在未定义行为。它提高了通常谎言的成本:仅 assert 的安全、optimizer 可见的溢出和未清理的堆。它也增加了编译时间。在大型翻译单元上跑四个 Profile 是一笔真实的账单。按 Profile 缓存目标文件,否则测试床会成为人们关掉的东西。
Sanitizer 行需要匹配的库,也不会捕获每个数据竞争或每个未初始化读取。MSVC、libstdc++ 和 libc++ 对什么是有定义的各执一词。锁定编译器。不要把 GCC Debug 摘要和 Clang Release 摘要比较然后把不匹配称为模型失败。
对于打印时间戳、指针值或无序容器迭代顺序的测试,stdout 摘要会失败。稳定化输出或 hash 一个结构化的结果文件而不是原始 stdout。计时不是摘要。不要把这个测试床变成一个基准。
团队如果只发布一个 -O0 二进制且从不启用 NDEBUG,收益甚微。禁止 RTTI、异常和 sanitizer 的固件树需要一个精简的矩阵,而不是 profiles.txt 的复制粘贴。如果 subject 是一个需要分钟级实例化的头文件-only 模板汤,在一个只包含补丁符号的提取 TU 上运行矩阵。
对于纯风格 diff、注释重写和 CMake 清理,这种方法也是错误的工具。那些更改不会在 -O2 下分裂。把四次编译花在触及算术、索引、生命周期或错误路径的函数上。
提示词会腐坏。测试旁边的文件中的 Profile 不会——如果运行器是合并门的话。把矩阵放在已经编译 AI 补丁的同一个 job 中。让提示词远离标志论述。模型仍然会写 assert。账本仍然会在 ndebug_o0 上失败。这才是额外编译的意义所在。
Release 构建是 Debug 命令的一个不同规格。两者都评分。然后把分歧当作被拒绝的补丁,而不是绿色 CI 日志中一个有趣的脚注。