为什么我的C++编译器不能优化这些内存写操作?

4
我创建了这个程序。它没有任何有趣的功能,只是使用处理能力。
通过使用objdump -d查看输出,即使在使用O3编译时,我也可以看到最后三个rand调用和相应的mov指令。
为什么编译器不会意识到内存不会被使用,并用while(1){}替换下半部分?我正在使用gcc,但我主要关心标准需要什么。
/*
 * Create a program that does nothing except slow down the computer.
 */
#include <cstdlib>
#include <unistd.h>

int getRand(int max) {
  return rand() % max;
}

int main() {
  for (int thread = 0; thread < 5; thread++) {
    fork();
  }
  int len = 1000;
  int *garbage = (int*)malloc(sizeof(int)*len);
  for (int x = 0; x < len; x++) {
    garbage[x] = x;
  }
  while (true) {
    garbage[getRand(len)] = garbage[getRand(len)] - garbage[getRand(len)];
  }
}

你在使用gcc时指定了哪些标志? - James Oravec
我正在使用的完整命令是:g++ -O3 slowdown.cc - Joshua Snider
我想你可以争论一下,产生不同的伪随机序列会改变程序的可观察行为——如果程序实际上输出了稍后的 rand() 的结果,而这个程序没有。 - M.M
fork() 不会创建多个线程。它会创建一个新的独立的完整进程。它们的内存是共享的写时复制,而不像线程共享内存那样读写共享。在Linux上,glibc的fork()实现使用clone(),但使用与线程创建不同的标志。因此,多个进程分别具有自己的rand() RNG状态。即使它们是共享内存线程,它们也会各自进行malloc。 - Peter Cordes
还要注意的是,在进行下一次迭代之前,fork()循环并没有检查它是否是父进程。因此,实际上你会生成2^5(32)个无限循环进程。每次循环都会使进程数量翻倍。 - Peter Cordes
@PeterCordes:指数部分是有意为之的,因为我的电脑非常强大。 - Joshua Snider
4个回答

10

因为GCC不能够在动态分配的内存上执行此优化。然而,如果你将garbage改为本地数组,GCC就会将循环编译成这样:

.L4:
    call    rand
    call    rand
    call    rand
    jmp .L4

这仅仅是重复调用rand函数(因为该调用具有副作用),但优化了读写操作。

如果GCC更加聪明,它也可以优化掉rand函数的调用,因为其副作用只会影响后续的rand函数调用,在这种情况下没有后续调用。然而,这种优化可能只是浪费编译器开发人员的时间。


不错的观察。我在godbolt上尝试了一下,发现只有gcc能够优化掉数组访问(以及rand()输出的模1000)。而clang和icc则不能。此外,即使使用-fwhole-program,gcc也只是避免了对getRand()的非内联定义。也就是说,它可以将其视为已声明为staticinline。另外,即使len已知为编译时常量,gcc也只能用int garbage[1000]而不是int garbage[len]来实现这个优化。 - Peter Cordes

5

通常情况下,它不能确定这里的 rand() 没有可观察的副作用,并且不需要删除这些调用。

它可以删除写入操作,但数组的使用可能足以抑制这种情况。

标准既不要求也不禁止它所做的事情。只要程序具有正确的可观察行为,任何优化都纯粹是实现质量问题。


那么,它可以用 while(true) { rand();} 替换最后的循环,但是没有这样做? - Joshua Snider
是的,这就是我建议的。 - Alan Stokes
1
rand()是一个标准库函数,因此编译器知道它的确切作用(标准对其进行了定义)。 - M.M
@M.M 它可以知道,但在优化时不需要利用那些知识。我纠正了我的过于限制性的陈述。 - Alan Stokes
rand确实具有副作用。虽然在这种情况下它们并不重要,正如我在我的答案中指出的那样。理论上,编译器可以优化调用。 - interjay
显示剩余2条评论

3
这段代码会导致未定义行为,因为它有一个无限循环且没有可观察行为。因此,任何结果都是允许的。
在C++14中,文本为1.10/27:
实现可以假定任何线程最终都会执行以下操作之一:
  • 终止,
  • 调用库I/O函数,
  • 访问或修改volatile对象,或者
  • 执行同步操作或原子操作。
[注:这旨在允许编译器进行转换,例如删除空循环,即使无法证明终止。—end note ]
我不认为rand()算作I/O函数。 相关问题

从理论上讲,这可能是真的(虽然在这里成为原因的可能性非常小)。但是在这里,您可以轻松地将循环更改为有限的,并且可以看到结果是相同的。 - interjay
如果 OP 更新代码以避免无限循环并仍然显示问题,我会删除这个答案。 - M.M
我认为这样一个过于追求细节的回答在这里没有任何用处。此外,我认为实际上任何编译器都将允许无限循环,因为许多程序都需要它们。 - interjay
@interjay 我链接了一个例子,展示编译器如何移除无限循环。 - M.M
一个人可以既严谨又正确。而这个规则确实是为编译器编写者的利益而添加的,因为它是一种有用的优化。 - Alan Stokes
这并不是一个吹毛求疵的答案 - 这个规则的确是为了改变编译器优化器的行为而添加的,这也是问题所在。 - Puppy

0

让数组溢出的机会来崩溃吧!编译器不会猜测getRand的输出范围。


为什么不呢?这个模数意味着我们知道0 <= getRand < max。 - Joshua Snider
rand 返回一个 int,所以模数可能是负数,你需要深入挖掘!我不相信任何优化器会玩这个游戏。 - user1196549

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接