强制执行顺序

Enforcing order of execution

I would like to ensure that the calculations requested are executed exactly in the order I specify, without any alterations from either the compiler or CPU (including the linker, assembler, and anything else you can think of).


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”,等等

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

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

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

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

想到的几个想法:

如果有人能想到任何可行的解决方案(无论是我提出的任何想法还是其他想法),那将是理想的。在我看来,"pragma" 选项或 "function call" 似乎是最好的方法。

终极目标

要有一些东西来标记一小块简单且主要是香草的 C 代码是受保护的并且对任何(实际上是大多数)优化都是不可触及的,同时允许对其余代码进行大量优化,涵盖来自两者的优化CPU 和编译器。

"clever enough to recognise the + 2 - 2 is redundant and optimise this away"

没有!所有体面的编译器都会应用 constant propagation 并计算出 a 是常量并将您的所有语句优化为等同于 a = 1; 的内容。这里是 example with assembly.

现在,如果您创建一个 volatile,编译器必须假定对 a 的任何更改都可能对 C++ 程序之外产生影响。仍将执行恒定传播以优化这些计算中的每一个,但保证会发生中间分配。这里是example with assembly

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

然而这并不理想。优化器可能会胜过您,并且使用这种方法,您将失去跨函数边界的全局优化。

Recommendation/quote 当天:

Don't diddle code; Find better algorithms
- B.W.Kernighan & P.J.Plauger

用汇编语言编写这段关键代码。

你现在的情况很不寻常。大多数时候人们 希望 编译器进行优化,因此编译器开发人员不会花费太多开发精力来避免它们。即使使用了您确实获得的旋钮(编译指示、单独编译、间接寻址,...),您也永远无法确定某些东西不会被优化。在现代编译器中,any 方法无法关闭您提到的一些不需要的优化(例如,常量折叠)。

如果您使用汇编语言,您可以确定您得到的正是您编写的内容。如果您以任何其他方式进行操作,您将不会有那种程度的信心。

这不是一个完整的答案,但它提供了信息,部分回答,而且对于评论来说太长了。

明确目标

问题其实是求reproducibility of floating-point results,不是执行顺序。此外,执行顺序无关紧要;我们不关心 (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 添加到加数之前,FMA 在乘积中实际上具有无限精度,因此属于“允许超额精度”规则。)但是,有一些警告。一个实现可能首先评估具有超精度的操作,然后将其四舍五入到标称精度。通常,这可能会导致与以标称精度执行单个操作不同的结果。对于加法、减法、乘法、除法甚至平方根的基本运算,如果超额精度足够大于标称精度,则不会发生这种情况。 (有证据表明,具有足够超额精度的结果总是足够接近无限精确的结果,即四舍五入到标称精度得到相同的结果。)对于标称精度为 IEEE-754 基本 32- 的情况,这是正确的bit二进制浮点格式,超精度为64位格式。但是,标称精度是64位格式,超精度是Intel的80位格式,就不是这样了。

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

其他问题

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

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

不相关的问题

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

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

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

脚注

1 我将不得不检查其他修改对象的东西,例如 ++,但这些对于本次讨论可能并不重要。