yuqi-zheng

编译器优化:LICM 何时会失效 —— 别名陷阱


循环不变代码外提(Loop-Invariant Code Motion, LICM)是最可靠的编译器优化之一:如果某个计算在迭代之间不变,就把它移到循环之前、只算一次。当这种优化意外失效时,原因往往是别名(aliasing)—— 编译器无法证明一个值真的是不变的,因为某个指针写操作可能会改变它。

场景设定

从一个在 C 字符串中搜索叹号的函数开始:

bool has_exclamation(const char* str) {
    for (size_t i = 0; i < strlen(str); ++i)
        if (str[i] == '!') return true;
    return false;
}

strlen(str) 出现在循环条件里。任何读者都能看出:str 指向一块固定的内存,循环体并不修改这个字符串,所以 strlen 在每次迭代中应当返回同一个值。编译器也同意这一点:它会把 strlen(str) 外提到循环之前,只算一次,把结果作为固定的循环上界。

现在加一行:

std::size_t num_compares = 0;

bool has_exclamation(const char* str) {
    for (size_t i = 0; i < strlen(str); ++i) {
        ++num_compares;
        if (str[i] == '!') return true;
    }
    return false;
}

唯一的改动是在循环里递增一个全局计数器。优化消失了。strlen(str) 现在每次迭代都会被调用,这让函数在长字符串上慢得令人难以接受。

为什么一次全局写操作会打破 LICM

C 和 C++ 标准定义了别名规则,规定哪些指针类型可以合法地指向同一块内存。其中一条规则是:char*unsigned char*std::byte* 可以与任何类型的任何对象别名。这是有意为之,正是这一条让 memcpy 这类代码能在任意对象的原始字节上工作。

它在这里的后果是:编译器不能假设 strnum_compares 分别占据不同的内存。具体来说:

  • strchar*,可以与任何东西别名。
  • num_compares 是一个位于全局地址的 std::size_t
  • ++num_compares 修改了那个地址处的内存。
  • 据编译器所知,str 有可能指向 num_compares 所占的字节,这意味着它正在扫描的字符串可能会随着 num_compares 的写入而改变。

这听起来很荒谬 —— 没人会去构造一个指向 size_tchar* —— 但编译器的别名分析是一个作用于类型和地址的形式系统,不是基于程序员意图。它必须处理类型系统所允许的最坏情形。

既然 str 可能与 num_compares 别名,那么对 num_compares 的写入就可能改变字符串的内容,这意味着每次写入后 strlen(str) 可能返回不同的值。因此外提 strlen(str) 是不正确的,编译器就不做这个优化。

各编译器的实际表现

不同编译器之间有差异:

Clang 稍微激进一些。如果 num_compares 被声明为 static、其地址也从未被取用或传出当前翻译单元,Clang 可能判定没有外部 char* 能够到达它,从而做相应的优化。一旦变量确实跨翻译单元被使用、或者它的地址被取用,优化就失效了。

GCC 和 MSVC 通常更保守。即使换用本不该产生别名的类型 —— 比如把 const char* 换成别的指针类型 —— 这些编译器在某些配置下仍可能不外提 strlen

原系列的作者曾针对这一具体模式向 GCC 和 LLVM 提交过优化缺陷报告。

修复方案

改用 string_view

bool has_exclamation(std::string_view sv) {
    for (char c : sv) {
        ++num_compares;
        if (c == '!') return true;
    }
    return false;
}

std::string_view 把长度作为一个普通的整型成员来存储,而不是从指针派生出来。访问 .size() 是一次整数加载,不需要通过指针做一次内存扫描。编译器看到 size 存在 string_view 对象里、++num_compares 不会修改这个对象,于是就能断定 size 是循环不变的。LICM 成功。

这是首选方案。它也更清晰地传达了意图:string_view 表达的是”一个固定长度的字符串视图”,而不是”一个指向未知长度、以空字符终止的序列的指针”。

显式缓存长度

bool has_exclamation(const char* str) {
    const size_t len = strlen(str);
    for (size_t i = 0; i < len; ++i) {
        ++num_compares;
        if (str[i] == '!') return true;
    }
    return false;
}

把长度存入一个 const 局部变量,让”不变性”成为显式事实。一个从未被取地址的局部变量不可能与任何东西别名 —— 编译器知道它无法通过任何指针到达。循环条件从一个外部指针都触及不到的栈槽里读 len,因此 LICM 都不必发生,值直接被使用。

减少全局状态

全局计数器本身就是一个结构性问题。全局变量从程序的任何地方都可以被访问,总会带来别名的不确定性。把计数器作为引用参数传入、或在局部累积后作为返回值返回,会给编译器更多关于”写入发生在哪里”的信息。

更宏观的启示

char* 的别名规则是 C 和 C++ 中影响最深远的规则之一。因为字符指针可以合法地与任何东西别名,任何通过 char* 的写入 —— 或任何”可能被某个 char* 观察到的对象附近”的写入 —— 都会造成别名上的不确定性,阻碍一大类优化。

这不是编译器的 bug 或不足。在这门语言的规则之下,这是正确的行为。修复的办法是使用能让编译器证明所需不变量的类型和结构:用 string_view 代替 char*、用局部变量代替全局、用 const 引用代替裸指针。

这类问题在实践中的”指纹”是:一个逻辑上毫不相关的修改引起了性能退化。在循环里加一个计数器、一条调试输出、一个统计更新,本不该影响循环上界如何计算 —— 但通过别名规则,它就能产生影响。用 Compiler Explorer 检查汇编、在循环条件里搜寻意料之外的内存读取,是诊断这类问题最快的方法。