为什么 gcc 以三种不同的方式实现 fmin 和 fmax?

jye*_*lon 5 assembly gcc x86-64 micro-optimization

我这里有一些例程,它们都做同样的事情:它们将浮点数限制在 [0,65535] 范围内。令我惊讶的是,编译器(gcc -O3)使用三种不同的方式来实现 float-min 和 float-max。我想了解为什么它会生成三种不同的实现。好的,这是 C++ 代码:

float clamp1(float x) {
    x = (x < 0.0f) ? 0.0f : x;
    x = (x > 65535.0f) ? 65535.0f : x;
    return x;
}

float clamp2(float x) {
    x = std::max(0.0f, x);
    x = std::min(65535.0f, x);
    return x;
}

float clamp3(float x) {
    x = std::min(65535.0f, x);
    x = std::max(0.0f, x);
    return x;
}
Run Code Online (Sandbox Code Playgroud)

这是生成的程序集(删除了一些样板)。可在https://godbolt.org/z/db775on4j上使用 GCC10.3重现-O3。(还显示 clang14 的选择。)

CLAMP1:
    movaps  %xmm0, %xmm1
    pxor    %xmm0, %xmm0
    comiss  %xmm1, %xmm0
    ja  .L9
    movss   .LC1(%rip), %xmm0     # 65535.0f
    movaps  %xmm0, %xmm2
    cmpltss %xmm1, %xmm2
    andps   %xmm2, %xmm0
    andnps  %xmm1, %xmm2
    orps    %xmm2, %xmm0
.L9:
    ret
Run Code Online (Sandbox Code Playgroud)
CLAMP2:
    pxor    %xmm1, %xmm1
    comiss  %xmm1, %xmm0
    ja  .L20
    pxor    %xmm0, %xmm0
    ret
.L20:
    minss   .LC1(%rip), %xmm0      # 65535.0f
    ret
Run Code Online (Sandbox Code Playgroud)
CLAMP3:
    movaps  %xmm0, %xmm1
    movss   .LC1(%rip), %xmm0      # 65535.0f
    comiss  %xmm1, %xmm0
    ja  .L28
    ret
.L28:
    maxss   .LC2(%rip), %xmm1      # 0.0f
    movaps  %xmm1, %xmm0
    ret
Run Code Online (Sandbox Code Playgroud)

所以这里似乎有 MIN 和 MAX 的三种不同实现:

  • 使用比较和分支
  • 使用minssmaxss
  • 使用比较、andpsandnpsorps

有人可以澄清以下问题:

  • 这些速度都是一样的,还是其中一个速度更快?
  • 编译器最终如何选择所有这些不同的实现?
  • andps带有、andnps、 等等的东西到底是什么?
  • 同时 使用minssmaxss且不使用分支会更快吗?

xiv*_*r77 5

这些速度都是一样的,还是其中一个速度更快?

它不一样,但差异取决于机器和给定的输入。

编译器最终如何选择所有这些不同的实现?

因为编译器并不像你想象的那么智能。

andps、andnps 等东西到底是什么?

就是下面这个逻辑。

float y = 65535;
float m = std::bit_cast<float>(-(x < y));
return x & m | y & ~m;
Run Code Online (Sandbox Code Playgroud)

您无法&在 C++(和 C)中处理浮点数,但您会明白的。

同时使用 minss 和 maxss 并且不使用分支会更快吗?

通常,是的。您可以在 ( https://uops.info/table.html )查看每条指令的延迟和吞吐量。我会在汇编中编写以下内容。

xorps xmm1, xmm1
minss xmm0, [_ffff]
maxss xmm0, xmm1
Run Code Online (Sandbox Code Playgroud)

然而,如果你知道某个分支很可能被采用,那么用分支跳过 a 可能会更快。让我们看看编译器如何处理分支提示。

float clamp_minmax(float x) {
  return std::max(std::min(x, 65535.0f), 0.0f);
}

float clamp_hint(float x) {
  if (__builtin_expect(x > 65535, 0)) {
    return 65535;
  }
  if (__builtin_expect(x < 0, 0)) {
    return 0;
  }
  return x;
}
Run Code Online (Sandbox Code Playgroud)

GCC 基本上不在乎,但有趣的是 Clang 会生成不同的代码。

clamp_minmax(float):
        movss   xmm1, dword ptr [rip + .LCPI0_0]
        minss   xmm1, xmm0
        xorps   xmm0, xmm0
        maxss   xmm0, xmm1
        ret

clamp_hint(float):
        movaps  xmm1, xmm0
        movss   xmm0, dword ptr [rip + .LCPI1_0]
        ucomiss xmm1, xmm0
        ja      .LBB1_2
        xorps   xmm0, xmm0
        maxss   xmm0, xmm1
.LBB1_2:
        ret
Run Code Online (Sandbox Code Playgroud)

ucomiss -> ja并不比单个版本快minss,但是如果您可以maxss通过分支跳过大部分时间,则分支版本可能会比无分支版本运行得更快,因为您可以跳过maxss无分支版本中不可避免的延迟,因为它依赖于以前的minss

另请参阅(在 x86 上提供无分支 FP 最小值和最大值的指令是什么?)。