C++优化器重排序clock()调用是否合法?

74

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++标准?


2
不,它并不是这样的。编译器应该期望函数具有一些副作用。一些编译器对于“纯常量”函数有语言扩展。你可以要求你的编译器显示汇编代码(例如使用GCCg++ -O2 -S -fverbose-asm your-code.cc)。 - Basile Starynkevitch
7
“which guarantees different result than original code (zero vs. greater than zero time reported)” - 就标准而言,并不能保证这一点,因为标准并未提及任何特定操作应该花费多长时间(除了调用sleep函数)。对veryLongComputation()的调用可能会瞬间完成。 - Benjamin Lindley
1
@BenjaminLindley “对 veryLongComputation() 的调用可能是瞬间完成的” - 我不同意。有很多算法,如果给定足够大的数据,在C++标准编写时所知的任何计算硬件上都不能在太阳系的末日之前完成。 - Paul Jurczak
3
“@Paul,被视为I/O的是发送到cout部分,而不是计算部分。你所看到的是完全合规的。在clock调用之间看到的任何非负差异,可能足够小以四舍五入为0,都是可以接受的。想象一个程序,在其中2次调用clock的差异是如此之大,以至于你以大约50%的概率获得0或1,具体取决于你何时启动以及其他正在运行的内容。优化是否应该保留这个确切的概率?” - Marc Glisse
2
从抽象机的角度来看,你如何区分这两个版本之间的差异?计算需要多长时间是完全没有保证的。(实际例子:编译器可以完全评估您的计算)。如果您的计算执行了具有保证最小持续时间(例如睡眠)的副作用,则此重新排序将无效。IO不足以说明问题,因为您无法区分重新排序和真正快速的IO之间的区别。适用as-if规则。 - usr
显示剩余17条评论
7个回答

18

嗯,C标准的第5.1.2.3款有一些内容指出:

在抽象机器中,所有表达式都按语义规定进行评估。如果实际实现可以推断出某个表达式的值未被使用并且没有产生任何必要的副作用(包括通过调用函数或访问易失对象引起的任何副作用),则无需评估该表达式的部分内容。

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

此外-重组确实对计算结果有影响,但是从编译器的角度来看,它处于 int main() 的世界中,在进行时间测量时会露出来,请求内核提供当前时间,然后返回到主世界,此时外部世界的实际时间并不重要。clock()本身不会影响程序、变量和程序行为,程序的行为也不会影响 clock() 函数。

时钟值用于计算它们之间的差异-这就是你要求的。如果在两次测量之间发生了什么事情,则从编译器的角度来看并不重要,因为你要求的是时钟差异,而测量之间的代码不会影响测量过程。

然而,这并不能改变所描述的行为非常不愉快的事实。

尽管不准确的测量结果非常不愉快,但情况可能会更糟甚至更危险。

考虑下面从此网站中摘取的代码:

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()世界中思考,并认为由于变量pwd之后不再使用并且不会影响程序本身,因此memset是死代码。


另一方面,它确实会影响函数。如果在不同的地方调用它,它将返回不同的值。如果这不是长时间计算引起的副作用,我不知道还有什么。 "测量之间的代码不会影响测量过程"是正确的。但它非常影响数值。这就像说“变量的值不会改变求和过程”。 - sharpneli
我认为总和并不相同。您正在求和的变量几乎是精确和确定性的。另一方面,时钟时间则不是。实际上,时间差测量的结果在某种意义上非常随机... - Jendas
2
@sharpneli “除非编译器能够证明[...]”但这正是编译器在这里所做的。如果你将veryLongComputation的主体隐藏起来,我强烈怀疑它会重新排序。但在这里,它详细查看了veryLongComputation,证明其中没有副作用,然后进行了重新排序。不,不同的运行时间(挥手表示非常不同)不算作副作用。 - Marc Glisse
1
@MSalters:如果pwd是易失性的,将其传递给memset会导致未定义的行为。您不能通过非易失性限定的左值访问易失性对象,这正是memset所做的。 - R.. GitHub STOP HELPING ICE
5
std::fill 可以使用。一个 volatile 指针仍然是迭代器。这证明了 C++ 并不比 C 更难预测。 - MSalters
显示剩余5条评论

15
编译器无法交换两个clock调用,t1必须在t0之后设置。这两个调用都是可观察的副作用。编译器可以重新排序那些可观察副作用之间的任何内容,甚至覆盖可观察副作用,只要这些观察结果与抽象机器的可能观察结果一致即可。
由于C++抽象机器没有正式限制有限速度,它可以在零时间内执行veryLongComputation()。执行时间本身不被定义为可观察效果,实际实现可以匹配它。
请注意,很多答案取决于C++标准对编译器不施加限制。

分配给非易失性变量的赋值不是可观察的副作用。 - Pete Becker
2
应该更明确:两个“调用”都是可观察的副作用。 - MSalters
这是一个“如何防止重新排序的问题”:https://dev59.com/QloU5IYBdhLWcg3wIkeI - Ciro Santilli OurBigBook.com

8

是的,如果编译器可以看到在 clock() 调用之间发生的整个代码,则是合法的。

请注意,此处保留了HTML标签。

1
你有一个标准的引用或参考资料来证实这个吗? - user3920237
2
@remyabel 原问题中的引用允许这样做。关键是要认识到调用 clock 的部分并不是被重新排序的 - 被重新排序的是计算部分。根据 as-if 规则,任何透明的计算都可以在任何时候进行计算。 - o11c
1
更重要的是,如果编译器可以保证veryLongComputation()不会引起任何可观察的行为。 - M.M
如果编译器可以保证veryLongComputation()不会导致任何可观察的行为,那么计算时间就不是一个可观察的量吗? - Paul Jurczak
2
@PaulJurczak 没错;可观察行为是对易失变量的写操作和对库函数的调用。 - M.M
显示剩余4条评论

4
如果veryLongComputation()内部执行任何不透明函数调用,则编译器无法保证其副作用与clock()的副作用可互换,因此不能互换。
否则,是可以互换的。
这就是使用一种时间不是一流实体的语言所付出的代价。
请注意,内存分配(例如new)可能属于此类别,因为分配函数可以在不同的翻译单元中定义并且直到当前翻译单元已编译才进行编译。因此,如果仅分配内存,则编译器被迫将分配和释放视为一切的最坏情况障碍 - 包括 clock(),内存屏障和其他所有内容 - 除非它已经具有内存分配器的代码并且可以证明这是没有必要的。实际上,我认为没有编译器会查看分配器代码来尝试证明这一点,因此这些类型的函数调用在实践中充当屏障。

为什么函数不是一等实体是个问题? - harold
@harold:我没有提到函数是一等实体。 - user541686

2
按照我的理解,不允许这样做。标准的要求是(§1.9/14):
每个完整表达式相关联的值计算和副作用在下一个将被评估的完整表达式的值计算和副作用之前进行排序。
编译器超出此范围重新排序的程度由“as-if”规则(§1.9/1)定义:
该国际标准对符合规范的实现结构没有任何要求。 特别是,它们不需要复制或模拟抽象机的结构。相反,符合规范的实现需要模拟抽象机的可观察行为,如下所述。
这就引出了问题,即是否官方认为这种行为(由cout写入的输出)是可观察行为。简短的答案是是的(§1.9/8):
符合规范的实现的最低要求为: [...] — 在程序终止时,写入文件的所有数据必须与根据抽象语义执行程序可能产生的可能结果之一完全相同。
按照我的理解,这意味着如果调用clock仍然产生与按顺序执行调用相同的输出,则可以将其与您的长时间计算的执行重排。
但是,如果您想采取额外步骤以确保正确的行为,则可以利用另一个规定(也是§1.9/8):
— 对易失性对象的访问严格按照抽象机的规则进行评估。
要利用这一点,您需要稍微修改代码,变成像这样:
auto volatile t0 = clock();
auto volatile r  = veryLongComputation();
auto volatile t1 = clock();

现在,我们不再需要根据标准的三个不同部分来得出结论,并且仍然只有一个相对确定的答案。我们可以看一句话,就能得到一个绝对确定的答案——使用这段代码,重新排列 clock 的用法与长时间计算是明确被禁止的。请注意保留 HTML 标签。

5
按照这种逻辑,即使将“1 + 1”优化为“2”,也是无效的,因为代码可能正在计算时间。我认为这种逻辑是不正确的。 - user743382
你似乎读错了。"与完整表达式相关联的每个值计算和副作用都在下一个要评估的完整表达式相关联的每个值计算和副作用之前排序。" 这并没有明确说明常量折叠的事情。 - Jerry Coffin
这根本就没有意义。抽象语义允许任何语句经过任意长的时间(除了sleep 当然例外)。抽象机器设置了允许的结果范围,而不是一些“未优化”的构建。 - MSalters
@MSalters:谁说过什么关于“未优化”的构建? - Jerry Coffin
2
无论如何,这不是一个典型的语言律师问题:这不涉及某些假设实现可能滥用不当措辞的措辞,而是一个真实世界中的编译器根据标准的确切措辞进行优化,几乎所有优化编译器都具有相似的优化,即使它们不适用于此特定的测试程序。即使您得出结论(正确或错误),即标准不打算允许这些优化,期望对当前实现可移植的代码也必须处理它。 - user743382
显示剩余19条评论

1
假设序列在循环中,并且 veryLongComputation() 随机抛出异常。那么将计算多少个 t0 和 t1?它是否预先计算随机变量并基于预计算重新排序 - 有时重新排序,有时不重新排序?
编译器是否足够智能,知道仅内存读取就是从共享内存读取的读取。读取是测量核反应堆中控制棒移动距离的指标。时钟调用用于控制移动速度。
或者,时间控制磨光哈勃望远镜的镜面。哈哈大笑。
移动时钟调用似乎太危险了,不能让编译器编写者决定。因此,如果合法,可能标准存在缺陷。
我个人认为。

如果时间很重要,你应该使用比时间无操作代码更可靠的机制。并且/或者禁用所有优化 - 大多数优化器所做的都涉及重新排序指令。 - cHao

-1

这绝对是不允许的,因为正如您所指出的那样,它会改变程序的可观察行为(产生不同的输出)。我不会进入假设情况,即veryLongComputation()可能不消耗任何可测量的时间--鉴于函数的名称,这可能不是真实情况。但即使是这种情况,也并不重要。您也不会期望可以重新排列fopenfwrite,对吧。

在输出 t1-t0 中,t0t1 都被使用。因此,对于两者的初始化表达式必须执行,并且执行必须遵循所有标准规则。虽然函数调用的结果被使用,但它不能被优化掉,尽管它不是直接依赖于 t1 或反之亦然,所以人们可能天真地认为它可以随意移动,为什么不呢?也许是在并未依赖于计算的情况下进行了 t1 的初始化?
然而,间接地,t1 的结果当然取决于 veryLongComputation() 的副作用(特别是计算需要时间等),这正是存在“序列点”这种东西的原因之一。

有三个“表达式结束”序列点(加上三个“函数结束”和“初始化器结束”的 SP),并且在每个序列点,保证之前的所有评估的副作用都已执行,并且尚未执行后续评估的任何副作用。
如果您移动这三个语句,就无法遵守此承诺,因为所有调用的函数的可能副作用是未知的。只有在编译器能够保证它会遵守承诺时,才允许进行优化。但是,由于库函数是不透明的,它们的代码不可用(veryLongComputation 中的代码也不一定在该翻译单元中已知)。

编译器有时会对库函数拥有“特殊知识”,例如某些函数不会返回或可能返回两次(考虑exitsetjmp)。但是,由于每个非空的、非平凡的函数(从其名称中可以看出,veryLongComputation是相当复杂的)都将消耗时间,因此编译器对于否则不透明的clock库函数具有“特殊知识”,实际上必须明确禁止在此调用周围重新排序调用,因为这样做不仅可能,而且一定会影响结果。

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


1
我认为你错误地假设了MSVC看不到 veryLongComputation 的主体部分。MSVC具有链接时代码生成功能,这基本上意味着它可以看到该主体部分。 - MSalters
@MSalters:也许是这样,但只要它看不到“clock”的代码,就与此无关。为了使优化合法,必须能够证明在 任何 函数中都没有发生副作用,而它无法做到这一点(因为它需要查看 每一个 函数)。此外,必须确保可观察行为是相同的。这也可以被证明不是这种情况。 - Damon
调用clock是可观察的(所有库调用都是)。不需要证明任何进一步的事情。而且,不,行为不需要与非优化版本完全相同。标准仅要求结果与抽象机器的可能结果之一相同,并且在一单位的time_t内运行肯定是可能的,因此是一个有效的结果。 - MSalters
吹毛求疵:库I/O调用是副作用。(§1.9/12) 不进行I/O的库调用不是。即使是I/O函数也不一定需要被调用,除非调用会影响可观察行为。 - cHao

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