【问题标题】:AVX2 SIMD XOR not yielding performance improvements in .NETAVX2 SIMD XOR 在 .NET 中没有产生性能改进
【发布时间】:2019-09-25 23:06:32
【问题描述】:

我正在使用 System.Runtime.Intrinsics 命名空间中的 .NET Core 3.0 对硬件内在函数的新支持。

我有一些代码在循环中执行 4 个 XOR 操作 - 下面是一个简化的示例(我没有在 IDE 中编写此代码,因此请忽略任何语法错误:

private static unsafe ulong WyHashCore(byte[] array)
{
    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            ulong a = Read64(ptr, i);
            ulong b = Read64(ptr, i + 8);
            ulong c = Read64(ptr, i + 16);
            ulong d = Read64(ptr, i + 24);

            // XOR them with some constants
            ulong xor1 = a ^ SOME_CONSTANT1;
            ulong xor2 = b ^ SOME_CONSTANT2;
            ulong xor3 = c ^ SOME_CONSTANT3;
            ulong xor4 = d ^ SOME_CONSTANT4;

            // Use the resulting values
        }
    }
}

Read64 方法如下所示:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static unsafe ulong Read64(byte* ptr, int start)
    => *(ulong*)(ptr + start);

我尝试将 4 个 XOR 行替换为:

byte[] array; // An array from elsewhere

private static unsafe ulong WyHashCore(byte[] array)
{
    var bVector = Vector256.Create(SOME_CONSTANT1, SOME_CONSTANT2, SOME_CONSTANT3, SOME_CONSTANT4);

    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            ulong a = Read64(ptr, i);
            ulong b = Read64(ptr, i + 8);
            ulong c = Read64(ptr, i + 16);
            ulong d = Read64(ptr, i + 24);

            // Create a 256-bit vector from the 4 64-bit integers
            var aVector = Vector256.Create(a, b, c, d);

            // XOR the 2 vectors
            var res = Avx2.Xor(aVector, bVector);

            // Get the resulting values out of the result vector
            ulong xor1 = res.GetElement(0);
            ulong xor2 = res.GetElement(1);
            ulong xor3 = res.GetElement(2);
            ulong xor4 = res.GetElement(3);

            // Use the resulting values
        }
    }
}

这确实给出了预期的结果 - 但它比仅乘以标量要慢 15 倍

我是在哪里出错了,还是误用了 SIMD?

** 更新 ** 我更新了代码以使用“正确”的方式向向量加载和卸载数据,现在它比标量代码快 3.75 倍!

byte[] array; // An array from elsewhere
private static readonly Vector256<ulong> PrimeVector = Vector256.Create(SOME_CONSTANT1, SOME_CONSTANT2, SOME_CONSTANT3, SOME_CONSTANT4);

private static unsafe ulong WyHashCore(byte[] array)
{
    // Create space on the stack to hold XOR results
    var xorResult = stackalloc ulong[4];

    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            // Create a 256-bit vector from the 4 64-bit integers
            var vector = Avx.LoadVector256((ulong*)(ptr + i));

            // XOR the 2 vectors
            var res = Avx2.Xor(vector, PrimeVector);

            // Store the resulting vector in memory
            Avx2.Store(xorResult, res);

            // Get the resulting values out of the result vector
            var xor1 = *xorResult;
            var xor2 = *(xorResult + 1);
            var xor3 = *(xorResult + 2);
            var xor4 = *(xorResult + 3);

            // Use the resulting values
        }
    }
}

【问题讨论】:

  • 能否贴出更完整的代码。第二个示例中的循环很重要。我认为您应该直接从内存中读取字节,而不是使用Vector256.Create(a, b, c, d)。另外,我认为必须像这样存储。这种元素加载和存储的开销很大。
  • @usr 我的目的是保持可读性,专注于手头的问题,但我可以看到细节如何相关 - 我已经更新了代码示例以更好地匹配现实
  • @usr 我刚刚尝试使用Avx2.LoadVector256((ulong*)(ptr + p)); 在循环中加载向量——这确实比使用Vector256.Create 快得多!这使我与标量版本的性能大致相当。当然它可以做得更快......
  • 请始终发布您拥有的最佳代码。确保也将结果直接存储到内存中。另外,请张贴拆解清单。你肯定是在没有附加调试器的情况下以发布模式运行的,对吧?
  • 使用Avx.Store 非常成功 - SIMD 版本现在比标量版本快 3.75 倍!我会用更新的代码写一个答案 - 感谢@usr 的帮助 :)

标签: c# .net-core simd intrinsics .net-core-3.0


【解决方案1】:

TL;DR AVX2 硬件内部函数使用不正确会导致生成效率非常低的 SIMD 代码。

错误在于指令在缓冲区中加载、操作和存储数据的方式。该操作应使用 AVX/AVX2 Avx2.Xor 内部函数和内存来执行,这将加快加载时间 4 倍并返回 Vector256。另一方面,这将使对 Vector256.Create 的调用变得多余,并进一步加快执行速度。最后,应使用 Avx2.Store() 内部函数将数据存储在数组中。这再次将代码加速大约 4 倍。

应该应用的最后一个优化是利用 CPU 指令级并行性。通常 SIMD 指令在预定义数量的 CPU 周期内执行,延迟可能大于 1 个 CPU 周期。这些参数是特定于 CPU 的,可以在以下位置找到:

由于所有可以应用的优化都非常复杂,我稍后会在更长的文章中解释它们,但总的来说,与您正在处理的问题的基本情况相比,由于矢量化,我预计速度会提高 4 倍。

您使用的代码示例是一个简单的循环,以四无符号四字步骤修改数据,是通过优化编译器进行自动向量化的完美候选者。当 GCC 9.1 使用选项 -O3 -march=haswell 优化相同的 C++ 循环时,生成的机器代码会显示应用于循环的所有标准优化:

#include <cstdint>
void hash(uint64_t* buffer, uint64_t length) {

    uint64_t* pBuffer = buffer;
    const uint64_t CONST1 = 0x6753ul;
    const uint64_t CONST2 = 0x7753ul;
    const uint64_t CONST3 = 0x8753ul;
    const uint64_t CONST4 = 0x9753ul;

    for(uint64_t i = 0; i < length; i += 4)
    {
        *pBuffer ^= CONST1;
        *(pBuffer + 1) ^= CONST2;
        *(pBuffer + 2) ^= CONST3;
        *(pBuffer + 3) ^= CONST4;
    }
}

Godbolt Compiler Explorer result GCC 9.1

    test    rsi, rsi
    je      .L11
    cmp     rsi, -4
    ja      .L6
    lea     rdx, [rsi-1]
    vmovdqa ymm1, YMMWORD PTR .LC0[rip]
    xor     eax, eax
    shr     rdx, 2
    inc     rdx
.L5:
    vpxor   ymm0, ymm1, YMMWORD PTR [rdi]
    inc     rax
    add     rdi, 32
    vmovdqu YMMWORD PTR [rdi-32], ymm0
    cmp     rax, rdx
    jb      .L5
    vzeroupper
.L11:
    ret
.L6:
    vmovdqa ymm1, YMMWORD PTR .LC0[rip]
    xor     eax, eax
.L3:
    vpxor   ymm0, ymm1, YMMWORD PTR [rdi]
    add     rax, 4
    add     rdi, 32
    vmovdqu YMMWORD PTR [rdi-32], ymm0
    cmp     rsi, rax
    ja      .L3
    vzeroupper
    jmp     .L11
.LC0:
    .quad   26451
    .quad   30547
    .quad   34643
    .quad   38739

Godbolt Compiler Explorer result Clang 8.0

 .LCPI0_0:
    .quad   26451                   # 0x6753
    .quad   30547                   # 0x7753
    .quad   34643                   # 0x8753
    .quad   38739                   # 0x9753
 hash(unsigned long*, unsigned long):                             # @hash(unsigned long*, unsigned long)
    test    rsi, rsi
    je      .LBB0_3
    xor     eax, eax
    vmovaps ymm0, ymmword ptr [rip + .LCPI0_0] # ymm0 = [26451,30547,34643,38739]
 .LBB0_2:                                # =>This Inner Loop Header: Depth=1
    vxorps  ymm1, ymm0, ymmword ptr [rdi + 8*rax]
    vmovups ymmword ptr [rdi + 8*rax], ymm1
    add     rax, 4
    cmp     rax, rsi
    jb      .LBB0_2
 .LBB0_3:
    vzeroupper
    ret

【讨论】:

  • 不幸的是 .NET 不做自动矢量化 :( 非常想知道你对 CPU 指令级并行的想法!
  • @Cocowalla:创建 ILP 供 CPU 使用,例如将 float 与 8 个 sum0sum1sum2、...变量的数组相加,所以有sum0 += arr[i]的8个依赖链。 (但不是使用标量变量,而是使用 SIMD 向量作为累加器,因此您的代码可以同时进行 4 或 8 个 vector 加法以隐藏 FP 加法的延迟)。见latency vs throughput in intel intrinsicsWhy does mulss take only 3 cycles on Haswell, different from Agner's instruction tables?
  • 还有关于 CPU 如何利用 ILP 的介绍,see this answer。例如4 个标量 xor 操作可以并行运行,因此如果您确实需要对 XOR 结果执行一些不可向量化的操作,您最好只将其编写为标量。
  • @Cocowalla:从 SIMD 向量提取到整数与仅执行标量加载和标量异或具有相似或更多的成本。虽然也许如果您执行 SIMD 加载、SIMD XOR,然后将结果存储到 32 字节数组并从那里重新加载,您可以赢得吞吐量,并且只需要 1 个向量常量而不是整数寄存器或内存源操作数中的 4 个标量常量,如果它们不适合 32 位符号扩展立即数。不是延迟,但假设 OoO exec 可以保持循环的多次迭代(如果不是太大的话),这并不是很重要。
  • @Peter-Cordes 您指出的答案确实有助于更好地理解为什么 SIMD 收益可能不如预期,谢谢!
猜你喜欢
  • 2014-06-15
  • 2020-12-01
  • 2022-11-20
  • 2017-04-25
  • 2016-06-10
  • 2015-09-13
  • 2016-05-10
  • 2013-07-24
  • 2016-06-04
相关资源
最近更新 更多