为什么编译器没有合并多余的std :: atomic写入?

Pet*_*teC 47 c++ multithreading compiler-optimization c++11 stdatomic

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

#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);
}
Run Code Online (Sandbox Code Playgroud)

我尝试过的每个编译器都会发出三次上面的编写.什么合法的,无种族的观察者可以看到上述代码与具有单次写入的优化版本之间的差异(即,不是"假设"规则适用)?

如果变量是易变的,那么显然不适用优化.在我的情况下有什么阻止它?

这是编译器资源管理器中的代码.

Mar*_*oom 41

你指的是淘汰死亡店.

不禁止消除原子死存储,但是更难以证明原子存储符合这样.

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

来自N4455 No Sane编译器将优化原子

原子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);
Run Code Online (Sandbox Code Playgroud)

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

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

但是,根据我的理解,B循环和CASsing y = 2是一个数据竞争,因为两个线程的指令之间没有总顺序.
在B的循环之前执行A指令的执行是可观察的(即允许的),因此编译器可以优化到y.store(3, std::memory_order_seq_cst).

如果线程A和B以某种方式在线程A中的存储之间同步,那么将不允许优化(将引发部分顺序,可能导致B潜在地观察y = 2).

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

至于我的理解,由于原子操作的年龄相对较小以及推理内存排序,可见性和同步的难度,编译器不会对原子进行所有可能的优化,直到用于检测和理解必要的更强大的框架为止.条件已建立.

我相信你的例子是上面给出的计数线程的简化,因为它没有任何其他线程或任何同步点,我可以看到,我想编译器可以优化三个存储.

  • @MSalters:据我所知,编译器可以进行优化,但现在却选择不这样做,因为这会违反程序员对进度条这样的事情的期望.需要新语法以允许程序员选择.所写的标准允许在C++抽象机上发生的任何可能的重新排序(在编译时)作为*总是*发生的排序,但这是不可取的.另见http://wg21.link/p0062. (3认同)
  • 你指的是N4455,但似乎对N4455的解释完全不同于我.甚至N4455中的第一个示例也比您的示例(添加而不是直接存储)更复杂,并且该示例被描述为"无争议"(可以进行优化).鉴于N4455还声明LLVM实现了所提到的一些优化,可以安全地假设最简单的一个肯定是实现的. (2认同)
  • @MargaretBloom:1)在此顺序一致与放松无关紧要(区别仅在*其他*存储位置起作用时才有意义)。2)在您的y == 2检查示例中,有所谓的逻辑竞争,但没有数据竞争。这是非常重要的区别。考虑“未指定”与“未定义”的行为:可能曾经看到“ y == 2”,也可能没有看到,但没有鼻恶魔。3)在单个原子上的操作总是(总有)顺序(即使带有``松弛'')。该顺序可能只是不可预测的。4)我同意原子可能会造成混乱。;-) (2认同)

Pet*_*des 37

所编写的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
Run Code Online (Sandbox Code Playgroud)

该标准并不能保证一个观察者旋转上y(用原子负载或CAS)永远不会看到y == 2.依赖于此的程序会产生数据争用错误,但只有花园种类的错误种类,而不是C++未定义行为类型的数据竞争.(只有非原子变量才是UB).期望有时看到它的程序不一定是错误的.(见下文:进度条.)

可以在C++抽象机器上选择任何可能的排序(在编译时)作为始终发生的排序.这是执行中的as-if规则.在这种情况下,就好像 所有三个商店在全局订单中背靠背发生一样,没有来自其他线程的加载或存储在y=1和之间发生y=3.

它不依赖于目标架构或硬件; 就像放宽原子操作的编译时重新排序一样,即使是针对强排序的x86也是如此.编译器不必保留您考虑所编译的硬件时可能期望的任何内容,因此您需要屏障.障碍可以编译为零asm指令.


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

这是一个实施质量问题,可以改变实际硬件上观察到的性能/行为.

最明显的问题是进度条.将商店从一个循环中沉没(不包含其他原子操作)并将它们全部折叠成一个将导致进度条保持在0然后在最后100%正确.

在你不需要它的情况下,没有C++ 11 std::atomic方法阻止它们这样做,所以现在编译器只是选择永远不要将多个原子操作合并为一个.(将它们合并为一个操作不会改变它们相对于彼此的顺序.)

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

但是,有些情况下它会非常有用,例如shared_ptr在循环中避免无用的ref count inc/dec.

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


讨论正在进行扩展std::atomicAPI给程序员这样的优化控制,此时编译器将能派上大用场优化,它可以发生,即使在精心编写的代码,是不是故意低效.以下工作组讨论/提案链接中提到了一些有用的优化案例示例:

另请参阅有关Richard Hodges 对'num num'的Can num ++原子回答的相同主题的讨论(见评论).另请参阅对同一问题的答案的最后一部分,其中我更详细地论证了这种优化是允许的.(在此处简短说明,因为那些C++工作组链接已经承认当前标准的编写允许它,并且当前的编译器不会故意优化.)


在当前标准中,volatile atomic<int> y将是确保不允许对其进行优化的一种方法.(正如Herb Sutter在SO回答中指出的那样,volatile并且atomic已经分享了一些要求,但它们是不同的).另请参阅std::memory_ordervolatile cppreference 的关系.

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

使用volatile atomic<T>大多是固定的进度条问题,但它是一种丑陋,如果/当C++的语法不同决定了控制优化,以便编译器可以开始在实践中这样做可能都看傻了几年.

我认为我们可以确信编译器在有办法控制它之前不会开始进行这种优化.希望它会成为某种选择(如a 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.
Run Code Online (Sandbox Code Playgroud)

即使使用volatile atomic<int> y,也允许编译器y.store()退出if/else并只执行一次,因为它仍然使用相同的值完成1个存储.(这将在else分支中的长循环之后).特别是如果商店只是relaxedrelease代替seq_cst.

volatile确实停止了问题中讨论的合并,但是这指出其他优化atomic<>对于实际性能也可能是有问题的.


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

然而,令人困惑的程序员理由肯定是合理的.无锁代码很难在一开始就正确编写.

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

  • @EricTowers不,在Duff的设备中,输出寄存器肯定会被声明为volatile(这是volatile的教科书案例),输出将如预期的那样. (3认同)
  • @PeteC:是的,我认为重要的是要认识到允许优化,并且不这样做是QOI问题,而不是标准遵从问题,并且将来的标准可能会有所改变。 (2认同)

Ser*_*tch 9

当您在一个线程中更改原子的值时,某些其他线程可能正在检查它并根据原子的值执行操作.您给出的示例非常具体,以至于编译器开发人员认为不值得优化.然而,如果一个线程用于原子设定例如连续值:0,1,2等等,其他线程可以被投入由原子的值所指示的时隙的东西.

  • 这方面的一个例子是一个进度条,它从一个`atomic`获取当前状态,而工作线程做一些工作并在没有其他同步的情况下更新`atomic`.优化将允许编译器只写100%一次而不进行冗余写入,这使得进度条不显示进度.应该允许这样的优化是有争议的. (3认同)
  • @nwp:标记为**允许它.可以在编译时选择在C++抽象机器上可能的任何重新排序,因为*总是*发生.这违反了程序员对进度条这样的事情的期望(将一个原子商店从一个不接触任何其他原子变量的循环中沉没,因为对非原子变量的并发访问是UB).目前,编译器选择不优化,即使他们可以.希望在允许的时候会有新的语法来控制.http://wg21.link/p0062和http://wg21.link/n4455. (3认同)

Dam*_*mon 5

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

必须满足之前发生的事先保证,除其他事项外,还排除重新排序或合并写入(第19段甚至明确说明了重新排序).

如果你的线程一个接一个地将三个值写入内存(比如说1,2和3),则另一个线程可能会读取该值.例如,如果您的线程被中断(或者即使它并发运行)而另一个线程写入该位置,那么观察线程必须以完全相同的顺序看到操作(通过调度或巧合,或者无论什么原因).这是一个保证.

如果你只做了一半的写入(甚至只有一次写入),这怎么可能?事实并非如此.

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

如果未按要求执行存储(以及加载),则无法提供给出的保证.所有这些,并以相同的顺序.

  • 优化不会违反先发生的保证.在另一个例子中,它们可能是,但不是在这一个.显然可以为OP的例子提供保证.没有任何东西被重新排序,因此该部分与问题无关. (8认同)
  • 你说有些东西禁止在\ [intro.multithread]中合并写入.**请引用它**.我找不到它了. (5认同)
  • @Damon你能更具体地说明文本中的哪些部分不允许这种优化? (4认同)
  • @OrangeDog因此不太可能逐字出现.虽然它可能来自持续传播,内联和任何其他优化. (2认同)
  • @Deduplicator:没有这样的语言可以保证其他线程有时必须从另一个线程中的写入序列中看到中间值.编译器避免这种优化这一事实是一个实施质量问题,直到C++标准委员会增加了一种选择性地允许它的方式,因为它可能是一个问题.请参阅[我的回答](/sf/ask/3217227121/#45971285),了解支持此解释的标准工作组提案的一些链接这是允许的. (2认同)
  • @OrangeDog:我的意思是非`易失性``原子`。我认为你是对的,`volatile atomic` 可以便携地禁用这样的优化,这有时可以在典型的当前 SMP 硬件上实现。(编译器内存屏障也会像 GNU C `asm("":::"memory")`。)但是 C++ 标准仍然不能保证在 `y` 上旋转的线程会看到 `y==2` . 例如,在单处理器机器上这是*非常*不可能的,并且确定性上下文切换或其他东西可能会使它变得不可能。 (2认同)

Per*_*xty 5

NB:我打算对此发表评论,但这有点过于罗嗦.

一个有趣的事实是,这种行为不是在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);
}
Run Code Online (Sandbox Code Playgroud)

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

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

我不能轻易理解为什么提供这个定义,但它确实向开发人员提供了一些卡片,以便他们可能知道(在他们的平台上)在统计上可以工作的线程之间进行偶然的通信.

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

看起来C++标准为您提供了围绕'数据竞赛'的确定性,但允许某些有竞争条件的有趣游戏,最终分析不同的东西.

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

  • 没有人说这是数据竞争 (4认同)