yuqi-zheng

编译器优化:编译器如何比较固定长度的字符串


当你在 C++ 中写 sv == "ABCDEFG" 时,你可能会以为编译器会去调 memcmp。对于短字符串与编译期常量的比较,它并不会。相反,现代编译器会把整个比较内联并特化为一小段整数加载和位运算 —— 没有函数调用、没有分支,有时还会用”重叠读取”以 2 的幂大小的加载覆盖非 2 的幂的长度。

场景设定

看一组函数,每个都把 std::string_view 与特定长度的字符串字面量比较:

bool t7(std::string_view sv) {
    return sv == "ABCDEFG"sv;
}

编译器在编译期就知道目标字符串:长度、内容,以及它在内存中的字节表示。这些知识已足以生成专门针对”这一次比较”的代码。

第一步:长度检查

每个生成的函数都先测试输入长度:

cmp rdi, 7
jne .L_return_false

长度不匹配时,函数立刻返回 false,根本不去碰字符串数据。这一条分支让失败路径无需任何内存访问。

2 的幂长度:直接的整数比较

对长度恰好为 1、2、4 或 8 字节的字符串,办法很直接:把整个字符串作为一个对应宽度的整数加载,与编码为立即数的期望值比较。

  • 长度 1:cmp byte ptr [rsi], 'A'
  • 长度 4:cmp dword ptr [rsi], 0x44434241
  • 长度 8:cmp qword ptr [rsi], <8 字节立即数>

立即数 0x44434241"ABCD" 四字节按小端解释的 32 位整数:'A' 是 0x41,'B' 是 0x42,依此类推。比较是一条指令。如果 CPU 能把加载和比较融合,它甚至可能执行为一个微操作。

非 2 的幂长度:重叠读取

长度 7 没法用单一的 4 字节或 8 字节加载来处理,除非越界读取(可能非法)或留下字节未检查。编译器的做法是重叠读取:一次从起点读 4 字节,一次把结尾对齐到最后一个字节读 4 字节,中间那 1 字节两次都会被读到。

"ABCDEFG"(7 字节),编译器生成:

mov eax, 1145258561        ; "ABCD" 作为 32 位整数
xor eax, dword ptr [rsi]   ; 与输入前 4 字节做 XOR

mov ecx, 1195787588        ; "DEFG" 作为 32 位整数
xor ecx, dword ptr [rsi+3] ; 与输入第 3..6 字节做 XOR

or  ecx, eax               ; 合并:只有两次 XOR 都为 0 时结果才为 0
sete al                    ; zero 标志为真时把结果置 1

[rsi+3] 的加载读取字节 3、4、5、6 —— 所以字节 3('D')在两次加载里都被读到一次。这是有意为之,无害:一个字节要么匹配它期望的值、要么不匹配,读两次不会改变这个事实。

为什么用 XOR 而不是 CMP?

XOR 的方案在”无分支合并多次比较”上非常优雅。当且仅当 a == ba XOR b 为 0。把加载的 4 字节与期望的 4 字节做 XOR,得到的值在四个字节都匹配时为 0。再用 OR 把两次 XOR 结果合并,最终结果只有在两组 4 字节都匹配时才为 0。最后一条 sete 读 zero 标志,得到布尔返回值。

整个 7 字节比较最终是:两次加载、两次 XOR、一次 OR、一次 sete —— 除了最初那次长度检查之外没有任何条件分支。

长度 3、5、6、9

同样的重叠读取策略适用于其他非 2 的幂长度:

  • 长度 3:一次 2 字节加载加一次偏移 1 字节的 2 字节加载
  • 长度 5:两次 4 字节加载,有 3 字节重叠
  • 长度 6:两次 4 字节加载,有 2 字节重叠
  • 长度 9:一次 8 字节加载加一次有 7 字节重叠的 8 字节加载,或一次 8 字节加载加一次 1 字节检查

共同的关键点是:在 x86 上,非对齐 32 位和 64 位加载很便宜 —— 大多数情况下硬件无开销地处理非对齐,用”比实际需要更大的加载”覆盖非对齐数据是一个完全合法的策略。

为什么这件事很重要

这个优化对程序员是不可见的。你写 sv == "ABCDEFG",编译器就产生上面这段位运算序列。抽象并没有”漏出来” —— 编译器只是选了一个比朴素地调用 memcmp 更好的实现。

在热路径上,性能差异是有意义的。调用 memcmp 涉及一次 call、一次 ret,而 memcmp 内部对任何短于一个缓存行的长度都有一些启动开销。内联版本不走 call、不做启动,通常还更少内存访问。

从程序员视角看,要点就是:写清晰可读的比较,剩下的交给编译器。不要手工把字符串比较展开成逐字节循环,也不要手动调 memcmp —— 在它能生成更好的代码时。使用 std::string_view::operator==std::string::operator==,并信任优化器。

Compiler Explorer 让这件事容易直接观察:用 Clang 在 x86-64、-O2 下编译一个返回 sv == "ABCDEFG"sv 的函数,看输出。然后改变字符串长度,观察代码模式在”对齐情形”和”重叠情形”之间切换。