教你使用.NET快速比較兩個byte數(shù)組是否相等
前言
之前在群里面有群友問過一個這樣的問題,在.NET中如何快速的比較兩個byte數(shù)組是否完全相等,聽起來是一個比較兩個byte數(shù)組是完全相等是一個簡單的問題,但是深入研究以后,覺得還是有很多方案的,這里和大家一起分享下。
評測方案
這里為了評測不同方案的性能,我們用到了BenchmarkDotNet這個庫,這個庫目前已經被收入.NET基金會下,BenchmarkDotNet可以很方便的評測方法執(zhí)行的性能,支持幾乎所有的.NET運行環(huán)境,并且能輸出詳細的報表。使用起來也非常簡單,你只需要安裝BenchmakrDotNet的Nuget包,然后使用其提供的類和方法即可,這里是它的項目地址和幫助文檔。
我們通過BenchmarkDotNet來構建一個這樣的評測用例.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using CompareByte;
// 需要引入BenchmarkDotNet的命名空間
// 運行Benchmark相當簡單,只需要執(zhí)行這個靜態(tài)方法,泛型是需要評測的類
var summary = BenchmarkRunner.Run<BenchmarkCompareMethod>();
// 我們需要一些評測內存結果信息
// 并且生成HTML報表
[MemoryDiagnoser]
[HtmlExporter]
public class BenchmarkCompareMethod
{
// 準備兩個數(shù)組,填充4MB大小的數(shù)據(jù)
private static readonly byte[] XBytes = Enumerable.Range(0, 4096000).Select(c => (byte) c).ToArray();
private static readonly byte[] YBytes = Enumerable.Range(0, 4096000).Select(c => (byte) c).ToArray();
public BenchmarkCompareMethod()
{
// 修改數(shù)組最后一個元素,使其不同
XBytes[4095999] = 1;
YBytes[4095999] = 2;
}
[Benchmark(Baseline = true)]
public void ForCompare()
{
.....
}
}
需要注意的是,為了保證評測的結果與生產環(huán)境一致,BenchmarkDotNet是要求使用Release模式運行程序,這樣的話不僅代碼編譯成IL時優(yōu)化,程序運行中JIT也會更加積極的參與生產機器碼優(yōu)化。需要在項目文件夾下面使用dotnet run -c Release來運行評測。
幾種不同的方案
For循環(huán)
一開始看到這個需求,第一個想到的就是直接使用for循環(huán)對byte[]進行按下標比較,我覺得也是大家第一時間能想到的方案,那我們就上代碼跑跑看速度。
public static bool ForCompare(byte[]? x, byte[]? y)
{
if (ReferenceEquals(x, y)) return true; // 引用相等,可以直接認為相等
if (x is null || y is null) return false; // 兩者引用不相等情況下,一方為null那就不相等
if (x.Length != y.Length) return false; // 兩者長度不等,那么肯定也不相等
for (var index = 0; index < x.Length; index++)
{
if (x[index] != y[index]) return false;
}
return true;
}
最終的結果如下所示,我們可以看到其實使用for循環(huán)進行比較是很快的,4MB大小的數(shù)組2ms左右就能比較完畢。

其實還有一個優(yōu)化點,.NET的JIT對一些方法默認是不做inline內聯(lián)優(yōu)化的,這樣每次還有一個方法調用的開銷,我們讓jit去積極的進行內聯(lián),再來試試。方法也很簡單,只需要引入System.Runtime.CompilerServices命名空間,然后在方法上面打上頭標記即可。
要搞清楚為什么方法內聯(lián)有用,首先要知道當一個方法被調用的時候發(fā)生了什么
1、首先會有個執(zhí)行棧,存儲目前所有活躍的方法,以及它們的本地變量和參數(shù)2、當一個新的方法被調用了,一個新的棧幀會被加到棧頂,分配的本地變量和參數(shù)會存儲在這個棧幀3、跳到目標方法代碼執(zhí)行4、方法返回的時候,本地方法和參數(shù)會被銷毀,棧頂被移除5、返回原來地址執(zhí)行
這就是通常說的方法調用的壓棧和出棧過程,因此,方法調用需要有一定的時間開銷和空間開銷,當一個方法體不大,但又頻繁被調用時,這個時間和空間開銷會相對變得很大,變得非常不劃算,同時降低了程序的性能。所以內聯(lián)簡單的說就是把目標方法里面代碼復制到調用方法的地方,無需壓棧、跳轉和出棧。
不過并不是所有的方法內聯(lián)都有益處,需要方法體比較小,如果方法體很大的話在每一個調用的地方都會發(fā)生替換,浪費內存。
using System.Runtime.CompilerServices; ..... [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public static bool ForCompare(byte[]? x, byte[]? y)
再來跑一下試試。

最后可以看到性能提升了30%呀,分配的字節(jié)數(shù)少了50% (雖然本來就只有2字節(jié)),講道理就可以直接交差了。
Memcmp
但是群里面還有小伙伴就不滿足了,有沒有其它的方案?有個小伙伴就跳出來說,操作系統(tǒng)是不是提供了類似的功能?會不會使用C/C++代碼運行起來會更加快速?
沒錯,操作系統(tǒng)確實提供了這樣的函數(shù),微軟提供了一個名為mscrt(微軟C運行時庫)的庫,里面就提到了memcmp這個函數(shù)就可以來比較兩個buffer是否相等。MSDN鏈接.
函數(shù)簽名是這樣的,這個函數(shù)位于mscrt.dll內。
int memcmp( const void *buffer1, // 數(shù)組1指針 const void *buffer2, // 數(shù)組2指針 size_t count // 比較字節(jié)數(shù) );
既然有現(xiàn)成的C語言代碼,那么C#應該如何調用它呢?實際上C#經常被大家成為C++++是有一定道理的,它在設計之初就考慮了和C、C++等代碼的交互。這里使用到了C#的Native Interop - P/Invoke技術,可以很方便的使用C風格的ABI(C++、Rust等等都提供C語言ABI生成),在.NET底層大量的代碼都是通過這種方式和底層交互,有興趣的可以戳鏈接了解更詳細的信息。
那么如何使用它呢?以我們上面的函數(shù)為例,我們只需要引入System.Runtime.InteropServices命名空間,然后按照上面memcmp函數(shù)的簽名轉換為C#代碼就行了,最終的代碼如下所示。
using System;
using System.Runtime.InteropServices;
namespace CompareByte;
public static class BytesCompare
{
[DllImport("msvcrt.dll")] // 需要使用的dll名稱
private static extern unsafe int memcmp(byte* b1, byte* b2, int count);
// 由于指針使用是內存不安全的操作,所以需要使用unsafe關鍵字
// 項目文件中也要加入<AllowUnsafeBlocks>true</AllowUnsafeBlocks>來允許unsafe代碼
public static unsafe bool MemcmpCompare(byte[]? x,byte[]? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
if (x.Length != y.Length) return false;
// 在.NET程序的運行中,垃圾回收器可能會整理和壓縮內存,這樣會導致數(shù)組地址變動
// 所以,我們需要使用fixed關鍵字,將x和y數(shù)組'固定'在內存中,讓GC不移動它
// 更多詳情請看 https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/fixed-statement
fixed (byte* xPtr = x, yPtr = y)
{
return memcmp(xPtr, yPtr, x.Length) == 0;
}
}
}
那我們來跑個分吧,看看結果怎么樣。

結果真的是Amazing呀,比我們使用的for循環(huán)方案足足快了80+%,從原來需要1.7ms左右到現(xiàn)在只需要300us。
64字長優(yōu)化
那是不是證明C#就是沒有C跑的那么快呢?C#那還有沒有優(yōu)化的空間呢?當然是有方法的,實際上memcmp使用的算法和我們現(xiàn)在用的不一樣。
我們知道衡量算法的時間復雜度是使用大O來表示,而這個其實是代碼執(zhí)行時間隨數(shù)據(jù)規(guī)模增長的變化趨勢的一個體現(xiàn)。比如我輸入的數(shù)據(jù)量大小為n,完成這個函數(shù)我近似需要執(zhí)行n次,那么時間復雜度就是O(n)。
再來回到我們的問題中,在最壞的情況下(x和y引用不相等且的長度相等),我們上面寫的ForCompare就會進入for循環(huán)來遍歷x和y每一個元素進行比較,所以它的時間復雜度就是O(n),那么問題的關鍵就是如何降低它的時間復雜度。
一個數(shù)組它的地址空間是連續(xù)的,另外byte類型的長度是8bit,默認比較方式就像下圖一樣,一個元素一個元素的比較,也就是每8bit每8bit進行比較。

那我們能讓他一次比較更多的位數(shù)嗎?比如一次16位、32位、64位?當然是可以的,畢竟我們現(xiàn)在基本都是64位的CPU,不嚴謹?shù)恼f實際上CPU一次能處理64位數(shù)據(jù),那么我們如何讓它一次性能比較64位呢?
有小伙伴就說,很簡單嘛,byte是8bit,我們直接用long不就有64bit了嗎?沒錯,就是這么簡單,我們可以把byte*指針強轉為long*指針,然后一次性比較64位,如下圖所示。

上代碼(我這用的是UInt64包裝的ulong,一樣是64位,沒有符號位會更快一點):
public static unsafe bool UlongCompare(byte[]? x, byte[]? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
if (x.Length != y.Length) return false;
fixed (byte* xPtr = x, yPtr = y)
{
return UlongCompareInternal(xPtr, yPtr, x.Length);
}
}
private static unsafe bool UlongCompareInternal(byte* xPtr, byte* yPtr, int length)
{
// 指針+偏移量計算出數(shù)組最后一個元素地址
byte* lastAddr = xPtr + length;
byte* lastAddrMinus32 = lastAddr - 32;
while (xPtr < lastAddrMinus32) // 我們一次循環(huán)比較32字節(jié),也就是256位
{
// 一次判斷比較前64位
if (*(ulong*) xPtr != *(ulong*) yPtr) return false;
// 第二次從64為開始,比較接下來的64位,需要指針偏移64位,一個byte指針是8為,所以需要偏移8個位置才能到下一輪起始位置
// 所以代碼就是xPtr+8
if (*(ulong*) (xPtr + 8) != *(ulong*) (yPtr + 8)) return false;
// 同上面一樣,第三次從第128位開始比較64位
if (*(ulong*) (xPtr + 16) != *(ulong*) (yPtr + 16)) return false;
// 第四次從第192位開始比較64位
if (*(ulong*) (xPtr + 24) != *(ulong*) (yPtr + 24)) return false;
// 一輪總共比較了256位,讓指針偏移256位
xPtr += 32;
yPtr += 32;
}
// 因為上面是一次性比較32字節(jié)(256位),可能數(shù)組不能為32整除,最后只留下比如30字節(jié),20字節(jié)
// 最后的幾個字節(jié),我們用循環(huán)來逐字節(jié)比較
while (xPtr < lastAddr)
{
if (*xPtr != *yPtr) return false;
xPtr++;
yPtr++;
}
return true;
}
那我們來跑個分吧。

可以看到基本和memcmp打平了,幾us的差別可以看做是誤差。大佬們一直說,C#是一門下限低,上限高的語言,你開心的話寫出來的代碼完全媲美C++,代碼里面還能嵌入匯編,只是有點麻煩,O(∩_∩)O哈哈~
SIMD
那么我們就這樣滿足了嗎?
小伙伴又問了,既然我們可以一次性比較64位,那我們能比較更多的位數(shù)嗎?比如128位,256位?答案是當然可以,這個是CPU的一個技術,叫Single Instruction Multiple Data,簡稱為SIMD,SIMD主要就是說CPU中可以單條指令實現(xiàn)數(shù)據(jù)的并行處理,這類指令在數(shù)字信號處理、圖像處理、以及多媒體信息處理等領域非常有效。
我們打開CPU-Z,可以看到指令集有很多,這都是CPU為了特殊的程序單獨做的優(yōu)化。

MMX:MMX 是MultiMedia eXtensions(多媒體擴展)的縮寫,是第六代CPU芯片的重要特點。MMX技術是在CPU中加入了特地為視頻信號(Video Signal),音頻信號(Audio Signal)以及圖像處理(Graphical Manipulation)而設計的57條指令,因此,MMX CPU極大地提高了電腦的多媒體(如立體聲、視頻、三維動畫等)處理功能。
SSE:SSE是 “因特網數(shù)據(jù)流單指令序列擴展 ( Internet Streaming SIMD Extensions)的縮寫。SSE除保持原有的MMX指令外,又新增了70條指令,在加快浮點運算的同時,改善了內存的使用效率,使內存速度更快,后面有一些增強版SSE2、SSE3等等。
EM64T:Intel的EM64T技術,EM64T技術官方全名是Extended Memory 64 Technology,中文解釋就是擴展64bit內存技術。
AES:AES(Advanced Encryption Standard,高級加密標準)指令集,是專門為加密解密設計的,與此前相比AES加密/解密之性能高出3倍。
AVX:Advanced Vector eXtentions(AVX)在2008年由Intel與AMD提出,并于2011年分別在Sandy Bridge以及Bulldozer架構上提供?持。AVX的主要改進在于對寄存器長度的擴展以及提供了更靈活的指令集。 AVX對 XMM 寄存器做了擴展,從原來的128-bit擴展到了256-bit,256-bit的寄存器命名為 YMM 。YMM的低128-bit是與XMM混? 的。
對于這些指令集,在.NET上提供了System.Runtime.Intrinsics.X86命名空間,其中支持了各種指令集原生的訪問,想了解更多的東西,可以戳這個鏈接。由于SIMD在.NET上有著天然的支持,可以很方便的寫出SIMD代碼,而其它編程語言平臺或多或少支持都不是很完美。
| 類名 | 作用 |
|---|---|
| Aes | 此類通過內部函數(shù)提供對 Intel AES 硬件指令的訪問權限。 |
| Avx | 該類通過內聯(lián)函數(shù)提供對 Intel AVX 硬件指令的訪問權限。 |
| Avx2 | 此類通過內部函數(shù)提供對 Intel AVX2 硬件指令的訪問。 |
| Bmi1 | 此類通過內部函數(shù)提供對 Intel BMI1 硬件指令的訪問權限。 |
| Bmi2 | 此類通過內部函數(shù)提供對 Intel BMI2 硬件指令的訪問權限。 |
| Fma | 此類通過內部函數(shù)提供對 Intel FMA 硬件指令的訪問權限。 |
| Lzcnt | 此類通過內部函數(shù)提供對 Intel LZCNT 硬件指令的訪問權限。 |
| Pclmulqdq | 此類通過內部函數(shù)提供對 Intel PCLMULQDQ 硬件指令的訪問權限。 |
| Popcnt | 此類通過內部函數(shù)提供對 Intel POPCNT 硬件指令的訪問權限。 |
| Sse | 此類通過內部函數(shù)提供對 Intel SSE 硬件指令的訪問權限。 |
| Sse2 | 此類通過內部函數(shù)提供對 Intel SSE2 硬件指令的訪問權限。 |
| Sse3 | 此類通過內部函數(shù)提供對 Intel SSE3 硬件指令的訪問權限。 |
| Sse41 | 此類通過內部函數(shù)提供對 Intel SSE 4.1 硬件指令的訪問。 |
| Sse42 | 此類通過內部函數(shù)提供對 Intel SSE4.2 硬件指令的訪問權限。 |
| Ssse3 | 此類通過內部函數(shù)提供對 Intel SSSE3 硬件指令的訪問權限。 |
| X86Base | 通過內部函數(shù)提供對 x86 基本硬件指令的訪問。 |
Sse
我們看到SSE系列的指令集可以操作128位,那我們就來試試128位會不會更快一些,直接上代碼。
using System.Runtime.Intrinsics.X86; // 需要引入這個命名空間
namespace CompareByte;
public static class BytesCompare
{
......
public static unsafe bool Sse2Compare(byte[]? x, byte[]? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
if (x.Length != y.Length) return false;
fixed (byte* xPtr = x, yPtr = y)
{
return Sse2CompareInternal(xPtr, yPtr, x.Length);
}
}
private static unsafe bool Sse2CompareInternal(byte* xPtr, byte* yPtr, int length)
// 這里的算法與64位大體一樣,只是位數(shù)變成了128位
byte* lastAddr = xPtr + length;
byte* lastAddrMinus64 = lastAddr - 64;
const int mask = 0xFFFF;
while (xPtr < lastAddrMinus64)
// 使用Sse2.LoadVector128()各加載x和y的128位數(shù)據(jù)
// 再使用Sse2.CompareEqual()比較是否相等,它的返回值是一個128位向量,如果相等,該位置返回0xffff,否則返回0x0
// CompareEqual的結果是128位的,我們可以通過Sse2.MoveMask()來重新排列成32位,最終看是否等于0xffff就好
if (Sse2.MoveMask(Sse2.CompareEqual(Sse2.LoadVector128(xPtr), Sse2.LoadVector128(yPtr))) != mask)
{
return false;
}
if (Sse2.MoveMask(Sse2.CompareEqual(Sse2.LoadVector128(xPtr + 16), Sse2.LoadVector128(yPtr + 16))) != mask)
if (Sse2.MoveMask(Sse2.CompareEqual(Sse2.LoadVector128(xPtr + 32), Sse2.LoadVector128(yPtr + 32))) != mask)
if (Sse2.MoveMask(Sse2.CompareEqual(Sse2.LoadVector128(xPtr + 48), Sse2.LoadVector128(yPtr + 48))) != mask)
xPtr += 64;
yPtr += 64;
while (xPtr < lastAddr)
if (*xPtr != *yPtr) return false;
xPtr++;
yPtr++;
return true;
}
放到JIT里面看看,有沒有生成SIMD代碼,可以明顯的看到匯編代碼里面已經有了SIMD代碼。

來看看跑分結果。

可以看到對比memcmp的方式快了2+%,那按道理來說從64位到128位應該快50%左右才對,為什么只快了2+%呢?
其實這是因為SIMD是單條指令多數(shù)據(jù)處理,其中運算還是CPU內部的64位單元處理,只是少了多條指令的開銷。另外是因為原本64位是只比較了一次,而SIMD需要經歷CompareEqual、MoveMask最后還需和mask掩碼比較,總共次數(shù)多了2次。只能說明在我們的這個場景下,提升會比較有限。
需要注意目標平臺需要能支持這些特殊的指令集,可以通過Sse2.IsSupported方法來判斷。
Avx2
既然128位的SSE系列指令集能在原來的基礎上提升2%,那我們來看看支持256位的Avx2指令集會提升多少。代碼和SSE指令集幾乎一樣,只是調用的方法類名變動了。
using System.Runtime.Intrinsics.X86; // 需要引入這個命名空間
namespace CompareByte;
public static class BytesCompare
{
......
public static unsafe bool Avx2Compare(byte[]? x, byte[]? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
if (x.Length != y.Length) return false;
fixed (byte* xPtr = x, yPtr = y)
{
return Avx2CompareInternal(xPtr, yPtr, x.Length);
}
}
private static unsafe bool Avx2CompareInternal(byte* xPtr, byte* yPtr, int length)
byte* lastAddr = xPtr + length;
byte* lastAddrMinus128 = lastAddr - 128;
const int mask = -1;
while (xPtr < lastAddrMinus128)
// 更換為Avx2指令集,一次加載256位
if (Avx2.MoveMask(Avx2.CompareEqual(Avx.LoadVector256(xPtr), Avx.LoadVector256(yPtr))) != mask)
{
return false;
}
if (Avx2.MoveMask(Avx2.CompareEqual(Avx.LoadVector256(xPtr + 32), Avx.LoadVector256(yPtr + 32))) != mask)
if (Avx2.MoveMask(Avx2.CompareEqual(Avx.LoadVector256(xPtr + 64), Avx.LoadVector256(yPtr + 64))) != mask)
if (Avx2.MoveMask(Avx2.CompareEqual(Avx.LoadVector256(xPtr + 96), Avx.LoadVector256(yPtr + 96))) != mask)
xPtr += 128;
yPtr += 128;
while (xPtr < lastAddr)
if (*xPtr != *yPtr) return false;
xPtr++;
yPtr++;
return true;
}
再來看看跑分結果。

可以看到,Avx2指令集對于memcmp和Sse2是有一定的提升的,有2+%左右的速度提升,另外相較于原本的for循環(huán)比較提升了86%。
SequenceCompare
那么是不是以后我們寫比較兩個數(shù)組相等的代碼都要寫這一長串的unsafe代碼呢?其實并不是,在.NET Core時代引入了Span這個特性,這個特性就是為了能安全的直接操作內存;與此同時,也提供了SequenceEquals方法,能快速的比較兩個序列,使用也非常簡單,那究竟性能怎么樣呢?我們上代碼,跑個分。
// 代碼非常簡單,只需要調用System.Linq.SequenceEqual方法即可
public static bool SequenceCompare(byte[]? x, byte[]? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
if (x.Length != y.Length) return false;
return x.SequenceEqual(y);
}

結果也是相當不錯的,比memcmp和SSE2的方式都要快一點,略遜于Avx2,但是它用起來很簡單,那么它是如何做到這么快的呢?讓我們看看它的源碼,
鏈接貌似也沒有什么技巧,那是不是JIT編譯的時候有優(yōu)化,給自動向量化了呢?我們將代碼復制出來,然后單獨跑了一下,再用WinDBG打開,我們可以看到確實JIT優(yōu)化引入了一些自動向量化(SIMD)的操作。

總結
通過這幾種方案的對比,最推薦的用法當然就是直接使用.NET庫提供的SequenceEquals方法來完成比較,如果是在.NET Framework中,由于沒有這樣的優(yōu)化,所以大家也可以嘗試上文中提到的SSE2等方法。
那么大家還有什么其它好的方式呢?歡迎在評論區(qū)留言!
筆者水平有限,如有錯漏請批評指正 :)
本文源碼鏈接
參考文獻
Checking equality for two byte arrays
到此這篇關于.NET如何快速比較兩個byte數(shù)組是否相等的文章就介紹到這了,更多相關.NET比較兩個byte數(shù)組是否相等內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
.Net使用Cancellation?Framework取消并行任務
這篇文章介紹了.Net使用Cancellation?Framework取消并行任務的方法,文中通過示例代碼介紹的非常詳細。對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-06-06
asp.net core為IHttpClientFactory添加動態(tài)命名配置
某些時候我們需要為HttpClient動態(tài)配置一些東西, 例如證書等, 例如服務是一個回調服務, 而被回調方采用了自定義的https(即自定義證書),本文就將講述如何實現(xiàn)這種需求2021-06-06
ASP.NET中的跳轉 200, 301, 302轉向實現(xiàn)代碼
跳轉非常常用,在哪里都一樣,這里的一些說明和用法也如此,不止適用于asp.net,其他語言也會用得到。跳轉的目的本來很簡單,就是當用戶或系統(tǒng)需要時從一個頁面轉向另一個頁面,但自從有了各種各樣的需求,還有那個什么SEO的東西之后,跳轉被搞得極其復雜2008-09-09

