提问者:小点点

为什么没有一个主要的编译器优化这个检查值是否已经设置的条件存储?


我偶然发现了这篇Reddit帖子,这是对以下代码片段的一个笑话,

void f(int& x) {
    if (x != 1) {
        x = 1;
    }
}
void g(int& x) {
    x = 1;
}

说这两个函数不等同于“编译器”。我确信任何主要的C编译器都会将条件赋值优化为无条件存储,从而为fg发出相同的汇编代码。

然而,他们没有。

谁能给我解释一下为什么会这样?

我想的是:无条件存储很可能会更快,因为我们无论如何都要访问内存才能读取比较的值,分支代码会给分支预测器带来压力。编译器(AFAIK)也不应该认为存储是副作用,即使后续的内存访问可能会更快或更慢,这取决于是否采取了f中的分支,由于缓存局部性。

那么编译器就是不能弄清楚这一点吗?虽然fg的等价性可能不一定是微不足道的证明,但我觉得这些编译器能够解决更难的问题。所以我可能错了,这些函数毕竟不是等价的,或者这里发生了什么?


共3个答案

匿名用户

静态const int val=1是不安全的;生活在只读内存中。无条件存储版本将分段错误尝试写入只读内存。

首先检查的版本可以安全地在C抽象机器中调用该对象(通过const_cast),因此优化器必须尊重任何未写入的对象最初是const并且在只读内存中的可能性。

  • 是否允许在const定义的对象上抛弃const,只要它没有实际修改?引用标准来支持这一点。

在一个静默忽略写入只读地址的尝试,或者只有读写RAM的系统上,这不是问题。但是像x86-64这样的主流非嵌入式平台确实有内存保护,一些嵌入式目标可能会在尝试存储到ROM时出错。在抽象机器中编写一个const对象仍然是C UB,但是编译器理论上可以发明在为一个系统生成asm时已经存在的值的写入,如果其他限制不能阻止它。如果编译器开发人员真的编写和维护代码来花费编译时间寻找这种优化,这是不太可能的。

一般来说,编译器不能发明对抽象机器不写入的对象的写入,以防另一个线程也在写入它,我们会踩到这个值。例如x.store(x. load())可以将x重置回较早的值,使另一个线程的x丢失计数。(除了原子RMW是安全的,就像只有当值已经是0时才原子存储0的比较交换。)

由于我们已经读取了对象(并且在读取和另一个线程可以同步的潜在写入之间没有任何东西),我们可以假设没有其他线程写入,因为这将是我们无条件读取的数据竞争UB。

在这种情况下,看到任何其他值都会导致一个存储,所以任何对其他线程存储的值的踩踏都可以用if在另一个存储之后运行,看到一个非1值,然后决定存储一个1来解释。(除非无条件存储可能存在内存排序问题?我认为在无竞争的程序中可能不会,尤其是在使用其强有序内存模型为x86编译时。)

我认为线程安全在这种情况下不是一个真正的问题,假设在asm中以原子方式存储int1,因此其他线程都将在no-UB情况下读取1,其中没有任何其他编写器可以与此函数的执行重叠。

但总的来说,在实践中,为编译器发明非原子加载存储相同的值是一个线程安全问题(例如,我似乎记得读过IA-64GCC为奇数长度memcpy或位字段或其他东西的数组末尾的字节做到了这一点,当它在uint8_t锁旁边的结构中时,这是个坏消息。)所以编译器开发人员有理由不愿意发明存储。

    null

注意1:原子性
对于大多数C实现来说,原子存储在asm中是微不足道的,它需要int对齐,并在寄存器和内部数据路径至少与int一样宽的机器上运行。但是在这种情况下,原子性实际上并不是必要的:一次存储1个字节也没关系。如果没有其他编写者,用已经存在的值重写每个字节永远不会改变值。如果有其他编写者,C抽象机器中存在UB,我们只是在改变症状。例如,如果在我们存储了4个字节中的3个之后,另一个线程存储了-1,则最终结果是0x00ffffff

问题是暂时在内存中留下一个不同的值,例如,像超级猫建议的那样,将整个东西清除为零,然后设置低位。这将被允许在抽象机器中真正发生的赋值。(但可能只有对于死亡站9000编译器才是合理的,它故意敌视并在尽可能多的情况下使用UB破坏代码。与真正的编译器相反,真正的编译器在设计时考虑了系统/内核编程和像Linux内核使用的手工滚动原子。)

由于C变量不是原子的

如果许多线程在同一个对象上运行此代码,则无条件写入在正常的CPU架构上是安全的,但速度要慢得多(争夺缓存行的MESI独占所有权与共享所有权)。

弄脏缓存行也是不可取的。

(之所以安全,仅仅是因为它们都存储相同的值。如果甚至一个线程存储了不同的值,如果它碰巧不是修改顺序中的最后一个,它可能会覆盖该存储,修改顺序是由获得缓存行所有权的CPU提交存储的顺序决定的。)

这种写前检查的习惯用法实际上是一些多线程代码会做的一件真实的事情,以避免对变量进行缓存行乒乓,如果每个线程都写了已经存在的值,这些变量将被高度竞争:

>

  • C优化:条件存储以避免弄脏缓存行

    奇怪的优化?在'libuv'中。请解释

    分支错误预测与缓存未命中

    还涉及CPU体系结构注意事项:

    >

  • x86如何处理存储条件指令?(它没有,除了AVX或AVX-512掩码存储。这不是很相关,因为您仍然必须先读取才能生成条件。x86cmpxchg进行=not!=比较。并且使用lock cmpxchg来获得原子RMW以确保没有线程安全问题,总是会弄脏缓存行。)

    是什么特别将x86缓存行标记为脏行——任何写入,还是需要显式更改?硬件中的静默存储优化可能是两全其美的,也许当软件无条件存储已经存在的值时,甚至不需要缓存获得该行的独占所有权。但是据我所知,没有CPU真正对L1d进行静默存储优化。不幸的是,一些对L3(Skylake和ICE Lake用于存储全零缓存行)做了优化的CPU已经在微码中禁用了它,因为它有可能存在依赖数据的时序侧通道。

    通过内联程序集锁定内存操作-并讨论cmpxchg在失败时写入目标缓存行吗?如果不是,它是否比xchg用于自旋锁更好?re:纯读后跟写可能会导致对缓存行的两个非核心请求:一个是在MESI共享状态下获取它,然后是读取所有权(RFO)以获得独占所有权。这与自旋锁或互斥锁的问题相同,如果您开始悲观,并在尝试lock cmpxchgxchg之前先检查只读,从而尽量不打扰其他内核。

    在这种情况下,如果您不希望大部分时间都避免存储,您应该无条件地这样做,这样只有来自写请求的RFO,而不是更早的共享请求。(这也避免了可能的分支错误预测,或者在32位ARM上,可以使用谓词存储,避免等待加载的停滞。存储缓冲区可以将执行与提交缓存的缓存未命中存储分离。)

  • 匿名用户

    这是否构成优化取决于x非1的频率,这是C编译器事先不知道的。如果x几乎总是1,那么if(x!=1)back可能会比x=1更快。

    (有趣的是,一些虚拟机,如Java虚拟机,确实在运行时分析执行模式,并即时执行此类优化,如果事实证明他们的假设是错误的,他们甚至可能撤销此类优化,因此理论上,如果我们相信在运行时分析执行模式的开销小于它们节省的开销,它们可以在某些边际情况下优于C。我真的不知道。我只是觉得他们这样做很有趣。)

    匿名用户

    对我来说,最明显的答案是,这种优化不值得努力实现。这不是一个经常发生的代码模式,执行优化的收益太小。编写编译器时,总是要权衡要实现哪些优化。添加优化需要时间,增加了代码的复杂性,对于“现实生活中的代码”中很少发生的事情,或者收益非常小的事情,这样做只是浪费时间。只有当它从更一般的优化中自然脱落时,这样的事情才会被优化。