当函数放在条件语句中时性能更好?

Better performance when function placed within conditional statement?

在对 round() 实现中的 copysign() 函数的一个实现进行基准测试时遇到了这个“异常”:

float copysign(float x, float y){
        float absx = std::fabs(x);
        /* use atan2 to distinguish -0. from 0. */
        if (y > 0.f || (y == 0.f && std::atan2(y, -1.f) > 0.f)) {
            return absx;
        } else {
            return absx * -1.0f;
        }
    }
    

通过 quick-benc(和另一个衡量 rdtsc/val 的基准实用程序),当 atan2() 从条件语句中移出时,它会导致性能大大降低:

    float copysign(float x, float y){
        float absx = std::fabs(x);
        float atan2y = std::atan2(y, -1.f); /* use atan2 to distinguish -0. from 0. */
        if (y > 0.f || (y == 0.f && atan2y > 0.f)) {
            return absx;
        } else {
            return absx *= -1.0f;
        }
    }

编译器资源管理器中有问题的函数GCC / Clang

快速 C++ 基准测试:

海湾合作委员会 11.2:(-O3 -ffast-math / -O3

CLANG 13.0 (-Ofast / -O3)

Another Benchmark utility copysign() 和 round() 实施的结果 (rdtsc/val):

copysign():

~0.60 for "strd"  (std::copysign(), same with copysign())
~3.12 for "cs1"   (atan2() within condition statement)
~17.5 for "cs2"   (atan2() as variable in condition statement)

并且在链接的 round() 实现中使用时:

~6.67 for case "strd"
~2.56 for case "cs1" 
~20.5 for case "cs2" 

问:在条件语句中使用 atan2() 会发生什么情况?它只是内联吗?

GCC 有关于 -ffast-math 用法的警告:

This option should never be turned on by any -O option since it can result in incorrect output for programs which depend on an exact implementation of IEEE or ISO rules/specifications for math functions.

问:这是启用 -ffast-math 时性能降低的原因吗(请参阅链接的 GCC 快速基准)?

What happens to atan2() when used within condition statement? Is it just inlined?

汇编中没有调用 std::atan2,因此我们知道它一定是内联扩展的(它也可能作为死代码被删除,但我们可以推断情况并非如此)。

启用 -ffast-math 后,y > 0.f || (y == 0.f && std::atan2(y, -1.f) > 0.f) 基本上被编译为 y >= 0,因为编译器不关心 -0 和 +0 之间的区别。这是 “可能导致输出不正确”的示例。如果您想按照 IEEE/ISO 处理 -0,请不要使用 -ffast-math.

这里有两个主要问题与标题问题不同:

  • 您在 quick-bench 上的基准测试看起来有缺陷,允许大量 constant-propagation 进入您的 copysign 实施。检查汇编。也许使用一个全局数组;编译器不知道是否有东西修改了它。并且可能生成一个随机 FP 值(一旦在循环之外)以将符号应用于。
    x=rand(); for(...) something = copysign(x, arr[i]);
    使用 copysign(x,x) 很奇怪 - 如果函数正确生成该输出,智能编译器可以将其优化到 x 而无需做任何工作。

  • -ffast-math 意味着 -fno-signed-zeros,在优化时不关心 signed-zero 语义,从而破坏了您的 copysign 实现。 (y == 0.0f 为真让编译器假设它实际上 0.0f,忽略 -0.0f 的可能性。)


题目问题:条件语句中的atan2。

y==0.fy == 0.f && std::atan2(y, -1.f) > 0.f 的短路评估仅 运行 秒 atan2。这种情况很少见,因此通常不会发生。 (虽然没有 -ffast-math,它仍然会发生。但是 -ffast-math,如果 [=24],编译器假设 y 实际上是 0.0f =] 比较为真,允许它通过 atan2 执行 constant-propagation 并删除调用。)

在 fast-math 的情况下,进一步 constant-propagation 将 atan2() > 0.f 条件变成常量 true,导致基本为零的 asm 指令。

您已经在 godbolt 上链接了 asm; copysign_1 中的任何地方都没有调用 atan2,它所做的只是 return (y>0.0f || y == 0.0f) ? fabs(x) : -fabs(x)。 (因为你用 -ffast-math 编译,这意味着 -fno-signed-zeros,所以 atan2 的东西可以完全优化掉,除了 jne 上的奇怪 ja 而不是单个 jaejnae.)

copysign_2 中,带有 -ffast-math 的 GCC 确实设法将 float atan2y = std::atan2(y, -1.f); 拉入使用它的条件中。 Clang 也能做到这一点;您链接了 clang 基准测试但链接了 GCC asm,这很奇怪。


但是如果没有 -ffast-math(就像你在 Quick-bench 上使用的那样),clang 13 不允许这样做,因此它会无条件地为每个输入调用 atan2那是因为默认是-fmath-errno,很遗憾。

使用任意 y 值调用 atan2 可能是一个可见的 side-effect,因为它会为无效输入值设置 errno。我认为?也许不会; atan2 手册页说它没有错误(没有设置 errno 的情况),并且在描述中仅在 x 或 y 为 NaN 时返回 NaN。也许编译器没有意识到这一点?但无论出于何种原因,使用 -fno-math-errno 让 GCC 和 clang 将你的无条件 atan2 拉到分支中,所以即使它实际上会调用它,它只发生在罕见的 y == 0.0 情况下(即y+0.0f-0.0f)

(我想我记得 clang 没有默认为 math-errnor,只有 GCC,但也许他们改变了或者我把它和 [=53= 混在一起了)。你应该总是用 [=47= 编译] 除非你的程序非常奇怪地想检查 errno 是否有 EDOMERANGE 或库函数调用后类似的 FP 错误,甚至包括 sqrt,而不是使用 fenv.h测试 FP 环境标志。)

此外,-ftrapping-math 不幸的是默认情况下启用,即使它不能完全正常工作。它可能会尝试将其视为可能 运行 SIGFPE signal-handler 代码,因此可能是可见的 side-effect。或者至少在 FP 环境中设置一个粘性位,其他代码可以用 fenv.

读取

顺便说一句,这就是 GCC 编译你的 copysign_1:

的方式
# gcc -O3 -ffast-math
copysign_1(float, float):
        comiss  xmm1, DWORD PTR .LC1[rip]            # compare y against 0.0f
        andps   xmm0, XMMWORD PTR .LC0[rip]          # clear the sign bit in x
        ja      .L3                                  # if (y>0)  return fabs(x)
        jne     .L5                                  # if (y!=0) return -fabs(x)
.L3:
        ret
.L5:
        xorps   xmm0, XMMWORD PTR .LC2[rip]          # flip the sign bit of x
        ret

如您所见,它因负零而中断,-0.0f,在这种情况下清除输出的符号位。编译器当然会在没有 -ffast-math 的情况下生成正确的 asm,但速度较慢并且实际上为 -0.0+0.0.

调用 atan2

顺便说一句,尽管 Quick-Bench 缺少编译器选项设置,但它确实有 -Ofast,目前相当于 -O3 -ffast-math。这确实使除了 M_STDNO 之外的所有东西都具有相同的速度。不知道为什么会慢;由于您使用 Benchmark::DoNotOptimize 的方式,asm 涉及很多 store/reload,但仍然允许通过 copysign 函数使用大量 constant-propagation。 (所以他们只是在做 andpsorps 来清除或设置某些东西的符号位。)


这对于 IEEE 浮点数

来说非常over-complicated

由于您显然使用的是 C++20,因此您也可以将 std::bit_cast<uint32_t>(x)y 转换为整数并使用按位运算,至少在检查 float 具有其顶部的标志位或其他东西。 (例如 static_assert -0.0f 具有 bit-pattern 0x80000000;您可以在 static_assert 等 constexpr 中使用 std::bit_cast。)

您不需要任何分支或乘法,您只想(u32x & 0x7FFFFFFFU) | (u32y & 0x80000000U) 将 y 的符号位与 x 的指数和尾数合并。