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的想法做一些测试.
正如我在评论中提到的,您很可能在这里使用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++ 编译器最佳,您应该考虑让编译器生成代码。
| 归档时间: |
|
| 查看次数: |
449 次 |
| 最近记录: |