Skip to content

从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基

上一篇留下的那句话

ch00-01 立铁律一「先正确,再快」的时候,我们甩了一句很重的话:带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。 这一篇就把这句话拆开,让你看清楚 UB 到底是怎么把一个性能数字变成谎话的。看懂这一步,你就能理解一件可能让你困惑的事:为什么一本讲性能优化的卷,第一章不讲 cache、不讲 SIMD,却摆了一整套 sanitizer 工具链。

UB 是怎么把性能数字变成谎话的

C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访问、解引用空指针、读未初始化的变量、数据竞争……标准对这些程序的行为什么都不保证:什么都可能发生,包括你最不想的那种「看起来正常跑、结果全错」。

可怕的地方在于:编译器是主动利用这条规则的。-O2 下,编译器会合法地假设「这段代码不会触发 UB」,然后基于这个假设做激进优化。这是标准允许的,也是现代编译器日常在做的事。落到性能测量上,后果主要有三类,每一类都够让你的数字作废:

第一类:你要测的代码整段被删掉。 你的 benchmark 算出一个结果却没人用,编译器判定它是死代码,直接消除(DCE),你美滋滋地测出一个 0.3 纳秒——测的是空。这是 ch00-01 那个 benchmark 里 volatile global_sink 为什么必须存在的原因。

第二类:编译器替你的循环下了结论。 一个有符号循环变量如果可能溢出,那就是 UB;编译器可以假设它不会溢出,进而推出循环上界、把整段循环 hoist 成常量、或者干脆折叠掉。你以为测了 N 次迭代,实际一次都没跑。

第三类,也是最阴险的一类:你以为在测 A,其实踩在 B 的内存上。 越界写、use-after-free、未初始化读,这些不会让程序崩,只会让你的 benchmark 读写到「别的变量」的内存。你测出来「这段代码 50 纳秒」,其实那 50 纳秒里有一半在污染隔壁的数据结构,数字毫无意义,而且程序还在「正常」跑。

举一个最小、也最经典的例子,让你有体感。下面这个函数判断「x+1 是否大于 x」:

cpp
bool always_bigger(int x) { return (x + 1) > x; }   // x+1 溢出 = UB

-O2 下,gcc 会基于「有符号溢出不可能发生」把整个函数折叠成 return true;,汇编就一行 movl $1, %eax; ret,什么 x 都不关心。于是哪怕你传 INT_MAX(那个 x+1 真会溢出的值),它也理直气壮返回 true。这是 gcc -fstrict-overflow 文档化了的行为(-O2 默认开启),cppreference 的未定义行为页面也拿它当标准反例。

口说无凭,我在自己机器上(GCC 16.1.1)实跑了一遍,给函数喂同一个值 INT_MAX:

text
# -O2(允许 UB 假设:整段折叠成 return true)
$ g++ -O2 ub_fold.cpp -o ub_fold && ./ub_fold 2147483647
f(INT_MAX) = 1

# -O2 -fwrapv(强制有符号溢出回绕成定义行为:老实算)
$ g++ -O2 -fwrapv ub_fold.cpp -o ub_fold_wrap && ./ub_fold_wrap 2147483647
f(INT_MAX) = 0      # INT_MAX+1 回绕成 INT_MIN,INT_MIN > INT_MAX 为假

同一个优化级别(-O2)、同一份输入(INT_MAX),唯一差别是「有符号溢出算 UB」还是「定义成回绕」,结果一个 1 一个 0。这就是 UB 在性能测量里最致命的地方:你测的对象本身是不稳定的,编译选项一动,测的就不是同一个东西了。 顺带一个坑:你可能想用 -O0 当对照(指望它老实做加法、回绕出 0),但当代 GCC 的中间端即便 -O0 也会识别 (x+1)>x 这个惯用法、照样折叠,所以这个对照最好用 -fwrapv 来做,比换优化级别更干净。

所以一个带 UB 的 benchmark,在 -O2 下测出来的「快了 30%」,很可能只是「编译器基于 UB 假设把你的循环删掉了一半」省出来的 30%。这 30% 到了生产环境(不同的编译选项、不同的输入分布)会瞬间蒸发,甚至变成「慢了」。你拿着这个假数字去做架构决策,就是在沙子上盖楼。

所以 sanitizer 不是「调试工具」,是性能测量的可信度地基

理清了上面这一层,你就明白为什么这卷把 sanitizer 排在 ch00,而不是像别处那样把它们归到「调试技巧」里顺手提一句。四兄弟各管一类「会让性能数字作废」的 UB:

  • ASan(AddressSanitizer)管内存错——越界、use-after-free、重复释放。这些正是上面「第三类阴险情况」的元凶,它们让你的 benchmark 偷偷踩到别人的内存。
  • UBSan(UndefinedBehaviorSanitizer)管语言层 UB——有符号溢出、空指针解引用、错类型 cast、非法 shift。这些正是上面「第一类、第二类」的元凶,它们让编译器有理由把你测的代码改写成空壳。
  • MSan(MemorySanitizer)管未初始化读——读到的是「随机值」,你测的本质是个随机数发生器。
  • TSan(ThreadSanitizer)管并发数据竞争——而数据竞争本身就是 UB。这一条在并发性能测量里特别要命:你的多线程 benchmark 没过 TSan,那串漂亮的吞吐数字毫无意义。TSan 的机制 vol5 已经讲透了,我们这里只取「它为什么是性能地基」这一个视角:并发性能数字的可信度,前提是没有数据竞争。

把这四条放在一起,结论非常硬:一个没过 sanitizer 的性能数字,你不知道它测的是什么。 前提都不成立,谈不上精确不精确。所以本卷的规矩是:任何进入性能对比的代码,先在 sanitizer 下跑干净,再谈数字。

这章的 sanitizer 三篇

具体怎么开、怎么读它的报错、怎么和 -O2 共处、调试版和上生产的取舍——这些是 sanitizer 三篇的活,这篇只是路口牌,指一下它们各讲什么:

  • 「ASan 工具家族与内存安全」:从 Heartbleed 这个真实灾难说起,拆开 shadow memory 这套机制,实测越界、UAF、全局越界,理清 ASan / LSan / MSan / TSan / UBSan 五兄弟各自的职责和为什么它们大多互斥(不能同时开)。
  • 「Valgrind 与 ASan 对照」:把 Valgrind 的动态二进制翻译路线和 ASan 的编译期插桩路线摆在一起,讲透两条路在性能、能抓的错、使用门槛上的本质差别。
  • 「Sanitizer 工具链全景」:从用户态 -fsanitize= 一路讲到内核态的 KASAN / KMSAN / UBSAN / KCSAN / KFENCE,讲清「编译期插桩 vs 采样」两条线,以及「调试时开满」和「常驻生产」的分层防御。

读完这三篇,你就具备了「让性能数字可信」的完整工具链。它们是性能卷的地基,不是配角。

然后呢:从「测的是真东西」到「测得准」

sanitizer 解决的是前提——保证你测的确实是你要测的那段逻辑,而不是一个被 UB 改写过的空壳、或踩在别人内存上的噪声。但「测的是真东西」只是第一步,一个真实的性能数字本身还是一个随机变量:CPU 频率在飘、线程被调度走、缓存冷热在变、页表在按需建……同一个函数跑两遍,数字会不一样。

「测的是真东西」加上「数字是随机变量」这两条加起来,就引出了 ch01——Benchmark 方法论。那一章是本卷的锚点,专门讲怎么把一个随机变量测成一个可信的结论、怎么对比两个数字才算数。换句话说:ch00 给你一把「让数字不作假」的尺子,sanitizer 三篇是这把尺子的校准源;ch01 接过去讲,怎么用这把校准过的尺子量出真东西。

参考资源

  • cppreference:未定义行为
  • GCC 文档:-fstrict-overflow / -fwrapv(man gcc 或 gcc.gnu.org/onlinedocs/)
  • 本卷 sanitizer 三篇(见上「这章的 sanitizer 三篇」一节)
  • Bryant, R. E., O'Hallaron, D. R. 《Computer Systems: A Programmer's Perspective》第 5 章(「先正确再快」的前提)

v0.7.1-2-g3718060 · 3718060 · 2026-07-06