C++ 集中化 SIMD 使用

C++ Centralizing SIMD usage

我有一个图书馆和许多依赖于该图书馆的项目。我想使用 SIMD 扩展优化库中的某些过程。然而,保持便携对我来说很重要,所以对用户来说它应该是非常抽象的。 我在一开始就说过我不想使用其他一些可以解决问题的很棒的库。我实际上想了解我想要的是否可能以及在多大程度上。

我的第一个想法是有一个 "vector" 包装器 class,SIMD 的使用对用户是透明的,"scalar" 向量 class 可以是在目标机器上没有 SIMD 扩展可用的情况下使用。 我想到了使用预处理器 select 一个向量 class 的天真的想法,这取决于编译库的目标。因此,一个标量向量 class,一个带有 SSE(基本上是这样的:http://fastcpp.blogspot.de/2011/12/simple-vector3-class-with-sse-support.html)等等...都具有相同的界面。 这给了我很好的性能,但这意味着我必须为我使用的任何类型的 SIMD ISA 编译库。我宁愿在运行时动态评估处理器功能,并且 select "best" 可用的实现。

所以我的第二个猜测是有一个通用的 "vector" class 和抽象方法。 "processor evaluator" 函数将比 return 实例最佳实现。显然,这会导致难看的代码,但指向矢量对象的指针可以存储在类似智能指针的容器中,该容器仅委托对矢量对象的调用。实际上我更喜欢这种方法,因为它是抽象的,但我不确定调用虚拟方法是否真的会破坏我使用 SIMD 扩展获得的性能。

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

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,矢量对象的大小方法将只是 return 4,如果使用标量版本,则为 1。 是否有一种正确的方法可以在不损失太多性能的情况下仅使用运行时信息来实现这一点?抽象对我来说比性能更重要,但由于这是性能优化,如果不能加速我的应用程序,我不会包括它。

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

如果所有内容都在 编译 时内联,您的想法将只能编译为高效代码,这与运行时 CPU 调度不兼容。对于 v.load()、v.store() 和 v.size() 在运行时实际不同取决于 CPU,它们必须是实际的函数调用,不是单一的指令。开销将是致命的。


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

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


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

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


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


如果您希望它与非常 C++ey 代码(C++ish?这是一个词吗?)即模板化 类 和函数一起工作,使用该库的程序可能已经完成了CPU 调度,并安排获得基线和多个 CPU-需求版本的函数编译。

我在一个分形项目中就是这么做的。它适用于向量大小 1、2、4、8 和 16 的浮点数和 1、2、4、8 的双精度数。我在 运行 时使用 CPU 调度程序来 select 以下指令集: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();
    }
}

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

使用 VLC,作为 CPU 调度程序、模板和宏,您可以编写 code/kernel 使其看起来与标量代码几乎相同,而无需为每个不同的指令集和向量复制源代码尺寸。这是你的二进制文件,而不是你的源代码。

I have described CPU dispatchers several times. You can also see some example using templateing and macros for a dispatcher here:

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

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 优化相比,SIMD 优化通常不再具有很好的可读性,但当然更快。 我现在有两个 类(当然更有可能)一个标量向量和一个 SSE 向量,它们都具有内联方法。我向用户展示了类似的东西: typedef void(*MEM_COPY_FUNC)(void *, const void *, size_t);

extern MEM_COPY_FUNC memCopyPointer;

我像这样声明我的函数,正如 Z 玻色子指出的那样: 模板 void MemCopyTemplate(void *pDest, const void *prc, size_t 大小) { 向量类型 v; 字节 *pDst, *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>;

正如你们所建议的那样。非常感谢。