chr*_*nte 56 c++ micro-optimization compiler-optimization
我偶然发现了这篇Reddit 帖子,它是对以下代码片段的一个玩笑,
void f(int& x) {
if (x != 1) {
x = 1;
}
}
void g(int& x) {
x = 1;
}
Run Code Online (Sandbox Code Playgroud)
说这两个函数不等同于“编译器”。我确信任何主要的 C++ 编译器都会将条件赋值优化为无条件存储,从而为f
和发出相同的汇编代码g
。
谁能向我解释为什么会这样?
我的想法是:无条件存储很可能会更快,因为无论如何我们都必须访问内存来读取比较值,并且分支代码会给分支预测器带来压力。此外,编译器不应将存储视为副作用(AFAIK),即使后续内存访问可能会更快或更慢,具体取决于是否f
由于缓存局部性而采用分支。
那么编译器就无法弄清楚这一点吗?虽然证明f
和 的等价性g
可能并不容易,但我觉得这些编译器能够解决更困难的问题。那么我可能错了,这些功能毕竟不相等,或者这里发生了什么?
Pet*_*des 73
const
static const int val = 1;
生活在只读内存中是不安全的。无条件存储版本在尝试写入只读内存时会出现段错误。
首先检查的版本可以安全地在 C++ 抽象机中调用该对象(通过const_cast
),因此优化器必须考虑到任何未写入的对象最初位于const
只读内存中的可能性。
在一个默默地忽略写入只读地址的尝试的系统上,或者只有读+写 RAM 的系统上,这不会是一个问题。但像 x86-64 这样的主流非嵌入式平台确实具有内存保护,并且某些嵌入式目标可能会在尝试存储到 ROM 时出错。在抽象机中编写对象仍然是 C++ UB const
,但理论上,如果其他限制不阻止的话,编译器可以在为系统生成 asm 时发明已经存在的值的写入,在该系统中不会出现故障。如果编译器开发人员实际上编写和维护代码来花费编译时间寻找这种优化,这是不可能的。
一般来说,编译器不能发明对抽象机不写入的对象的写入,以防另一个线程也在写入它并且我们会踩到该值。例如x.store(x.load())
可以重置x
回较早的值,从而使另一个线程x++
丢失计数。(除了原子 RMW 是安全的,就像比较交换一样,0
仅当值已经是 时才以原子方式存储 a 0
。)
由于我们已经读取了该对象(并且读取和潜在写入之间没有任何其他线程可以同步的内容),因此我们可以假设没有其他线程写入,因为这将是我们无条件读取的数据争用 UB。
在这种情况下,看到任何其他值都会导致存储,因此任何对其他线程存储的值的步进都可以同样很好地解释为if
在另一个存储之后运行,看到一个非1
值,然后决定存储A 1
。(除非无条件存储可能存在内存排序问题?我认为可能不会出现在无竞争程序中,尤其是在使用强排序内存模型编译 x86 时。)
我认为对于这种情况,线程安全并不是一个真正的问题,假设在 asm 中以原子方式存储 int 11
,因此其他线程将在无 UB 情况下全部读取,其中没有任何其他编写器可以与该函数的执行。
但总的来说,发明非原子加载 + 存储回相同的值在实践中一直是编译器的线程安全问题(例如,我似乎记得读过 IA-64 GCC 对刚刚经过数组末尾的字节执行了此操作)奇数长度memcpy
或位字段或其他东西,当它位于 旁边的结构中时,这是个坏消息uint8_t lock
。)因此编译器开发人员有理由不愿意发明存储。
arr[i] = arr[i] == x ? new : arr[i];
来无条件存储某些内容,在这种情况下,您当然不能在只读内存上调用它,并让编译器知道它不必担心在以下情况下避免非原子RMW其他线程。它可以通过屏蔽优化存储,但不能发明新存储)。volatile
访问,这可能会成为滚动您自己的原子(如 Linux 内核所做的)的问题。发明的存储仅适用于已经明确存储到对象的代码路径,但发明的加载始终适用于实际对象或 C++ 引用,但指针解引用则不然。(C++ 引用不可为空,我认为只能在有效对象上使用,这与指向数组末尾的指针不同。但 John Bollinger 指出,引用可能比原始对象更长寿,从而变得过时。在有可能出现这种情况的情况下,如果指向的内存可能已被系统调用取消映射,那么发明的负载就不安全。)注 1:原子性对于大多数 C++ 实现来说,
原子存储在 asm 中是微不足道的int
,它需要对齐,并且在寄存器和内部数据路径至少与 一样宽的机器上运行int
。但在这种情况下原子性实际上并不是必需的:一次存储 1 个字节也可以。如果没有其他写入器,则用已有的值重写每个字节不会改变该值。如果还有其他作者的话,C++ 抽象机中就有 UB,而我们只是改变症状。例如,最终结果是0x00ffffff
如果另一个线程-1
在我们存储 4 个字节中的 3 个字节之后存储。
问题是暂时在内存中留下不同的值,例如将整个值清除为零,然后设置低位,如 supercat 所建议的那样。这将允许在抽象机中真正发生的分配。(但可能只对 Deathstation 9000 编译器来说是合理的,它故意敌对并在尽可能多的情况下用 UB 破坏代码。与真正的编译器相反,真正的编译器是在设计时考虑到系统/内核编程和手动原子(如 Linux 内核)用途。)
由于 C++ 变量不是atomic<>
,所以我们不能破坏由不同线程领导的释放序列。没有任何东西可以合法地在 ISO C++ 中acquire
对普通数据进行加载int
。但在 GNU C++ 中,他们可以使用__atomic_load_n(&x, __ATOMIC_ACQUIRE)
.
如果许多线程在同一个对象上运行此代码,则无条件写入在普通 CPU 架构上是安全的,但速度要慢得多(争夺缓存行的 MESI 独占所有权,而不是共享。)
弄脏高速缓存行也可能是不可取的。
(之所以安全,只是因为它们都存储相同的值。即使一个线程存储不同的值,如果它碰巧不是修改顺序中的最后一个(由 CPU 获得所有权的顺序确定),则该存储可能会被覆盖。提交其存储的缓存行。)
这种“写前检查”习惯用法实际上是真实存在的,某些多线程代码会这样做,以避免在变量上进行高速缓存行乒乓球,如果每个线程都写入已经存在的值,则这些变量将发生高度争用:
还有相关的 CPU 架构注意事项:
x86 如何处理存储条件指令?(它没有,除了 AVX 或 AVX-512 掩码存储。这不是很相关,因为您仍然必须先阅读才能生成条件。x86cmpxchg
不会==
比较!=
。并与lock cmpxchg
获得原子 RMW为了确保没有线程安全问题,总是会弄脏缓存行。)
具体是什么将 x86 缓存行标记为脏 - 任何写入,或者是否需要显式更改?-硬件中的静默存储优化可能是两全其美的,当软件无条件存储已经存在的值时,也许不需要缓存获得该行的独占所有权。但据我所知,没有 CPU 实际上对 L1d 进行静默存储优化。不幸的是,一些针对 L3 的技术(Skylake和 Ice Lake用于存储全零缓存行)已经在微代码中禁用了它,因为可能存在依赖于数据的时序侧通道。
通过内联汇编锁定内存操作- 以及关于cmpxchg 失败时是否写入目标缓存行的讨论?如果不是,对于自旋锁来说它比 xchg 更好吗?回复:纯读取后写入的事实可能会导致对缓存行的两个非核心请求:一个是为了将其置于 MESI 共享状态,然后是一个读取所有权 (RFO) 以获得独占所有权。lock cmpxchg
如果您开始悲观并尝试在尝试or之前先检查只读,从而尽量不干扰其他内核,那么这与自旋锁或互斥体有同样的问题xchg
。
在这种情况下,如果您不希望在大部分时间避免存储,则应该无条件地执行此操作,这样只有写入请求中的 RFO,而不是早期的共享请求。(这也避免了可能的分支错误预测,或者在可以进行谓词存储的 32 位 ARM 上,避免了等待加载的停滞。存储缓冲区可以将执行与提交到高速缓存的高速缓存未命中存储分离。)
这是否构成优化取决于x
非 1 的频率,这是 C++ 编译器事先不知道的。如果x
几乎总是 1,那么if( x != 1 ) return
可能会比 更快x = 1
。
(有趣的是,一些虚拟机(例如 Java 虚拟机)确实会在运行时分析执行模式,并即时执行此类优化,如果事实证明它们的假设是错误的,它们甚至可能会撤消此类优化,因此它们可以理论上,如果我们相信在运行时分析执行模式的开销小于它们节省的开销,那么在某些边际情况下会优于 C++。我真的不知道。我只是觉得他们这样做很有趣。)
对我来说,最明显的答案是这种优化不值得付出努力来实现。这不是经常出现的代码模式,并且执行优化的收益太小。在编写编译器时,总是需要权衡要实现的优化。添加优化需要时间并增加代码的复杂性,对于“现实生活代码”中很少发生的事情或增益很小的情况执行此操作纯粹是浪费时间。像这样的东西只有在更一般的优化中自然出现时才会被优化。
归档时间: |
|
查看次数: |
5926 次 |
最近记录: |