C + + 优化器将调用重新排序为时钟()是否合法?

C++程式语言 第四版,第225页内容: 只要结果与简单的执行顺序相同,编译器就可以重新排序代码以提高性能。一些编译器,例如在发布模式下的 Visual C + + ,会重新排序这些代码:

#include <time.h>
...
auto t0 = clock();
auto r  = veryLongComputation();
auto t1 = clock();


std::cout << r << "  time: " << t1-t0 << endl;

变成这种形式:

auto t0 = clock();
auto t1 = clock();
auto r  = veryLongComputation();


std::cout << r << "  time: " << t1-t0 << endl;

它保证了不同于原始代码的结果(零与大于报告的零时间)。有关详细示例,请参阅 我的另一个问题。这种行为是否符合 C + + 标准?

4926 次浏览

是的,这是合法的-如果编译器可以看到 clock()调用之间发生的全部代码。

有个叫 Subclause 5.1.2.3 of the C Standard [ISO/IEC 9899:2011]的东西说:

在抽象机器中,所有表达式都按照 实际的实现不需要计算 如果它能推断出它的值没有被使用,并且没有 产生所需的副作用(包括调用 函数或访问易失性对象)。

因此,我真的怀疑 这种行为-你所描述的-符合标准

此外,重组确实对计算结果有影响,但是如果你从编译器的角度来看——它存在于 int main()世界中,当进行时间测量时——它向外窥视,请求内核给出当前时间,然后回到主世界,在那里外部世界的实际时间并不真正重要。时钟()本身不会影响程序和变量,程序行为也不会影响时钟()函数。

时钟值用于计算它们之间的差异——这就是您要求的。如果两个测量之间有什么事情发生,从编译器的角度来看,这是不相关的,因为你要求的是时钟差,测量之间的代码不会影响测量过程。

然而,这并没有改变这样一个事实,即所描述的行为是非常令人不快的。

尽管不准确的测量令人不快,但它可能会变得更糟,甚至更危险。

考虑取自 这个网站的以下代码:

void GetData(char *MFAddr) {
char pwd[64];
if (GetPasswordFromUser(pwd, sizeof(pwd))) {
if (ConnectToMainframe(MFAddr, pwd)) {
// Interaction with mainframe
}
}
memset(pwd, 0, sizeof(pwd));
}

正常编译时,一切正常,但是如果应用了优化,memset 调用将被优化掉,这可能导致严重的安全缺陷。为什么它被优化了?它非常简单; 编译器再次在它的 main()世界中思考,并认为 memset 是一个死存储,因为变量 pwd事后不会使用,也不会影响程序本身。

至少根据我的阅读,不,这是不允许的。标准的要求是(1.9/14) :

与完整表达式相关联的每个值计算和副作用在每个值计算和与下一个要计算的完整表达式相关联的副作用之前进行排序。

编译器可以自由重新排序的程度超出了“似是而非”规则(1.9/1)所定义的范围:

本国际标准对合格实施方案的结构没有要求。 特别是,他们不需要复制或模仿抽象机器的结构,而是遵循 实现需要模拟(仅)抽象机器的可观察行为,如下所述。

这就留下了一个问题: 有问题的行为(由 cout编写的输出)是否是官方观察到的行为。简而言之,答案是肯定的(1.9/8) :

对符合要求的实施的最低要求是:
[...]
在程序终止时,所有写入文件的数据应与根据抽象语义执行程序可能产生的结果之一相同。

至少在我阅读它的时候,这意味着与执行长计算相比,对 clock的调用可以被重新安排,当且仅当它仍然产生与按顺序执行调用相同的输出时。

然而,如果你想采取额外的步骤来确保正确的行为,你可以利用另一个条款(也是1.9/8) :

ーー根据抽象机器的规则严格评估对易失性对象的访问。

为了利用这一点,您可以稍微修改您的代码,使之类似于:

auto volatile t0 = clock();
auto volatile r  = veryLongComputation();
auto volatile t1 = clock();

现在,我们不必将结论建立在标准的三个独立部分之上,并且仍然只有一个 很公平确定的答案,我们可以只看一个句子,并且有一个 当然确定的答案——通过这个代码,重新排序使用 clock vs. ,长时间的计算是禁止的。

当然,没有是允许的,因为它改变了,正如您已经注意到的,程序的可观察行为(不同的输出)(我不会进入假设的情况,即 veryLongComputation()可能不会消耗任何可测量的时间-给定函数的名称,大概不是这种情况。但即使是这样,也没什么关系)。你不会指望它是允许重新排序 fopenfwrite,你会。

t0t1都用于输出 t1-t0。因此,必须执行 t0t1的初始化器表达式,并且这样做必须遵循所有标准规则。函数的结果被使用,所以不可能优化出函数调用,尽管它不依赖于 直接,反之亦然,所以人们可能天真地倾向于认为移动它是合法的,为什么不呢。也许在初始化 t1之后,它不依赖于计算?
然而,间接地,t1 当然了的结果取决于 veryLongComputation()的副作用(特别是计算时间,如果没有别的) ,这正是存在“序列点”的原因之一。

有三个“表达式结束”序列点(加上三个“功能结束”和“初始化器结束”SP) ,并且在每个序列点都保证以前评估的所有副作用都已经执行,并且没有执行后续评估的副作用。
如果在这三个语句之间移动,就不可能遵守这个承诺,因为所有称为 不知道的函数都可能产生副作用。只有当编译器能够保证遵守承诺时,它才被允许进行优化。它不能,因为库函数是不透明的,它们的代码不可用(veryLongComputation必须的中的代码在该翻译单元中也是已知的)。

然而,编译器有时确实对库函数有“特殊的了解”,比如有些函数不会返回,或者可能返回两次(想想 exitsetjmp)。
然而,由于每个非空的、非平凡的函数(而且 veryLongComputation从它的名字来看是相当不平凡的) 威尔都会消耗时间,因此,如果编译器具有关于不透明的 clock库函数的“特殊知识”,那么实际上必须明确禁止在这个函数周围重新排序调用,因为编译器知道这样做不仅可能会影响结果,而且 威尔也会影响结果。

现在有趣的问题是 为什么编译器到底做了什么?我能想到两种可能性。也许你的代码触发了一个“看起来像基准”的启发,编译器试图欺骗,谁知道呢。这不是第一次了(想想 SPEC2000/179。艺术,或太阳蜘蛛的两个历史性的例子)。另一种可能性是,在 veryLongComputation()内部的某个地方,您无意中调用了未定义行为。在这种情况下,编译器的行为甚至是合法的。

编译器无法交换这两个 clock调用。t1必须设置在 t0之后。这两个调用都是可观察到的副作用。编译器可以在这些可观察到的效果之间重新排序任何东西,甚至在可观察到的副作用之上,只要观察结果与抽象机器的可能观察结果一致。

由于 C + + 抽象机没有形式上限制在有限的速度上,所以它可以在零时间内执行 veryLongComputation()。执行时间本身并不定义为可观察到的效果。真正的实现可能与之匹配。

请注意,这个答案在很大程度上取决于 C + + 标准 没有对编译器的限制。

如果 veryLongComputation()在内部执行任何不透明的函数调用,那么不会,因为编译器不能保证它的副作用可以与 clock()的副作用互换。

否则,是的,它是可以互换的。
这是使用时间不是一流实体的语言所要付出的代价。

注意,内存分配(例如 new)可以属于这个类别,因为分配函数可以在不同的翻译单元中定义,直到当前的翻译单元已经编译之后才进行编译。因此,如果你只是分配内存,编译器就不得不把分配和释放作为所有事情的最坏情况屏障—— clock()、内存屏障和其他所有事情——除非它已经有了内存分配器的代码,并且能够证明这是不必要的。在实践中,我不认为任何编译器实际上会查看分配程序代码来证明这一点,所以这些类型的函数调用在实践中会成为障碍。

让我们假设序列在一个循环中,veryLongComputation()随机抛出一个异常。那么要计算多少个 t0和 t1呢?它是否预先计算随机变量,并根据预先计算重新排序-有时重新排序,有时不?

编译器是否足够聪明,知道只是内存读取就是从共享内存中读取。这是一个测量控制棒在核反应堆中移动了多远的方法。时钟调用用于控制它们移动的速度。

或者也许是时机控制了哈勃望远镜镜面的研磨

移动时钟调用似乎太危险了,不能让编译器作者来决定。因此,如果它是合法的,也许标准是有缺陷的。

我的天。