通过将 float 放入 int 变量的内联 ASM 舍入的优点

Merit of inline-ASM rounding via putting float into int variable

我继承了一段很有趣的代码:

inline int round(float a)
{
  int i;
  __asm {
    fld   a
    fistp i
  }
  return i;
}

我的第一个冲动是放弃它并用 (int)std::round 替换调用(C++11 之前的版本,如果今天发生的话会使用 std::lround),但过了一会儿我开始怀疑如果它毕竟有一些优点......


此函数的用例是 [-100, 100] 中的所有值,因此即使 int8_t 也足以容纳结果。 fistp 至少需要一个 32 位内存变量,但是,少于 int32_t 和多一样浪费。

现在,很明显,将浮点数转换为整数并不是最快的处理方式,因为必须根据标准将舍入模式切换为 truncate,然后再切换回来。 C++11 提供了 std::lround 函数,它缓解了这个特殊问题,但考虑到值通过 float->long->int 而不是直接到达它应该到达的位置,似乎仍然更浪费。

另一方面,在函数中使用 inline-ASM,编译器无法将 i 优化到寄存器中(即使可以,fistp 需要一个内存变量),所以std::lround好像也差不了多少...

我最紧迫的问题是假设(如此函数所做的那样)舍入模式将始终为 round-to-nearest 的安全性如何,显然如此(无检查)。由于 std::lround 必须保证某种独立于舍入模式的行为,这个假设只要成立,似乎总是使内联 ASM 舍入成为更好的选择。

我还非常不清楚 std::fesetround 设置并由 std::lround 替代 std::lrint 使用的舍入模式和 fistp 中采用的舍入模式ASM 指令保证相同或至少同步。


这些是我的考虑因素,也就是我不知道如何就保留或更换功能做出明智的决定。

现在回答问题:


在对这些考虑因素或我没有想到的考虑因素进行更全面的了解后,是否建议使用此功能?

风险有多大?

是否存在为什么它不会比 std::lroundstd::lrint 快的推理?

在没有性能成本的情况下是否可以进一步改进?

如果程序是为 x86-64 编译的,这个推理会改变吗?

TL;DR:使用 lrintf(x)(int)nearbyintf(x),具体取决于您的编译器更喜欢哪个。

检查 asm 以查看在 SSE4.1 可用时(例如 -march=nehalem 或 penryn,或更高版本)使用或不使用 -ffast-math 时哪一个内联。有时您可能需要 -fno-math-errno 才能让 GCC 内联,但无论如何都会内联。这是 100% 安全的,除非您实际上期望 lrintfsqrtf 或其他数学函数设置 errno,并且通常建议与 -fno-trapping-math.

一起使用

尽可能避免使用内联汇编。编译器不会 "understand" 它的作用,因此它们无法通过它进行优化。例如如果该函数内联某处使其参数成为编译时常量,它仍将 fld 一个常量并将其 fistp 存入内存,然后将其加载回整数寄存器。纯 C 将让编译器传播常量并且只是 mov r32, imm32,或者进一步传播常量并将其折叠成其他东西。更不用说 CSE,并且将转换提升到循环之外。 (MSVC inline asm doesn't let you specify that an asm block is a pure function, and only needs to be run if the output value is needed, and that it doesn't depend on a global。GNU C 内联 asm 确实允许该部分,但这仍然是一个糟糕的选择,因为它对编译器不透明)。

The GCC wiki even has a page on this subject,解释了与我上一段相同的内容(以及更多内容),因此内联汇编绝对应该是最后的手段。

在这种情况下,我们可以让编译器从纯 C 中生成好的代码,所以我们绝对应该这样做。

当前舍入模式下的 Float->int 只需要一条机器指令(见下文),但诀窍是让编译器发出它(并且只发出它)。将数学库函数内联可能很棘手,因为其中一些函数必须设置 errno and/or 在某些情况下引发不精确的异常。 (-fno-math-errno 可以提供帮助,如果您不能使用完整的 -ffast-math 或等效的 MSVC)

With some compilers (gcc but not clang), lrintf is good。但是,这并不理想:float->long->int 与直接 int 不同,因为它们的大小不同。 x86-64 SystemV ABI(除了 Windows 之外的所有东西都使用)有 64 位 long.

64 位 long 更改了 lrint 的溢出语义:您将获得 [=30] 的低 32 位而不是 0x80000000(在具有 SSE 指令的 x86 上) =](如果值超出 long 的范围,则为全零)。

这个 lrintf 不会自动矢量化(除非编译器可以证明浮点数在范围内),因为只有标量,没有 SIMD,转换指令 floats 或 double 到打包的 64 位整数 (until AVX512DQ)。直接转换为 int 的 C 数学库函数的 IDK,但您可以使用 (int)nearbyintf(x),它在 64 位代码中更容易自动矢量化。请参阅下面的部分,了解 gcc 和 clang 的处理情况。

不过,除了击败自动矢量化之外,cvtss2si rax, xmm0 在任何现代微体系结构上都没有直接的速度损失(参见 Agner Fog's insn tables)。它只是为 REX 前缀花费了一个额外的指令字节。

在 AArch64(又名 ARM64)上gcc4.8 compiles lround into a single fcvtas x0, s0 instruction,所以我猜 ARM64 在硬件中提供了这种时髦的舍入模式(但 x86 没有)。奇怪的是,-ffast-math 使更少的内联函数,但这是笨重的旧 gcc4.8。对于 ARM(不是 64),gcc4.8 不内联任何东西,即使使用 -mfloat-abi=hard -mhard-float -march=armv7-a。也许这些都不是正确的选择; IDK ARM 很好:/

如果您有很多转换要做,您可以使用 SSE / AVX 内在函数手动矢量化 x86,like _mm_cvtps_epi32 (cvtps2dq),甚至将生成的 32 位整数元素压缩到16 位或 8 位(使用 packssdw。但是,使用编译器可以自动矢量化的纯 C 是一个很好的计划,因为它是可移植的。


lrintf

#include <math.h>
int round_to_nearest(float f) {  // default mode is always nearest
  return lrintf(f);
}

the Godbolt Compiler explorer 的编译器输出:

       ########### Without -ffast-math #############
    cvtss2si        eax, xmm0    # gcc 6.1  (-O3 -mx32, so long is 32bit)

    cvtss2si        rax, xmm0    # gcc 4.4 through 6.1  (-O3).  can't auto-vectorize, though.

    jmp     lrintf               # clang 3.8 (-O3 -msse4.1), still tail-calls the function :/

             ###### With -ffast-math #########
    jmp     lrintf               # clang 3.8 (-O3 -msse4.1 -ffast-math)

很明显 clang 不能很好地处理它,但即使是古老的 gcc 也很棒,即使没有 -ffast-math


Don't use roundf/lroundf: it has non-standard rounding semantics (halfway cases away from 0, instead of to even). This leads to worse x86 asm,但实际上更好的ARM64 asm。那么也许 将它用于 ARM?不过,它确实具有固定的舍入行为,而不是使用当前的舍入模式。

如果您希望 return 值作为 float,而不是转换为 int,最好是 use nearbyintfrint 必须在输出 != 输入时引发 FP 不精确异常。 (但是 SSE4.1 roundss 可以通过其直接控制字节的第 3 位实现任一行为)。


直接将 nearbyint() 截断为 int

#include <math.h>
int round_to_nearest(float f) {
  return nearbyintf(f);
}

来自 the Godbolt Compiler explorer 的编译器输出。

        ########  With -ffast-math ############
    cvtss2si        eax, xmm0      # gcc 4.8 through 6.1 (-O3 -ffast-math)

    # clang is dumb and won't fold the roundss into the cvt.  Without sse4.1, it's a function call
    roundss xmm0, xmm0, 12         # clang 3.5 to 3.8 (-O3 -ffast-math -msse4.1)
    cvttss2si       eax, xmm0

    roundss   xmm1, xmm0, 12      # ICC13 (-O3 -msse4.1 -ffast-math)
    cvtss2si  eax, xmm1

        ######## WITHOUT -ffast-math ############
    sub     rsp, 8
    call    nearbyintf                    # gcc 6.1 (-O3 -msse4.1)
    add     rsp, 8                        # and clang without -msse4.1
    cvttss2si       eax, xmm0

    roundss xmm0, xmm0, 12               # clang3.2 and later (-O3 -msse4.1)
    cvttss2si       eax, xmm0

    roundss   xmm1, xmm0, 12             # ICC13 (-O3 -msse4.1)
    cvtss2si  eax, xmm1

Gcc 4.7 及更早版本:只有 cvttss2si 没有 -msse4.1,但如果 SSE4.1 可用则发出 roundss。它的 nearbyint 定义必须使用 inline-asm,因为 asm 语法在 intel-syntax 输出中被破坏。可能这就是它被插入的方式,然后在意识到它正在转换为 int 时没有被优化掉。


它在 asm 中是如何工作的

Now, quite obviously casting the float to int is not the fastest way to do things, as for that the rounding mode has to be switched to truncate, as per the standard, and back afterwards.

只有当您的目标是没有 SSE 的 20 年前的 CPU 时,这才是正确的。 (你说的是float,不是double,所以我们只需要SSE,不需要SSE2,最老的没有SSE2的CPU是Athlon XP)。

现代系统在 xmm 寄存器中进行浮点运算。 SSE 有转换 scalar float to signed int with truncation (cvttss2si) or with the current counting mode (cvtss2si) 的说明。 (注意第一个中 Truncate 的额外 t。其余的助记符是 Convert Scalar Single-precision To Signed Integer。)double 也有类似的指令,x86-64 允许目标成为一个 64 位整数寄存器。

另请参阅 标签 wiki。

cvtss2si 基本上存在是因为 C 将 float 转换为 int 的默认行为。更改舍入模式很慢,因此 Intel 提供了一种不会很糟糕的方法。

我认为即使是现代 Windows 的 32 位版本也需要足够新的硬件才能拥有 SSE2,以防对任何人都重要。 (SSE2 是 AMD64 ISA 的一部分,64 位调用约定甚至在 xmm 寄存器中传递 float / double args)。