【发布时间】:2010-12-04 11:08:36
【问题描述】:
我一直在分析我们在英特尔酷睿双核上的一些核心数学,在研究平方根的各种方法时,我发现了一些奇怪的东西:使用 SSE 标量运算,取倒数平方根更快并乘以得到 sqrt,而不是使用本机 sqrt 操作码!
我正在使用类似以下的循环对其进行测试:
inline float TestSqrtFunction( float in );
void TestFunc()
{
#define ARRAYSIZE 4096
#define NUMITERS 16386
float flIn[ ARRAYSIZE ]; // filled with random numbers ( 0 .. 2^22 )
float flOut [ ARRAYSIZE ]; // filled with 0 to force fetch into L1 cache
cyclecounter.Start();
for ( int i = 0 ; i < NUMITERS ; ++i )
for ( int j = 0 ; j < ARRAYSIZE ; ++j )
{
flOut[j] = TestSqrtFunction( flIn[j] );
// unrolling this loop makes no difference -- I tested it.
}
cyclecounter.Stop();
printf( "%d loops over %d floats took %.3f milliseconds",
NUMITERS, ARRAYSIZE, cyclecounter.Milliseconds() );
}
我已经为 TestSqrtFunction 尝试了几个不同的主体,但有些时间确实让我摸不着头脑。到目前为止,最糟糕的是使用本机 sqrt() 函数并让“智能”编译器“优化”。在 24ns/float 时,使用 x87 FPU 这太糟糕了:
inline float TestSqrtFunction( float in )
{ return sqrt(in); }
接下来我尝试使用内部函数强制编译器使用 SSE 的标量 sqrt 操作码:
inline void SSESqrt( float * restrict pOut, float * restrict pIn )
{
_mm_store_ss( pOut, _mm_sqrt_ss( _mm_load_ss( pIn ) ) );
// compiles to movss, sqrtss, movss
}
这更好,为 11.9ns/float。我还尝试了Carmack's wacky Newton-Raphson approximation technique,它在 4.3ns/float 上比硬件运行得更好,尽管误差为 1 in 210(这对我的目的来说太多了)。
当我尝试对 reciprocal 平方根进行 SSE 运算,然后使用乘法得到平方根 ( x * 1/√x = √x ) 时,这真是太棒了。尽管这需要两个相关操作,但它是迄今为止最快的解决方案,1.24ns/float 并且精确到 2-14:
inline void SSESqrt_Recip_Times_X( float * restrict pOut, float * restrict pIn )
{
__m128 in = _mm_load_ss( pIn );
_mm_store_ss( pOut, _mm_mul_ss( in, _mm_rsqrt_ss( in ) ) );
// compiles to movss, movaps, rsqrtss, mulss, movss
}
我的问题基本上是什么给了? 为什么 SSE 的硬件内置平方根操作码比从其他两个数学运算中合成它要慢?
我确定这确实是操作本身的成本,因为我已经验证:
- 所有数据都适合缓存,并且 访问是顺序的
- 函数是内联的
- 展开循环没有区别
- 编译器标志设置为完全优化(我检查过,程序集很好)
(edit:stephentyrone 正确指出对长字符串的操作应该使用矢量化 SIMD 打包操作,例如 rsqrtps — 但这里的数组数据结构仅用于测试目的:什么我真的想衡量的是 scalar 在无法矢量化的代码中使用的性能。)
【问题讨论】:
-
x / sqrt(x) = sqrt(x)。或者,换一种说法:x^1 * x^(-1/2) = x^(1 - 1/2) = x^(1/2) = sqrt(x)
-
当然,
inline float SSESqrt( float restrict fIn ) { float fOut; _mm_store_ss( &fOut, _mm_sqrt_ss( _mm_load_ss( &fIn ) ) ); return fOut; }。但这是一个坏主意,因为如果 CPU 将浮点数写入堆栈然后立即将它们读回,它很容易导致加载命中存储停顿——特别是从向量寄存器到浮点寄存器以获取返回值是个坏消息。此外,SSE 内在函数表示的底层机器操作码无论如何都采用地址操作数。 -
LHS 的重要性取决于给定 x86 的特定 gen 和步进:我的经验是,在 i7 之前的任何设备上,在寄存器集之间移动数据(例如 FPU 到 SSE 到
eax)是非常糟糕,而 xmm0 和堆栈之间的往返不是,因为英特尔的存储转发。您可以自己确定时间来确定。通常,查看潜在 LHS 的最简单方法是查看发出的程序集并查看数据在寄存器组之间的位置;您的编译器可能会做聪明的事,也可能不会。至于标准化向量,我在这里写下了我的结果:bit.ly/9W5zoU -
对于 PowerPC,是的:IBM 有一个 CPU 模拟器,可以通过静态分析预测 LHS 和许多其他管道气泡。一些 PPC 还具有您可以轮询的 LHS 硬件计数器。 x86 更难;好的分析工具越来越少(VTune 这些天有些坏了),重新排序的管道不太确定。您可以尝试通过测量每个周期的指令来凭经验测量它,这可以通过硬件性能计数器精确完成。可以使用例如 PAPI 或 PerfSuite (bit.ly/an6cMt) 读取“指令退休”和“总周期”寄存器。
-
你也可以简单地在一个函数上写一些排列,然后给它们计时,看看是否有任何特别受停顿的影响。英特尔没有公布关于他们的管道工作方式的许多细节(他们的 LHS 完全是一个肮脏的秘密),所以我学到的很多东西都是通过查看导致其他拱门停滞的场景(例如 PPC ),然后构建一个受控实验,看看 x86 是否也有。
标签: performance assembly floating-point x86 sse