通过将float放入int变量

时间:2016-06-03 17:52:26

标签: stl x86 rounding inline-assembly c++17

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

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就像浪费更多。

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

另一方面,使用函数中的inline-ASM,编译器无法将i优化到寄存器中(即使它可以,fistp期望一个内存变量),所以{{ 1}}似乎没有太糟糕......

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

我还很清楚,std::lround设置的舍入模式是否由std::fesetround替代std::lround设置的舍入模式以及std::lrint ASM中使用的舍入模式指令保证相同或至少是同步的。

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

现在回答问题:

根据对这些注意事项的更为明智的看法或我未曾想到的这些注意事项,使用此功能似乎是明智的吗?

风险有多大?

是否存在为什么它不会比fistpstd::lround更快的推理?

如果没有性能成本,是否可以进一步改进?

如果程序是针对x86-64编译的,那么这种推理是否有任何改变?

1 个答案:

答案 0 :(得分:2)

TL; DR :使用lrintf(x)(int)nearbyintf(x),具体取决于您的编译器更喜欢哪一个。 (检查asm以查看哪一个内联,有或没有-ffast-math

当你可以避免它时,不要使用内联asm。编制者不会理解"它做了什么,所以他们无法通过它进行优化。例如如果在某个地方内联该函数使其参数成为编译时常量,那么它仍将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 inline asm允许该部分,但它仍然是一个糟糕的选择,因为它对编译器不透明。)

The GCC wiki even has a page on this subject,解释与前一段(以及更多)相同的内容,因此内联asm绝对应该是最后的选择。

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

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

<强> With some compilers (gcc but not clang), lrintf is good 即可。但这不是理想的:float - &gt; long - &gt; intint的{​​{1}}直接相同;重量不一样。 x86-64 SystemV ABI(除了Windows之外的所有内容使用)都有64位long

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

long不会自动向量化(除非编译器可以证明浮点数将在范围内),因为只有标量而不是SIMD转换{{1}的指令} s或lrintf打包64位整数(until AVX512DQ)。 C数学库函数的IDK可直接转换为float,但您可以使用double,它可以在64位代码中更轻松地自动向量化。请参阅下面的部分,了解gcc和clang如何做到这一点。

除了击败自动矢量化之外,任何现代微体系结构都没有int的直接速度惩罚(见Agner Fog's insn tables)。它只需要为REX前缀花费额外的指令字节。

在AArch64(又名ARM64)gcc4.8 compiles lround into a single fcvtas x0, s0 instruction上,所以我猜ARM64在硬件中提供了那种时髦的舍入模式(但是x86并没有)。奇怪的是,(int)nearbyintf(x)使内联函数更少,但是那些笨重的旧gcc4.8。对于ARM(不是64),即使使用cvtss2si rax, xmm0,gcc4.8也不会内联任何内容。也许那些不是正确的选择; IDK ARM非常好:/

如果您要进行大量转换,可以使用SSE / AVX内在函数like _mm_cvtps_epi32-ffast-math)手动对x86进行矢量化,甚至将生成的32位整数元素打包为16或8位(带-mfloat-abi=hard -mhard-float -march=armv7-a。但是,使用纯C,编译器可以自动向量化是一个很好的计划,因为它是可移植的。

cvtps2dq

packssdw

the Godbolt Compiler explorer的编译器输出:

lrintf

很明显,clang并不能很好地应对它,但即使是古老的gcc也很棒,即使没有#include <math.h> int round_to_nearest(float f) { // default mode is always nearest return lrintf(f); } 也能做得很好。

Don't use roundf/lroundf:它具有非标准的舍入语义(距离0的中间情况,而不是偶数)。 This leads to worse x86 asm,但实际上更好的是ARM64 asm。也许将它用于ARM?但它确实具有固定的舍入行为,而不是使用当前的舍入模式。

如果您希望返回值为 ########### 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) ,而不是转换为int,则use nearbyintf可能更好。输出!= input时,-ffast-math必须引发FP不精确异常。 (但SSE4.1 float可以实现其立即控制字节的第3位的行为。)

直接将rint截断为roundss

nearbyint()

the Godbolt Compiler explorer的编译器输出。

int

Gcc 4.7及更早版本:只有#include <math.h> int round_to_nearest(float f) { return nearbyintf(f); } 没有 ######## 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 ,但如果SSE4.1可用则会发出cvttss2si。它附近的定义必须使用inline-asm,因为asm语法在intel-syntax输出中被破坏。可能这是它插入的方式,然后当它意识到它转换为int时没有被优化掉。

它在asm中的工作原理

  

现在,很明显将float转换为int并不是最快的方法,因为舍入模式必须按照标准切换到truncate,然后再转回。

如果你的目标是在没有SSE的情况下使用已有20年历史的CPU,那么这是唯一的。 (你说-msse4.1,而不是roundss,所以我们只需要SSE,而不是SSE2。没有SSE2的最老的CPU是Athlon XP。

现代系统在xmm寄存器中执行浮点运算。 SSE有关于转换scalar float to signed int with truncation (cvttss2si)with the current counting mode (cvtss2si)的说明。 (注意第一个中的Truncate的额外float。其余的助记符是将标量单精度转换为有符号整数。)double有类似的指令,x86-64允许目的地是一个64位整数寄存器。

另请参阅标记wiki。

t基本上是存在的,因为C的默认行为是将float转换为int。更改舍入模式的速度很慢,因此英特尔提供了一种无法实现的方法。

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