为了学术目的,我在C ++中使用基本的数学函数实现。今天,我对Square Root的以下代码进行了基准测试:
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<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<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 ?? ?? ??
答案 0 :(得分:3)
你的循环都非常可怕,除了sqrt函数调用或FSQRT指令之外还有许多瓶颈。并且至少比最佳标量SQRTSS(单精度)代码慢2倍。这可能比一个体面的SSE2矢量化循环可能实现的速度慢8倍。即使不重新排序任何数学运算,您也可以超越SQRTSS吞吐量。
https://gcc.gnu.org/wiki/DontUseInlineAsm的许多原因都适用于您的示例。编译器不能通过你的函数传播常量,并且它不知道结果总是非负的(如果它不是NaN)。如果您稍后对数字求平方,它也无法将其优化为fabs()
。
同样非常重要的是,您使用SSE2 SQRTPS(_mm_sqrt_ps()
)来破坏自动矢量化。 A&#34;无错误检查&#34;使用内在函数的标量sqrt()函数也遇到了这个问题。 IDK是否有任何方法可以在没有/fp:fast
的情况下获得最佳结果,但我对此表示怀疑。 (除了在汇编中编写一个完整的循环,或者用内在函数自己矢量化整个循环)。
令人印象深刻的是,您的Haswell CPU尽可能快地运行函数调用循环,尽管inline-asm循环可能甚至不会使FSQRT吞吐量饱和。
由于某种原因,您的库函数调用正在调用double sqrt(double)
,而不是C ++重载float sqrt(float)
。这导致转换为double并返回float。您可能需要#include <cmath>
to get the overloads,或者可以致电sqrtf()
。 Linux上的gcc和clang使用您当前的代码调用sqrtf()(不转换为double和back),但也许他们的<random>
标题恰好包含<cmath>
,而MSVC不会包含__libm_sse2_sqrt_precise
。吨。或者可能还有其他事情发生。
库函数调用循环将总和保存在内存中(而不是寄存器)。显然,32位版本的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
使用的调用约定不会保留任何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上的循环携带依赖性是一个延迟瓶颈,其中包括存储转发往返:
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
乱序执行使得每次迭代的其余代码重叠,因此您只是吞吐量的瓶颈,但无论库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将数据输入/输出内联asm。但更糟糕的是,您在x87堆栈上生成结果,编译器希望在XMM寄存器中进行SSE数学运算。
然后MSVC毫无理由地将自己射入脚中,将总和保留在内存中而不是XMM寄存器中。它查看inline-asm语句以查看它们影响哪些寄存器,因此IDK为什么它没有看到你的inline-asm语句没有破坏任何XMM regs。
因此,MSVC在这里做了一个很多差的工作:
/fp:fast
因此它具有与函数调用循环相同的循环承载依赖链(ADDSS +存储转发)。 Haswell FSQRT每8-17周期吞吐量有一个,所以它可能仍然是瓶颈。 (涉及数组值的所有存储/重新加载对于每次迭代都是独立的,并且乱序执行可以重叠多次迭代以隐藏该延迟链。但是,它们将阻塞加载/存储执行单元并且有时会延迟关键-path通过额外的周期加载/存储。这称为资源冲突。)
如果没有sqrtf()
,errno
库函数必须设置// 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));
}
,如果结果为NaN。这就是为什么它不能只是一个SQRTSS。
如果您确实希望自己实现无检查标量sqrt函数,那么您可以使用英特尔内在函数语法来实现:
-ffast-math
这将编译为具有gcc和clang( without # 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
)的近乎最佳的标量循环。请参阅the Godbolt compiler explorer:
-O3
此循环应该只对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(只有# 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
)使用SQRTSS甚至没有检查NaN。我认为这是合法的,而不是编译器错误。也许它看到代码没有使用errno?
/fp:fast
不幸的是,gcc在这里射击自己,就像MSVC使用inline-asm一样:有一个商店转发往返作为循环携带的依赖。所有溢出/重新加载都可以在JBE跳过的块内。也许gcc的东西是负面的输入会很常见。
更糟糕的是,如果您使用-ffast-math
或_mm_sqrt_ss
,即使像clang这样聪明的编译器也无法将_mm_sqrt_ss
重写为SQRTPS。 Clang通常非常擅长将内在函数映射到1:1的指令,并且如果你错过了组合事物的机会,它会提出更优化的混洗和混合。
因此,启用快速FP数学运算后,使用sqrt()
是一个很大的损失。 clang将sqrt_new()
库函数调用版本编译为RSQRTPS + newton-raphson迭代。
另请注意,您的微基准测试代码对fld n
实施的延迟不敏感,只对吞吐量敏感。延迟通常在实际FP代码中很重要,而不仅仅是吞吐量。但在其他情况下,就像对许多数组元素独立地做同样的事情一样,延迟并不重要,因为无序执行可以通过从许多循环迭代中获得指令来充分隐藏它。
正如我之前提到的,extra store/reload round trip your data takes on its way in/out of MSVC-style inline-asm的延迟是一个严重的问题。当MSVC内联函数时,{{1}}不会直接来自数组。
BTW,Skylake的SQRTPS / SS吞吐量为每3个周期一个,但仍有12个周期的延迟。 SQRTPD / SD吞吐量=每4-6个周期一个,延迟= 15-16个周期。所以FP平方根在Skylake上比在Haswell上更加流水线化。这放大了基准FP sqrt延迟与吞吐量之间的差异。答案 1 :(得分:2)
在发布模式下进行编译并启用所有优化
他们并非全部开启,你错过了一个。在IDE中,它是Project&gt;属性&gt; C / C ++&gt;代码生成&gt;浮点模型。您将其保留为默认设置/ fp:precise。这对生成的机器代码有一个非常明显的副作用:
00091197 E8 F2 18 00 00 call __libm_sse2_sqrt_precise (092A8Eh)
也许这很直观,在CRT中调用辅助函数总是比像FSQRT这样的内联指令慢。
关于/ fp的确切语义有很多话要说,关于它的MSDN文章不是很好。微软从英特尔购买代码并且无法获得允许他们重新发布汇编代码的源许可证也很难进行逆向工程。它的最初目标当然是处理由英特尔8087 FPU设计引起的可怕的浮点一致性问题。现在不再那么重要,所有主流的C和C ++编译器现在都会发出SSE2代码。自VS2012以来,MSVC ++就这样做了。这些英特尔库函数现在主要确保浮点运算仍能产生与旧版本编译器一致的结果。
__libm_sse2_sqrt_precise()
相当多。在尝试记录无证件功能的风险很大时,我想我看到了:
_matherr()
函数。这些与精确度实际上没有任何关系:)看到这个执行85%性能是一个很好的结果,但是FSQRT比SQRTSD慢得多。后者在现代处理器中获得了更多的热爱。
如果您关心快速浮点运算,请将设置更改为/ fp:fast。产生:
00D91310 sqrtsd xmm0,xmm0
内联指令而不是库调用。换句话说,跳过上一个列表中的前3个项目符号。也很方便地击败FSQRT。