简介
在之前的文章中,我们多次提到 Vector - SIMD 技术,也答应大家在后面分享更多.NET7 中优化的例子,今天就带来一个使用 SIMD 优化Guid.Equals()
方法性能的例子。
为什么 Guid 能使用 SIMD 优化?
首先就需要介绍一些背景知识,那就是Guid
它是什么,在我们人类眼中,Guid
就是一串字符串,如下方所示的那样。
"D313CD46-2724-7359-84A0-9E73C861CCD2"
而在定义中,全局唯一标识符(GUID,Globally Unique Identifier)是一种由算法生成的二进制长度为128 位的数字标识符。GUID 主要用于在拥有多个节点、多台计算机的网络或系统中。在理想情况下,任何计算机和计算机集群都不会生成两个相同的 GUID。GUID 的总数达到了 2^128(3.4×10^38)个,所以随机生成两个相同 GUID 的可能性非常小,但并不为 0。GUID 一词有时也专指微软对 UUID 标准的实现。
大家可以看到我着重标记了它的位数是128 位,128 位意味着什么?就是如果比较两个 Guid 是否相等的话,不管是 64 位 CPU 还是 32 位的 CPU 需要多条指令比较多次。如果我们用上了 Vector?是不是会有更好的性能呢?
首先我们来看看 Guid 是如何定义的,看看能不能直接读取 128 位数据,从而用上 Vector。Guid 它是值类型的,是一个结构体。代码如下所示,我省略了部分信息。
代码语言:javascript复制 public readonly partial struct Guid
{
...
private readonly int _a; // Do not rename (binary serialization)
private readonly short _b; // Do not rename (binary serialization)
private readonly short _c; // Do not rename (binary serialization)
private readonly byte _d; // Do not rename (binary serialization)
private readonly byte _e; // Do not rename (binary serialization)
private readonly byte _f; // Do not rename (binary serialization)
private readonly byte _g; // Do not rename (binary serialization)
private readonly byte _h; // Do not rename (binary serialization)
private readonly byte _i; // Do not rename (binary serialization)
private readonly byte _j; // Do not rename (binary serialization)
private readonly byte _k; // Do not rename (binary serialization)
...
}
可以看到它由 1 个 32 位 int,2 个 16 位的 short 和 8 个 8 位的 byte 组成,至于为什么需要这样组成,其实是一个标准化的东西,为了在生成和序列化时更快。
我们使用ObjectLayoutInspector
可以打印出 Guid 的数据结构,数据结果如下图所示,和我们源码里面看到的一致:
那么 Guid 是否能使用 SIMD 优化的结论显而易见:
- Guid 有 128 位,现在 CPU 都是 64 位或者 32 位,还存在提升空间
- Guid 是结构体类型,结构体类型在内存中是连续存储,我们可以直接读取内存来访问整个结构体
SIMD 优化代码
根据我们前面文章中,Min 和 Max 方法在.NET7 被优化的经验,我们可以直接写下面这样的代码。
代码语言:javascript复制[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool EqualsCore(in Guid left, in Guid right)
{
// 检测硬件是否支持Vector128
if (Vector128.IsHardwareAccelerated)
{
// 支持Vector128就好办了,直接加载比较
return Vector128.LoadUnsafe(ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(in left))) == Vector128.LoadUnsafe(ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(in right)));
}
// 如果不支持,那么从Guid头部读取内存
// 32位比较四次
ref int rA = ref Unsafe.AsRef(in left._a);
ref int rB = ref Unsafe.AsRef(in right._a);
return rA == rB
&& Unsafe.Add(ref rA, 1) == Unsafe.Add(ref rB, 1)
&& Unsafe.Add(ref rA, 2) == Unsafe.Add(ref rB, 2)
&& Unsafe.Add(ref rA, 3) == Unsafe.Add(ref rB, 3);
}
在上面的代码中,我们可以看到不仅提供了 Vector 加速的方案,还有不支持回退的场景。不过那段 Vector 代码是不是不太好理解?我们逐个部分来解析一下。我们首先看左右的部分,右边也是同样的意思Vector128.LoadUnsafe(ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(in left)))
。
ref Unsafe.AsRef(in left)
是获取 left Guid 它的首地址指针,此时返回的其实是Guid*
ref Unsafe.As<Guid, byte>(...)
将Guid*
指针转换为byte*
指针Vector128.LoadUnsafe(...)
由于 Guid 已经变为 Byte 指针,所以就能直接 LoadUnsafe 了
最后 right Guid 也使用相同的方式加载,最后使用==
比较两个Vector
是否相等就好了。其实==
还使用了CompareEqual
和MoveMask
两个指令,只是在.NET7 中 JIT 会把两个向量的比较给优化。看下方图片中红色框标记的部分,就是这两个指令。
那么.NET6 下==
没有优化,那该怎么办呢?根据这里的汇编指令,Meziantou[1]大佬给出了.NET6 下同样功效的优化代码:
static class GuidExtensions
{
public static bool OptimizedGuidEquals(in Guid left, in Guid right)
{
if (Sse2.IsSupported)
{
Vector128<byte> leftVector = Unsafe.ReadUnaligned<Vector128<byte>>(
ref Unsafe.As<Guid, byte>(
ref Unsafe.AsRef(in left)));
Vector128<byte> rightVector = Unsafe.ReadUnaligned<Vector128<byte>>(
ref Unsafe.As<Guid, byte>(
ref Unsafe.AsRef(in right)));
// 使用Sse2.CompareEqual()比较是否相等,它的返回值是一个128位向量,如果相等,该位置返回0xffff,否则返回0x0
// CompareEqual的结果是128位的,我们可以通过Sse2.MoveMask()来重新排列成16位,最终看是否等于0xffff就好
var equals = Sse2.CompareEqual(leftVector, rightVector);
var result = Sse2.MoveMask(equals);
return (result & 0xFFFF) == 0xFFFF;
}
return left == right;
}
}
从下图的汇编代码中,可以看到是一样的效果:
总结
最终这一波操作下来,我们可以看到Guid.Equals
的性能提升了 30%。如果你的程序中使用 Guid 作为数据库、对象主键的,只需要升级.NET7 或者用上面的GuidExtensions
就能获得这样的性能提升。
参考资料
[1]
Meziantou: https://www.meziantou.net/faster-guid-comparisons-using-vectors-simd-in-dotnet.htm