yuqi-zheng

编译器优化:指针别名如何扼杀性能


当两个指针引用同一个内存位置时,它们就”别名”(alias)。在 C 和 C++ 中,编译器常常无法证明两个指针不别名 —— 当它无法证明这一点时,就必须假设最坏情形。结果就是生成的代码比必要多得多地读写内存,让原本简单的优化失效。

一个具体的例子

考虑一个通用累加器,把一段整数求和到一个成员变量里:

template<typename T>
struct Counter {
    T total = 0;
    void count(std::span<const int> data) {
        for (int x : data)
            total += x;
    }
};

行为看起来很明显:把所有值累加到 total,结束。但编译器能否生成高效代码,完全取决于类型 T

当 T 是 int 时

看一下内循环的汇编:

mov eax, DWORD PTR [rdi]     ; 从内存加载 total
add eax, DWORD PTR [rsi]     ; 加上当前元素
mov DWORD PTR [rdi], eax     ; 把 total 存回内存

每次迭代都从内存加载 total、加上、再存回去。迭代之间没有把 total 保留在寄存器里。

原因在于 C++ 的严格别名规则。span 中元素是 inttotal 也是 int。因为相同类型的两个指针在 C++ 里允许别名,编译器必须考虑这种可能:data 中的某个元素正是 total 本身 —— 例如 data[3]total 占据同一地址。如果真是这样,每次迭代都加载/存储 total 是必要的,这样通过 span 读取时才能看到正确的值。

当 T 是 long 时

T 改为 long,汇编会剧变:

mov rax, QWORD PTR [rdi]     ; 循环前只加载一次 total
.loop:
    movsx rdx, DWORD PTR [rsi]
    add rax, rdx             ; 在寄存器中累加
    add rsi, 4
    cmp rcx, rsi
    jne .loop
mov QWORD PTR [rdi], rax     ; 循环后只存一次 total

total 在循环之前加载一次,循环之后存回一次。所有累加都发生在寄存器中。这正是你手工优化时会写出的代码。

差别在于:longint 是不同的类型。C++ 的严格别名规则禁止不同基本类型之间的别名(有几个特定例外)。因此编译器能保证 int span 里的任何元素都不会与 longtotal 重叠,就可以在整个循环中把 total 保留在寄存器里。

为什么别名阻碍优化

内存读写模式只是表面现象。别名上的不确定性会阻塞一系列编译器 pass:

寄存器提升(register promotion)。 如果一次指针写入可能在背后修改变量,编译器就不能把它保留在寄存器里。

循环不变代码外提(LICM)。 一个值只有在循环中不可能改变时才算循环不变。如果任何指针写入可能触及这个值,它就不再能证明是不变的。

向量化。 自动 SIMD 要求循环迭代不通过内存相互干扰。未解决的别名让这个证明不可能完成。

死存储消除和加载转发。 当编译器无法追踪哪些存储影响哪些加载时,它必须保留所有这些内存操作。

解决方案

使用局部变量

这是最可移植、语义也最清晰的修法:

void count(std::span<const int> data) {
    T local_total = total;
    for (int x : data)
        local_total += x;
    total = local_total;
}

局部变量可以证明是不别名的 —— 任何外部指针都不可能到达一个从未被取址、也从未逃逸的栈变量。编译器可以自由地把它保留在寄存器里。这不依赖于具体类型,也不需要任何非标准扩展。

使用标准算法

total += std::accumulate(data.begin(), data.end(), T{});

std::accumulate 被定义为顺序求和,不允许重排。要向量化你会用 std::reduce,它允许不确定的执行顺序。无论哪种,算法内部的临时累加器从构造上就是不别名的。

使用 __restrict(非可移植)

void count(std::span<const int> data) __restrict {
    // ...
}

这是 GCC/Clang 的扩展,告诉编译器 this 不与作用域内任何其他指针别名。效果等同于使用局部变量,但这是你对编译器的承诺,而不是结构上的保证。违反它会产生未定义行为,而且这个标注无法移植到 MSVC。

根本的紧张关系

C 和 C++ 给程序员几乎不受约束的能力去转换指针、重新解释内存。这种灵活性对系统编程很有价值,但代价是:除非编译器能通过类型信息或显式标注证明安全,否则它必须保守对待指针关系。

采用更严格所有权模型的语言 —— 尤其是 Rust —— 从结构上消除了大部分别名歧义。Rust 中的 &mut T 被保证是那个位置唯一活着的可变引用。Fortran 采用类似的思路,默认数组参数不别名。两者的编译器在能生成的代码质量上都因此受益。

对 C 和 C++ 开发者而言,实际的启示是:在热循环中把累加状态放进局部变量。这是给编译器所需信息的最简单的改动,运行时代价为零。