如果 RMW 操作没有任何变化,是否可以针对所有内存顺序对其进行优化

If a RMW operation changes nothing, can it be optimized away, for all memory orders?

本文关键字:内存 优化 顺序 操作 RMW 任何 变化 如果 是否      更新时间:2023-10-16

在 C/C++ 内存模型中,编译器是否可以组合然后删除冗余/NOP 原子修改操作,例如:

x++,
x--;

甚至只是

x+=0; // return value is ignored

对于原子标量x

这是否适用于顺序一致性或只是较弱的内存顺序?

(注意:对于较弱的记忆顺序仍然起作用;对于放松,这里没有真正的问题。再次编辑:不,在这种特殊情况下实际上有一个严重的问题。看看我自己的答案。甚至没有放松也被清除。

编辑:

问题不在于特定访问的代码生成:如果我想在第一个示例中看到在英特尔上生成的两个lock add,我会使x不稳定。

问题是这些 C/C++ 指令是否有任何影响:编译器是否可以过滤并删除这些 NUL 操作(不是松弛顺序操作),作为一种源到源的转换?(或抽象树到抽象树的转换,也许在编译器"前端")

编辑2:

假设摘要:

  • 并非所有操作都放松
  • 没有什么是易失性的
  • 原子
  • 对象实际上可能被多个函数和线程访问(没有地址不共享的自动原子)

可选假设:

如果需要,您可以假设原子的地址没有被占用,所有访问都是按名称进行的,并且所有访问都有一个属性:

  1. 该变量在任何地方的访问都不是,具有宽松的加载/存储元素:所有加载操作都应该有获取,所有存储都应该有释放(所以所有 RMW 都应该至少acq_rel)。

  2. 或者,对于那些放宽的访问,访问代码不会出于更改值以外的目的读取值:放松的 RMW 不会进一步保存值(并且不会测试值以决定下一步要做什么)。换句话说,除非负载具有获取,否则没有数据或控件依赖于原子对象的值。

  3. 或者原子的所有访问都是顺序一致的。

也就是说,我对这些(我认为很常见)用例特别好奇。

注意:即使访问是使用宽松的内存顺序完成的,当代码确保观察者具有相同的内存可见性时,访问也不会被视为"完全放松",因此这被认为对 (1) 和 (2) 有效:

atomic_thread_fence(std::memory_order_release);
x.store(1,std::memory_order_relaxed);

因为内存可见性至少与仅x.store(1,std::memory_order_release);一样好

这被认为对 (1) 和 (2) 有效:

int v = x.load(std::memory_order_relaxed);
atomic_thread_fence(std::memory_order_acquire);

出于同样的原因。

这是愚蠢的,微不足道的(2)(i只是一个int)

i=x.load(std::memory_order_relaxed),i=0; // useless

因为没有保留任何来自放松操作的信息。

这适用于 (2):

(void)x.fetch_add(1, std::memory_order_relaxed);

这不适用于 (2):

if (x.load(std::memory_order_relaxed))
f();
else
g();

由于相应的决定是基于放松的负荷,因此也不是

i += x.fetch_add(1, std::memory_order_release);

注意:(2) 涵盖了原子最常见的用途之一,即线程安全引用计数器。(更正:目前尚不清楚所有线程安全计数器在技术上都符合描述,因为获取只能在 0 后递减时完成,然后根据 counter>0 在没有获取的情况下做出决定;决定不做某事但仍然......

不,绝对不完全是。 它至少是线程内的内存屏障,用于更强的内存顺序。


对于mo_relaxed原子组学,是的,我认为理论上可以完全优化它,就好像它不在源头中一样。 线程根本不是它可能所属的发布序列的一部分,这是等效的。

如果您使用fetch_add(0, mo_relaxed)的结果,那么我认为将它们折叠在一起并仅执行负载而不是0的 RMW 可能并不完全等效。 围绕放松的 RMW 的此线程中的障碍仍然会影响所有操作,包括对放松的操作进行排序。非原子操作。 通过将 load+store 绑定为原子 RMW,订购商店的东西可以在不订购纯负载时订购原子 RMW。

但我不认为任何C++排序是这样的:mo_release商店订购较早的加载和存储,而atomic_thread_fence(mo_release)就像asm StoreStore + LoadStore障碍。(在栅栏上打折)。 所以是的,鉴于任何C++强加的排序也适用于松弛的负载,同样适用于松弛的 RMW,我认为int tmp = shared.fetch_add(0, mo_relaxed)可以优化为仅负载。


(在实践中,编译器根本不优化原子,基本上将它们都视为volatile atomic,即使对于mo_relaxed也是如此。 为什么编译器不合并冗余的 std::atomic writes?和 http://wg21.link/n4455 + http://wg21.link/p0062。 这太难了/没有机制可以让编译器知道什么时候这样做。

但是,是的,纸面上的 ISO C++ 标准并不能保证其他线程实际上可以观察到任何给定的中间状态。

思想实验:考虑在单核协作多任务系统上实现C++。它通过在需要时插入 yield 调用来实现std::thread以避免死锁,但不在每个指令之间。 标准中没有任何内容要求在num++num--之间产生任何值,以使其他线程观察该状态。

as-if 规则基本上允许编译器选择一个合法/可能的排序,并在编译时决定每次都会发生这种情况。

在实践中,如果解锁/重新锁定实际上从未给其他线程带来锁定的机会,如果--/++组合在一起,而不修改原子对象,这可能会产生公平性问题! 这就是编译器不优化的原因。


一个或两个操作的任何更强的排序都可以开始,也可以成为与读取器同步的发布序列的一部分。 执行发布存储/RMW 的获取加载的读取器与此线程同步,并且必须看到此线程的所有先前效果都已发生。

IDK 读者如何知道它看到的是这个线程的发布存储区而不是以前的一些值,所以一个真实的例子可能很难编造。 至少我们可以创建一个没有可能 UB 的 UB,例如,通过读取另一个松弛的原子变量的值,这样如果我们没有看到这个值,我们就避免数据竞争 UB。

考虑以下顺序:

// broken code where optimization could fix it
memcpy(buf, stuff, sizeof(buf));
done.store(1, mo_relaxed);       // relaxed: can reorder with memcpy
done.fetch_add(-1, mo_relaxed);
done.fetch_add(+1, mo_release);  // release-store publishes the result

这可以优化为仅done.store(1, mo_release);,从而正确地将1发布到另一个线程,而不会在更新buf值之前过早显示1的风险。

但它也可以优化取消对 RMW 在放松商店之后的围栏,这仍然会被打破。 (而不是优化的错。

// still broken
memcpy(buf, stuff, sizeof(buf));
done.store(1, mo_relaxed);       // relaxed: can reorder with memcpy
atomic_thread_fence(mo_release);

我还没有想到安全代码被这种合理的优化破坏的例子。当然,即使它们seq_cst,也只是完全移除这对并不总是安全的。


seq_cst的递增和递减仍然会产生一种内存障碍。如果不对它们进行优化,早期的商店就不可能与后来的负载交错。 为了保持这一点,针对 x86 进行编译可能仍然需要发出mfence

当然,显而易见的事情是lock add [x], 0它实际上确实对我们x++/x--的共享对象执行了虚拟 RMW。 但我认为仅内存屏障,而不是与对该实际对象或缓存行的访问相结合就足够了。

当然,它必须充当编译时内存屏障,阻止跨它的非原子和原子访问的编译时重新排序。

对于acq_rel或较弱的fetch_add(0)或取消序列,运行时内存屏障可能在 x86 上免费发生,只需要限制编译时排序。

另请参阅我的回答中关于num++可以为"int num"原子吗?的部分,以及对Richard Hodges的回答的评论。 (但请注意,其中一些讨论被关于++--之间何时对其他对象进行修改的争论所混淆。 当然,原子所暗示的这个线程操作的所有顺序都必须保留。


正如我所说,这都是假设的,真正的编译器在 N4455/P0062 尘埃落定之前不会优化原子。

C++ 内存模型为对同一原子对象的所有原子访问提供了四个一致性要求。无论内存顺序如何,这些要求都适用。如非规范性符号中所述:

前面的四个一致性要求实际上不允许编译器将原子操作重新排序到单个对象,即使这两个操作都是宽松的加载

强调添加。

鉴于这两个操作都发生在同一个原子变量上,并且第一个操作肯定发生在第二个之前(由于在它之前被排序),因此这些操作不会重新排序。同样,即使使用了relaxed操作。

如果编译器删除了这对操作,则可以保证其他线程永远不会看到递增的值。所以现在的问题变成了标准是否需要其他线程才能看到递增的值。

它没有。如果没有某种方法可以保证某些事情在增量之后"发生">并在递减之前"发生",则无法保证任何其他线程上的任何操作肯定会看到递增的值。

这留下了一个问题:第二个操作总是撤消第一个操作吗?也就是说,递减是否会撤消增量?这取决于所讨论的标量类型。++ 和 -- 仅针对atomic的指针和整数专用化定义。所以我们只需要考虑这些。

对于指针,递减会撤消增量。原因是递增+递减指针不会导致指向同一对象的相同指针的唯一方法是,递增指针本身就是 UB。也就是说,如果指针无效,则为 NULL,或者是指向对象/数组的过去结束指针。但是编译器不必考虑 UB 案例,因为...它们是未定义的行为。在递增有效的所有情况下,指针递减也必须有效(或 UB,可能是由于有人释放内存或以其他方式使指针无效,但同样,编译器不必关心)。

对于无符号整数,递减始终会撤消增量,因为无符号整数的环绕行为是明确定义的。

这留下了有符号整数。C++通常会使有符号整数溢出/下溢到 UB 中。幸运的是,原子数学并非如此;该标准明确指出:

对于有符号整数类型,算术定义为使用 2 的补码表示形式。没有未定义的结果。

两个补体原子的环绕行为工作。这意味着递增/递减总是导致恢复相同的值。

因此,标准中似乎没有任何内容可以阻止编译器删除此类操作。同样,无论内存顺序如何。

现在,如果您使用非松弛内存排序,则实现无法完全删除原子组学的所有痕迹。这些排序背后的实际内存屏障仍然需要发出。但是可以在不发射实际原子操作的情况下发射障碍。

在 C/C++ 内存模型中,编译器是否可以组合然后删除 冗余/NOP原子修饰操作,

不,删除部分是不允许的,至少不是以问题建议的特定方式允许的:这里的目的是描述有效的源到源转换,抽象树到抽象树,或者更确切地说是对源代码的更高级别描述,该描述编码了后期编译阶段可能需要的所有相关语义元素。

假设代码生成可以在转换后的程序上完成,而无需与原始程序进行检查。因此,只允许不能破坏任何代码的安全转换

(注意:对于较弱的记忆顺序,仍然做某事;对于放松, 这里没有真正的问题。

不。即使这样也是错误的:即使是宽松的操作,无条件删除也不是有效的转换(尽管在大多数实际情况下它肯定是有效的,但大多数正确仍然是错误的,"在>99% 的实际情况下是正确的"与问题无关):

在引入标准线程之前,卡住的程序是一个无限循环,是一个空循环,不执行外部可见的副作用:没有输入、输出、易失性操作,实际上没有系统调用。一个永远不会执行可见操作的程序被卡住了,它的行为没有定义,这允许编译器假设纯算法终止:只包含不可见计算的循环必须以某种方式退出(包括异常退出)。

对于线程,这个定义显然是不可用的:一个线程中的循环并不是整个程序,而一个卡住的程序实际上是一个没有线程的程序,可以产生有用的东西,并且禁止这样做是合理的。

但是卡住的非常有问题的标准定义不是描述程序执行,而是描述单个线程:如果线程不会执行可能对可观察到的副作用产生影响的副作用,则线程被卡住,即:

  1. 显然没有可观察的(没有 I/O)
  2. 没有可能与其他线程交互的操作

2.的标准定义非常大且简单,线程间通信设备上的所有操作都很重要:任何原子操作,任何互斥锁上的任何操作。要求的全文(相关部分以粗体显示):

[intro.progress]

该实现可能假定任何线程最终都会执行一个 以下情况:

  • 终止
  • 调用库 I/O 函数,
  • 通过易失性 GL值执行访问,或
  • 执行同步操作或原子操作

[注意:这旨在允许编译器转换,例如删除 空循环,即使无法证明终止。—尾注]

该定义甚至没有具体说明:

线程
  • 间通信(从一个线程到另一个线程)
  • 共享状态(由多个线程可见)
  • 对某种状态的修改
  • 非线程私有对象

这意味着所有这些愚蠢的操作都算数:

  • 对于栅栏

    • 在至少完成一次原子存储的线程中执行获取围栏(即使后面没有原子操作)可以与另一个隔离墙或原子操作同步
  • 对于互斥锁

    • 锁定本地最近创建的、明显无用的函数私有互斥锁;
    • 锁定
    • 互斥锁以在锁定互斥锁的情况下解锁它;
  • 对于原子学

    • 读取声明为常量限定的原子变量(不是对非常量原子的常量引用);
    • 读取原子,忽略值,即使使用宽松的内存排序;
    • 将一个非常量限定的原子设置为它自己的不可变值(当整个程序中没有任何内容将其设置为非零值时,将变量设置为零),即使有宽松的排序;
    • 对其他线程无法访问的局部原子变量进行操作;
  • 对于线程操作:

    • 创建一个线程(可能什么都不做)并加入它似乎创建了一个 (NOP) 同步操作。

这意味着没有程序代码的早期本地转换,不会留下转换到后续编译器阶段的痕迹,并且根据标准删除即使是最愚蠢和无用的线程间原语也是绝对的、无条件有效的,因为它可能会删除最后一个可能有用的(但实际上无用)循环中的操作(循环不必拼写forwhile,它是任何循环结构,例如向后goto)。

但是,如果线程间原语上的其他操作保留在循环中,或者显然如果 I/O 已完成,则这不适用。

这看起来像一个缺陷。

有意义的要求应基于:

  • 不仅在使用线程原语时,
  • 不是孤立的任何线程(因为您看不到线程是否对任何事情做出了贡献,但至少要求与另一个线程进行有意义的交互并且不使用私有原子或互斥锁会比当前要求更好),
  • 基于做一些有用的事情(程序可观察量)和线程间交互,有助于完成某事。

我现在不建议更换,因为线程规范的其余部分对我来说甚至不清楚。