数组的快速近似倒数平方根

时间:2016-07-27 20:23:22

标签: c++ arrays optimization sse simd

如何使用popcnt和SSE4.2在cpu上更快地计算数组的近似倒数平方根?

输入是存储在浮点数组中的正整数(范围从0到大约200,000)。

输出是浮点数组。

两个数组都有正确的内存对齐方式。

下面的代码只使用1 xmm寄存器,在linux上运行,可以由gcc -O3 code.cpp -lrt -msse4.2编译

谢谢。

#include <iostream>
#include <emmintrin.h>
#include <time.h>

using namespace std;
void print_xmm(__m128 xmm){
    float out[4];
    _mm_storeu_ps(out,xmm);
    int i;
    for (i = 0; i < 4; ++i) std::cout << out[i] << " ";
    std::cout << std::endl;
}

void print_arr(float* ptr, size_t size){
    size_t i;
    for(i = 0; i < size; ++i){
        cout << ptr[i] << " ";
    }
    cout << endl;
}

int main(void){
    size_t size = 25000 * 4;
        // this has to be multiple of 4
    size_t repeat = 10000;
        // test 10000 cycles of the code
    float* ar_in = (float*)aligned_alloc(16, size*sizeof(float));
    float* ar_out = (float*)aligned_alloc(16, size*sizeof(float));
    //fill test data into the input array
    //the data is an array of positive numbers.
    size_t i;
    for (i = 0; i < size; ++i){
        ar_in[i] = (i+1) * (i+1);
    }
    //prepare for recipical square root.
    __m128 xmm0;
    size_t size_fix = size*sizeof(float)/sizeof(__m128);
    float* ar_in_end  = ar_in + size_fix;
    float* ar_out_now;
    float* ar_in_now;
    //timing
    struct timespec tp_start, tp_end;
    i = repeat;
    clock_gettime(CLOCK_MONOTONIC, &tp_start);
    //start timing
    while(--i){
        ar_out_now = ar_out;
        for(ar_in_now = ar_in;
            ar_in_now != ar_in_end;
            ar_in_now += 4, ar_out_now+=4){
            //4 = sizeof(__m128)/sizeof(float);
            xmm0 = _mm_load_ps(ar_in_now);
            //cout << "load xmm: ";
            //print_xmm(xmm0);
            xmm0 = _mm_rsqrt_ps(xmm0);
            //cout << "rsqrt xmm: ";
            //print_xmm(xmm0);
            _mm_store_ps(ar_out_now,xmm0);
        }
    }
    //end timing
    clock_gettime(CLOCK_MONOTONIC, &tp_end);
    double timing;
    const double nano = 0.000000001;

    timing = ((double)(tp_end.tv_sec  - tp_start.tv_sec )
           + (tp_end.tv_nsec - tp_start.tv_nsec) * nano)/repeat;

    cout << "    timing per cycle: " << timing << endl;
    /* 
    cout << "input array: ";
    print_arr(ar_in, size);
    cout << "output array: ";
    print_arr(ar_out,size);
    */
    //free mem
    free(ar_in);
    free(ar_out);
    return 0;
}

3 个答案:

答案 0 :(得分:4)

你的花车阵列有多大?如果它已经在L1(或者L2)中已经很热,那么这个代码的gcc5.3输出会影响现代Intel CPU上的uop吞吐量,因为它会产生一个循环,其中包含6个融合域uop,每次迭代执行一个向量。 (因此它将以每2个周期一个向量运行)。

要在现代Intel CPU上实现每个时钟吞吐量1个向量,需要展开循环(请参阅下文,了解未展开的asm无法正常工作的原因)。让编译器为你做这件事可能是好的(而不是在C ++源代码中手工完成)。例如使用配置文件引导优化(gcc -fprofile-use),或者只是盲目地使用-funroll-loops

理论上,每个时钟16个字节足以使一个内核的主存储器带宽饱和。然而,IIRC Z Boson已经观察到使用多个核心带来了更好的带宽,可能是因为多个核心保留了更多未完成的请求,并且一个核心上的停顿不会使内存空闲。但是,如果核心上的L2输入很热,那么最好使用该核心来处理数据。

在Haswell或更高版本中,每个时钟加载和存储的16个字节只是L1缓存带宽的一半,因此您需要AVX版本才能达到最大每核心带宽。

如果你在内存上遇到瓶颈,you might want to do a Newton-Raphson iteration to get a nearly-full accuracy 1/sqrt(x),特别是如果你为一个大阵列使用多个线程。(因为如果一个线程无法维持一个加载+存储,那就没关系每个时钟。)

或者稍后在加载此数据时可能只是动态使用rsqrt。它的非常便宜,具有高吞吐量,但仍然延迟类似于FP添加。同样,如果它是一个不适合缓存的大数组,那么通过对数据进行较少的单独传递来提高计算强度是一件大事。 (Cache Blocking aka Loop Tiling也是一个好主意,如果你可以这样做:在一个缓存大小的数据块上运行算法的多个步骤。)

如果找不到有效使用缓存的方法,请仅使用缓存绕过NT存储作为最后的手段。如果你可以转换一些你将要使用的数据,那就更好了,所以当它下次使用时它会在缓存中。

对于Intel SnB系列CPU,主循环(来自.L31 to jne .L31 on the Godbolt compiler explorer)为6 uops,因为indexed addressing modes don't micro-fuse。 (不幸的是,Agner Fog's microarch pdf尚未记录这一点。)

在Nehalem上有4个融合域uop,只有三个ALU uop,所以Nehalem应该每时钟运行一次。

.L31:    # the main loop: 6 uops on SnB-family, 4 uops on Nehalem
    rsqrtps xmm0, XMMWORD PTR [rbx+rax]       # tmp127, MEM[base: ar_in_now_10, index: ivtmp.51_61, offset: 0B]
    movaps  XMMWORD PTR [rbp+0+rax], xmm0       # MEM[base: ar_out_now_12, index: ivtmp.51_61, offset: 0B], tmp127
    add     rax, 16   # ivtmp.51,
    cmp     rax, 100000       # ivtmp.51,
    jne     .L31      #,

由于你想要写一个单独的目的地,所以没有办法让循环到4个融合域的uops,所以它可以在每个时钟运行一个向量而不展开。 (加载和存储都需要是单寄存器寻址模式,因此使用由src - dst索引的current_dst而不是递增src的技巧不起作用。)

修改你的C ++所以gcc会使用指针增量只能保存一个uop,因为你必须增加src和dst。即float *endp = start + length;for (p = start ; p < endp ; p+=4) {}会形成像

这样的循环
.loop:
    rsqrtps  xmm0, [rsi]
    add      rsi, 16
    movaps   [rdi], xmm0
    add      rdi, 16
    cmp      rdi, rbx
    jne      .loop

希望gcc在展开时会做这样的事情,否则rsqrtps + movaps如果仍然使用索引寻址模式,则会自己4个融合域uops ,并且没有任何数量的展开会使你的循环每个时钟以一个向量运行。

答案 1 :(得分:3)

因为这是一个算术强度非常低的流式计算,所以几乎可以肯定是内存限制。如果您使用非临时加载和存储,您可能会发现可测量的加速。

userType

修改

我运行了一些测试,看看在不同的硬件上看起来是什么样的。不出所料,结果对使用的硬件非常敏感。我认为这可能是由于现代CPU中读/写缓冲区数量的增加。

所有代码都是使用gcc-6.1和

编译的
<bean parent="transactional-cache">
    <property name="name" value="userType"/>
</bean>

Intel Core i3-3120M @ 2.5GHz; 3MB缓存

{{1}}

Intel Core i7-6500U CPU @ 2.50GHz; 4MB缓存

{{1}}

将问题大小增加到2MB,NTA Prefetch + NTA存储的运行时间比OP的解决方案减少了约30%。

结果:问题规模太小,无法从NTA中获益。在较旧的架构上,这是有害的。在较新的架构上,它与OP的解决方案相同。

结论:在这种情况下可能不值得付出额外的努力。

答案 2 :(得分:1)

当然,你必须测量它,但是有已知的代码来计算(不是非常精确)反平方根,检查https://www.beyond3d.com/content/articles/8/

float InvSqrt (float x) {
    float xhalf = 0.5f*x;
    int i = *(int*)&x;
    i = 0x5f3759df - (i>>1);
    x = *(float*)&i;
    x = x*(1.5f - xhalf*x*x);
    return x;
}

经过测试(使用VS2015和GCC 5.4.0)转换为SSE2,link

__m128 InvSqrtSSE2(__m128 x) {
    __m128 xhalf = _mm_mul_ps(x, _mm_set1_ps(0.5f));

    x = _mm_castsi128_ps(_mm_sub_epi32(_mm_set1_epi32(0x5f3759df), _mm_srai_epi32(_mm_castps_si128(x), 1)));

    return _mm_mul_ps(x, _mm_sub_ps(_mm_set1_ps(1.5f), _mm_mul_ps(xhalf, _mm_mul_ps(x, x))));
}

更新我

Mea culpa!感谢@EOF,他注意到转换不正确,将其替换为强制转换