为什么编译器不合并冗余的 std::atomic writes?

Why don't compilers merge redundant std::atomic writes?

本文关键字:atomic writes std 编译器 合并 冗余 为什么      更新时间:2023-10-16

我想知道为什么没有编译器准备将相同值的连续写入合并到单个原子变量中,例如:

#include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}

我尝试过的每个编译器都会发出上述写入三次。哪个合法的、无种族的观察者可以看到上述代码与单次写入的优化版本之间的差异(即"as-if"规则不适用)?

如果变量是可变的,那么显然没有优化是适用的。在我的情况下,是什么阻止了它?

下面是编译器资源管理器中的代码。

编写的C++11/C++14 标准确实允许将三个存储折叠/合并为一个具有最终值的存储。 即使在这样的情况下:

y.store(1, order);
y.store(2, order);
y.store(3, order); // inlining + constant-folding could produce this in real code

该标准保证在y(具有原子载荷或CAS)上旋转的观察者会看到y == 2。 依赖于此的程序将具有数据竞争错误,但只有花园品种错误类型的竞争,而不是C++未定义行为类型的数据竞赛。 (它只有非原子变量的UB)。 一个期望有时看到它的程序甚至不一定有问题。(请参阅下面的 re:进度条。

在C++抽象机器上可能的任何排序都可以(在编译时)作为始终发生的排序。这是实际操作中的假设规则。 在这种情况下,就好像所有三个存储都以全局顺序连续发生,y=1y=3之间没有加载或来自其他线程的存储。

它不依赖于目标体系结构或硬件;就像即使针对强序 x86 时也允许对宽松的原子操作进行编译时重新排序一样。 编译器不必保留您在考虑要编译的硬件时可能期望的任何内容,因此您需要障碍。 障碍可以编译为零 asm 指令。


那么为什么编译器不进行这种优化呢?

这是一个实现质量问题,可能会改变在实际硬件上观察到的性能/行为。

问题最明显的情况是进度条。 将存储从循环中下沉(不包含其他原子操作)并将它们全部折叠成一个将导致进度条保持在 0,然后在最后变为 100%。

没有C++11std::atomic的方法可以在您不想要的情况下阻止它们这样做,因此现在编译器只是选择从不将多个原子操作合并为一个。 (将它们全部合并到一个操作中不会更改它们相对于彼此的顺序。

编译器编写者正确地注意到,程序员期望每次源代码y.store()时,原子存储实际上都会发生在内存中。 (请参阅此问题的大多数其他答案,这些答案声称商店需要单独发生,因为可能的读者等待看到中间值。 即它违反了最小惊喜原则。

但是,在某些情况下,它会非常有帮助,例如避免循环中无用的shared_ptr引用计数 inc/dec。

显然,任何重新排序或合并都不能违反任何其他排序规则。 例如,num++; num--;仍然必须完全阻碍运行时和编译时重新排序,即使它不再在num时接触内存。


目前正在讨论扩展std::atomicAPI,以使程序员能够控制此类优化,此时编译器将能够在有用时进行优化,即使在精心编写的代码中也不会故意效率低下,这也可能发生。 以下工作组讨论/提案链接中提到了一些有用的优化案例:

  • http://wg21.link/n4455: N4455 没有理智的编译器可以优化原子
  • http://wg21.link/p0062:WG21/P0062R1:编译器何时应该优化原子组学?

另请参阅理查德·霍奇斯(Richard Hodges)对"int num"的原子化吗?(见评论)。 另请参阅我对同一问题的回答的最后一部分,其中我更详细地论证了这种优化是允许的。 (这里简短一点,因为那些C++工作组链接已经承认当前编写的标准确实允许它,并且当前的编译器只是没有故意优化。


在当前标准中,volatile atomic<int> y将是确保不允许对其商店进行优化的一种方式。 (正如赫伯·萨特(Herb Sutter)在SO答案中指出的那样,volatileatomic已经共享了一些要求,但它们是不同的)。 另请参阅std::memory_ordervolatile在cpp偏好上的关系。

不允许优化对volatile对象的访问(例如,因为它们可能是内存映射的 IO 寄存器)。

使用volatile atomic<T>主要可以解决进度条问题,但它有点丑陋,如果/当C++决定使用不同的语法来控制优化以便编译器可以在实践中开始这样做时,几年后可能会看起来很愚蠢。

我认为我们可以确信,在有办法控制它之前,编译器不会开始进行这种优化。 希望它是某种选择加入(如memory_order_release_coalesce),当编译为C++任何代码时,不会改变现有代码C++11/14代码的行为。 但它可能就像 wg21/p0062 中的提议:标记不要使用[[brittle_atomic]]优化案例。

WG21/P0062警告说,即使volatile atomic也不能解决所有问题,不鼓励将其用于此目的。 它给出了这个例子:

if(x) {
foo();
y.store(0);
} else {
bar();
y.store(0);  // release a lock before a long-running loop
for() {...} // loop contains no atomics or volatiles
}
// A compiler can merge the stores into a y.store(0) here.

即使有volatile atomic<int> y,编译器也可以将y.store()if/else中下沉并只执行一次,因为它仍然以相同的值执行 1 个存储。 (这将是在 else 分支中的长循环之后)。 特别是如果商店只是relaxedrelease而不是seq_cst

volatile确实停止了问题中讨论的合并,但这表明对atomic<>的其他优化也可能对实际性能造成问题。


不优化的其他原因包括:没有人编写复杂的代码来允许编译器安全地进行这些优化(而不会出错)。 这还不够,因为N4455说LLVM已经实现或可以轻松实现它提到的几个优化。

不过,程序员感到困惑的原因当然是合理的。 首先,无锁代码很难正确编写。

在使用原子武器时不要随意:它们并不便宜,也没有太多优化(目前根本没有)。但是,避免使用std::shared_ptr<T>进行冗余原子操作并不总是那么容易,因为它没有非原子版本(尽管这里的答案之一提供了一种简单的方法来定义 gcc 的shared_ptr_unsynchronized<T>)。

你指的是死存储消除。

消除原子死存储并不被禁止,但很难证明原子存储符合这样的条件。

传统的编译器优化(如死存储消除)可以对原子操作执行,甚至是顺序一致的操作。
优化器必须小心避免跨同步点这样做,因为另一个执行线程可以观察或修改内存,这意味着传统的优化在考虑原子操作优化时必须考虑比通常更多的干预指令。
在消除死存储的情况下,仅仅证明一个原子存储后支配另一个存储并别名以消除另一个存储是不够的。

从 N4455 没有理智的编译器可以优化原子

在一般情况下,原子 DSE 的问题在于它涉及寻找同步点,在我的理解中,这个术语是指代码中线程 A 上的指令与另一个线程 B 上的指令之间存在发生前关系的点。

考虑由线程 A 执行的以下代码:

y.store(1, std::memory_order_seq_cst);
y.store(2, std::memory_order_seq_cst);
y.store(3, std::memory_order_seq_cst);

可以优化为y.store(3, std::memory_order_seq_cst)吗?

如果线程 B 正在等待查看y = 2(例如,使用 CAS),则永远不会观察到代码是否得到优化。

然而,在我的理解中,在y = 2上进行 B 循环和 CASsing 是一种数据竞赛,因为两个线程的指令之间没有完全的顺序。
在 B 的循环之前执行 A 的指令是可观察的(即允许的),因此编译器可以优化以y.store(3, std::memory_order_seq_cst)执行。

如果线程 A 和 B 以某种方式在线程 A 中的存储之间同步,则不允许优化(将诱导部分顺序,可能导致 B 可能观察到y = 2)。

证明没有这样的同步是困难的,因为它涉及考虑更广泛的范围并考虑架构的所有怪癖。

至于我的理解,由于原子操作的年龄相对较小,并且难以推理内存排序,可见性和同步,编译器不会对原子执行所有可能的优化,直到构建了更强大的框架来检测和理解必要的条件。

我相信您的示例是对上面给出的计数线程的简化,因为它没有任何其他线程或任何同步点,据我所知,我想编译器可以优化三个存储。

当您在一个线程中更改原子的值时,其他线程可能正在检查它并根据原子的值执行操作。您给出的示例非常具体,以至于编译器开发人员认为它不值得优化。但是,如果一个线程正在为原子设置连续值:012等,则另一个线程可能会在原子值指示的插槽中放置一些东西。

注意:我本来想评论一下,但它有点太罗嗦了。

一个有趣的事实是,这种行为不是C++数据竞赛。

第14页的注21很有趣: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf(我的强调):

如果程序的执行包含两个数据争用

,则程序的执行包含数据争用 不同线程中的冲突操作,其中至少有一个是 不是原子的

同样在第11页注5:

"宽松"原子操作甚至不是同步操作 但是,与同步操作一样,它们不能对 数据竞赛。

因此,就C++标准而言,对原子的冲突操作从来都不是数据竞赛。

这些操作都是原子的(特别是放松的),但这里没有数据竞赛!

我同意这两者在任何(合理的)平台上都没有可靠/可预测的区别:

include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}

include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
}

但在内存模型提供的定义C++,它不是数据竞赛。

我不容易理解为什么提供该定义,但它确实给了开发人员几张卡片,让他们在线程之间进行随意的通信,他们可能知道(在他们的平台上)在统计上会起作用。

例如,将一个值设置为 3 次,然后读回它将显示对该位置的某种程度的争用。这种方法不是确定性的,但许多有效的并发算法不是确定性的。 例如,超时try_lock_until()始终是一种争用条件,但仍然是一种有用的技术。

看起来C++标准为您提供了有关"数据竞赛"的确定性,但允许某些具有竞争条件的乐趣和游戏,这些条件最终分析是不同的东西。

简而言之,该标准似乎指定了其他线程可能看到设置3次的值的"锤击"效果,其他线程必须能够看到该效果(即使它们有时可能看不到! 在这种情况下,其他线程在某些情况下可能会看到锤击的几乎所有现代平台。

简而言之,因为标准(例如 20 左右和以下的 paragaraphs[intro.multithread])不允许这样做。

有一些先行保证必须得到满足,并且排除了重新排序或合并写入的可能性(第19段甚至明确地说明了重新排序)。

如果线程将三个值(假设为 1、2 和 3)一个接一个地写入内存,则不同的线程可能会读取该值。例如,如果您的线程被中断(或者即使它同时运行),并且另一个线程写入该位置,则观察线程必须以与操作发生的顺序完全相同的顺序查看操作(通过调度或巧合,或任何原因)。这是一个保证。

如果您只执行一半的写入(甚至只执行一次写入),这怎么可能?其实不然。

如果您的线程写出 1 -1-1,但另一个线程偶尔写出 2 或 3 怎么办?如果第三个线程观察该位置并等待一个因为优化而从未出现的特定值怎么办?

如果存储(和负载)未按请求执行,则无法提供提供的保证。所有这些,并且顺序相同。

该模式的一个实际用例,如果线程在不依赖或修改y的更新之间执行重要操作,可能是: *线程 2 读取y的值以检查线程 1 取得了多少进展。

因此,也许线程 1 应该在步骤 1 中加载配置文件,将其解析的内容放入数据结构中作为步骤 2,并在步骤 3 中显示主窗口,而线程 2 正在等待步骤 2 完成,以便它可以并行执行另一个依赖于数据结构的任务。 (当然,此示例需要获取/发布语义,而不是宽松排序。

我很确定一个符合标准的实现允许线程 1 在任何中间步骤中都不会更新y——虽然我没有仔细研究语言标准,但如果它不支持另一个线程轮询y可能永远不会看到值 2 的硬件,我会感到震惊。

但是,这是一个假设实例,其中优化状态更新可能是悲观的。 也许编译器开发人员会来这里说为什么编译器选择不这样做,但一个可能的原因是让你搬起石头砸自己的脚,或者至少把自己放在脚趾上。

编译器编写器不能只执行优化。他们还必须说服自己,优化在编译器编写者打算应用它的情况下是有效的,在它无效的情况下不会应用,它不会破坏实际上被破坏的代码,而是"工作"在其他实现上。这可能比优化本身要多。

另一方面,我可以想象在实践中(即在应该完成工作的程序中,而不是基准测试中),这种优化将节省很少的执行时间。

因此,编译器编写者将查看成本,然后查看收益和风险,并可能决定反对它。

让我们离三家商店紧挨在一起的病理情况走得更远一点。 假设在存储之间正在执行一些重要的工作,并且这些工作根本不涉及y(以便数据路径分析可以确定这三个存储实际上是冗余的,至少在此线程中),并且本身不会引入任何内存障碍(因此其他内容不会强制存储对其他线程可见)。 现在很有可能其他线程有机会在存储之间完成工作,也许这些其他线程操纵y并且该线程有理由需要将其重置为 1(第二个存储)。 如果前两家商店被放弃,这将改变行为。

由于 std::atomic 对象中包含的变量应该从多个线程访问,因此应该期望它们至少表现得像使用 volatile 关键字声明一样。

这是 CPU 架构引入缓存行等之前的标准和推荐做法。

[编辑2] 有人可能会争辩说,std::atomic<>是多核时代的volatile变量。 如 C/C++ 中所定义,volatile仅足以同步来自单个线程的原子读取,ISR 修改变量(在这种情况下,这实际上是从主线程看到的原子写入)。

我个人感到宽慰的是,没有编译器会优化对原子变量的写入。 如果写入被优化掉,如何保证这些写入中的每一个都可能被其他线程中的读者看到? 不要忘记,这也是 std::atomic<> 合约的一部分。

考虑这段代码,其中的结果将受到编译器疯狂优化的极大影响。

#include <atomic>
#include <thread>
static const int N{ 1000000 };
std::atomic<int> flag{1};
std::atomic<bool> do_run { true };
void write_1()
{
while (do_run.load())
{
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
}
}
void write_0()
{
while (do_run.load())
{
flag = -1; flag = -1; flag = -1; flag = -1;
}
}

int main(int argc, char** argv) 
{
int counter{};
std::thread t0(&write_0);
std::thread t1(&write_1);
for (int i = 0; i < N; ++i)
{
counter += flag;
std::this_thread::yield();
}
do_run = false;
t0.join();
t1.join();
return counter;
}

[编辑] 起初,我并没有提出volatile是原子学实施的核心,但是......

由于似乎有人怀疑volatile是否与原子学有关,我调查了此事。这是VS2017 stl的原子实现。 正如我所猜测的,易变关键字无处不在。

// from file atomic, line 264...
// TEMPLATE CLASS _Atomic_impl
template<unsigned _Bytes>
struct _Atomic_impl
{   // struct for managing locks around operations on atomic types
typedef _Uint1_t _My_int;   // "1 byte" means "no alignment required"
constexpr _Atomic_impl() _NOEXCEPT
: _My_flag(0)
{   // default constructor
}
bool _Is_lock_free() const volatile
{   // operations that use locks are not lock-free
return (false);
}
void _Store(void *_Tgt, const void *_Src, memory_order _Order) volatile
{   // lock and store
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Load(void *_Tgt, const void *_Src,
memory_order _Order) const volatile
{   // lock and load
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Exchange(void *_Left, void *_Right, memory_order _Order) volatile
{   // lock and exchange
_Atomic_exchange(&_My_flag, _Bytes, _Left, _Right, _Order);
}
bool _Compare_exchange_weak(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{   // lock and compare/exchange
return (_Atomic_compare_exchange_weak(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
bool _Compare_exchange_strong(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{   // lock and compare/exchange
return (_Atomic_compare_exchange_strong(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
private:
mutable _Atomic_flag_t _My_flag;
};

MS stl 中的所有专业都使用易失性的关键功能。

以下是其中一个关键函数的声明:

inline int _Atomic_compare_exchange_strong_8(volatile _Uint8_t *_Tgt, _Uint8_t *_Exp, _Uint8_t _Value, memory_order _Order1, memory_order _Order2)

您会注意到所需的volatile uint8_t*保存 std::atomic 中包含的值。 这种模式可以在MS std::atomic<>实现中观察到,在这里,gcc团队或任何其他stl提供商都没有理由以不同的方式做到这一点。