Pas*_*uoq 18 floating-point gcc c99 clang fenv
我想计算两个IEEE 754二进制64号的总和,四舍五入.为此我在下面写了C99程序:
#include <stdio.h>
#include <fenv.h>
#pragma STDC FENV_ACCESS ON
int main(int c, char *v[]){
fesetround(FE_UPWARD);
printf("%a\n", 0x1.0p0 + 0x1.0p-80);
}
Run Code Online (Sandbox Code Playgroud)
但是,如果我使用各种编译器编译并运行我的程序:
$ gcc -v
…
gcc version 4.2.1 (Apple Inc. build 5664)
$ gcc -Wall -std=c99 add.c && ./a.out
add.c:3: warning: ignoring #pragma STDC FENV_ACCESS
0x1p+0
$ clang -v
Apple clang version 1.5 (tags/Apple/clang-60)
Target: x86_64-apple-darwin10
Thread model: posix
$ clang -Wall -std=c99 add.c && ./a.out
add.c:3:14: warning: pragma STDC FENV_ACCESS ON is not supported, ignoring
pragma [-Wunknown-pragmas]
#pragma STDC FENV_ACCESS ON
^
1 warning generated.
0x1p+0
它不起作用!(我期待结果0x1.0000000000001p0).
实际上,计算是在编译时以默认的舍入到最近模式完成的:
$ clang -Wall -std=c99 -S add.c && cat add.s
add.c:3:14: warning: pragma STDC FENV_ACCESS ON is not supported, ignoring
pragma [-Wunknown-pragmas]
#pragma STDC FENV_ACCESS ON
^
1 warning generated.
…
LCPI1_0:
.quad 4607182418800017408
…
callq _fesetround
movb $1, %cl
movsd LCPI1_0(%rip), %xmm0
leaq L_.str(%rip), %rdx
movq %rdx, %rdi
movb %cl, %al
callq _printf
…
L_.str:
.asciz "%a\n"
是的,我确实看到了每个编译器发出的警告.我知道在线的比例上打开或关闭适用的优化可能是棘手的.如果可能的话,我仍然希望在文件的范围内关闭它们,这足以解决我的问题.
我的问题是:我应该使用什么命令行选项与GCC或Clang一起编译一个C99编译单元,其中包含用于以默认值以外的FPU舍入模式执行的代码?
在研究这个问题时,我发现这个GCC C99合规页面,包含下面的条目,我将留在这里以防其他人觉得它很有趣.GRRRR.
floating-point | | environment access | N/A | Library feature, no compiler support required. in <fenv.h> | |
我找不到任何可以完成您想要的操作的命令行选项。然而,我确实找到了一种重写代码的方法,这样即使进行了最大程度的优化(甚至是架构优化),GCC 和 Clang 都不会在编译时计算该值。相反,这迫使它们输出在运行时计算该值的代码。
#include <fenv.h>
#include <stdio.h>
#pragma STDC FENV_ACCESS ON
// add with rounding up
double __attribute__ ((noinline)) addrup (double x, double y) {
int round = fegetround ();
fesetround (FE_UPWARD);
double r = x + y;
fesetround (round); // restore old rounding mode
return r;
}
int main(int c, char *v[]){
printf("%a\n", addrup (0x1.0p0, 0x1.0p-80));
}
Run Code Online (Sandbox Code Playgroud)
即使使用最大优化和架构优化,GCC 和 Clang 也会产生以下输出:
gcc -S -x c -march=corei7 -O3(Godbolt GCC):addrup:
push rbx
sub rsp, 16
movsd QWORD PTR [rsp+8], xmm0
movsd QWORD PTR [rsp], xmm1
call fegetround
mov edi, 2048
mov ebx, eax
call fesetround
movsd xmm1, QWORD PTR [rsp]
mov edi, ebx
movsd xmm0, QWORD PTR [rsp+8]
addsd xmm0, xmm1
movsd QWORD PTR [rsp], xmm0
call fesetround
movsd xmm0, QWORD PTR [rsp]
add rsp, 16
pop rbx
ret
.LC2:
.string "%a\n"
main:
sub rsp, 8
movsd xmm1, QWORD PTR .LC0[rip]
movsd xmm0, QWORD PTR .LC1[rip]
call addrup
mov edi, OFFSET FLAT:.LC2
mov eax, 1
call printf
xor eax, eax
add rsp, 8
ret
.LC0:
.long 0
.long 988807168
.LC1:
.long 0
.long 1072693248
Run Code Online (Sandbox Code Playgroud)
clang -S -x c -march=corei7 -O3(Godbolt GCC):addrup: # @addrup
push rbx
sub rsp, 16
movsd qword ptr [rsp], xmm1 # 8-byte Spill
movsd qword ptr [rsp + 8], xmm0 # 8-byte Spill
call fegetround
mov ebx, eax
mov edi, 2048
call fesetround
movsd xmm0, qword ptr [rsp + 8] # 8-byte Reload
addsd xmm0, qword ptr [rsp] # 8-byte Folded Reload
movsd qword ptr [rsp + 8], xmm0 # 8-byte Spill
mov edi, ebx
call fesetround
movsd xmm0, qword ptr [rsp + 8] # 8-byte Reload
add rsp, 16
pop rbx
ret
.LCPI1_0:
.quad 4607182418800017408 # double 1
.LCPI1_1:
.quad 4246894448610377728 # double 8.2718061255302767E-25
main: # @main
push rax
movsd xmm0, qword ptr [rip + .LCPI1_0] # xmm0 = mem[0],zero
movsd xmm1, qword ptr [rip + .LCPI1_1] # xmm1 = mem[0],zero
call addrup
mov edi, .L.str
mov al, 1
call printf
xor eax, eax
pop rcx
ret
.L.str:
.asciz "%a\n"
Run Code Online (Sandbox Code Playgroud)
现在更有趣的部分是:为什么这样有效?
好吧,当他们(GCC 和/或 Clang)编译代码时,他们尝试查找并替换可以在运行时计算的值。这称为恒定传播。如果您只是编写了另一个函数,则恒定传播将停止发生,因为它不应该跨函数。
但是,如果他们看到理论上可以用其代码代替函数调用的函数,他们可能会这样做。这称为函数内联。如果函数内联适用于某个函数,我们就说该函数是(令人惊讶的)inlinable。
如果一个函数对于给定的一组输入总是返回相同的结果,那么它被认为是纯函数。我们还说它没有副作用(意味着它不会改变环境)。
现在,如果一个函数是完全内联的(意味着它不会对外部库进行任何调用,除了 GCC 和 Clang 中包含的一些默认值 - 、libc等libm)并且是纯函数,那么它们将对该函数应用常量传播。
换句话说,如果我们不希望它们通过函数调用传播常量,我们可以执行以下两种操作之一:
__attribute__ ((noinline))现在,最后一个是最简单的。正如您可能已经猜到的,__attribute__ ((noinline))将函数标记为不可内联。既然我们可以利用这一点,我们所要做的就是创建另一个函数来执行我们想要的任何计算,用 标记它__attribute__ ((noinline)),然后调用它。
编译时,它们不会违反内联规则,并且扩展后也不会违反常量传播规则,因此,将在运行时使用适当的舍入模式集来计算该值。
clang 或 gcc-frounding-math告诉他们代码可能以非默认舍入模式运行。 它并不完全安全(它假设相同的舍入模式始终处于活动状态),但总比没有好。在某些情况下,您可能仍然需要使用volatile以避免CSE,或者可能是其他答案中的 noinline 包装器技巧,如果将其限制为单个操作,在实践中可能会效果更好。
正如您所注意到的,GCC 不支持#pragma STDC FENV_ACCESS ON. 默认行为类似于FENV_ACCESS OFF. 相反,您必须使用命令行选项(或者可能是每个函数的属性)来控制 FP 优化。
如https://gcc.gnu.org/wiki/FloatingPointMath中所述,默认情况下不-frounding-math启用,因此 GCC 在编译时进行常量传播和其他优化时采用默认舍入模式。
但对于gcc -O3 -frounding-math,常数传播被阻止。即使你不打电话fesetround;实际发生的情况是,如果在调用 main 之前舍入模式已经设置为其他值,则 GCC 使 asm 是安全的。
但不幸的是,正如 wiki 所指出的,GCC 仍然假设相同的舍入模式在任何地方都有效(GCC bug #34678)。这意味着它将在调用之前/之后对相同输入进行CSE 两次计算fesetroundCSE 两次计算,因为它不视为fesetround特殊。
#include <fenv.h>
#pragma STDC FENV_ACCESS ON
void foo(double *restrict out){
out[0] = 0x1.0p0 + 0x1.0p-80;
fesetround(FE_UPWARD);
out[1] = 0x1.0p0 + 0x1.0p-80;
}
Run Code Online (Sandbox Code Playgroud)
编译如下(Godbolt)(与 clang10.1 基本相同)。还包括您的main,它确实可以实现您想要的 asm 。
foo:
push rbx
mov rbx, rdi
sub rsp, 16
movsd xmm0, QWORD PTR .LC1[rip]
addsd xmm0, QWORD PTR .LC0[rip] # runtime add
movsd QWORD PTR [rdi], xmm0 # store out[0]
mov edi, 2048
movsd QWORD PTR [rsp+8], xmm0 # save a local temporary for later
call fesetround
movsd xmm0, QWORD PTR [rsp+8]
movsd QWORD PTR [rbx+8], xmm0 # store the same value, not recalc
add rsp, 16
pop rbx
ret
Run Code Online (Sandbox Code Playgroud)
这与@Marc Glisse 在其他答案下的评论中警告的问题相同,以防您的 noinline 函数在更改舍入模式之前和之后执行相同的数学运算。
(而且,GCC 选择在第一次调用之前fesetround不进行数学计算也有一定的运气成分,因此它只需要溢出结果而不是两个输入。x86-64 System V 没有任何调用保留的 XMM 寄存器.)