【基础】
命名空间 System.Runtime.Intrinsics 与 System.Runtime.Intrinsics.X86 从.NET Core 3.0开始正式发布,前者提供了64~256位的向量类型,后者提供了SSE
~AVX2
的指令集,还有诸如Bmi
、Popcnt
之类的指令集体系。
创建一个向量的方法有很多,包括但不限于 LoadVector 、 Vector.Create Vector.CreateScalar 等方法。下面是一个反转字节数组的每一个字节的完整的例子:
private static readonly byte[] _bitReverseTable16 =
{
0x0, 0x8, 0x4, 0xC, 0x2, 0xA, 0x6, 0xE,
0x1, 0x9, 0x5, 0xD, 0x3, 0xB, 0x7, 0xF
};
/// <summary>
/// AVX优化直接实现
/// </summary>
public static byte[] Byte_Reverse_10(this byte[] src, byte[] dest)
{
unsafe
{
var length = src.Length;
var stop = length - (length & 15);
fixed (byte* pSrc = src, pDest = dest, pTable16 = _bitReverseTable16)
{
var t = Avx.LoadVector128(pTable16);
var mask = Vector128.Create((byte)0x0F);
for (int i = 0; i < stop; i += 16)
{
var v = Sse2.LoadVector128(pSrc + i);
var r = Sse2.Or(
Sse2.ShiftRightLogical(Sse2.And(v, Vector128.Create((byte)0xaa)).AsUInt16(), 1),
Sse2.ShiftLeftLogical(Sse2.And(v, Vector128.Create((byte)0x55)).AsUInt16(), 1));
r = Sse2.Or(
Sse2.ShiftRightLogical(Sse2.And(r.AsByte(), Vector128.Create((byte)0xcc)).AsUInt16(), 2),
Sse2.ShiftLeftLogical(Sse2.And(r.AsByte(), Vector128.Create((byte)0x33)).AsUInt16(), 2));
r = Sse2.Or(
Sse2.ShiftRightLogical(Sse2.And(r.AsByte(), Vector128.Create((byte)0xf0)).AsUInt16(), 4),
Sse2.ShiftLeftLogical(Sse2.And(r.AsByte(), Vector128.Create((byte)0x0f)).AsUInt16(), 4));
Sse2.Store((ushort*)(pDest + i), r);
}
for (int i = stop; i < length; i++)
pDest[i] = pTable16[pSrc[i]];
return dest;
}
}
}
例子很好懂,如果你此前没接触过这两个命名空间,读完之后应该就会明白它的具体用法了。
值得一提的是上面代码中最显眼的就是 AsUInt16()
和 AsByte()
等类的方法,这也是为了保证泛型的设计风格不得已而为之,虽然用起来不如C++直接写那么爽 ,但更清晰,而且编译后方法就消失了^_^,具体可以直接在反汇编代码里查看,这里就不赘述了。
【用法与用途】
如果是在C++中,理想的高性能类库应该是在文件开始加入大量预编译宏来确保它能在目标机器上发挥出最高的性能。
但.NET Core的JIT特性使得这个工作量大大降低。下面是我实现的一个 xxHash 的库的一部分:
public static XXHashProvider<uint> CreateXXHash32(uint seed)
{
#if !DOTNET_FRAMEWORK45_OR_LATER
return Avx2.IsSupported ? new XXHash32ExpliImpl(seed) : (XXHashProvider<uint>)new XXHash32Impl(seed);
#else
return new XXHash32Impl(seed);
#endif
}
条件编译常量DOTNET_FRAMEWORK45_OR_LATER
很好理解,就是如果在.NET Framework上编译,则不使用显式的指令,直接创建一个普通实现的类的实例。
代码中 Avx2.IsSupported
表示机器是否支持Avx2
指令集。鉴于JIT会自动优化掉不会使用的分支,这个操作是没有开销的。
以上就是设计支持显式硬件指令的C#库的方法或者说思路。
然后,在讨论用途之前,我们先看一组针对字节数组中每个字节按位反转的算法的测试数据:
预热中
====测试开始====
直接实现 成功, 864ms.
四路直接实现 成功, 760ms.
采用Int型整合直接实现 成功, 207ms.
直接查表实现 成功, 212ms.
四路并行查表实现 成功, 197ms.
Int整合查表实现 成功, 201ms.
高低16位单独查表实现 成功, 372ms.
SSE优化高低16位查表实现 成功, 138ms.
AVX优化高低16位查表实现 成功, 146ms.
AVX优化直接实现 成功, 147ms.
====测试结束====
(测试使用的是一个长度为4亿的随机内容的字节数组)
后面三个直接使用SIMD指令的实现不谈,这里值得关注的是采用Int型整合直接实现
,在多次测试中,尽管不用查表,但它在多次测试数据中甚至优于直接用一个256位表的算法:
/// <summary>
/// 采用Int型整合直接实现
/// </summary>
public static byte[] Byte_Reverse_03(byte[] src, byte[] dest)
{
unsafe
{
var length = src.Length;
var stop = length - (length & 3);
fixed (byte* pSrc = src, pDest = dest)
{
for (int i = 0; i < stop; i += 4)
*(uint*)(pDest + i) = (*(uint*)(pSrc + i)).Reverse_BO_32I();
for (int i = stop; i < length; i++)
pDest[i] = pSrc[i].Reverse_BO_8U();
return dest;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static uint Reverse_BO_32I(this uint x)
{
var t = ((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1);
t = ((t & 0xcccccccc) >> 2) | ((t & 0x33333333) << 2);
t = ((t & 0xf0f0f0f0) >> 4) | ((t & 0x0f0f0f0f) << 4);
return t;
}
我们都知道要想提高效率就要从IO和操作宽度上做文章,以上代码一次读取一个4字节的数据,然后进行操作,这就是它比直接查表效率还高的原因。
然后问题来了,显式使用SIMD指令的实现与其相比,并没有绝对的优势。
Avx2
从诞生到现在已经很多年了,然而如果我们的目标是写一个绝对泛用的项目,肯定不能假设用户的CPU都是最新出品的,况且 .NET Core 不止支持 x86
架构,它也支持 ARM
架构,除了指令集的问题外,端序也是必须考量的一个元素……
扯远了,我的意思是,由此可以发现, 显式SIMD虽好,但并不值得在所有能用的地方都尽可能采用,明显增加了工作量外,收益却并不足够,得不偿失。
但这并不是说显式SIMD的用法就是鸡肋。比如如果你想要实现一个非常苛求性能算法,比如各种加密或哈希算法,那么使用显式SIMD是值得的。
比如前文提到的 xxHash
算法,这个算法我是真的喜欢,不仅因为它的输入输出是端序无关的, 也不仅因为它不需要查找表即可实现高效率计算,更重要的是这个算法对于C#而言实在是得天独厚:它在最初的设计上就顾全了向量操作,只要机器支持,.NET Framework 或 .NET Core 的 JIT 可以直接把代码进行优化:
//核心代码:
do
{
_acc32_1 = Round32(_acc32_1, array.GetLittleEndianUInt32(ibStart));
ibStart += 4;
_acc32_2 = Round32(_acc32_2, array.GetLittleEndianUInt32(ibStart));
ibStart += 4;
_acc32_3 = Round32(_acc32_3, array.GetLittleEndianUInt32(ibStart));
ibStart += 4;
_acc32_4 = Round32(_acc32_4, array.GetLittleEndianUInt32(ibStart));
ibStart += 4;
} while (ibStart < limit);
但这是否意味着不必实现它的显式SIMD版本呢?
并不是。因为运行时的机制,C#的代码并不是“所见即所得”的,看着C#的代码不太容易估算到它对应的指令,JIT的优化对于开发者而言更像是一个黑盒子。而显式SIMD指令的好处就在于,它是所见即所得的,也就是说,我们可以利用它来精确控制我们要进行的高宽度操作:
do
{
var data = Sse2.LoadVector128((uint*)(pByte + ibStart));
_acc32 = Sse2.Add(_acc32, Sse41.MultiplyLow(data, Vector_PRIME32_2));
_acc32 = Sse41.MultiplyLow(
Sse2.Or(
Sse2.ShiftLeftLogical(_acc32, 13),
Sse2.ShiftRightLogical(_acc32, 19)),
Vector_PRIME32_1);
ibStart += 16;
} while (ibStart < limit);
当然对于xxHash算法而言,只如此使用的收益很小,在数据大小几十亿字节的情况下才会体现出个微小的优势。
不过,此时我们是每次读取的128位数据,如果我们在do的外部再加一个循环,然后使用 Prefetch 方法提前加载更多的数据进入缓存,则会有10%~20%的性能提升。 具体的大小可以自己预估,这里我使用的是256字节。
好吧,我放弃了。其实xxHash其实是个不好的例子,因为它的设计够好了,足够JIT优化,而它也很少用于计算这么大的数据的摘要,我会这么干只是因为我太喜欢这个算法了,其实直接用通用的版本也是可以的。
所以,没错,本文其实是安利xxHash的文章……
咳,换个例子。说CRC32。
抛开作弊一样的 Sse42.Crc32 方法(指令)不谈,CRC32是一个非常常见算法,用途多种多样,常规的实现方法,无论是查表(256*sizeof(uint32)
)还是直接计算,效率都慢的多。想要高速计算,效果最显著的就是扩大查找表,4*256*sizeof(uint32)
,8*256*sizeof(uint32)
,16*256*sizeof(uint32)
……https://docs.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.x86.bmi1
在我的测试中,比起xxhash,直到使用 8*256*sizeof(uint32)
(Slicing-by-8) 的查找表才没那么丢人,要想相差无几还是得从16*256*sizeof(uint32)
(Slicing-by-16) 开始,但这意味着你需要一个16kb的数组去记录这一切……
说回正题,从上面的字节反转就能看出,JIT对于查表操作的优化并不理想,而使用显式SIMD的写法可以使得计算效率有明显的提升,所以CRC算法还是值得搞一搞的……
另外,诸如 Bmi1 类 、Lzcnt 类、Popcnt 类 等成员提供了一些对于bit
的操作和统计,对于某些算法和数据结构的实现很有用,比如小波树……
稍作总结。
System.Runtime.Intrinsics 和 System.Runtime.Intrinsics.X86 两个命名空间更像是备胎,用于查漏补缺和锦上添花,在更多的情况下不用也无伤大雅。它的作用是在极度追求性能的场景下,显式而精确地使用高级指令集完成相关操作。
以上就是我对这两个命名空间的具体印象。