原文链接:
https://devblogs.microsoft.com/dotnet/dotnet-8-hardware-intrinsics/
Hardware Intrinsics in .NET 8
Tanner Gooding [MSFT]
December 11th, 2023
译文:
.NET在通过JIT编译器本质上理解的API提供对附加硬件功能的访问方面有着悠久的历史。这始于2014年的.NET Framework,并随着2019年.NET Core 3.0的引入而扩展。从那时起,运行时迭代地提供了更多的API,并在每个版本中更好地利用了这一点。
简要概述如下:
System.Numerics
命名空间中公开的 API
Vector
Vector2
、Vector3
、Vector4
、Matrix4x4
、Quaternion
和Plane
System.Runtime.Intrinsics
命名空间中公开的 API
Vector128
和Vector256
x86
和x64
引入Sse、
Sse2、
Sse3
、Ssse3
、Sse41
、Sse42
、Avx
、Avx2
、Fma
、Bmi1
、Bmi2
、Lzcnt
、Popcnt
、Aes
、Pclmul
System.Runtime.Intrinsics
命名空间中添加了 Arm 支持
Vector64
Arm/
Arm64
引入AdvSimd、
ArmBase、
Dp
、Rdm
、Aes
、Crc32
、Sha1
、Sha256
X86Base
/x86
引入x64
x86/x64
引入AvxVnni
System.Numerics
实现以使用System.Runtime.Intrinsics
Vector64
、Vector128
和Vector256
类型上引入了重要的新功能x86/
x64
引入X86Serialize
Vector
公开的API界面具有奇偶性Wasm引入PackedSimd
和WasmBase
Vector512
x86/
x64
引入Avx512F、
Avx512BW
、Avx512CD
、Avx512DQ
、Avx512Vbmi
由于这项工作,每个版本的.NET库和应用程序都获得了更多的能力来利用底层硬件。在这篇文章中,我将深入介绍我们在.NET 8中引入的内容以及它所支持的功能类型。
WebAssembly,简称Wasm,本质上是在浏览器中运行的代码,它允许比典型的解释型脚本支持更高的性能配置文件。作为一个平台,Wasm已经开始提供底层SIMD(单指令,多数据)支持,以便可以加速核心算法,.NET相应地选择通过硬件内部函数公开对此功能的支持。
这种支持与其他平台提供的基础非常相似,因此我们不会详细介绍。相反,您可以简单地期望使用Vector128
的现有跨平台算法将隐式地照亮支持的地方。如果您想更直接地利用Wasm独有的功能,那么您可以显式地使用PackedSimd
命名空间中的WasmBase
和System.Runtime.Intrinsics.Wasm
类公开的API。
AVX-512是为x86和x64计算机提供的新功能集。它带来了沿着的大量新指令和硬件功能,包括支持16个额外的SIMD寄存器,专用掩码,以及一次操作512位数据。访问此功能需要一个相对较新的处理器,即需要英特尔的Skylake-X或更新版本,以及AMD的Zen 4或更新版本。因此,可以利用此新功能的用户数量较少,但它可以为硬件带来的改进仍然很重要,并且值得支持数据繁重的工作负载。此外,JIT将在其确定存在益处的情况下针对现有SIMD代码机会性地利用这些指令。一些例子包括:
vpternlog
而不是and, andn, or
(Vector128.ConditionalSelect
)x + Vector128.Create(5)
)long
/ulong
(Int64
/UInt64
)操作Vector
允许扩展到512位,在.NET 8中没有完成为了支持512位的新向量大小,.NET引入了Vector512
类型。这公开了与其他固定大小的向量类型(如Vector256
)相同的通用API表面。它同样继续暴露Vector512.IsHardwareAccelerated
属性,该属性允许您确定是否应该在硬件中加速通用逻辑,或者是否最终通过软件回退来模拟行为。
Vector 512在Ice Lake和更新的硬件上默认使用AVX-512加速(因此Vector512.IsHardwareAccelerated
报告true
),其中AVX-512指令不会导致CPU显著降频;而使用AVX-512指令会导致Skylake-X,Cascade Lake和库珀Lake硬件上更显著的降频(另请参见2.5.3 Skylake Server Power Management
中的Intel® 64 and IA-32 Architectures Optimization Reference Manual: Volume 1
)。虽然这最终有利于大型工作负载,但它可能会对其他较小的工作负载产生负面影响,因此我们默认在这些平台上报告false
为Vector512.IsHardwareAccelerated
。Avx512F.IsSupported
仍然会报告true,如果直接调用,Vector512
的底层实现仍然会使用AVX-512
指令。这允许工作负载利用他们知道的功能,而不会意外地对其他人造成负面影响。
这一功能的实现得益于我们在英特尔的朋友们的巨大贡献。多年来,.NET团队和英特尔已经进行了多次合作,我们继续在整体设计和实现方面进行合作,从而使AVX-512支持登陆.NET 8。
还有来自.NET社区的大量输入和验证,帮助实现了成功并使发布变得更好。
如果您想贡献或提供输入,请加入我们在GitHub上的dotnet/runtimerepos,并按照我们的时间表在.NET Foundation YouTube频道上收听API Review,您可以看到我们讨论.NET库的新添加,甚至通过聊天频道提供您自己的输入。
与名称相反,AVX-512不仅仅是512位支持。额外的寄存器、掩码支持、嵌入式舍入或广播支持以及新指令也都适用于128位和256位向量。这意味着您现有的工作负载可以隐式地变得更好,并且您可以显式地利用新功能,而这种隐式的点亮是不可能的。
当SSE于1999年在Intel Pentium III上首次引入时,它提供了8个寄存器,每个寄存器长度为128位。这些寄存器被称为xmm0
到xmm7
。当x64平台后来于2003年在AMD Athlon 64上推出时,它提供了8个额外的寄存器,可以访问64位代码。这些寄存器被命名为xmm8
到xmm15
。这种初始支持使用了一种简单的编码方案,其工作方式与通用指令非常相似,只允许指定2个寄存器。对于需要2个输入的加法,这意味着其中一个寄存器既充当输入又充当输出。这意味着如果你的输入和输出需要不同,你需要2条指令来完成操作。z = x + y
会变成z = x; z += y
。在高级别上,这些行为是相同的,但在低级别上,有两个步骤而不是一个步骤来实现它。
2011年,英特尔在基于桑迪桥的处理器上推出了AVX,将支持扩展到256位,从而进一步扩展了这一点。这些较新的寄存器被命名为ymm0
到ymm15
,只有直到ymm7
的寄存器才能访问32位代码。这也引入了一种称为VEX
(矢量扩展)的新编码,允许对3个寄存器进行编码。这意味着您可以直接编码z = x + y
,而不必将其分为两个单独的步骤。
AVX-512随后由英特尔于2017年推出,采用基于Skylake-X的处理器。这将支持扩展到512位,并将寄存器命名为zmm0
到zmm15
。它还引入了16个新寄存器,恰当地命名为zmm16
到zmm31
,并且还有xmm16-xmm31
和ymm16-ymm31
变体。与前面的情况一样,只有zmm7
以下的寄存器才能访问32位代码。它引入了8个新的寄存器,命名为k0
到k7
,旨在支持“掩码”和另一种名为EVEX
(增强型矢量扩展)的新编码,允许表达所有这些新信息。EVEX编码还具有允许以更紧凑的方式表达更常见的信息和操作的其他特征。这可以帮助减少代码大小,同时提高性能。
有很多新功能,太多了,无法在这篇博客文章中涵盖所有内容。但一些最值得注意的新指令提供了以下内容:
Abs
、Max
、Min
和移位等操作-以前必须使用多条指令来模拟此功能64位整数支持是值得注意的,因为这意味着处理64位数据不需要使用较慢或替代的代码序列来支持相同的功能。这使得编写代码并期望其行为相同变得更加容易,而不管您正在使用的底层数据类型如何。
浮点数到无符号整数转换的支持也是出于类似的原因。从double
转换到long
需要一条指令,但是从double
转换到ulong
需要很多指令。使用AVX-512,这变成了一条指令,允许用户在处理无符号数据时获得预期的性能。这在各种图像处理或机器学习场景中很常见。
对浮点数据的扩展支持是我最喜欢的AVX-512特性之一。一些示例包括提取无偏指数(Avx512F.GetExponent
)或归一化尾数(Avx512F.GetMantissa
)、将浮点值舍入为特定小数位数(Avx512F.RoundScale
)、将值乘以2^x(Avx512F.Scale
,在C中称为scalebn
),以正确处理Min
和Max
(MinMagnitude
)来执行MaxMagnitude
、+0
、-0
和Avx512DQ.Range
,甚至可以进行简化,这在处理像Sin
或Cos
(Avx512DQ.Reduce
)这样的三角函数的大值时是有用的。
然而,我个人最喜欢的指令之一是名为vfixupimm
(Avx512F.Fixup
)的指令。在高级别上,此指令允许您检测许多输入边缘情况,并将输出“修复”为常见输出之一,并按元素执行此操作。这可以大大提高某些算法的性能,并大大减少所需的处理量。它的工作方式是它需要4个输入,即left
,right
,table
和control
。它首先对right
中的浮点值进行分类,并确定它是QNaN
(0)、SNaN
(1)、+/-0
(2)、+1
(3)、-Infinity
(4)、+Infinity
(5)、Negative
(6)还是Positive
(7)。然后,它使用它从4
读取table
位(QNaN
是0
,读取位0..3
;Negative
是6
读取位24..27
)。table
中这4位的值决定了结果。可能的结果(每个元素)是:
位模式 | 定义 |
---|---|
0b0000 | 左[i] |
0b0001 | 右[i] |
0b0010 | QNaN(右[i]) |
0b0011 | QNaN |
0b0100 | -Infinity |
0b0101 | +Infinity |
0b0110 | IsNegative(right[i])?-Infinity:+Infinity |
0b0111 | -0.0 |
0b1000 | +0.0 |
0b1001 | -1.0 |
0b1010 | +1.0 |
0b1011 | +0.5 |
0b1100 | +90.0 |
0b1101 | Pi / 2 |
0b1110 | MaxValue |
0b1111 | MinValue |
在SSE中,有一些支持在向量中重新排列数据。例如,你有0, 1, 2, 3
,你想订购3, 1, 2, 0
。随着AVX的引入和扩展到256位,这种支持也得到了扩展。然而,由于指令的操作方式,你实际上会执行两次相同的128位操作。这使得将现有算法扩展到256位变得简单,因为你实际上只是做了两次同样的事情。然而,当你实际上需要考虑整个向量时,它使使用其他算法变得更加困难。有一些指令可以让你在整个256位向量中重新排列数据,但它们通常在数据如何重新排列或它们支持的类型方面受到限制(字节元素的完全洗牌是缺少支持的一个明显例子)。AVX-512对于其扩展的512位支持有许多相同的考虑。但是,它还引入了新的指令来填充差距,现在可以让您完全重新排列任何大小的元素的元素。
最后,我个人最喜欢的指令之一是名为vpternlog
(Avx512F.TernaryLogic
)的指令。此指令允许您采用任何2个按位操作并将它们联合收割机组合,因此它们可以在单个指令中执行。例如,您可以执行(a & b) | c
。它的工作方式是它需要4个输入,a
,b
,c
和control
。然后你有三个键要记住:A: 0xF0
,B: 0xCC
,C: 0xAA
。为了表示所需的操作,您只需通过对这些键执行该操作来构建control
。所以,如果你想简单地返回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 | 和 |
nor | ~x | 和 |
xor | X ^ y | |
xnor | ~x ^ y |
然后还有一些特殊的操作,也支持上述基本操作,并且可以进一步扩展。
操作 | 定义 |
---|---|
假 | 位模式为0x00 |
真 | 0xFF的位模式 |
主要 | 如果两个或多个输入位为0,则返回0;如果两个或多个输入位为1,则返回1 |
次要 | 如果两个或多个输入位为1,则返回0;如果两个或多个输入位为0,则返回1 |
条件选择 | 逻辑上是(x & y) | (~x & z) ,因为它是(x and y) or (x nand y) |
在.NET 8中,我们没有完成对隐式识别和折叠这些模式以发出vpternlog
的支持。我们希望它在.NET 9中首次亮相。
在最简单的级别上,编写向量化代码涉及使用SIMD在单个指令中对类型Count
的T
不同元素执行相同的基本操作。当需要对所有数据执行相同的操作时,这非常有效。然而,并非所有数据都是统一的,有时您需要以不同的方式处理特定的输入。例如,您可能希望对正数和负数执行不同的操作。如果用户传入了NaN
,你可能需要返回一个不同的结果,等等。在编写常规代码时,你通常会用一个分支来处理这个问题,这工作得很好。但是,在编写向量化代码时,这样的分支会破坏使用SIMD指令的能力,因为您必须独立处理每个元素。.NET在不同的地方利用了这一点,包括新的TensorPrimitives
API,它允许我们处理不适合完整向量的尾随数据。
典型的解决方案是编写“无分支”代码。最简单的方法之一是计算两个答案,然后使用按位运算来选择正确的答案。你可以把它想象成一个三元条件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
和Arm 64上的bsl
。AVX-512则进一步引入了专用硬件支持来表达掩码并在寄存器中跟踪它们(前面提到的k0-k7
)。然后,它提供了额外的支持,允许这种掩蔽作为几乎任何其他操作的一部分来完成。因此,不必指定vcmpltps; vblendvps; vaddps
(比较,掩码,然后添加),您可以直接将掩码编码为加法的一部分(从而发出vcmpltps; vaddps
)。这允许硬件在更少的空间中表示更多的操作,提高代码密度,并更好地利用预期的行为。
值得注意的是,我们在这里没有直接公开与底层硬件的1对1概念。相反,JIT继续获取并返回用于比较结果的常规向量,并基于此进行相关的模式识别和掩蔽特征的后续机会光照。这允许暴露的API表面显著更小(减少超过3000个API),现有代码在很大程度上“只是工作”并利用较新的硬件支持而无需显式操作,并且希望支持AVX-512的用户不必学习新概念或以新方式编写代码。
AVX-512可用于加速所有与SSE或AVX相同的场景。识别.NET库已经使用这种加速的一种简单方法是搜索我们称之为Vector512.IsHardwareAccelerated
的地方
我们加速了以下案例:
在.NET库和一般的.NET生态系统中还有其他例子,太多了,无法列出和覆盖。这些包括但不限于颜色转换、图像处理、机器学习、文本转码、JSON解析、软件渲染、光线跟踪、游戏加速等场景。
我们计划继续改进.NET中的硬件内部支持,无论何时何地。请注意,以下项目是前瞻性的思考和推测。该列表是不完整的,我们不提供任何这些功能将土地或当他们将船舶,如果他们这样做。
我们长期路线图中的一些项目包括以下内容:
Arm64的SVE
和SVE 2x86/x64的AVX10
Vector
隐式扩展到512位ISimdVector
接口,允许更好地重用SIMD逻辑x + y
而不是Sse.Add(x, y)
)value + value
而不是value * 2
或Sse.UnpackHigh(value, value)
而不是Sse.Shuffle(value, value, 0b11_11_10_10)
Shuffle
或ConditionalSelect
这样的情况下的非确定性行为Shuffle
将任何超出范围的索引视为将目标元素归零ShuffleUnsafe
)将允许超出范围索引的不同行为vpternlog
)