显式多线程 SIMD 操作的最快方法是啥?

Posted

技术标签:

【中文标题】显式多线程 SIMD 操作的最快方法是啥?【英文标题】:What is the fastest way for a multithread SIMD operation explicitly?显式多线程 SIMD 操作的最快方法是什么? 【发布时间】:2017-05-25 21:50:10 【问题描述】:

使用intrinsics 是SIMDizing 的常用方法。例如,我可以通过_mm256_add_epi32 对八个整数执行一条加法指令。添加后需要两个_mm256_load_si256和一个_mm256_store_si256,如下:

__m256i vec1 = _mm256_load_si256((__m256i *)&A[0]); // almost 5 cycles
__m256i vec2 = _mm256_load_si256((__m256i *)&B[0]); // almost 5 cycles
__m256i vec3 = _mm256_add_epi32( vec1 , vec2); // almost 1 cycle
_mm256_store_si256((__m256i *)&C[0], vec3); // almost 5

它在 CPU 的单核上执行指令。我的 Core i7 有 8 个核心(4 个实心);我想像这样将操作发送到所有核心:

int i_0, i_1, i_2, i_3, i_4, i_5, i_6, i_7 ; // These specify the values in memory
//core 0
__m256i vec1_0 = _mm256_load_si256((__m256i *)&A[i_0]);  
__m256i vec2_0 = _mm256_load_si256((__m256i *)&B[i_0]); 
__m256i vec3_0 = _mm256_add_epi32( vec1 , vec2); 
_mm256_store_si256((__m256i *)&C[i_0], vec3_0);

//core 1
__m256i vec1_1 = _mm256_load_si256((__m256i *)&A[i_1]);
__m256i vec2_1 = _mm256_load_si256((__m256i *)&B[i_1]);
__m256i vec3_1 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_1], vec3_1);

//core 2
__m256i vec1_2 = _mm256_load_si256((__m256i *)&A[i_2]);
__m256i vec2_2 = _mm256_load_si256((__m256i *)&B[i_2]);
__m256i vec3_2 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_2], vec3_2);

//core 3
__m256i vec1_3 = _mm256_load_si256((__m256i *)&A[i_3]);
__m256i vec2_3 = _mm256_load_si256((__m256i *)&B[i_3]);
__m256i vec3_3 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_3], vec3_3);

//core 4
__m256i vec1_4 = _mm256_load_si256((__m256i *)&A[i_4]);
__m256i vec2_4 = _mm256_load_si256((__m256i *)&B[i_4]);
__m256i vec3_4 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_4], vec3_4);

//core 5
__m256i vec1_5 = _mm256_load_si256((__m256i *)&A[i_5]);
__m256i vec2_5 = _mm256_load_si256((__m256i *)&B[i_5]);
__m256i vec3_5 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_5, vec3_5);

//core 6
__m256i vec1_6 = _mm256_load_si256((__m256i *)&A[i_6]);
__m256i vec2_6 = _mm256_load_si256((__m256i *)&B[i_6]);
__m256i vec3_6 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_6], vec3_6);

//core 7
__m256i vec1_7 = _mm256_load_si256((__m256i *)&A[i_7]);
__m256i vec2_7 = _mm256_load_si256((__m256i *)&B[i_7]);
__m256i vec3_7 = _mm256_add_epi32( vec1 , vec2);
_mm256_store_si256((__m256i *)&C[i_7], vec3_7);

POSIX Thread 可用,openMP 在这种情况下也很有用。但是,与此操作的几乎5+5+1 cyles 相比,创建和维护线程花费了太多时间。因为,所有数据都是依赖的,所以我不需要查看共享内存。实现此操作的最快显式方法是什么?

因此,我从事 GPP 工作,GPU 可能不是答案。我还想实现一个库,因此编译器基础解决方案可能是一个挑战者。这个问题对于多线程来说已经足够大了。这是为了我的研究,因此我可以改变问题以适应这个概念。我想实现一个库并将其与 OpenMP 等其他解决方案进行比较,希望我的库将比其他当前解决方案更快。 GCC 6.3/clang 3.8, Linux Mint, Skylake

提前致谢。

【问题讨论】:

除非这是在一个深度循环的底部,在另一个循环被多次调用,并且你有一个显示它是瓶颈的配置文件,那么你可以选择的任何方法都将是最快的。在不知道是否确实存在问题的情况下编写最快的代码时请小心。如果这是需要更快的代码,那么继续进行实验,看看分析器说了什么。我什至不知道上面的代码会按原样使用多个 CPU - 相反,我相信它会将它们全部排在同一个核心上。 @MichaelDorgan,谢谢,我把问题改得更笼统了。它不是在一个循环中,但是,它可以是。我正在为我的应用程序实现一个多线程 SIMD 库,它是我的问题的简化版本。 一个问题是所有内核都在竞争相同的内存和 L3 和/或 L4 缓存。如果进程的内存带宽受限,只有 1 或 2 个内核,那么使用额外的内核将无济于事。 把你的问题分解成块。如果在将其拆分为多个内核时,您的内存访问模式是分散的,那么您从多线程中获得的好处很少。相反,如果您在内存中进行了严格的操作,那么将其划分到多个内核上并在较小的块上操作可能会有所帮助。您可能需要添加一些预取指令来帮助提前准备加载。 只有当问题足够大时,在多核上拆分计算才有意义。在你的情况下,它绝对没有。所以你需要使用操作系统提供的线程功能。如果您想减少线程创建开销,请考虑使用线程池。 【参考方案1】:

如果你的问题很大,你必须多线程。

您可以选择 openmp 或 pthread,它们会为您提供相似的性能水平(使用 pthread 可能会更好一些,但这将是不可移植的并且维护起来更复杂)。

您的代码将受带宽限制,绝对不受计算限制。

为了达到最大吞吐量,需要通过多线程来交错独立的内存操作。

一个非常简单的解决方案,比如

extern "C" void add(int* a, int* b, int* c, int N) 
    #pragma omp parallel for
    for(int i = 0; i < N; ++i) 
        a[i] = b[i] + c[i];
    

可能会在所有系统上使用每个编译器为您提供可接受的性能。

事实上,让编译器优化可能会给你带来很好的性能,并且肯定会帮助你编写可读的代码。

但有时,即使是最好的编译器也不能给出令人满意的结果(总是检查你的程序集的性能关键部分)。

他们需要帮助,有时您需要自己编写程序集。

这是我将遵循的优化此循环的路径,直到获得我想要的结果。

首先,您可以实施一些经典的优化技巧:

    常量和别名

通过 __restrict 关键字提供常量并防止别名:

extern "C" void add(int* __restrict a, const int* __restrict b, const int* __restrict c, int N) 
    #pragma omp parallel for
    for(int i = 0; i < N; ++i) 
        a[i] = b[i] + c[i];
    

这将有助于编译器,因为它会知道 a、b 和 c 不能alias 彼此。

    对齐信息

告诉编译器你的指针正确对齐

#define RESTRICT __restrict

    typedef __attribute__((aligned(32))) int* intptr;

    extern "C" void add(intptr RESTRICT a, const intptr RESTRICT b, const intptr RESTRICT c, int N) 
        #pragma omp parallel for
        for(int i = 0; i < N; ++i) 
            a[i] = b[i] + c[i];
        
    

这也将有助于编译器生成 vload 指令而不是 vloadu(未对齐加载)。

    展开内部循环(如果可以的话):

如果你知道你的问题大小是 256 位的倍数,你甚至可以展开一个内部循环:

#define RESTRICT __restrict

typedef __attribute__((aligned(32))) int* intptr;

extern "C" void add(intptr RESTRICT a, const intptr RESTRICT b, const intptr RESTRICT c, int N) 
    #pragma omp parallel for
    for(int i = 0; i < N; i += 8) 
        #pragma unroll
        for(int k = 0; k < 8; ++k)
        a[i+k] = b[i+k] + c[i+k];
    

使用该代码,clang 4.0 提供了相当简洁的组装:

...
 vmovdqu ymm0, ymmword ptr [rdx + 4*rcx]
 vpaddd  ymm0, ymm0, ymmword ptr [rsi + 4*rcx]
 vmovdqu ymmword ptr [rdi + 4*rcx], ymm0
...

由于某些原因,您需要调整您的属性和编译指示以与其他编译器获得相同的结果。

    内在函数

如果你想确定你有正确的程序集,那么你必须去内在/程序集。

一些简单的东西:

#define RESTRICT __restrict

typedef __attribute__((aligned(32))) int* intptr;

extern "C" void add(intptr RESTRICT a, const intptr RESTRICT b, const intptr RESTRICT c, int N) 
    #pragma omp parallel for
    for(int i = 0; i < N; i += 8) 
        __m256i vb = _mm256_load_si256((__m256i*) (b + i));
        __m256i vc = _mm256_load_si256((__m256i*) (c + i));
        _mm256_store_si256((__m256i*) (a + i), _mm256_add_epi32(vb, vc));
    

    非临时存储: 作为最后的优化,您可以在 store 指令上使用 non-temporal hint,因为循环的其他迭代不会读取您刚刚写入的值:
typedef __attribute__((aligned(32))) int* intptr;
extern "C" void add(intptr RESTRICT a, const intptr RESTRICT b, const intptr RESTRICT c, int N) 
    #pragma omp parallel for
    for(int i = 0; i < N; i += 8) 
        __m256i vb = _mm256_load_si256((__m256i*) (b + i));
        __m256i vc = _mm256_load_si256((__m256i*) (c + i));
        _mm256_stream_si256((__m256i*) (a + i), _mm256_add_epi32(vb, vc));
    

它为您提供了该程序集:

.L3:
        vmovdqa ymm0, YMMWORD PTR [rdx+rax]
        vpaddd  ymm0, ymm0, YMMWORD PTR [rsi+rax]
        vmovntdq        YMMWORD PTR [rdi+rax], ymm0
        add     rax, 32
        cmp     rcx, rax
    jne     .L3
    vzeroupper

如果您对每一步的 cmp 指令感到担心,您可以在循环中展开更多步骤,但 branch prediction 在现代处理器上做得相当不错

[编辑:添加 pthread] 如上所述,pthread 管理起来有点痛苦...... 这是一个完整的 pthread 示例:

#include <pthread.h>
#include <cstdlib>
#include <cstdio>
#include <immintrin.h>

typedef struct AddStruct 
    int *a, *b, *c;
    int N;
 AddStruct_t;

void* add(void* s);

int main() 
    const int N = 1024*1024*32; // out of cache
    int *a, *b, *c;
    int err;
    err = posix_memalign((void**) &a, 32, N*sizeof(int));
    err = posix_memalign((void**) &b, 32, N*sizeof(int));
    err = posix_memalign((void**) &c, 32, N*sizeof(int));
    for(int i = 0; i < N; ++i) 
        a[i] = 0;
        b[i] = 1;
        c[i] = i;
    
int slice = N / 8;
pthread_t threads[8];
AddStruct_t arguments[8];
for(int i = 0; i < 8; ++i) 
    arguments[i].a = a + slice * i;
    arguments[i].b = b + slice * i;
    arguments[i].c = c + slice * i;
    arguments[i].N = slice;


for(int i = 0; i < 8; ++i) 
    if(pthread_create(&threads[i], NULL, add, &arguments[i])) 
        fprintf(stderr, "ERROR CREATING THREAD %d\n", i);
        abort();
    
   

for(int i = 0; i < 8; ++i) 
    pthread_join(threads[i], NULL);


for(int i = 0; i < N; ++i) 
    if(a[i] != i + 1) 
        fprintf(stderr, "ERROR AT %d: expected %d, actual %d\n", i, i+1, a[i]);
        abort();
    


fprintf(stdout, "OK\n");


void* add(void* v) 
    AddStruct_t* s = (AddStruct_t*) v;
    for(int i = 0; i < s->N; i += 8) 
        __m256i vb = _mm256_load_si256((__m256i*) (s->b + i));
        __m256i vc = _mm256_load_si256((__m256i*) (s->c + i));
        _mm256_stream_si256((__m256i*) (s->a + i), _mm256_add_epi32(vb, vc));
    

这段代码在我的 Xeon E5-1620 v3 上实现了 34 GB/s,DDR4 内存 @ 2133 MHz,而一开始的简单解决方案是 33 GB/S。

所有这些努力都是为了节省 3% :)。但有时这 3% 可能很关键。

请注意,内存初始化应由执行计算的同一内核执行(尤其是 NUMA 系统)以避免页面迁移。

【讨论】:

谢谢,一个很好的答案。你也可以添加一些显式的多线程吗?可移植性不是我关心的问题,因为我在 Skylake 上执行代码。我使用了pthreads,但我的解决方案并不好。事实上,我可能会更改编译器并希望库独立工作。 我添加了一个带有 pthread 的示例,但是,为了代码的可读性,我真的会考虑 openmp。不管怎样,做你的性能测量,然后选择你喜欢的多线程库 您在答案开头的简单解决方案就是您所需要的。对于小 N,您不想使用多线程,而对于大 N,它的内存带宽受限,因此展开、对齐、限制实际上是无用的。 顺便说一句,您可以使用for simd aligned(a,b,c:32)(我的语法可能不正确)进行对齐。 GCC 的 -funroll-loops 在使用 OpenMP 的情况下比没有使用 OpenMP 时优化得更差(我通过将函数移动到单独的目标文件中发现了这一点,并且它变得更快)。展开是您仍然必须使用 GCC 手动完成的操作才能获得良好的结果。同样,尽管这些优化仅在操作不受内存带宽限制的情况下才有用。 OP的问题太宽泛了。【参考方案2】:

实现最快的方法是:

void add_ints(int *vec1, int *vec2, int *vec3 int n)
 int i; 
#pragma simd
for (i=0; i<n; i++)
  vec3[i] = vec1[i] + vec2[i] ;
 

“自己动手”是否更快值得调查。但是“自己动手”可能更容易出错......这使得实施速度变慢。

对于这些简单的问题,人们会期望编译器编写者足够老练,能够理解简单问题的最快解决方案,而且通常他们甚至可以很好地找到复杂问题的最快解决方案......而且#pragma 的使用有助于他们。

其次;与单核上的直接“SIMD”相比,我很少发现“SIMD 并行”在 IO 驱动的问题(例如 ^this^)上工作得更快。 我通常会实现略低于 1600 MB/秒的吞吐量,这在 1600 内存上似乎相当不错。 除非 GPU 的 IO 带宽高于 1600 MB/秒,否则在单个主机内核上可能会更好,并在需要更多数学/IO 时使用 GPU。

但是,您可以而且应该亲自尝试一下。 (是的...以下示例不在 icc 网站上)

#pragma omp parallel for simd schedule(static,10) 
  for (i=0; i<N; i++)  vec3[i] = vec1[i] + vec2[i]; 

在您掌握了简单的方法之后,您可以测量“您拥有的滚动”在使用单核和多核的 -O3 编译器上的性能有多好。

向量的另一个选择是 CILK+ 。当具有 MATLAB 或 Fortran 背景的人尤其如此,因为向量和矩阵/数组结构非常相似。

基本上,SIMD 内在函数在早期就“流行”了,一旦编译器和 OpenMP 将它们引入到它们的内部,那么仅在编译器无法提供向量机的情况下使用内在函数似乎会更好- 为您编码。

【讨论】:

我所知道的唯一支持#pragma sims 的编译器是ICC,这不是问这个问题的人正在使用或希望针对的目标。它也应该是完全没有必要的。如果您使用适当的编译器开关,优化器将做出正确的决定,而不需要分散在整个代码库中的不可移植的#pragmas。 #pragma omp simd 从 ~4.9-5.1 版本开始受到 GCC 的支持。所以它被 icc、ifort 和 gcc 以及一些基于 llvm 的支持。我知道的唯一一个完全不支持的 x86 编译器是 MS 编译器。 #pragmas 很容易被编译器忽略,可能有 Gcc 版本的开关?是否甚至需要编译指示都需要查看矢量报告。 OP 以“最容易实现”结束了这个问题,即使我能做到,编译指示也很容易。但是如果编译器优化了代码,那么就不需要编译指示。执行汇编程序类型加载似乎更难实现。 OP 有一个寻找问题的解决方案,并且应该根据可靠的事实将自定义库放在一起。因此尝试多种方法是合理的。 谢谢,您的解决方案很棒,但我想明确地实现它。 这是有道理的@FackedDeveloper 理想情况下,您可以回复它的运行速度。

以上是关于显式多线程 SIMD 操作的最快方法是啥?的主要内容,如果未能解决你的问题,请参考以下文章

python 继承式多线程

6第七周-网络编程-继承式多线程

搜索硬盘中所有文件的最快方法是啥?

Native vs. Protothreads,哪个更容易?

显式多对多关系棱镜

SIMD和多线程之间的区别[关闭]