.NET 有着悠久的历史,在通过 JIT 编译器本质理解的 API 提供对额外硬件功能的访问。这始于 2014 年的 .NET Framework,并在 2019 年引入 .NET Core 3.0 时得到扩展。从那时起,运行时在每个版本中都迭代地提供了更多的 API 并更好地利用了这些 API。
简要概述如下:
Vector<T>
Vector128<T>
和 Vector256<T>
Vector64<T>
Vector64<T>
、Vector128<T>
和 Vector256<T>
类型引入了在各平台上均可工作的重要新功能Vector<T>
的 API 达到一致Vector512<T>
因为这些工作,每一个版本的 .NET 库和应用程序都获得了更多的能力来利用底层硬件。在这篇文章中,我将深入介绍我们在 .NET 8 中引入的内容以及它所启用的功能类型。
WebAssembly(简称 Wasm)本质上是在浏览器中运行的代码,它提供了比典型解释型脚本支持更高的性能。作为一个平台,Wasm 已经开始提供底层的 SIMD(单指令多数据)支持,以便加速核心算法,而 .NET 也相应地选择通过硬件内在函数来暴露对这一功能的支持。
这种支持与其他平台提供的基础非常相似,因此我们不会详细介绍。相反,你可以简单地期待你现有的使用Vector128<T>
的跨平台算法在支持的地方会隐式地提升性能。如果你想更直接地利用 Wasm 独有的功能,那么你可以显式地使用 System.Runtime.Intrinsics.Wasm 命名空间中的 PackedSimd 和 WasmBase 类暴露的 API。
AVX-512 是为 x86 和 x64 计算机提供的一组新功能。它带来了一大批之前不可用的新指令和硬件功能,包括对 16 个额外的 SIMD 寄存器的支持、专用掩码和一次处理 512 位数据的能力。访问这些功能需要相对较新的处理器,即需要英特尔的 Skylake-X 或更新的处理器,以及 AMD 的 Zen4 或更新的处理器。因此,能够利用这一新功能的用户数量较少,但它可以为该硬件带来的改进仍然是显著的,并且值得为数据密集型工作负载提供支持。此外,JIT 会在确定存在好处的情况下,机会性地使用这些指令来优化现有的 SIMD 代码。一些例子包括:
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 位支持。额外的寄存器、掩码支持、内嵌舍入或广播支持以及新指令,同样适用于 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 位整数支持是值得注意的,因为这意味着处理 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 可以用来加速所有 SSE 或 AVX 场景下的相同情况。一个简单的方法来识别.NET 库中已经使用这种加速的地方,是搜索我们调用Vector512.IsHardwareAccelerated
的地方,这可以通过 source.dot.net 来完成。
我们已经加速了如下情况:
System.Collections.BitArray
– 创建,按位与,按位或,按位异或,按位非System.Linq.Enumerable
– 最大值和最小值System.Buffers.Text.Base64
– 解码,编码System.String
– 相等,忽略大小写System.Span
– IndexOf
,IndexOfAny
,IndexOfAnyInRange
,SequenceEqual
,Reverse
,Contains
等在整个.NET 库和一般的.NET 生态系统中还有其他例子,太多了无法一一列举和覆盖。这些包括但不限于颜色转换、图像处理、机器学习、文本转码、JSON 解析、软件渲染、光线追踪、游戏加速等场景。
我们计划在适当的时候继续改进.NET 中的硬件内在支持。请注意以下项目是前瞻性的和推测性的。这个列表不是完整的,我们不保证这些功能会实现,或者如果它们实现了,会在何时发布。
我们长期路线图上的一些项目包括:
Vector<T>
隐式扩展到 512 位ISimdVector<TSelf, T>
接口以允许更好地复用 SIMD 逻辑x + y
代替Sse.Add(x, y)
)value + value
代替value * 2
或Sse.UnpackHigh(value, value)
代替Sse.Shuffle(value, value, 0b11_11_10_10)
)如果你正在寻找在.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