C ++集中SIMD使用

时间:2015-10-01 18:06:54

标签: c++ optimization simd

我有一个库和许多项目,具体取决于该库。我想使用SIMD扩展优化库内的某些过程。然而,对我来说保持便携是很重要的,所以对用户来说它应该是非常抽象的。 我在开始时说,我不想使用其他一些很棒的库。我实际上想要了解我想要的是否可能以及在何种程度上。

我的第一个想法是拥有一个“向量”包装类,SIMD的使用对用户是透明的,如果目标机器上没有SIMD扩展,则可以使用“标量”向量类。 我想到了天真的想法,使用预处理器从多个中选择一个矢量类,具体取决于编译库的目标。所以有一个标量向量类,一个带SSE(基本上就是这样的东西:http://fastcpp.blogspot.de/2011/12/simple-vector3-class-with-sse-support.html)等等......所有这些都有相同的接口。 这给了我很好的性能,但这意味着我必须为我使用的任何类型的SIMD ISA编译库。我宁愿在运行时动态评估处理器功能,并选择“最佳”实现。

所以我的第二个猜测是使用抽象方法获得一般的“向量”类。 “处理器评估程序”功能将返回最佳实现的实例。显然这会导致丑陋的代码,但是指向vector对象的指针可以存储在类似智能指针的容器中,该容器只是将调用委托给vector对象。实际上我更喜欢这种方法,因为它的抽象,但我不确定调用虚方法实际上是否会破坏我使用SIMD扩展获得的性能。

我想出的最后一个选项是优化整个例程,并在运行时选择最佳例程。我不喜欢这个想法,因为这迫使我多次实现整个功能。我宁愿这样做一次,使用我对vector类的想法我想做这样的事情,例如:

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”是一个正确的值,因此不会发生重叠。这个例子应该只显示我更喜欢的东西。例如,在使用SSE的情况下,向量对象的大小方法将返回4,在使用标量版本的情况下,返回1。 有没有一种正确的方法来实现这一点,只使用运行时信息而不会失去太多的性能?抽象对我来说比性能更重要,但由于这是性能优化,如果不加速我的应用程序,我不会包括它。

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

3 个答案:

答案 0 :(得分:5)

如果所有内容都在编译时间内编译,那么您的想法将只编译为高效代码,这与运行时CPU调度不兼容。对于v.load(),v.store()和v.size()在运行时实际上根据CPU而不同,它们必须是实际的函数调用,而不是单个指令。开销将是杀手锏。

如果您的库具有足够大的功能而无需内联工作,那么函数指针非常适合基于运行时CPU检测的调度。 (例如,制作多个版本的memcpy,并为每次调用支付一次运行时检测的开销,而不是每次循环迭代两次。)

这个不应该在您图书馆的外部API / ABI 中显示,除非您的功能大部分都太短,以至于额外(直接)呼叫/转发的开销很重要。在库函数的实现中,将要创建CPU特定版本的每个子任务放入辅助函数中。通过函数指针调用这些辅助函数。

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

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

x264(经过大量优化的开源h.264视频编码器)广泛使用这种技术,具有函数指针数组。例如,请参阅x264_mc_init_mmx()。 (该功能处理运动补偿功能的所有CPU调度,从MMX到AVX2)。我假设libx264在&#34;编码器init&#34;中执行CPU调度。功能。如果您没有要求库的用户调用的函数,那么当您使用库的程序启动时,您应该研究某种运行全局构造函数/初始化函数的机制。

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

答案 1 :(得分:1)

我正是通过分形项目完成的。它适用于浮动的矢量大小为1,2,4,8和16,双重的1,2,4,8的矢量大小。我在运行时使用CPU调度程序来选择以下指令集:SSE2,SSE4.1,AVX,AVX + FMA和AVX512。

我使用矢量大小为1的原因是测试性能。已经有一个SIMD库可以完成所有这些:Agner Fog&#39; 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();
    }
}

however, writing an efficient memcpy is complicating。对于大尺码,您应该考虑使用非模拟商店,而使用IVB及以上商品rep movsb请注意,除了我将单词vector更改为Vec8f之外,该代码与您要求的相同。

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

I have described CPU dispatchers several times。您还可以在此处查看使用模板和宏的调度程序示例:alias of a function template

编辑:这是我的内核的一部分示例,用于计算一组等于矢量大小的像素的Mandelbrot集。在编译时,我将TYPE设置为floatdoubledoubledouble,将N设置为1,2,4,8或16.类型doubledouble被描述为{{ 3}}我创建并添加到VCL。这产生Vector类型的Vec1f,Vec4f,Vec8f,Vec16f,Vec1d,Vec2d,Vec4d,Vec8d,doubledouble1,doubledouble2,doubledouble4,doubledouble8。

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>;
}
....

这为在运行时找到的指令集的每个不同的可能数据类型向量组合设置函数指针。然后我可以打电话给我感兴趣的任何功能。

答案 2 :(得分:0)

感谢Peter Cordes和Z boson。在你的回复中,我找到了满足我的解决方案。 我选择Memcopy只是为了一个例子,因为每个人都知道它以及它的美丽简洁(但也很慢),与SIMD优化相比,而SIMD优化通常不再可读,但当然要快得多。 我现在有两个类(当然更可能)标量向量和SSE向量都使用内联方法。给用户我显示如下:     typedef void(* MEM_COPY_FUNC)(void *,const void *,size_t);

extern MEM_COPY_FUNC memCopyPointer;

我宣布我的功能是这样的,正如Z boson所指出的那样:     模板     void MemCopyTemplate(void * pDest,const void * prc,size_t size)     {         VectorType v;         byte * pDst,* pSrc;         uint32 mask;

    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>;

正如你们两个人所说的那样。非常感谢。