Why are unnecessary atomic loads not optimized away?

Oli*_*liv 6 c++ code-generation atomic volatile compiler-optimization

Let's consider this trivial code:

#include <atomic>

std::atomic<int> a;
void f(){
    for(int k=0;k<100;++k)
        a.load(std::memory_order_relaxed);
}
Run Code Online (Sandbox Code Playgroud)

MSVC, Clang and GCC all perform 100 loads of a, while it seems obvious it could have been optimized away. I expected the function f to be a nop (See generated code here)

Actually, I expected this code generation for a volatile atomic:

volatile std::atomic<int> va;
void g(){
    for(int k=0;k<100;++k)
        va.load(std::memory_order_relaxed);
}
Run Code Online (Sandbox Code Playgroud)

Why do compilers not optimize away unnecessary atomic loads?

Pet*_*des 2

如果调用者这样做g(); atomic_thread_fence(acquire);,它可能会与另一个线程创建一个发生之前的关系,如果执行了零负载,则该关系将不存在。由于负载结果被丢弃,因此它无法知道它同步的内容,但完全优化负载的效果并不完全明显。

也许存在一些计时原因,在某些实际实现上意味着另一个线程中的某些存储应该对该宽松的负载可见。这听起来相当手动,但某些运行时计时条件可能会导致无 UB 执行,而这种执行可能会通过优化所有负载而被破坏。

将 100 个负载压缩为 1 而不是 0 不会出现此问题,但仍需要特定的优化过程来查找未被任何内存排序效果(如栅栏)分隔的未使用负载。似乎很难否认它的安全性。如果代码依赖于作为延迟测量(或 MMIO)发生的每个负载,则它们应该使用volatile atomic.


一般来说,折叠多个加载或存储是一个单独的事情,已经讨论过:编译器是否可以优化两个原子加载?/为什么编译器不合并冗余的 std::atomic 写入?

如果其他线程同时写入,同一原子对象的两次连续加载可能会或可能不会产生相同的值。如果编译器使 asm 只加载一次,那么它就可以在编译时有效地确定部分运行时内存排序。这通常在一定程度上是可以的,但正如 WG21 文件指出的那样,这并不总是可以,特别是对于商店而言。(为具有强大内存模型的 ISA 编译 C++ 程序也会使某些 ISO-C++ 允许的内存排序在实际执行中变得不可能,但它不会消除跨线程的顺序一致执行的可能交错。)

这些都不是将 100 个未使用的负载减少到 1 个的真正障碍,但它与编译器现在选择根本不优化原子有关,因为它与更棘手的问题有关。


实际原因

编译器内部可能会使用一些现有的支持来volatile处理原子,即不假设多次读取将给出相同的值。这具有基本上像对待它们一样的副作用volatile atomic

对于 GCC,@o11c链接了https://gcc.gnu.org/wiki/Atomic/GCCMM/Optimizations,了解 GCC 在优化原子方面的限制。它声称[intro.races]/19将禁止int x=a.load(relaxed); int y=a.load(relaxed);视为x=y=a.load(relaxed);. 但这是一种误读。它禁止以其他顺序执行它们,这可能导致x的 修改顺序 具有更新的值a,但强制它们都具有相同的修改顺序值不会违反读-读一致性规则[intro. races]/16注释是总结。该 GCC wiki 页面的最后一次编辑显然是在 2016 年,即 WG21/P0062R1 之前;希望大多数涉及 C/C++ 原子的 GCC 开发人员从那时起就意识到 ISO 标准在纸面上允许优化,即使对于所使用的负载也是如此。

此外,编译器开发人员可能不愿意添加寻找很少有利可图的优化的代码。GCC、LLVM 和 MSVC 等编译器的较大代码库需要更多的开发工作来维护,可能会减慢其他功能的添加和维护。

此外,寻找这样的优化会使编译时间变慢。在这里这可能不是问题;现代提前编译器已经将程序逻辑转换为 SSA 形式,即使在比这更简单的情况下,也应该很容易找到未使用的结果(例如,将加载结果分配给优化后未使用的本地变量时)。对于这种微不足道的情况,编译器已经可以警告未使用的返回值。