【问题标题】:Understanding why an ASM fsqrt implementation is faster than the standard sqrt function了解为什么 ASM fsqrt 实现比标准 sqrt 函数更快
【发布时间】:2016-09-22 06:08:19
【问题描述】:

出于学术目的,我一直在使用 C++ 中的基本数学函数实现。今天,我对平方根的以下代码进行了基准测试:

inline float sqrt_new(float n)
{
    __asm {
        fld n
            fsqrt
    }
}

我惊讶地发现它始终比标准的 sqrt 函数快(它需要标准函数的大约 85% 的执行时间)。

我不太明白为什么,很想更好地理解它。下面我展示了我用来分析的完整代码(在 Visual Studio 2015 中,在发布模式下编译并打开所有优化):

#include <iostream>
#include <random>
#include <chrono>
#define M 1000000

float ranfloats[M];

using namespace std;

inline float sqrt_new(float n)
{
    __asm {
        fld n
            fsqrt
    }
}

int main()
{
    default_random_engine randomGenerator(time(0));
    uniform_real_distribution<float> diceroll(0.0f , 1.0f);

chrono::high_resolution_clock::time_point start1, start2;
chrono::high_resolution_clock::time_point end1, end2;
float sqrt1 = 0;
float sqrt2 = 0;


for (int i = 0; i<M; i++) ranfloats[i] = diceroll(randomGenerator);


start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i<M; i++) sqrt1 += sqrt(ranfloats[i]);
end1 = std::chrono::high_resolution_clock::now();

start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i<M; i++) sqrt2 += sqrt_new(ranfloats[i]);
end2 = std::chrono::high_resolution_clock::now();

auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
auto time2  = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();


cout << "Time elapsed for SQRT1: " << time1 << " seconds" << endl;
cout << "Time elapsed for SQRT2: " << time2 << " seconds" << endl;

cout << "Average of Time for SQRT2 / Time for SQRT1: " << time2 / time1  << endl;
cout << "Equal to standard sqrt? " << (sqrt1 == sqrt2) << endl;



system("pause");
return 0;
}

编辑:我正在编辑问题以包含两个循环的反汇编代码,它们在 Visual Studio 2015 中计算平方根。

首先,for (int i = 0; i&lt;M; i++) sqrt1 += sqrt(ranfloats[i]);的反汇编:

00091194 0F 5A C0             cvtps2pd    xmm0,xmm0  
00091197 E8 F2 18 00 00       call        __libm_sse2_sqrt_precise (092A8Eh)  
0009119C F2 0F 5A C0          cvtsd2ss    xmm0,xmm0  
000911A0 83 C6 04             add         esi,4  
000911A3 F3 0F 58 44 24 4C    addss       xmm0,dword ptr [esp+4Ch]  
000911A9 F3 0F 11 44 24 4C    movss       dword ptr [esp+4Ch],xmm0  
000911AF 81 FE 90 5C 46 00    cmp         esi,offset __dyn_tls_dtor_callback (0465C90h)  
000911B5 7C D9                jl          main+190h (091190h) 

接下来是for (int i = 0; i&lt;M; i++) sqrt2 += sqrt_new(ranfloats[i]);的反汇编:

00091290 F3 0F 10 00          movss       xmm0,dword ptr [eax]  
00091294 F3 0F 11 44 24 6C    movss       dword ptr [esp+6Ch],xmm0  
0009129A D9 44 24 6C          fld         dword ptr [esp+6Ch]  
0009129E D9 FA                fsqrt  
000912A0 D9 5C 24 6C          fstp        dword ptr [esp+6Ch]  
000912A4 F3 0F 10 44 24 6C    movss       xmm0,dword ptr [esp+6Ch]  
000912AA 83 C0 04             add         eax,4  
000912AD F3 0F 58 44 24 54    addss       xmm0,dword ptr [esp+54h]  
000912B3 F3 0F 11 44 24 54    movss       dword ptr [esp+54h],xmm0  
000912B9 ??                   ?? ?? 
000912BA ??                   ?? ?? 
000912BB ??                   ?? ?? 
000912BC ??                   ?? ?? 
000912BD ??                   ?? ?? 
000912BE ??                   ?? ?? 
000912BF ??                   ?? ?? 
000912C0 ??                   ?? ?? 
000912C1 ??                   ?? ?? 
000912C2 ??                   ?? ?? 
000912C3 ??                   ?? ?? 
000912C4 ??                   ?? ?? 
000912C5 ??                   ?? ?? 
000912C6 ??                   ?? ?? 
000912C7 ??                   ?? ?? 
000912C8 ??                   ?? ?? 
000912C9 ??                   ?? ?? 
000912CA ??                   ?? ?? 
000912CB ??                   ?? ?? 
000912CC ??                   ?? ?? 
000912CD ??                   ?? ?? 
000912CE ??                   ?? ?? 
000912CF ??                   ?? ?? 
000912D0 ??                   ?? ?? 
000912D1 ??                   ?? ?? 
000912D2 ??                   ?? ?? 
000912D3 ??                   ?? ?? 
000912D4 ??                   ?? ?? 
000912D5 ??                   ?? ?? 
000912D6 ??                   ?? ?? 
000912D7 ??                   ?? ?? 
000912D8 ??                   ?? ?? 
000912D9 ??                   ?? ?? 
000912DA ??                   ?? ?? 
000912DB ??                   ?? ?? 
000912DC ??                   ?? ?? 
000912DD ??                   ?? ?? 
000912DE ??                   ?? ?? 

【问题讨论】:

  • 其中一个原因可能是标准的 sqrt 来自链接库,因此它不能被内联并且容易出现calling overhead,而您的实现是完全可内联的。
  • 我在我的电脑上得到了相同的时间。使用/fp:fast 选项可使库版本的速度几乎提高一倍。
  • 我想如果我真的想知道答案,我会输出汇编程序 (/FAs) 并检查代码。
  • @Sergey 不,我没有。但实际上,由于仍然很少有关于发生了什么的线索,我认为最好的办法实际上是用完整的反汇编来编辑问题
  • /fp:fast 通常没有那么危险,除非您出于某种原因特别选择了操作顺序,或者您希望遇到 NaN 并且仍然可以获得有用的结果。例如如果您知道您的数组在大数和小数之间交替,并且您不希望自动矢量化最终仅将小数相加,直到它们大到足以大于大数总和的 1 ulp。或者,如果您需要在不同编译器版本的不同构建中精确重现输出。

标签: performance visual-c++ sqrt


【解决方案1】:

你的两个循环都非常糟糕,除了 sqrt 函数调用或 FSQRT 指令之外还有许多瓶颈。并且至少比最佳标量 SQRTSS(单精度)代码慢 2 倍。这可能比体面的 SSE2 矢量化循环可以实现的速度慢 8 倍。即使不重新排序任何数学运算,您也可以超过 SQRTSS 吞吐量。

https://gcc.gnu.org/wiki/DontUseInlineAsm 的许多原因都适用于您的示例。编译器将无法通过您的函数传播常量,并且它不会知道结果总是非负的(如果它不是 NaN)。如果您稍后对数字求平方,它也无法将其优化为 fabs()

同样重要的是,您使用 SSE2 SQRTPS (_mm_sqrt_ps()) 击败了自动矢量化。使用内在函数的“无错误检查”标量 sqrt() 函数也存在该问题。 IDK 如果没有/fp:fast 有任何方法可以获得最佳结果,但我对此表示怀疑。 (除了在汇编中编写整个循环,或者使用内在函数自己对整个循环进行矢量化)。


令人印象深刻的是,您的 Haswell CPU 能够以最快的速度运行函数调用循环,尽管 inline-asm 循环甚至可能不会使 FSQRT 吞吐量达到饱和。

由于某种原因,您的库函数调用正在调用 double sqrt(double),而不是 C++ 重载 float sqrt(float)。这导致转换为双精度并返回浮点数。可能你需要#include &lt;cmath&gt; to get the overloads,或者你可以打电话给sqrtf()。 Linux 上的 gcc 和 clang 使用您当前的代码调用 sqrtf()(不转换为 double 和返回),但可能它们的 &lt;random&gt; 标头恰好包含 &lt;cmath&gt;,而 MSVC 没有。或者可能还有其他事情发生。


库函数调用循环将总和保存在内存中(而不是寄存器)。显然,__libm_sse2_sqrt_precise 的 32 位版本使用的调用约定不保留任何 XMM 寄存器。 Windows x64 ABI 确实保留了 XMM6-XMM15、but wikipedia says this is new and the 32-bit ABI didn't do that。我假设如果有任何保留调用的 XMM 寄存器,MSVC 的优化器会利用它们。

无论如何,除了在每个独立的标量浮点数上调用 sqrt 的吞吐量瓶颈之外,对 sqrt1 的循环携带依赖是延迟瓶颈,其中包括存储转发往返:

000911A3 F3 0F 58 44 24 4C    addss       xmm0,dword ptr [esp+4Ch]  
000911A9 F3 0F 11 44 24 4C    movss       dword ptr [esp+4Ch],xmm0  

乱序执行让每次迭代的其余代码重叠,因此您只是吞吐量的瓶颈,但无论库 sqrt 函数的效率如何,这个延迟瓶颈都会将循环限制为每 6 + 3 = 9 次迭代循环。 (Haswell ADDSS 延迟 = 3,XMM 加载/存储的存储转发延迟 = 6 个周期。比整数寄存器的存储转发多 1 个周期。请参阅Agner Fog's instruction tables。)

SQRTSD 的吞吐量为每 8-14 个周期一个,因此循环携带的依赖不是 Haswell 的限制瓶颈。


inline-asm 版本 具有用于 sqrt 结果的存储/重新加载往返,但它不是循环携带的依赖链的一部分。 MSVC inline-asm syntax makes it hard to avoid store-forwarding round trips 将数据输入/输出内联汇编。但更糟糕的是,您在 x87 堆栈上生成结果,而编译器希望在 XMM 寄存器中进行 SSE 数学运算。

然后 MSVC 无缘无故地把自己踢到脚上,将总和保存在内存中而不是 XMM 寄存器中。它在 inline-asm 语句中查看它们影响哪些寄存器,所以 IDK 为什么它看不到您的 inline-asm 语句不会破坏任何 XMM regs。

因此,MSVC 在这里所做的工作比必要的要糟糕得多

00091290     movss       xmm0,dword ptr [eax]       # load from the array
00091294     movss       dword ptr [esp+6Ch],xmm0   # store to the stack
0009129A     fld         dword ptr [esp+6Ch]        # x87 load from stack
0009129E     fsqrt  
000912A0     fstp        dword ptr [esp+6Ch]        # x87 store to the stack
000912A4     movss       xmm0,dword ptr [esp+6Ch]   # SSE load from the stack (of sqrt(array[i]))
000912AA     add         eax,4  
000912AD     addss       xmm0,dword ptr [esp+54h]   # SSE load+add of the sum
000912B3     movss       dword ptr [esp+54h],xmm0   # SSE store of the sum

因此它与函数调用循环具有相同的循环携带依赖链(ADDSS + 存储转发)。 Haswell FSQRT 每 8-17 个周期有一个吞吐量,所以它可能仍然是瓶颈。 (涉及数组值的所有存储/重新加载对于每次迭代都是独立的,并且乱序执行可以重叠许多迭代以隐藏该延迟链。但是,它们会阻塞加载/存储执行单元,有时会延迟关键-path 以一个额外的周期加载/存储。这称为资源冲突。)


没有/fp:fast,如果结果为NaN,sqrtf() 库函数必须设置errno。这就是为什么它不能只内联到 SQRTSS。

如果您确实想自己实现无检查标量 sqrt 函数,则可以使用 Intel 内在语法:

// DON'T USE THIS, it defeats auto-vectorization
static inline
float sqrt_scalar(float x) {
    __m128 xvec = _mm_set_ss(x);
    xvec =  _mm_cvtss_f32(_mm_sqrt_ss(xvec));
}

这将编译为使用 gcc 和 clang 的近乎最佳的标量循环(没有 -ffast-math)。见the Godbolt compiler explorer:

# gcc6.2 -O3  for the sqrt_new loop using _mm_sqrt_ss.  good scalar code, but don't optimize further.
.L10:
    movss   xmm0, DWORD PTR [r12]
    add     r12, 4
    sqrtss  xmm0, xmm0
    addss   xmm1, xmm0
    cmp     r12, rbx
    jne     .L10

这个循环应该只在 SQRTSS 吞吐量上成为瓶颈(在 Haswell 上每 7 个时钟一个,明显比 SQRTSD 或 FSQRT 快),并且没有资源冲突。但是,与即使不重新排序 FP 添加的操作相比,它仍然是垃圾(因为 FP add/mul aren't truly associative):智能编译器(或使用内在函数的程序员)将使用 SQRTPS 来获得 4 个结果与 SQRTSS 产生的 1 相同的吞吐量。将 SQRT 结果的向量解包为 4 个标量,然后您可以保持完全相同的运算顺序,并对中间结果进行相同的舍入。我对 clang 和 gcc 没有这样做感到失望。

但是,gcc 和 clang 确实设法避免调用库函数。 clang3.9(只有-O3)使用 SQRTSS 甚至不检查 NaN。我认为这是合法的,而不是编译器错误。也许它看到代码没有使用errno?

另一方面,gcc6.2 推测性地内联 sqrtf(),使用 SQRTSS 并检查输入以查看它是否需要调用库函数。

# gcc6.2's sqrt() loop, without -ffast-math.
# speculative inlining of SQRTSS with a check + fallback
# spills/reloads a lot of stuff to memory even when it skips the call :(

# xmm1 = 0.0  (gcc -fverbose-asm says it's holding sqrt2, which is zero-initialized, so I guess gcc decides to reuse that zero)
.L9:
    movss   xmm0, DWORD PTR [rbx]
    sqrtss  xmm5, xmm0
    ucomiss xmm1, xmm0                  # compare input against 0.0
    movss   DWORD PTR [rsp+8], xmm5
    jbe     .L8                         # if(0.0 <= SQRTSS input || unordered(0.0, input)) { skip the function call; }
    movss   DWORD PTR [rsp+12], xmm1    # silly gcc, this store isn't needed.  ucomiss doesn't modify xmm1
    call    sqrtf                       # called for negative inputs, but not for NaN.
    movss   xmm1, DWORD PTR [rsp+12]
.L8:
    movss   xmm4, DWORD PTR [rsp+4]     # silly gcc always stores/reloads both, instead of putting the stores/reloads inside the block that the jbe skips
    addss   xmm4, DWORD PTR [rsp+8]
    add     rbx, 4
    movss   DWORD PTR [rsp+4], xmm4
    cmp     rbp, rbx
    jne     .L9

不幸的是,gcc 在这里自相残杀,就像 MSVC 对 inline-asm 所做的那样:有一个存储转发往返作为循环携带的依赖项。所有溢出/重新加载都可能在 JBE 跳过的块内。也许 gcc 的东西负面输入会很常见。


更糟糕的是,如果您使用/fp:fast-ffast-math,即使像clang 这样的聪明编译器也无法将您的_mm_sqrt_ss 重写为SQRTPS。 Clang 通常非常擅长将内在函数映射到指令 1:1,如果你错过了组合事物的机会,它会提出更优化的随机播放和混合。

所以在启用快速 FP 数学的情况下,使用 _mm_sqrt_ss 是一个很大的损失。 clang 将 sqrt() 库函数调用版本编译为 RSQRTPS + newton-raphson 迭代。


另请注意,您的微基准代码对 sqrt_new() 实现的延迟不敏感,只对吞吐量敏感。延迟在实际 FP 代码中通常很重要,而不仅仅是吞吐量。但在其他情况下,比如对许多数组元素独立执行相同的操作,延迟并不重要,因为乱序执行可以通过在许多循环迭代中运行指令来很好地隐藏它。

正如我之前提到的,extra store/reload round trip your data takes on its way in/out of MSVC-style inline-asm 的延迟在这里是一个严重的问题。当 MSVC 内联函数时,fld n 不直接来自数组。


顺便说一句,Skylake 的 SQRTPS/SS 吞吐量为每 3 个周期 1 个,但仍有 12 个周期的延迟。 SQRTPD/SD 吞吐量 = 每 4-6 个周期一个,延迟 = 15-16 个周期。所以 FP 平方根在 Skylake 上比在 Haswell 上更流水线化。这放大了基准测试 FP sqrt 延迟与吞吐量之间的差异。

【讨论】:

    【解决方案2】:

    在发布模式下编译并开启所有优化

    它们没有全部打开,你错过了一个。在 IDE 中,它是项目 > 属性 > C/C++ > 代码生成 > 浮点模型。您将其保留为默认设置 /fp:precise。这对生成的机器代码有非常明显的副作用:

      00091197 E8 F2 18 00 00       call        __libm_sse2_sqrt_precise (092A8Eh) 
    

    也许很直观,在 CRT 中调用辅助函数总是比像 FSQRT 这样的内联指令慢。

    关于/fp的确切语义有很多话要说,MSDN关于它的文章不是很好。逆向工程也很困难,微软从英特尔购买了代码,但无法获得允许他们重新发布汇编代码的源许可证。它最初的目标当然是处理因英特尔的 8087 FPU 设计引起的可怕的浮点一致性问题。这在今天已经不那么重要了,所有主流的 C 和 C++ 编译器现在都发出 SSE2 代码。 MSVC++ 从 VS2012 开始就这样做了。这些英特尔库函数现在主要确保浮点运算仍然产生与旧版本编译器一致的结果。

    __libm_sse2_sqrt_precise() 做了很多事情。冒着尝试记录未记录函数的巨大风险,我想我看到了:

    • 检查 MXCSR 寄存器的值,该寄存器是 SSE 的控制寄存器。如果它没有默认值 (0x1F80),则代码假定程序员使用了 _control_fp() 并跳转到使用 FPU 语义进行计算的传统 sqrt() 实现。
    • 检查 FPU 控制寄存器的值以查看是否启用了浮点异常。通常是关闭的,如果启用了,那么它会像上面一样跳转到 sqrt()。
    • 检查参数是否小于零,但不是负0,调用用户提供的_matherr()函数。
    • 最后用 SQRTSD 指令计算结果。

    这实际上与精度无关 :) 看到这个以 85% 的性能执行是相当好的结果,但是 FSQRT 比 SQRTSD 慢得多。后者在现代处理器中得到了更多硅的喜爱。

    如果您关心快速浮点运算,请将设置更改为 /fp:fast。产生:

      00D91310  sqrtsd      xmm0,xmm0 
    

    内联指令而不是库调用。换句话说,跳过前一个列表中的前 3 个项目符号。也轻松击败了 FSQRT。

    【讨论】:

    • 伟大的洞察力,谢谢!出于测试目的,如果我不能为整个应用程序使用/fp:fast,那么指定自定义sqrt_new() 函数归结为sqrtsd xmm0,xmm0 的最佳方法是什么?我认为这将是我的情况的理想比较。我试过float sqrt_new(float n) { _mm_cvtss_f32(_mm_sqrt_ss(_mm_set_ss(n))); },但它实际上比标准sqrt(n)慢得多/fp:fast
    • 该设置适用于每个源代码文件。所以只需移动“需要快速”的代码。
    • 天哪,那个库函数听起来比我想象的还要糟糕。我认为 glibc 的 libm sqrt 只需要检查 NaN 并设置 errno,而不是检查 MXCSR。不过,对于为什么 OP 的代码完全转换为 double 有任何想法吗?是不是因为他没有#include &lt;cmath&gt;,所以sqrt(float) 的浮点重载不可用?但是某些东西仍然必须声明sqrt(double),否则它不会编译,对吧?
    • @Louis15:/fp:fast 使整个事情比标量 sqrt 更快是正常的。它允许整个事物(包括添加)向量化(通过将 FP 数学视为关联)。您的 sqrt_new 仍然执行标量 sqrt 而不是一次 4 (SQRTPS)。或者,如果您的代码仍在调用 sqrt(double) 而不是 sqrt(float),则可能是 SQRTPD。
    • 这是一个库函数,支持1-800的电话号码,很难比较。我只关注OP的机器码,根本没有尝试源码。
    猜你喜欢
    • 2011-12-25
    • 1970-01-01
    • 1970-01-01
    • 2018-12-27
    • 2012-07-16
    • 2010-10-19
    • 1970-01-01
    • 1970-01-01
    • 2021-07-27
    相关资源
    最近更新 更多