Single instruction, multiple data (SIMD)

#C and CPP #C and CPP-speed up

正如SIMD的全称所表述的那样,它加快程序运行速度的办法是同时操作多个数据。如下图所示:

simd

假设图中的数据类型是float,那么图中左边的+就是常规float类型之间的加法,而图中右边的+则是SIMD的+operator. 此时,右侧方法的输出是两个float32x4(表示一个由4个大小为float32类型组成的类型)的数据,大小为128位(bits). 这个数据是会被存储在寄存器上。寄存器的大小由不同CPU而异。

常用的Intel CPU根据寄存器大小的区别,可以通过指令集SSE2(128bits), AVX2(256bits), AVX512(512bits)控制, 括号内是指令集对应的寄存器大小; ARM平台的CPU的SIMD指令集为NEON, 它的寄存器大小都是128bits.

根据上述信息我们可以知道,在不同平台上使用SIMD需要不同的指令才能实现。因此,如果你想让你的代码在不同平台上都通过SIMD加速,需要写不同的代码。所以SIMD是比较难维护的。一个解决方案是OpenCV给出的。它实现了一个universal intrinsics的指令集,使其能做所有平台上运行。我们可以通过这个指令集来完成,不过代价是必须引入OpenCV这个库。

下面,我们以dotProduct为例来看SIMD是如何实现的。

// "dotProductSIMD.hpp"
#pragma once

float dotproduct(const float *p1, const float * p2, size_t n);
float dotproduct_unloop(const float *p1, const float * p2, size_t n);
float dotproduct_avx2(const float *p1, const float * p2, size_t n);
float dotproduct_neon(const float *p1, const float * p2, size_t n);

这里我们定义的dotproduct()是常规的,dotproduct_unloop()对循环的次数进行了优化(由于每次循环之前涉及一次判断,因此循环次数越少,速度应该越快),dotproduct_avx2()是SIMD,基于Intel CPU的AVX2指令集,dotproduct_neon()也是SIMD,但是基于ARM的NEON指令集。

// "dotProductSIMD.cpp"

#include <iostream>
#include "dotProductSIMD.hpp"

#ifdef WITH_AVX2
#include <immintrin.h>
#endif 

#ifdef WITH_NEON
#include <arm_neon.h>
#endif


float dotproduct(const float *p1, const float * p2, size_t n)
{
    float sum = 0.0f;
    for (size_t i = 0; i < n ; i++)
        sum += (p1[i] * p2[i]);
    return sum;
}


float dotproduct_unloop(const float *p1, const float * p2, size_t n)
{
    if(n % 8 != 0)
    {
        std::cerr << "The size n must be a multiple of 8." <<std::endl;
        return 0.0f;
    }

    float sum = 0.0f;
    for (size_t i = 0; i < n; i+=8)
    {
        sum += (p1[i] * p2[i]);
        sum += (p1[i+1] * p2[i+1]);
        sum += (p1[i+2] * p2[i+2]);
        sum += (p1[i+3] * p2[i+3]);
        sum += (p1[i+4] * p2[i+4]);
        sum += (p1[i+5] * p2[i+5]);
        sum += (p1[i+6] * p2[i+6]);
        sum += (p1[i+7] * p2[i+7]);
    }
    return sum;

}

float dotproduct_avx2(const float *p1, const float * p2, size_t n)
{
#ifdef WITH_AVX2
    if(n % 8 != 0)
    {
        std::cerr << "The size n must be a multiple of 8." <<std::endl;
        return 0.0f;
    }

    float sum[8] = {0};
    __m256 a, b;
    __m256 c = _mm256_setzero_ps();

    for (size_t i = 0; i < n; i+=8)
    {
        a = _mm256_loadu_ps(p1 + i);
        b = _mm256_loadu_ps(p2 + i);
        c =  _mm256_add_ps(c, _mm256_mul_ps(a, b));
    }
    _mm256_storeu_ps(sum, c);
    return (sum[0]+sum[1]+sum[2]+sum[3]+sum[4]+sum[5]+sum[6]+sum[7]);
#else
    std::cerr << "AVX2 is not supported" << std::endl;
    return 0.0;
#endif
}


float dotproduct_neon(const float *p1, const float * p2, size_t n)
{
#ifdef WITH_NEON
    if(n % 4 != 0)
    {
        std::cerr << "The size n must be a multiple of 4." <<std::endl;
        return 0.0f;
    }

    float sum[4] = {0};
    float32x4_t a, b;
    float32x4_t c = vdupq_n_f32(0);

    for (size_t i = 0; i < n; i+=4)
    {
        a = vld1q_f32(p1 + i);
        b = vld1q_f32(p2 + i);
        c =  vaddq_f32(c, vmulq_f32(a, b));
    }
    vst1q_f32(sum, c);
    return (sum[0]+sum[1]+sum[2]+sum[3]);
#else
    std::cerr << "NEON is not supported" << std::endl;
    return 0.0;
#endif
}

如果对程序进行测速,那么在Intel 256位寄存器之下,我们应该能够获得$256\div32=8$倍提速; 在NEON 128位寄存器下,我们应该得到$4$倍提速。而单纯的减少循环次数并不会提速(尽管理论上会),这是因为我们的编译器非常聪明,会对原本最简单dotproduct()函数进行基础的优化。

需要注意的是,在使用SIMD时,需要使用类似

// 256bits aligned, C++17 standard
static_cast<float*>(aligned_alloc(256, nSize*sizeof(float))); 

语句对内存进行对齐,否则无法正常运行(原因?)。

这就是对于SIMD的简单介绍,例子中的代码来源于GitHub - 于仕琪老师的Repo: CPP,也是我学习C/CPP的场所。视频讲解参见Bilibili - 于仕琪老师的C/C++从基础语法到优化策略课程第8.2, 8.3节。

有用的链接: