需要在C#中使用多线程,AVX,GPU等进行快速数据多路分解

Mau*_*wer 8 c# algorithm performance multithreading avx

我有一个非常简单的功能,可以解复用从电路板获取的数据.所以数据来自帧,每帧由多个信号组成,作为1-dim阵列,我需要转换为锯齿状阵列,每个信号一个.基本上如下: 多路分解

我在C#工作,但我在C中有一个核心功能,可以完成单个信号的工作:

void Functions::Demux(short*pMux, short* pDemux, int nSignals, int signalIndex, int nValuesPerSignal)
{
    short* pMuxStart = pMux + signalIndex;
    for (size_t i = 0; i < nValuesPerSignal; i++)
        *pDemux++ = *(pMuxStart + i * nSignals);
}
Run Code Online (Sandbox Code Playgroud)

然后我通过C#/ CLI(使用pin_ptr<short>,所以没有副本)从C#和并行调用它:

Parallel.For(0, nSignals, (int i) =>
{
    Core.Functions.Demux(muxed, demuxed[i], nSignals, i, nFramesPerSignal);
});
Run Code Online (Sandbox Code Playgroud)

多路复用数据来自16k信号(16位分辨率),每个信号具有20k样本/ s,其变为16k*20k*2 = 640MB/s的数据速率.当在具有2 Xeon E5-2620 v4的工作站上运行代码(总共16个核心@ 2.1GHz)时,解复用器需要大约115%(10个数据需要11.5秒).

我需要至少减少一半的时间.有没有人知道某种方式,也许是AVX技术,或者更好的一些高性能库?或者可能有使用GPU的方法?我宁愿不改进CPU硬件,因为这可能会花费更多.

编辑 请考虑nSignals并且nValuesPerSignal可以更改并且交错的数组必须在nSignals单独的数组中拆分,以便在C#中进一步处理.

编辑:进一步测试

与此同时,在Cody Gray的评论之后,我用单核测试了:

void _Functions::_Demux(short*pMux, short** pDemux, int nSignals, int nValuesPerSignal)
{
    for (size_t i = 0; i < nSignals; i++)
    {
        for (size_t j = 0; j < nValuesPerSignal; j++)
            pDemux[i][j] = *pMux++;
    }
}
Run Code Online (Sandbox Code Playgroud)

从C++/CLI调用:

int nSignals = demuxValues->Length;
int nValuesPerSignal = demuxValues[0]->Length;

pin_ptr<short> pMux = &muxValues[0];

array<GCHandle>^ pins = gcnew array<GCHandle>(nSignals);
for (size_t i = 0; i < nSignals; i++)
    pins[i] = GCHandle::Alloc(demuxValues[i], GCHandleType::Pinned);

try
{
    array<short*>^ arrays = gcnew array<short*>(nSignals);
    for (int i = 0; i < nSignals; i++)
        arrays[i] = static_cast<short*>(pins[i].AddrOfPinnedObject().ToPointer());

    pin_ptr<short*> pDemux = &arrays[0];

    _Functions::_Demux(pMux, pDemux, nSignals, nValuesPerSignal);
}
finally
{ foreach (GCHandle pin in pins) pin.Free();    }
Run Code Online (Sandbox Code Playgroud)

并且我获得了大约105%的计算时间,这太多但是清楚地表明Parallel.For不是正确的选择.从您的回复中我想唯一可行的解​​决方案是SSE/AVX.我从来没有为此编写过代码,你们中有些人能指导我正确的方向吗?我想我们可以假设处理器将始终支持AVX2.

编辑:我的初始代码vs Matt Timmermans解决方案

在我的机器上,我比较了我的初始代码(我只是使用Parallel.For和调用C函数来负责解交织单个信号)和Matt Timmermans提出的代码(仍然Parallel.For以更聪明的方式使用).查看Parallel.For(我有32个线程)中使用的任务数量的结果(以毫秒为单位):

N.Taks   MyCode   MattCode
4        1649     841
8        997      740
16       884      497
32       810      290
Run Code Online (Sandbox Code Playgroud)

所以表现得到很大改善.但是,我仍然会对AVX的想法做一些测试.

Cod*_*ray 5

正如我在评论中提到的,您很可能在这里使用Parallel.For. 对于这种简单操作的成本来说,多线程的开销太大了。如果您非常需要原始速度以致于要在 C++ 中实现它,那么您根本不应该将 C# 用于任何对性能至关重要的事情。

相反,您应该让 C++ 代码一次并行处理多个信号。一个好的 C++ 编译器有一个比 C# JIT 编译器更强大的优化器,所以它应该能够自动向量化代码,让你写出可读而快速的东西。编译器开关可让您轻松指明目标机器上可用的指令集:SSE2、SSSE3、AVX、AVX2 等。编译器将自动发出适当的指令。

如果这仍然不够快,您可以考虑使用内部函数手动编写代码以发出所需的 SIMD 指令。从您的问题中不清楚输入的可变性如何。帧数是固定的吗?每个信号的值数量如何?

假设您的输入看起来与图表完全一样,您可以利用PSHUFB指令(由 SSSE3 及更高版本支持)用C++ 编写以下实现:

static const __m128i mask = _mm_setr_epi8(0, 1,   6,  7,  12, 13,
                                          2, 3,   8,  9,  14, 15,
                                          4, 5,  10, 11);

void Demux(short* pMuxed, short* pDemuxed, size_t count)
{
   for (size_t i = 0; i <= (count % 8); ++i)
   {
      _mm_store_si128((__m128i*)pDemuxed,
                      _mm_shuffle_epi8(_mm_load_si128((const __m128i*)pMuxed),
                                       mask));
      pMuxed   += 8;
      pDemuxed += 8;
   }
}
Run Code Online (Sandbox Code Playgroud)

在 128 位 SSE 寄存器中,我们可以打包 8 个不同的 16 位short值。因此,在循环内部,此代码short从输入数组中加载接下来的 8秒,重新打乱它们以使其按所需顺序排列,然后将结果序列存储回输出数组。它必须循环足够多的次数才能short对输入数组中的所有 8 组执行此操作,因此我们执行了count % 8多次。

生成的汇编代码类似于以下内容:

    mov     edx,  DWORD PTR [esp+12]       ; load parameters into registers (count)
    mov     ecx,  DWORD PTR [esp+8]        ; (pMuxed)
    mov     eax,  DWORD PTR [esp+4]        ; (pDemuxed)
    movdqa  xmm0, XMMWORD PTR [mask]       ; load 'mask' into XMM register
    and     edx,  7                        ; count % 8
    sub     ecx,  eax
    inc     edx

 Demux:
    movdqa  xmm1, XMMWORD PTR [ecx+eax]    ; load next 8 shorts from input array
    pshufb  xmm1, xmm0                     ; re-shuffle them
    movdqa  XMMWORD PTR [eax], xmm1        ; store these 8 shorts in output array

    add     eax, 16                        ; increment pointer
    dec     edx                            ; decrement counter...
    jne     Demux                          ;  and keep looping if necessary
Run Code Online (Sandbox Code Playgroud)

(我编写这段代码时假设输入和输出数组都在 16 字节边界上对齐,这允许使用对齐的加载和存储。在旧处理器上,这将比未对齐加载更快;在新一代处理器上,未对齐加载的惩罚实际上是不存在的。这在 C/C++ 中很容易确保和强制执行,但我不确定您如何在 C# 调用方中为这些数组分配内存。如果您控制分配,那么您应该能够控制对齐。如果不是,或者您只针对不惩罚未对齐加载的后期处理器,您可以更改代码以执行未对齐加载。使用_mm_storeu_si128_mm_loadu_si128内在函数,这将导致MOVDQU发出指令,而不是MOVDQA。)

循环内部只有 3 条 SIMD 指令,所需的循环开销极小。这应该相对较快,尽管几乎可以肯定有方法可以使它更快。

一项重要的优化是避免重复加载和存储数据。特别是要避免将输出存储到输出数组中。根据你要什么与解复用的输出,它会更有效,只是把它留在上证所与它有注册和工作。但是,这不会与托管代码很好地互操作(如果有的话),因此如果您必须将结果传递回 C# 调用方,您将受到很大限制。

要编写真正高效的 SIMD 代码,您需要具有较高的加载/存储比率计算。换句话说,您希望对加载和存储之间的数据进行大量操作。在这里,您只对加载和存储之间的数据执行一项操作(洗牌)。不幸的是,没有办法解决这个问题,除非您可以交错后续的“处理”代码。Demuxing 只需要一个操作,这意味着你的瓶颈将不可避免地是读取输入和写入输出所需的时间。

另一种可能的优化是手动展开循环。但这引入了许多潜在的复杂性,并要求您了解输入的性质。如果输入数组通常很短,展开就没有意义。如果它们有时短有时长,展开仍然可能没有意义,因为您必须明确处理输入数组很短的情况,提前跳出循环。如果输入数组总是很长,那么展开可能会提高性能。不过,不一定;如上所述,这里的循环开销非常小。

如果您需要根据每个信号的帧数和值数进行参数化,则很可能必须编写多个例程。或者,至少,各种不同的masks。这将显着增加代码的复杂性,从而增加维护成本(以及潜在的性能,因为您需要的指令和数据不太可能在缓存中),所以除非您真的可以做一些明显更多的事情比 C++ 编译器最佳,您应该考虑让编译器生成代码。