执行顺序

时间:2019-02-15 16:48:32

标签: c++ c compiler-optimization floating-accuracy

  

我想确保所请求的计算完全按照我指定的顺序执行,而编译器或CPU(包括链接器,汇编器以及您可以想到的其他任何东西)都没有任何改变。 / strong>


假定操作员从左到右的关联性是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”是多余的,并且可以对其进行优化。同样,他们可以识别出可以使用单个FMA编写“ += 2*b”后跟“ += c”。即使它们未在FMA中进行优化,也可能会切换这些操作的顺序等。此外,如果编译器未执行这些优化中的任何一项,则CPU可能会决定执行一些乱序执行,并决定可以在“ += c”之前执行“ += 2*b”,等等。

由于浮点算术是非关联的,因此每种优化类型都可能导致不同的最终结果,如果在某处内联以下内容,则可能会引起注意。

为什么要担心浮点关联性?

对于我的大多数代码,我希望获得尽可能多的优化,并且不关心浮点关联性或按位再现性,但是偶尔会有一个小片段(类似于上面的示例),希望不受干扰并得到完全尊重。这是因为我正在使用一种数学方法,而这恰好需要可重现的结果。

该如何解决?

一些想法浮现在脑海:

  • 禁用编译器优化和乱序执行
    • 我不希望这样,因为我希望对我的代码的其余99%进行优化。 (这似乎在割断我的鼻子,使我容颜)。我也很可能无权更改我的硬件设置。
  • 使用编译指示
  • 编写一些程序集
    • 这些代码段足够小,以至于这可能是合理的,尽管我对此不太确定,尤其是在调试时。
  • 将此文件放在单独的文件中,尽可能未优化地单独编译,然后使用函数调用进行链接
  • 易变变量
    • 在我看来,这些只是为了确保尊重和优化内存访问,但是也许它们可能有用。
  • 通过明智地使用指针来访问所有内容
    • 也许吧,但这看起来像是可读性,性能和等待发生错误的灾难。

如果任何人都可以想到任何可行的解决方案(无论是我提出的建议还是其他建议),那都是理想的选择。我认为“ pragma”选项或“函数调用”似乎是最好的方法。

最终目标

具有一些可以标记一小部分简单的,基本为香草的C代码的保护,并且对于任何(实际上是大多数)优化都是不可访问的,同时允许对其余代码进行大量优化,包括两个CPU的优化和编译器。

3 个答案:

答案 0 :(得分:2)

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

您遇到的情况很不寻常。大多数情况下,人们会希望进行编译器优化,因此编译器开发人员不会花费很多开发精力来避免这种情况。即使有了旋钮(编译指示,单独的编译,间接调用等),也永远不能确保不会优化某些东西。您提到的某些不良优化(例如恒定折叠)无法通过现代编译器中的任何手段关闭。

如果您使用汇编语言,则可以确保完全获得所编写的内容。如果您以其他任何方式进行操作,您将没有那种信心。

答案 1 :(得分:2)

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

明确目标

问题实际上是在寻找reproducibility of floating-point results,而不是执行顺序。同样,执行顺序也无关紧要。我们不在乎是先执行(a+b)+(c+d)中的a+b还是c+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位格式而超精度是Intel的80位格式是不正确的。

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

其他问题

除了使用过高的精度和诸如FMA或优化器重写表达式之类的功能外,还有其他一些因素会影响可重复性,例如对非正规(非零)的非标准处理,数学库例程之间的差异。 ({sinlog和类似的函数在不同的平台上返回不同的结果。没有人以已知的有限性能完全实现正确舍入的数学库例程。)

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

无关紧要的问题

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

使用指针不会解决任何问题。对于C或C ++,其中*p是指向p的指针的double与其中a是{{1}的a相同。 }。其中一个对象的名称为{double,其中一个没有名称,但是它们就像玫瑰一样:它们闻起来一样。 (有些问题是,如果您还有其他指针a,则编译器可能不知道q*q是否引用相同的东西。但是对于{{1 }}和*p。)

使用挥发性修饰词将无助于精度过高或表达式重写问题。那是因为只有一个对象(不是值)是易失性的,这意味着它在您写入或读取之前是无效的。但是,如果您编写它,则使用的是赋值表达式 1 ,因此有关丢弃多余精度的规则已经适用。读取对象时,您将强制编译器从内存中检索实际值,但是该值与非易失性对象分配后的值没有什么不同,因此什么也没完成。

脚注

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

答案 2 :(得分:1)

  

“足够聪明以识别+ 2-2是多余的,并对此进行优化   离开”

不!所有体面的编译器都将应用恒定传播并找出a是常量,并将所有语句优化为a = 1;。这里是example with assembly

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

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

但是,这并不理想。优化器的性能可能会超越您,并且通过这种方法,您将无法跨函数边界进行全局优化。

当天的推荐/报价:

  

不要欺骗代码;寻找更好的算法
  - B.W.Kernighan & P.J.Plauger