.NET8 硬件加速指令的支持

2023-12-26 14:24:16 浏览数 (1)

.NET 有着悠久的历史,在通过 JIT 编译器本质理解的 API 提供对额外硬件功能的访问。这始于 2014 年的 .NET Framework,并在 2019 年引入 .NET Core 3.0 时得到扩展。从那时起,运行时在每个版本中都迭代地提供了更多的 API 并更好地利用了这些 API。

简要概述如下:

  • 2014 年 – .NET 4.5.2 – 在 System.Numerics 命名空间中首次暴露的 API
    • 引入了 Vector<T>
    • 引入了 Vector2, Vector3, Vector4, Matrix4x4, Quaternion 和 Plane
    • 仅支持 64 位
    • 另见:https://devblogs.microsoft.com/dotnet/the-jit-finally-proposed-jit-and-simd-are-getting-married/
  • 2019 年 – .NET Core 3.0 – 在 System.Runtime.Intrinsics 命名空间中首次暴露的 API
    • 引入了 Vector128<T>Vector256<T>
    • 为 x86/x64 引入了 Sse, Sse2, Sse3, Ssse3, Sse41, Sse42, Avx, Avx2, Fma, Bmi1, Bmi2, Lzcnt, Popcnt, Aes 和 Pclmul
    • 支持 32 位和 64 位
    • 另见:https://devblogs.microsoft.com/dotnet/hardware-intrinsics-in-net-core/
  • 2020 年 – .NET 5 – 在 System.Runtime.Intrinsics 命名空间中增加了 Arm 支持
    • 引入了 Vector64<T>
    • 为 Arm/Arm64 引入了 AdvSimd, ArmBase, Dp, Rdm, Aes, Crc32, Sha1 和 Sha256
    • 为 x86/x64 引入了 X86Base
    • 另见:https://devblogs.microsoft.com/dotnet/announcing-net-5-0-preview-7/
  • 2021 年 – .NET 6 – 代码生成和基础设施改进
    • 为 x86/x64 引入了 AvxVnni
    • 重写了 System.Numerics 的实现,以使用 System.Runtime.Intrinsics
    • 另见:https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/
  • 2022 年 – .NET 7 – 支持编写跨平台算法
    • Vector64<T>Vector128<T>Vector256<T> 类型引入了在各平台上均可工作的重要新功能
    • 为 x86/x64 引入了 X86Serialize
    • 使上述向量类型和 Vector<T> 的 API 达到一致
    • 另见:https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/
  • 2023 年 – .NET 8 – Wasm 支持和 AVX-512
    • 为 Wasm 引入了 PackedSimd 和 WasmBase
    • 引入了 Vector512<T>
    • 为 x86/x64 引入了 Avx512F, Avx512BW, Avx512CD, Avx512DQ 和 Avx512Vbmi
    • 另见:本博客文章的其余部分

因为这些工作,每一个版本的 .NET 库和应用程序都获得了更多的能力来利用底层硬件。在这篇文章中,我将深入介绍我们在 .NET 8 中引入的内容以及它所启用的功能类型。

WebAssembly 支持

WebAssembly(简称 Wasm)本质上是在浏览器中运行的代码,它提供了比典型解释型脚本支持更高的性能。作为一个平台,Wasm 已经开始提供底层的 SIMD(单指令多数据)支持,以便加速核心算法,而 .NET 也相应地选择通过硬件内在函数来暴露对这一功能的支持。

这种支持与其他平台提供的基础非常相似,因此我们不会详细介绍。相反,你可以简单地期待你现有的使用Vector128<T>的跨平台算法在支持的地方会隐式地提升性能。如果你想更直接地利用 Wasm 独有的功能,那么你可以显式地使用 System.Runtime.Intrinsics.Wasm 命名空间中的 PackedSimd 和 WasmBase 类暴露的 API。

AVX-512 支持

AVX-512 是为 x86 和 x64 计算机提供的一组新功能。它带来了一大批之前不可用的新指令和硬件功能,包括对 16 个额外的 SIMD 寄存器的支持、专用掩码和一次处理 512 位数据的能力。访问这些功能需要相对较新的处理器,即需要英特尔的 Skylake-X 或更新的处理器,以及 AMD 的 Zen4 或更新的处理器。因此,能够利用这一新功能的用户数量较少,但它可以为该硬件带来的改进仍然是显著的,并且值得为数据密集型工作负载提供支持。此外,JIT 会在确定存在好处的情况下,机会性地使用这些指令来优化现有的 SIMD 代码。一些例子包括:

  • 使用 vpternlog 代替 and、andn、or,当进行位条件选择时(Vector128.ConditionalSelect)
  • 使用 EVEX 编码来减少代码字节数,例如用于嵌入式广播(x Vector128.Create(5))
  • 使用现在支持 AVX-512 的新指令,例如用于全宽度洗牌和许多 long/ulong(Int64/UInt64)操作
  • 还有其他改进,这里没有列出,你可以期待随着时间的推移会有更多的添加
  • 一些情况如 Vector<T> 允许扩展到 512 位在 .NET 8 中没有完成

为了支持新的 512 位向量大小,.NET 引入了 Vector512<T> 类型。这公开了与其他固定大小向量类型如 Vector256<T> 相同的一般 API 。同样,它继续公开 Vector512.IsHardwareAccelerated 属性,允许你确定通用逻辑是否应该在硬件中加速,或者如果它将通过软件回退来模拟行为。

Vector512 默认在 Ice Lake 及更新的硬件上通过 AVX-512 加速(因此 Vector512.IsHardwareAccelerated 返回为 true),在这些硬件上使用 AVX-512 指令不会导致 CPU 显著降频;而在基于 Skylake-X、Cascade Lake 和 Cooper Lake 的硬件上使用 AVX-512 指令可能会导致更显著的降频(另见英特尔 ® 64 与 IA-32 架构优化参考手册:第 1 卷中的 2.5.3 Skylake 服务器电源管理)。虽然这对大型工作负载最终有利,但它可能对其他较小的工作负载产生负面影响,因此我们默认在这些平台上返回 Vector512.IsHardwareAccelerated 为 false。Avx512F.IsSupported 仍然会报告为 true,并且如果直接调用,Vector512 的底层实现仍将使用 AVX-512 指令。这允许工作负载在明确知道有利的情况下利用这一功能,而不会意外地对其他工作产生负面影响。

特别感谢

这项功能的实现得益于我们在英特尔的朋友们的重大贡献。.NET 团队和英特尔多年来多次合作,这一次我们在整体设计和实现上共同努力,使得 AVX-512 支持得以在 .NET 8 中实现。

.NET 社区也提供了大量的意见和验证,帮助我们取得了成功,并使得发布的产品更加完善。

如果您想要贡献或提供意见,请加入 GitHub 上的 dotnet/runtime 仓库,并关注 .NET 基金会 YouTube 频道上的 API Review,您可以按照我们的日程表收看我们讨论 .NET 库的新添加内容,甚至可以通过聊天频道提供您自己的意见。

AVX-512 并不仅仅是 512 位支持

与名称相反,AVX-512 不仅仅关于 512 位支持。额外的寄存器、掩码支持、内嵌舍入或广播支持以及新指令,同样适用于 128 位和 256 位向量。这意味着你现有的工作负载可以隐性地得到改善,而且在隐性激活不可能的情况下,你可以显性地利用更多新功能。

1999 年,当 SSE 首次在英特尔奔腾 III 上推出时,它提供了 8 个每个 128 位长度的寄存器,这些寄存器被称为 xmm0 到 xmm7。后来在 2003 年,当 x64 平台在 AMD Athlon 64 上引入时,它又提供了 8 个额外的寄存器,这些寄存器能被 64 位代码访问,被命名为 xmm8 到 xmm15。这一初始支持使用了一种简单的编码方案,其工作方式与通用指令非常相似,仅允许指定 2 个寄存器。对于像加法这样需要 2 个输入的操作,这意味着其中一个寄存器充当输入和输出。如果你的输入和输出需要不同,你需要 2 条指令来完成操作。实际上,你的 z = x y 会变成 z = x; z = y。在高层次上,这些行为相同,但在底层,发生的是 2 步而不是 1 步。

随后在 2011 年,英特尔在基于 Sandy Bridge 的处理器上引入了 AVX,通过将支持扩展到 256 位。这些更新的寄存器被命名为 ymm0 到 ymm15,但只有 ymm7 及以下的寄存器能被 32 位代码访问。这还引入了一种新的编码,称为 VEX(向量扩展),它允许编码 3 个寄存器。这意味着你可以直接编码 z = x y,而不必将其分解成两个独立的步骤。

然后在 2017 年,英特尔在基于 Skylake-X 的处理器上引入了 AVX-512。这将支持扩展到 512 位,并将寄存器命名为 zmm0 到 zmm15。它还引入了 16 个新寄存器,恰当地命名为 zmm16 到 zmm31,它们也有 xmm16-xmm31 和 ymm16-ymm31 的变体。与前面的情况一样,只有 zmm7 及以下的寄存器能被 32 位代码访问。它引入了 8 个新的寄存器,命名为 k0 到 k7,旨在支持“掩码”,并引入了另一种新编码,称为 EVEX(增强向量扩展),它允许表达所有这些新信息。EVEX 编码还具有其他特性,允许以更紧凑的方式表达更多常见信息和操作。这有助于减小代码大小,同时提高性能。

新的指令集包括哪些内容?

新的功能非常多,这篇博文无法全部覆盖。但是一些最显著的新指令提供了以下功能:

  • 支持对 64 位整数进行 Abs、Max、Min 和位移操作——之前这些功能需要使用多条指令来模拟
  • 支持无符号整数与浮点类型之间的转换
  • 支持处理浮点数边缘情况
  • 支持完全重新排列向量中的元素或多个向量
  • 支持在单条指令中进行 2 个位运算

64 位整数支持是值得注意的,因为这意味着处理 64 位数据不需要使用更慢或替代的代码序列来支持相同的功能。这使得编写代码并期望它无论在处理什么底层数据类型时都能表现一致变得更加容易。

对于浮点数转换为无符号整数的支持也因类似的原因而显著。从 double 转换为 long 需要一条指令,但从 double 转换为 ulong 需要多条指令。有了 AVX-512,这变成了单条指令,并允许用户在处理无符号数据时获得预期的性能。这在各种图像处理或机器学习场景中很常见。

对浮点数据的扩展支持是我最喜欢的 AVX-512 特性之一。一些例子包括能够提取无偏指数(Avx512F.GetExponent)或规格化尾数(Avx512F.GetMantissa),将浮点值四舍五入到特定数量的小数位(Avx512F.RoundScale),将值乘以 2^x(Avx512F.Scale,在 C 语言中称为 scalebn),以正确处理 0 和-0 来执行 Min、Max、MinMagnitude 和 MaxMagnitude(Avx512DQ.Range),甚至执行归约,这在处理三角函数如 Sin 或 Cos 的大值时很有用(Avx512DQ.Reduce)。

然而,我个人最喜欢的指令是名为 vfixupimm(Avx512F.Fixup)的指令。从高层次来看,这条指令允许你检测许多输入边缘情况并“修正”输出为常见输出,并且可以逐元素进行。这可以大幅提高某些算法的性能,并大大减少所需的处理量。其工作原理是它接受 4 个输入,即左值、右值、表格和控制。它首先对右值中的浮点数进行分类,确定它是 QNaN(0)、SNaN(1)、 /-0(2)、 1(3)、-Infinity(4)、 Infinity(5)、负数(6)还是正数(7)。然后它使用这个分类从表格中读取 4 位(QNaN 是 0,读取位 0..3;负数是 6,读取位 24..27)。表格中这 4 位的值则决定了结果会是什么。可能的结果(每个元素)包括:

位模式

定义

0b0000

left[i]

0b0001

right[i]

0b0010

QNaN(right[i])

0b0011

QNaN

0b0100

-无穷大

0b0101

无穷大

0b0110

若 right[i]为负则为-无穷大,否则为 无穷大

0b0111

-0.0

0b1000

0.0

0b1001

-1.0

0b1010

1.0

0b1011

0.5

0b1100

90.0

0b1101

圆周率除以 2

0b1110

最大值

0b1111

最小值

SSE 提供了一定程度上对向量数据重新排列的支持。例如,如果你有 0, 1, 2, 3,想要将其重新排列为 3, 1, 2, 0。随着 AVX 的引入和向 256 位的扩展,这种支持也相应地扩大了。然而,由于指令的操作方式,你实际上是两次执行相同的 128 位操作。这使得将现有算法扩展到 256 位变得简单,因为你实际上是做了两次相同的事情。然而,当你真的需要将整个向量作为一个整体来考虑时,这使得其他算法的工作变得更加困难。确实有一些指令允许你在整个 256 位向量中重新排列数据,但它们通常要么在如何重新排列数据方面有限制,要么在它们支持的类型上有限制(完全随机排列字节元素是一个明显缺失的支持例子)。AVX-512 在其扩展的 512 位支持方面有许多相同的考虑。然而,它也引入了新的指令来填补这一空白,现在允许你为任何大小的元素完全重新排列元素。

最后,我个人的另一个最爱是一个名为 vpternlog(Avx512F.TernaryLogic)的指令。这个指令允许你取任何两个位运算并将它们组合起来,以便可以在单个指令中执行。例如,你可以执行(a & b) | c。它的工作方式是它需要 4 个输入,a、b、c 和控制。然后你需要记住 3 个关键点:A: 0xF0,B: 0xCC,C: 0xAA。为了表示所需的操作,你只需通过对这些键执行该操作来构建控制。所以,如果你想简单地返回 a,你会使用 0xF0。如果你想做 a & b,你会使用(byte)(0xF0 & 0xCC)。如果你想做(a & b) | c,那么它是(byte)((0xF0 & 0xCC) | 0xAA。总共有 256 种不同的操作可能,基本构建块是这些键和以下位运算:

操作

定义

not

~x

and

x & y

nand

~x & y

or

x

y

nor

~x

y

xor

x ^ y

xnor

~x ^ y

接下来是一些特殊操作,它们在上述基本操作的基础上提供了进一步的扩展。

操作

定义

false

位模式 0x00

true

位模式 0xFF

major

如果两个或更多输入位为 0,则返回 0;如果两个或更多输入位为 1,则返回 1

minor

如果两个或更多输入位为 1,则返回 0;如果两个或更多输入位为 0,则返回 1

条件选择

逻辑上为 `(x & y)

在 .NET 8 中,我们没有完成对这些模式的隐式识别,对 vpternlog 指令的支持。我们预计这一功能将在 .NET 9 中首次亮相。

掩码支持是什么?

在最基本的层面上,编写向量化代码涉及使用 SIMD(单指令多数据流)在单个指令中对类型为 T 的 Count 不同元素执行相同的基本操作。当需要对所有数据执行相同操作时,这种方法非常有效。然而,并非所有数据都是一致的,有时你需要对特定输入进行不同处理。例如,你可能想对正数与负数执行不同的操作。如果用户传入了 NaN,你可能需要返回不同的结果,等等。在编写常规代码时,通常会使用分支来处理这些情况,这样做非常有效。然而,在编写向量化代码时,这样的分支会打破使用 SIMD 指令的能力,因为你必须独立处理每个元素。.NET 在各个地方利用了这一点,包括新的 TensorPrimitives APIs,在这里它允许我们处理尾随数据,否则这些数据无法完全适应一个完整的向量。

典型的解决方案是编写“无分支”代码。做到这一点的最简单方法之一是计算两个答案,然后使用位运算来选择正确的答案。你可以将这看作是三元条件表达式 cond ? result1 : result2。为了在 SIMD 中支持这一点,存在一个名为 ConditionalSelect 的 API,它接受一个掩码和两个结果。掩码也是一个向量,但其值通常是 AllBitsSet 或 Zero。当你有这种模式时,ConditionalSelect 的实现实际上是(cond & result1) | (~cond & result2)。这实际上做的是从 result1 中取位,对应的在 cond 中的位是 1,否则从 result2 中取对应的位(当在 cond 中的位是 0)。所以如果你想将所有负值转换为 0,你会有像常规代码中的 (x < 0) ? 0 : x,以及向量化代码中的 Vector128.ConditionalSelect(Vector128.LessThan(x, Vector128.Zero), Vector128.Zero, x)。这可能更加冗长,但也能提供显著的性能提升。

当硬件首次开始支持 SIMD 时,你需要通过执行 3 条指令来非常直接地支持这种掩码操作:and、nand、or。随着新硬件的出现,添加了更优化的版本,允许你使用单一指令完成此操作,例如 x86/x64 上的 blendv 和 Arm64 上的 bsl。然后 AVX-512 进一步发展了这一概念,通过引入专用硬件支持来表达掩码并在寄存器中跟踪它们(前面提到的 k0-k7)。它还提供了额外的支持,允许在几乎任何其他操作中完成这种掩码处理。因此,与其必须指定 vcmpltps; vblendvps; vaddps(比较、掩码然后添加),不如直接将掩码编码为加法的一部分(因此发出 vcmpltps; vaddps)。这允许硬件在更小的空间内表示更多的操作,提高代码密度,并更好地利用预期行为。

值得注意的是,我们在这里并没有直接公开与底层硬件一一对应的掩码概念。相反,JIT 继续接受和返回常规向量作为比较结果,并基于此执行相关的模式识别和随后的掩码功能的机会性启用。这使得公开的 API 表面显著减小(减少了超过 3000 个 API),现有代码在很大程度上可以“直接工作”,并在没有显式操作的情况下利用新硬件支持,以及希望支持 AVX-512 的用户不必学习新概念或以新方式编写代码。

AVX-512 在实践中的应用示例

AVX-512 可以用来加速所有 SSE 或 AVX 场景下的相同情况。一个简单的方法来识别.NET 库中已经使用这种加速的地方,是搜索我们调用Vector512.IsHardwareAccelerated的地方,这可以通过 source.dot.net 来完成。

我们已经加速了如下情况:

  • System.Collections.BitArray – 创建,按位与,按位或,按位异或,按位非
  • System.Linq.Enumerable – 最大值和最小值
  • System.Buffers.Text.Base64 – 解码,编码
  • System.String – 相等,忽略大小写
  • System.SpanIndexOfIndexOfAnyIndexOfAnyInRangeSequenceEqualReverseContains

在整个.NET 库和一般的.NET 生态系统中还有其他例子,太多了无法一一列举和覆盖。这些包括但不限于颜色转换、图像处理、机器学习、文本转码、JSON 解析、软件渲染、光线追踪、游戏加速等场景。

接下来的计划

我们计划在适当的时候继续改进.NET 中的硬件内在支持。请注意以下项目是前瞻性的和推测性的。这个列表不是完整的,我们不保证这些功能会实现,或者如果它们实现了,会在何时发布。

我们长期路线图上的一些项目包括:

  • 对 Arm64 的 SVE 和 SVE2 支持
  • 对 x86/x64 的 AVX10 支持
  • 允许Vector<T>隐式扩展到 512 位
  • 一个ISimdVector<TSelf, T>接口以允许更好地复用 SIMD 逻辑
  • 一个分析器帮助用户鼓励使用跨平台 API,其中语义是相同的(使用x y代替Sse.Add(x, y)
  • 一个分析器识别可能有更优化选择的模式(做value value代替value * 2Sse.UnpackHigh(value, value)代替Sse.Shuffle(value, value, 0b11_11_10_10)
  • 在各种.NET API 中额外显式使用硬件内在
  • 额外的跨平台 API 帮助抽象常见操作
    • 这些 API 在所有平台上今天都有明确定义的行为,例如 Shuffle 将任何超出范围的索引视为归零目标元素
    • 新的 API,例如 ShuffleUnsafe,将允许超出范围索引的不同行为
    • 对于这样的情况,Arm64 将有相同的行为,而 x64 只有在最高有效位被设置时才有相同的行为
    • 获取掩码中第一个/最后一个匹配的索引
    • 获取掩码中匹配的数量
    • 确定是否存在任何匹配
    • 允许非确定性行为,例如 Shuffle 或 ConditionalSelect
  • 识别类似情况的额外模式
    • 内嵌掩码(AVX512,AVX10,SVE/SVE2)
    • 组合位运算(AVX512 上的 vpternlog)
    • 有限的 JIT 时间常量折叠机会

如果你正在寻找在.NET 中使用硬件内在,我们鼓励你尝试在System.Runtime.Intrinsics命名空间中可用的 API,记录 API 建议,你觉得缺失或可以改进的功能,并参与我们的预览版本,在功能发布前尝试它,这样你就可以帮助每个版本都比上一个版本更好!


作者:Tanner Gooding[1]

原文链接:https://devblogs.microsoft.com/dotnet/dotnet-8-hardware-intrinsics/

参考资料

[1]

Tanner Gooding: https://devblogs.microsoft.com/dotnet/author/tagoo

0 人点赞