【问题标题】:SSE instruction to check if byte array is zeroes C#SSE指令检查字节数组是否为零C#
【发布时间】:2016-01-22 12:46:06
【问题描述】:

假设我有一个byte[] 并想检查是否所有字节都为零。 For 循环是一种显而易见的方法,而 LINQ All() 是一种奇特的方法,但最高性能至关重要。

如何使用Mono.Simd 来加快检查字节数组是否全零?我正在寻找最前沿的方法,而不仅仅是正确的解决方案。

【问题讨论】:

  • 对 .NET 应用程序进行性能测试时,应确保运行几次并跳过第一次,因为 JIT 可能会参与其中。如果您想谈论绝对最快的性能,那么您可能也应该指定硬件......使用 BenchmarkDotNet 运行您的不同候选人并报告结果将是理想的,因为它确保以一种方式运行候选人尽可能准确,其输出包括硬件、GC 模式等运行参数。

标签: c# arrays performance mono simd


【解决方案1】:

最佳代码如下所示。 full source 提供其他方法和时间测量。

static unsafe bool BySimdUnrolled (byte[] data)
{
    fixed (byte* bytes = data) {
        int len = data.Length;
        int rem = len % (16 * 16);
        Vector16b* b = (Vector16b*)bytes;
        Vector16b* e = (Vector16b*)(bytes + len - rem);
        Vector16b zero = Vector16b.Zero;

        while (b < e) {
            if ((*(b) | *(b + 1) | *(b + 2) | *(b + 3) | *(b + 4) |
                *(b + 5) | *(b + 6) | *(b + 7) | *(b + 8) |
                *(b + 9) | *(b + 10) | *(b + 11) | *(b + 12) | 
                *(b + 13) | *(b + 14) | *(b + 15)) != zero)
                return false;
            b += 16;
        }

        for (int i = 0; i < rem; i++)
            if (data [len - 1 - i] != 0)
                return false;

        return true;
    }
}

最终被这段代码打败了:

static unsafe bool ByFixedLongUnrolled (byte[] data)
{
    fixed (byte* bytes = data) {
        int len = data.Length;
        int rem = len % (sizeof(long) * 16);
        long* b = (long*)bytes;
        long* e = (long*)(bytes + len - rem);

        while (b < e) {
            if ((*(b) | *(b + 1) | *(b + 2) | *(b + 3) | *(b + 4) |
                *(b + 5) | *(b + 6) | *(b + 7) | *(b + 8) |
                *(b + 9) | *(b + 10) | *(b + 11) | *(b + 12) | 
                *(b + 13) | *(b + 14) | *(b + 15)) != 0)
                return false;
            b += 16;
        }

        for (int i = 0; i < rem; i++)
            if (data [len - 1 - i] != 0)
                return false;

        return true;
    }
}

时间测量(在 256MB 阵列上):

LINQ All(b => b == 0)                   : 6350,4185 ms
Foreach over byte[]                     : 580,4394 ms
For with byte[].Length property         : 809,7283 ms
For with Length in local variable       : 407,2158 ms
For unrolled 16 times                   : 334,8038 ms
For fixed byte*                         : 272,386 ms
For fixed byte* unrolled 16 times       : 141,2775 ms
For fixed long*                         : 52,0284 ms
For fixed long* unrolled 16 times       : 25,9794 ms
SIMD Vector16b equals Vector16b.Zero    : 56,9328 ms
SIMD Vector16b also unrolled 16 times   : 32,6358 ms

结论:

  • Mono.Simd 只有一组有限的指令。我没有找到计算标量和(向量)或最大值(向量)的说明。然而,向量相等运算符返回 bool。
  • 循环展开是一种强大的技术。即使是最快的代码也能从使用它中获益良多。
  • LINQ 非常慢,因为它使用来自 lambda 表达式的委托。如果您需要最先进的性能,那么显然这不是可行的方法。
  • 介绍的所有方法都使用short circuit evaluation,这意味着它们一旦遇到非零就结束。
  • SIMD 代码最终被破解。关于 SIMD 是否真的让事情变得更快的争论还有其他问题。

Posted this code 在同行评审中,到目前为止发现并修复了 2 个错误。

【讨论】:

  • 这假设您的数组长度为 16*N,这是一个很大的假设,但在受控环境中可能有效,同样从您在 BySimdEquals 上的时间来看,我高度假设您没有使用 O= 运行它simd 并因此得到非 simd O=-simd 时间(?),这实际上并没有将代码执行时间提高那么多。用 C 编写代码并 p/调用 GC 固定的数组会更快。
  • 展开版本确实更快,但展开循环 2 次(因此每个循环仅比较 2 x 8 字节)在我的机器上提供与 16 次相似(如果不是更好)的性能。当您知道大多数 x64 机器只有两个 64 位数据通道(如果您有 2 个记忆棒并且它们安装在正确的插槽中)时,这是有道理的。内存读取可能是这里最大的瓶颈。
【解决方案2】:

标量实现处理 long ,一次是 64 位(8 字节),并从这种强大的并行性中获得大部分加速。

上述代码中的 SIMD/SSE 使用 128 位 SIMD/SSE(16 字节)指令。使用较新的 256 位(32 字节)SSE 指令时,SIMD 实现速度提高了约 10%。在最新处理器中使用 512 位(64 字节)的 AVX/AVX2 指令,使用这些指令的 SIMD 实施应该更快。

    private static bool ZeroDetectSseInner(this byte[] arrayToOr, int l, int r)
    {
        var zeroVector = new Vector<byte>(0);
        int concurrentAmount = 4;
        int sseIndexEnd = l + ((r - l + 1) / (Vector<byte>.Count * concurrentAmount)) * (Vector<byte>.Count * concurrentAmount);
        int i;
        int offset1 = Vector<byte>.Count;
        int offset2 = Vector<byte>.Count * 2;
        int offset3 = Vector<byte>.Count * 3;
        int increment = Vector<byte>.Count * concurrentAmount;
        for (i = l; i < sseIndexEnd; i += increment)
        {
            var inVector  = new Vector<byte>(arrayToOr, i          );
            inVector     |= new Vector<byte>(arrayToOr, i + offset1);
            inVector     |= new Vector<byte>(arrayToOr, i + offset2);
            inVector     |= new Vector<byte>(arrayToOr, i + offset3);
            if (!Vector.EqualsAll(inVector, zeroVector))
                return false;
        }
        byte overallOr = 0;
        for (; i <= r; i++)
            overallOr |= arrayToOr[i];
        return overallOr == 0;
    }

    public static bool ZeroValueDetectSse(this byte[] arrayToDetect)
    {
        return arrayToDetect.ZeroDetectSseInner(0, arrayToDetect.Length - 1);
    }

上面的代码中显示了一个改进的版本(感谢 Peter 的建议),它是安全的,并且已集成到 HPCsharp nuget 包中,使用 256 位 SSE 指令可提高 20% 的速度。

【讨论】:

  • 为什么你会|= 进入一个累加器,但每次迭代都检查那个累加器? |= 一个缓存行或两个向量一起用于一个 pcmpeqb / pmovmskb / test/ jnz 循环中断条件是有意义的。但是你想在发现以前的都是零之后开始一个新的orVector,打破了依赖链。如果按照编写的方式编译,它最多限制为每个周期 1 个向量(通过orVector 的数据依赖性,而不是像现代 x86 可以做到的每时钟 2x 16 字节负载(自 K10 以来的 AMD,自 Sandybridge 以来的英特尔)。或 2x 32-在 Haswell 及更高版本上每时钟加载字节数。
  • 是的,这是表明标量展开循环可以被击败的初始实现。后续实现在循环中使用几个独立的 orVector 来打破依赖关系。在不引入依赖关系的情况下对它们进行非零短路测试是很困难的。希望看到建议。
  • 在源中手动展开循环的顶部,执行orVector = new Vector&lt;byte&gt;(arrayToOr, i);,然后为后面的向量执行orVector |= new Vector&lt;byte&gt;(arrayToOr, i+1);... i + 2 等。在这个循环的底部,测试它是否非零。
  • 感谢您的解释 - 帮助很大并使其具体化。与上面展开的标量实现相比,这种方法通过 256 位(32 字节)SSE 安全 C# 实现带来了另外 10% 的加速,总增益为 20%。您能否进一步详细说明此方法如何没有依赖关系,因为它将所有读取的 Vector 都放入单个 orVector 中?我实现了单独/多个 orVectors,但只是为了比您的建议获得最小的性能提升。
  • 它在单个循环迭代的主体具有依赖关系,但会破坏循环迭代的依赖关系。所以没有循环携带的依赖(除了指针增量)。这可以让乱序执行在单独的迭代中重叠工作,因为它们是单独的依赖链。 (分支预测/推测执行避免了基于load+ALU结果等待条件分支的控制依赖跳出循环。即分支推测破坏了数据依赖。)
猜你喜欢
  • 2016-04-15
  • 1970-01-01
  • 2014-07-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多