c++集中SIMD的使用

C++ Centralizing SIMD usage

本文关键字:SIMD 集中 c++      更新时间:2023-10-16

我有一个库和很多项目依赖于这个库。我想使用SIMD扩展来优化库中的某些过程。然而,保持可移植性对我来说很重要,所以对用户来说它应该是相当抽象的。我在一开始就说过,我不想使用其他一些伟大的库来达到这个目的。我其实想知道我想要的是否可能,以及在多大程度上可能。

我的第一个想法是有一个"向量"包装器类,SIMD的使用对用户是透明的,如果目标机器上没有可用的SIMD扩展,可以使用"标量"向量类。幼稚的想法出现在我的脑海中,使用预处理器根据编译库的目标从许多向量类中选择一个。所以一个标量向量类,一个SSE类(基本上是这样的:http://fastcpp.blogspot.de/2011/12/simple-vector3-class-with-sse-support.html)等等……都有相同的界面。这给了我很好的性能,但这意味着我必须为我使用的任何类型的SIMD ISA编译库。我宁愿在运行时动态地评估处理器的能力,并选择可用的"最佳"实现。

所以我的第二个猜测是有一个具有抽象方法的通用"向量"类。"处理器求值器"函数将返回最佳实现的实例。显然,这将导致代码的丑陋,但是指向vector对象的指针可以存储在类似于智能指针的容器中,该容器只委托对vector对象的调用。实际上我更喜欢这个方法,因为它的抽象,但我不确定调用虚拟方法是否真的会杀死我使用SIMD扩展获得的性能。

我想出的最后一个选项是对整个例程进行优化,并在运行时选择最优的一个。我不太喜欢这个想法,因为这迫使我多次实现整个函数。我希望这样做一次,使用我的矢量类的想法,我想做这样的事情,例如:

void Memcopy(void *dst, void *src, size_t size)
{
    vector v;
    for(int i = 0; i < size; i += v.size())
    {
        v.load(src);
        v.store(dst);
        dst += v.size();
        src += v.size();
    }
}

我假设这里的"size"是正确的值,因此不会发生重叠。这个例子应该只是显示我想要的。例如,vector对象的size-方法在使用SSE的情况下只返回4,在使用标量版本的情况下返回1。是否有一种合适的方法来实现这一点,只使用运行时信息,而不会失去太多的性能?抽象对我来说比性能更重要,但由于这是一个性能优化,如果不能加速我的应用程序,我不会包括它。

我也在网上找到了这个:http://compeng.uni-frankfurt.de/?vc它是开源的,但我不明白如何选择正确的向量类

如果所有内容都在编译时内联,那么您的想法将只能编译成有效的代码,这与运行时CPU调度不兼容。要使v.c load()、v.c store()和v.c size()在运行时根据CPU的不同而有所不同,它们必须是实际的函数调用,而不是单个指令。开销太大了。


如果你的库有足够大的函数而不需要内联,那么函数指针对于基于运行时CPU检测的调度是很好的。(例如,创建多个版本的memcpy,并且每次调用一次支付运行时检测的开销,而不是每次循环迭代两次)

这个不应该在你的库的外部API/ABI中可见,除非你的函数大部分都很短,以至于额外(直接)调用/ret的开销很重要。在库函数的实现中,将想要创建cpu特定版本的每个子任务放入辅助函数中。通过函数指针调用这些辅助函数。


首先将函数指针初始化为可以在基线目标上工作的版本。例如,用于x86-64的SSE2,用于传统32位x86的标量或SSE2(取决于您是否关心Athlon XP和Pentium III),以及可能用于非x86架构的标量。在构造函数或库初始化函数中,执行CPUID并将函数指针更新为主机CPU的最佳版本。即使您的绝对基线是标量,您也可以将"良好性能"基线设置为SSSE3之类的基准,而不要在仅使用sse2的例程上花费太多/任何时间。即使您主要以SSSE3为目标,您的一些例程可能最终只需要SSE2,因此您最好将它们标记为这样,并让调度程序在只执行SSE2的cpu上使用它们。

更新函数指针甚至不需要任何锁。在构造函数完成设置函数指针之前从其他线程发生的任何调用都可能得到基线版本,但这没关系。在x86上存储指向对齐地址的指针是原子性的。如果在任何需要运行时CPU检测的例程版本的平台上它不是原子的,则使用c++ std:atomic(使用内存顺序宽松的存储和加载,而不是默认的顺序一致性,这会在每次加载时触发一个完整的内存屏障)。当通过函数指针调用时,最小化开销是非常重要的,不同线程看到函数指针变化的顺序是什么并不重要。他们在写一次。


x264(高度优化的开源h.264视频编码器)广泛使用这种技术,使用函数指针数组。例如:x264_mc_init_mmx()。(该函数处理从MMX到AVX2的所有运动补偿函数的CPU调度)。我假设libx264在"编码器初始化"函数中完成CPU调度。如果你的库没有一个用户需要调用的函数,那么你应该寻找某种机制,在使用你的库的程序启动时运行全局构造函数/init函数。


如果你想让这个工作与非常c++的代码(c++ ish?这是一个词吗?)即模板化类&函数,使用库的程序可能会做CPU调度,并安排获得基线和多个CPU需求版本的函数编译。

我就是这样做的一个分形项目。它适用于向量大小为1、2、4、8和16的float和1、2、4、8的double。我在运行时使用CPU调度程序来选择以下指令集:SSE2、SSE4.1、AVX、AVX+FMA和AVX512。

我使用向量大小为1的原因是为了测试性能。已经有一个SIMD库可以做到这一切:Agner Fog的Vector Class library。他甚至还提供了一个CPU调度程序的示例代码。

VCL在只有SSE(或者甚至只有SSE的AVX512)的系统上模拟AVX之类的硬件。它只实现了AVX两次(AVX512是四次),所以在大多数情况下,你可以只使用你想要的最大向量大小。

//#include "vectorclass.h"
void Memcopy(void *dst, void *src, size_t size)
{
    Vec8f v; //eight floats using AVX hardware or AVX emulated with SSE twice.
    for(int i = 0; i < size; i +=v.size())
    {
        v.load(src);
        v.store(dst);
        dst += v.size();
        src += v.size();
    }
}

(然而,写一个有效的内存是复杂的。对于大尺寸,您应该考虑非临时存储,并且在IVB及以上使用rep movsb代替)。请注意,该代码与您要求的代码相同,只是我将单词vector更改为Vec8f

使用VLC,作为CPU调度程序、模板和宏,您可以编写代码/内核,使其看起来几乎与标量代码相同,而不需要对每个不同的指令集和向量大小重复源代码。这是你的二进制文件,它会更大,而不是你的源代码。

我已经多次描述CPU调度程序。您还可以在这里看到一些使用模板和宏的调度程序示例:

编辑:这是我的内核的一部分的例子来计算一组等于向量大小的像素的Mandelbrot集合。在编译时,我将TYPE设置为floatdoubledoubledouble,将N设置为1、2、4、8或16。这里描述了类型doubledouble,它是我创建并添加到VCL中的。这产生了Vec1f, Vec4f, Vec8f, Vec16f, Vec1d, Vec2d, Vec4d, Vec8d, double1, double2, double4, double8的矢量类型。

template<typename TYPE, unsigned N>
static inline intn calc(floatn const &cx, floatn const &cy, floatn const &cut, int32_t maxiter) {
    floatn x = cx, y = cy;
    intn n = 0; 
    for(int32_t i=0; i<maxiter; i++) {
        floatn x2 = square(x), y2 = square(y);
        floatn r2 = x2 + y2;
        booln mask = r2<cut;
        if(!horizontal_or(mask)) break;
        add_mask(n,mask);
        floatn t = x*y; mul2(t);
        x = x2 - y2 + cx;
        y = t + cy;
    }
    return n;
}

所以我的几个不同的数据类型和向量大小的SIMD代码几乎与我将使用的标量代码相同。我还没有包括内核中循环遍历每个超像素的部分。

我的构建文件看起来像这样

g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -msse2          -Ivectorclass  kernel.cpp -okernel_sse2.o
g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -msse4.1        -Ivectorclass  kernel.cpp -okernel_sse41.o
g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -mavx           -Ivectorclass  kernel.cpp -okernel_avx.o
g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -mavx2 -mfma    -Ivectorclass  kernel.cpp -okernel_avx2.o
g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -mavx2 -mfma    -Ivectorclass  kernel_fma.cpp -okernel_fma.o
g++ -m64 -c -Wall -g -std=gnu++11 -O3 -fopenmp -mfpmath=sse -mavx512f -mfma -Ivectorclass  kernel.cpp -okernel_avx512.o
g++ -m64 -Wall -Wextra -std=gnu++11 -O3 -fopenmp -mfpmath=sse -msse2 -Ivectorclass frac.cpp vectorclass/instrset_detect.cpp kernel_sse2.o kernel_sse41.o kernel_avx.o kernel_avx2.o kernel_avx512.o kernel_fma.o -o frac

那么调度程序看起来像这样

int iset = instrset_detect();
fp_float1  = NULL; 
fp_floatn  = NULL;
fp_double1 = NULL;
fp_doublen = NULL;
fp_doublefloat1  = NULL;
fp_doublefloatn  = NULL;
fp_doubledouble1 = NULL;
fp_doubledoublen = NULL;
fp_float128 = NULL;
fp_floatn_fma = NULL;
fp_doublen_fma = NULL;
if (iset >= 9) {
    fp_float1  = &manddd_AVX512<float,1>;
    fp_floatn  = &manddd_AVX512<float,16>;
    fp_double1 = &manddd_AVX512<double,1>;
    fp_doublen = &manddd_AVX512<double,8>;
    fp_doublefloat1  = &manddd_AVX512<doublefloat,1>;
    fp_doublefloatn  = &manddd_AVX512<doublefloat,16>;
    fp_doubledouble1 = &manddd_AVX512<doubledouble,1>;
    fp_doubledoublen = &manddd_AVX512<doubledouble,8>;
}
else if (iset >= 8) {
    fp_float1  = &manddd_AVX<float,1>;
    fp_floatn  = &manddd_AVX2<float,8>;
    fp_double1 = &manddd_AVX2<double,1>;
    fp_doublen = &manddd_AVX2<double,4>;
    fp_doublefloat1  = &manddd_AVX2<doublefloat,1>;
    fp_doublefloatn  = &manddd_AVX2<doublefloat,8>;
    fp_doubledouble1 = &manddd_AVX2<doubledouble,1>;
    fp_doubledoublen = &manddd_AVX2<doubledouble,4>;
}
....

设置函数指针,指向运行时找到的指令集的每个不同可能的数据类型向量组合。然后我可以调用任何我感兴趣的函数。

感谢Peter Cordes和Z玻色子。听了你们两个的回答,我找到了一个令我满意的解决办法。我之所以选择Memcopy作为一个例子,只是因为每个人都知道它,而且它在天真地实现时非常简单(但也很慢),而SIMD优化通常不再具有良好的可读性,但当然要快得多。我现在有两个类(当然可能性更大)一个标量向量和一个SSE向量,它们都有内联方法。对于用户,我显示如下内容:typepedef void(*MEM_COPY_FUNC)(void *, const void *, size_t);

extern MEM_COPY_FUNC memCopyPointer;

我这样声明我的函数,正如Z玻色子指出的:模板void MemCopyTemplate(void *pDest, const void *prc, size_t大小){VectorType v;* pst, *pSrc;uint32面具;

    pDst = (byte *)pDest;
    pSrc = (byte *)prc;
    mask = (2 << v.GetSize()) - 1;
    while(size & mask)
    {
        *pDst++ = *pSrc++;
    }
    while(size)
    {
        v.Load(pSrc);
        v.Store(pDst);
        pDst += v.GetSize();
        pSrc += v.GetSize();
        size -= v.GetSize();
    }
}

在运行时,当库被加载时,我使用CPUID来做

memCopyPointer = MemCopyTemplate<ScalarVector>;

memCopyPointer = MemCopyTemplate<SSEVector>;
就像你们俩建议的那样。非常感谢。