教你使用.NET快速比較兩個byte數(shù)組是否相等
前言
之前在群里面有群友問過一個這樣的問題,在.NET中如何快速的比較兩個byte數(shù)組是否完全相等,聽起來是一個比較兩個byte數(shù)組是完全相等是一個簡單的問題,但是深入研究以后,覺得還是有很多方案的,這里和大家一起分享下。
評測方案
這里為了評測不同方案的性能,我們用到了BenchmarkDotNet
這個庫,這個庫目前已經(jīng)被收入.NET基金會下,BenchmarkDotNet
可以很方便的評測方法執(zhí)行的性能,支持幾乎所有的.NET運行環(huán)境,并且能輸出詳細的報表。使用起來也非常簡單,你只需要安裝BenchmakrDotNet
的Nuget包,然后使用其提供的類和方法即可,這里是它的項目地址和幫助文檔。
我們通過BenchmarkDotNet來構(gòu)建一個這樣的評測用例.
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using CompareByte; // 需要引入BenchmarkDotNet的命名空間 // 運行Benchmark相當簡單,只需要執(zhí)行這個靜態(tài)方法,泛型是需要評測的類 var summary = BenchmarkRunner.Run<BenchmarkCompareMethod>(); // 我們需要一些評測內(nèi)存結(jié)果信息 // 并且生成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() { ..... } }
需要注意的是,為了保證評測的結(jié)果與生產(chǎn)環(huán)境一致,BenchmarkDotNet
是要求使用Release
模式運行程序,這樣的話不僅代碼編譯成IL
時優(yōu)化,程序運行中JIT
也會更加積極的參與生產(chǎn)機器碼優(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; }
最終的結(jié)果如下所示,我們可以看到其實使用for
循環(huán)進行比較是很快的,4MB大小的數(shù)組2ms左右就能比較完畢。
其實還有一個優(yōu)化點,.NET的JIT
對一些方法默認是不做inline
內(nèi)聯(lián)優(yōu)化的,這樣每次還有一個方法調(diào)用的開銷,我們讓jit
去積極的進行內(nèi)聯(lián),再來試試。方法也很簡單,只需要引入System.Runtime.CompilerServices
命名空間,然后在方法上面打上頭標記即可。
要搞清楚為什么方法內(nèi)聯(lián)有用,首先要知道當一個方法被調(diào)用的時候發(fā)生了什么
1、首先會有個執(zhí)行棧,存儲目前所有活躍的方法,以及它們的本地變量和參數(shù)2、當一個新的方法被調(diào)用了,一個新的棧幀會被加到棧頂,分配的本地變量和參數(shù)會存儲在這個棧幀3、跳到目標方法代碼執(zhí)行4、方法返回的時候,本地方法和參數(shù)會被銷毀,棧頂被移除5、返回原來地址執(zhí)行
這就是通常說的方法調(diào)用的壓棧和出棧過程,因此,方法調(diào)用需要有一定的時間開銷和空間開銷,當一個方法體不大,但又頻繁被調(diào)用時,這個時間和空間開銷會相對變得很大,變得非常不劃算,同時降低了程序的性能。所以內(nèi)聯(lián)簡單的說就是把目標方法里面代碼復制到調(diào)用方法的地方,無需壓棧、跳轉(zhuǎn)和出棧。
不過并不是所有的方法內(nèi)聯(lián)都有益處,需要方法體比較小,如果方法體很大的話在每一個調(diào)用的地方都會發(fā)生替換,浪費內(nèi)存。
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
內(nèi)。
int memcmp( const void *buffer1, // 數(shù)組1指針 const void *buffer2, // 數(shù)組2指針 size_t count // 比較字節(jié)數(shù) );
既然有現(xiàn)成的C語言代碼,那么C#應該如何調(diào)用它呢?實際上C#經(jīng)常被大家成為C++++是有一定道理的,它在設計之初就考慮了和C、C++等代碼的交互。這里使用到了C#的Native Interop - P/Invoke
技術(shù),可以很方便的使用C風格的ABI(C++、Rust等等都提供C語言ABI生成),在.NET底層大量的代碼都是通過這種方式和底層交互,有興趣的可以戳鏈接了解更詳細的信息。
那么如何使用它呢?以我們上面的函數(shù)為例,我們只需要引入System.Runtime.InteropServices
命名空間,然后按照上面memcmp
函數(shù)的簽名轉(zhuǎn)換為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); // 由于指針使用是內(nèi)存不安全的操作,所以需要使用unsafe關(guān)鍵字 // 項目文件中也要加入<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程序的運行中,垃圾回收器可能會整理和壓縮內(nèi)存,這樣會導致數(shù)組地址變動 // 所以,我們需要使用fixed關(guān)鍵字,將x和y數(shù)組'固定'在內(nèi)存中,讓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; } } }
那我們來跑個分吧,看看結(jié)果怎么樣。
結(jié)果真的是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)
,那么問題的關(guān)鍵就是如何降低它的時間復雜度。
一個數(shù)組它的地址空間是連續(xù)的,另外byte
類型的長度是8bit
,默認比較方式就像下圖一樣,一個元素一個元素的比較,也就是每8bit
每8bit
進行比較。
那我們能讓他一次比較更多的位數(shù)嗎?比如一次16位、32位、64位?當然是可以的,畢竟我們現(xiàn)在基本都是64位的CPU,不嚴謹?shù)恼f實際上CPU一次能處理64位數(shù)據(jù),那么我們?nèi)绾巫屗淮涡阅鼙容^64位呢?
有小伙伴就說,很簡單嘛,byte
是8bit
,我們直接用long
不就有64bit
了嗎?沒錯,就是這么簡單,我們可以把byte*
指針強轉(zhuǎn)為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++,代碼里面還能嵌入?yún)R編,只是有點麻煩,O(∩_∩)O哈哈~
SIMD
那么我們就這樣滿足了嗎?
小伙伴又問了,既然我們可以一次性比較64位,那我們能比較更多的位數(shù)嗎?比如128位,256位?答案是當然可以,這個是CPU的一個技術(shù),叫Single Instruction Multiple Data,簡稱為SIMD,SIMD主要就是說CPU中可以單條指令實現(xiàn)數(shù)據(jù)的并行處理,這類指令在數(shù)字信號處理、圖像處理、以及多媒體信息處理等領(lǐng)域非常有效。
我們打開CPU-Z,可以看到指令集有很多,這都是CPU為了特殊的程序單獨做的優(yōu)化。
MMX:MMX 是MultiMedia eXtensions(多媒體擴展)的縮寫,是第六代CPU芯片的重要特點。MMX技術(shù)是在CPU中加入了特地為視頻信號(Video Signal),音頻信號(Audio Signal)以及圖像處理(Graphical Manipulation)而設計的57條指令,因此,MMX CPU極大地提高了電腦的多媒體(如立體聲、視頻、三維動畫等)處理功能。
SSE:SSE是 “因特網(wǎng)數(shù)據(jù)流單指令序列擴展 ( Internet Streaming SIMD Extensions)的縮寫。SSE除保持原有的MMX指令外,又新增了70條指令,在加快浮點運算的同時,改善了內(nèi)存的使用效率,使內(nèi)存速度更快,后面有一些增強版SSE2、SSE3等等。
EM64T:Intel的EM64T技術(shù),EM64T技術(shù)官方全名是Extended Memory 64 Technology,中文解釋就是擴展64bit內(nèi)存技術(shù)。
AES:AES(Advanced Encryption Standard,高級加密標準)指令集,是專門為加密解密設計的,與此前相比AES加密/解密之性能高出3倍。
AVX:Advanced Vector eXtentions(AVX)在2008年由Intel與AMD提出,并于2011年分別在Sandy Bridge以及Bulldozer架構(gòu)上提供?持。AVX的主要改進在于對寄存器長度的擴展以及提供了更靈活的指令集。 AVX對 XMM 寄存器做了擴展,從原來的128-bit擴展到了256-bit,256-bit的寄存器命名為 YMM 。YMM的低128-bit是與XMM混? 的。
對于這些指令集,在.NET上提供了System.Runtime.Intrinsics.X86
命名空間,其中支持了各種指令集原生的訪問,想了解更多的東西,可以戳這個鏈接。由于SIMD在.NET上有著天然的支持,可以很方便的寫出SIMD代碼,而其它編程語言平臺或多或少支持都不是很完美。
類名 | 作用 |
---|---|
Aes | 此類通過內(nèi)部函數(shù)提供對 Intel AES 硬件指令的訪問權(quán)限。 |
Avx | 該類通過內(nèi)聯(lián)函數(shù)提供對 Intel AVX 硬件指令的訪問權(quán)限。 |
Avx2 | 此類通過內(nèi)部函數(shù)提供對 Intel AVX2 硬件指令的訪問。 |
Bmi1 | 此類通過內(nèi)部函數(shù)提供對 Intel BMI1 硬件指令的訪問權(quán)限。 |
Bmi2 | 此類通過內(nèi)部函數(shù)提供對 Intel BMI2 硬件指令的訪問權(quán)限。 |
Fma | 此類通過內(nèi)部函數(shù)提供對 Intel FMA 硬件指令的訪問權(quán)限。 |
Lzcnt | 此類通過內(nèi)部函數(shù)提供對 Intel LZCNT 硬件指令的訪問權(quán)限。 |
Pclmulqdq | 此類通過內(nèi)部函數(shù)提供對 Intel PCLMULQDQ 硬件指令的訪問權(quán)限。 |
Popcnt | 此類通過內(nèi)部函數(shù)提供對 Intel POPCNT 硬件指令的訪問權(quán)限。 |
Sse | 此類通過內(nèi)部函數(shù)提供對 Intel SSE 硬件指令的訪問權(quán)限。 |
Sse2 | 此類通過內(nèi)部函數(shù)提供對 Intel SSE2 硬件指令的訪問權(quán)限。 |
Sse3 | 此類通過內(nèi)部函數(shù)提供對 Intel SSE3 硬件指令的訪問權(quán)限。 |
Sse41 | 此類通過內(nèi)部函數(shù)提供對 Intel SSE 4.1 硬件指令的訪問。 |
Sse42 | 此類通過內(nèi)部函數(shù)提供對 Intel SSE4.2 硬件指令的訪問權(quán)限。 |
Ssse3 | 此類通過內(nèi)部函數(shù)提供對 Intel SSSE3 硬件指令的訪問權(quán)限。 |
X86Base | 通過內(nèi)部函數(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的結(jié)果是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代碼,可以明顯的看到匯編代碼里面已經(jīng)有了SIMD代碼。
來看看跑分結(jié)果。
可以看到對比memcmp
的方式快了2+%,那按道理來說從64位到128位應該快50%左右才對,為什么只快了2+%呢?
其實這是因為SIMD是單條指令多數(shù)據(jù)處理,其中運算還是CPU內(nèi)部的64位單元處理,只是少了多條指令的開銷。另外是因為原本64位是只比較了一次,而SIMD需要經(jīng)歷CompareEqual
、MoveMask
最后還需和mask
掩碼比較,總共次數(shù)多了2次。只能說明在我們的這個場景下,提升會比較有限。
需要注意目標平臺需要能支持這些特殊的指令集,可以通過Sse2.IsSupported
方法來判斷。
Avx2
既然128位的SSE系列指令集能在原來的基礎(chǔ)上提升2%,那我們來看看支持256位的Avx2指令集會提升多少。代碼和SSE指令集幾乎一樣,只是調(diào)用的方法類名變動了。
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; }
再來看看跑分結(jié)果。
可以看到,Avx2指令集對于memcmp
和Sse2
是有一定的提升的,有2+%左右的速度提升,另外相較于原本的for
循環(huán)比較提升了86%。
SequenceCompare
那么是不是以后我們寫比較兩個數(shù)組相等的代碼都要寫這一長串的unsafe代碼呢?其實并不是,在.NET Core時代引入了Span這個特性,這個特性就是為了能安全的直接操作內(nèi)存;與此同時,也提供了SequenceEquals
方法,能快速的比較兩個序列,使用也非常簡單,那究竟性能怎么樣呢?我們上代碼,跑個分。
// 代碼非常簡單,只需要調(diào)用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); }
結(jié)果也是相當不錯的,比memcmp
和SSE2的方式都要快一點,略遜于Avx2,但是它用起來很簡單,那么它是如何做到這么快的呢?讓我們看看它的源碼,
鏈接貌似也沒有什么技巧,那是不是JIT編譯的時候有優(yōu)化,給自動向量化了呢?我們將代碼復制出來,然后單獨跑了一下,再用WinDBG打開,我們可以看到確實JIT優(yōu)化引入了一些自動向量化(SIMD)的操作。
總結(jié)
通過這幾種方案的對比,最推薦的用法當然就是直接使用.NET庫提供的SequenceEquals
方法來完成比較,如果是在.NET Framework中,由于沒有這樣的優(yōu)化,所以大家也可以嘗試上文中提到的SSE2等方法。
那么大家還有什么其它好的方式呢?歡迎在評論區(qū)留言!
筆者水平有限,如有錯漏請批評指正 :)
本文源碼鏈接
參考文獻
Checking equality for two byte arrays
到此這篇關(guān)于.NET如何快速比較兩個byte數(shù)組是否相等的文章就介紹到這了,更多相關(guān).NET比較兩個byte數(shù)組是否相等內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
.Net使用Cancellation?Framework取消并行任務
這篇文章介紹了.Net使用Cancellation?Framework取消并行任務的方法,文中通過示例代碼介紹的非常詳細。對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-06-06asp.net core為IHttpClientFactory添加動態(tài)命名配置
某些時候我們需要為HttpClient動態(tài)配置一些東西, 例如證書等, 例如服務是一個回調(diào)服務, 而被回調(diào)方采用了自定義的https(即自定義證書),本文就將講述如何實現(xiàn)這種需求2021-06-06ASP.NET中的跳轉(zhuǎn) 200, 301, 302轉(zhuǎn)向?qū)崿F(xiàn)代碼
跳轉(zhuǎn)非常常用,在哪里都一樣,這里的一些說明和用法也如此,不止適用于asp.net,其他語言也會用得到。跳轉(zhuǎn)的目的本來很簡單,就是當用戶或系統(tǒng)需要時從一個頁面轉(zhuǎn)向另一個頁面,但自從有了各種各樣的需求,還有那個什么SEO的東西之后,跳轉(zhuǎn)被搞得極其復雜2008-09-09