强制执行执行顺序

Enforcing order of execution

本文关键字:顺序 执行 强制执行      更新时间:2023-10-16

我想确保请求的计算完全按照我指定的顺序执行,而无需对编译器或 CPU(包括链接器、汇编器和您能想到的任何其他内容)进行任何更改。


在 C 语言中假定运算符从左到右关联性

我正在使用 C(可能也对C++解决方案感兴趣),它指出对于同等优先级的操作,存在假定的从左到右运算符关联性,因此
a = b + c - d + e + f - g ...;
等同于
a = (...(((((b + c) - d) + e) + f) - g) ...);

一个小例子

但是,请考虑以下示例:

double a, b = -2, c = -3;
a = 1 + 2 - 2 + 3 + 4;
a += 2*b;
a += c;

如此多的优化机会

对于许多编译器和预处理器来说,他们可能足够聪明地认识到"+ 2 - 2"是多余的,并对其进行优化。类似地,他们可以认识到"+= 2*b"后跟"+= c"可以用一个FMA来写。即使他们没有在 FMA 中进行优化,他们也可能会切换这些操作的顺序等。此外,如果编译器不进行任何这些优化,CPU很可能会决定执行一些乱序执行,并决定它可以在"+= 2*b"之前执行"+= c"等。

由于浮点运算是非关联的,因此每种类型的优化都可能导致不同的最终结果,如果将以下内容内联到某处,这可能会很明显。

为什么要担心浮点结合性?

对于我的大多数代码,我希望尽可能多地进行优化,并且不关心浮点关联性或按位再现性,但偶尔会有一小段(类似于上面的例子),我希望不被篡改并完全尊重。这是因为我正在使用一种数学方法,该方法完全需要可重复的结果。

我该怎么做才能解决这个问题?

想到的一些想法:

  • 禁用编译器优化和无序执行
    • 我不希望这样,因为我希望其他 99% 的代码得到大量优化。(这似乎是在割掉我的鼻子来羞辱我的脸)。我也很可能无权更改我的硬件设置。
  • 使用杂注
  • 编写一些程序集
    • 代码片段足够小,这可能是合理的,尽管我对此不是很有信心,尤其是在(当)涉及调试时。
  • 将其放在单独的文件中,尽可能未优化地单独编译,然后使用函数调用进行链接
  • 易失性变量
    • 在我看来,这些只是为了确保内存访问得到尊重和未优化,但也许它们可能被证明是有用的。
  • 通过明智地使用指针来访问所有内容
    • 也许,但这似乎是可读性、性能和等待发生的错误的灾难。

如果有人能想到任何可行的解决方案(无论是从我建议的任何想法还是其他方式),那将是理想的。在我看来,"编译指示"选项或"函数调用"似乎是最好的方法。

最终目标

有一些东西将一小堆简单且基本上是普通的 C 代码标记为受保护且无法触及任何(实际上是大多数)优化,同时允许对其余代码进行大量优化,涵盖来自 CPU 和编译器的优化。

这不是一个完整的答案,但它提供了信息,部分答案,并且太长而无法发表评论。

明确目标

这个问题实际上寻求浮点结果的可重复性,而不是执行顺序。此外,执行顺序无关紧要;我们不在乎,在(a+b)+(c+d)中,a+bc+d是先执行的。我们关心a+b的结果被添加到c+d的结果中,除非已知结果相同,否则不会对算术进行任何重新关联或其他重写。

浮点运算的可重复性通常是一个未解决的技术问题。(没有理论障碍;我们有可重现的基本操作。可重现性取决于硬件和软件供应商提供了什么,以及表达我们想要执行的计算的难度。

您是否希望在一个平台上实现可重复性(例如,始终使用同一数学库的相同版本)?您的代码是否使用任何数学库例程,如sinlog?您是否希望跨不同平台实现可重复性?使用多线程?跨编译器版本更改?

解决一些具体问题

问题中显示的示例在很大程度上可以通过将每个单独的浮点运算写入其自己的语句来处理,例如通过替换:

a = 1 + 2 - 2 + 3 + 4;
a += 2*b;
a += c;

跟:

t0 = 1 + 2;
t0 = t0 - 2;
t0 = t0 + 3;
t0 = t0 + 4;
t1 = 2*b;
t0 += t1;
a += c;

这样做的基础是,C 和 C++ 都允许实现在计算表达式时使用"超额精度",但要求在执行赋值或强制转换时"丢弃"该精度。将每个赋值表达式限制为一个操作或在每个操作后执行强制转换可以有效地隔离操作。

在许多情况下,编译器将使用名义类型的指令生成代码,而不是使用精度过高的类型的指令。特别是,这应该避免融合乘法加法 (FMA) 被乘法后加法所取代。(FMA 在将产品添加到加法之前实际上具有无限的精度,因此属于"允许超额精度"规则。但是,有一些警告。实现可能首先以超高精度计算操作,然后将其舍入到标称精度。通常,这可能会导致与以标称精度执行单个操作不同的结果。对于加法、减法、乘法、除法甚至平方根的基本运算,如果超额精度足以大于标称精度,则不会发生这种情况。(有证据表明,具有足够超额精度的结果总是足够接近无限精确的结果,因此舍入到标称精度得到相同的结果。对于标称精度为 IEEE-754 基本 32 位二进制浮点格式,而超额精度为 64 位格式的情况也是如此。但是,如果标称精度为 64 位格式,而多余的精度是英特尔的 80 位格式,则情况并非如此。

因此,此解决方法是否有效取决于平台。

其他问题

除了使用过多的精度和 FMA 或优化器重写表达式等功能外,还有其他影响可重复性的因素,例如次正态的非标准处理(特别是用零替换它们)、数学库例程之间的差异。(sinlog和类似的函数在不同的平台上返回不同的结果。没有人完全实现具有已知有界性能的正确舍入数学库例程。

这些问题在其他有关浮点重现性的 Stack 溢出问题以及论文、规范和标准文档中进行了讨论。

不相关的问题

处理器执行浮点运算的顺序无关紧要。计算的处理器重新排序遵循严格的语义;无论执行的时间顺序如何,结果都是相同的。(例如,如果将任务划分为子任务(例如,分配多个线程或进程来处理数组的不同部分),则处理器计时可能会影响结果。除其他问题外,它们的结果可能以不同的顺序到达,然后接收其结果的过程可能会以不同的顺序添加或以其他方式组合其结果。

使用指针不会解决任何问题。就 C 或 C++ 而言,*p其中p是指向double的指针与aadouble相同。一个物体有名字(a),一个没有,但它们就像玫瑰:它们闻起来是一样的。(存在这样的问题,如果你有其他指针q,编译器可能不知道*q*p是否引用同一事物。但这也适用于*qa

使用易失性限定符无助于提高精度过高或表达式重写问题的重现性。这是因为只有一个对象(而不是值)是可变的,这意味着在您编写或读取它之前它不起作用。但是,如果您编写它,则使用的是赋值表达式1,因此有关丢弃过多精度的规则已经适用。读取对象时,您将强制编译器从内存中检索实际值,但此值与赋值后的非易失性对象没有任何不同,因此不会完成任何操作。

脚注

1我必须检查修改对象的其他内容,例如++,但这些对于本次讨论可能并不重要。

用汇编语言编写这个关键的代码块。

你所处的情况是不寻常的。 大多数时候,人们希望编译器进行优化,因此编译器开发人员不会花费太多开发精力来避免它们。 即使使用您获得的旋钮(编译指示,单独编译,间接寻址等),您也永远无法确定某些内容不会得到优化。 您提到的一些不良优化(例如常量折叠)在现代编译器中无法以任何方式关闭。

如果你使用汇编语言,你可以确保你得到的正是你写的东西。 如果你以任何其他方式这样做,你就不会有那种程度的信心。

"足够聪明,可以识别 + 2 - 2 是多余的并优化它 离开">

不! 所有体面的编译器都会应用不断传播并找出a是常数,并将所有语句优化为等同于a = 1;的东西。 这里是带有程序集的示例。

现在,如果您进行易失性,编译器必须假设 a 的任何更改都可能在C++程序之外产生影响。仍将执行常量传播以优化这些计算中的每一个,但中介分配肯定会发生。这里是带有程序集的示例。

如果不希望发生持续传播,则需要停用优化。 在这种情况下,最好的方法是将代码分开,以便在进行所有优化的情况下编译其余代码。

然而,这并不理想。 优化器的性能可能优于您,并且使用这种方法,您将在函数边界上失去全局优化。

当天推荐/报价:

不要编写代码;查找更好的算法
- B.W.Kernighan & P.J.Plauger