.NET使用結(jié)構(gòu)體替代類提升性能優(yōu)化的技巧
前言
我們知道在C#和Java明顯的一個(gè)區(qū)別就是C#可以自定義值類型,也就是今天的主角struct
,我們有了更加方便的class
為什么微軟還加入了struct
呢?這其實(shí)就是今天要談到的一個(gè)優(yōu)化性能的Tips使用結(jié)構(gòu)體替代類。
那么使用結(jié)構(gòu)體替代類有什么好處呢?在什么樣的場(chǎng)景需要使用結(jié)構(gòu)體來(lái)替代類呢?今天的文章為大家一一解答。
注意:本文全部都以x64位平臺(tái)為例
現(xiàn)實(shí)的案例
舉一個(gè)現(xiàn)實(shí)系統(tǒng)的例子,大家都知道機(jī)票購(gòu)票的流程,開(kāi)始選擇起抵城市和機(jī)場(chǎng)(這是航線),然后根據(jù)自己的需要日期和時(shí)間,挑一個(gè)自己喜歡的航班和艙位,然后付款。
內(nèi)存占用
那么全國(guó)大約49航司,8000多個(gè)航線,平均每個(gè)航線有20個(gè)航班,每個(gè)航班平均有10組艙位價(jià)格(經(jīng)濟(jì)艙、頭等還有不同的折扣權(quán)益),一般OTA(Online Travel Agency:在線旅游平臺(tái))允許預(yù)訂一年內(nèi)的機(jī)票。也就是說(shuō)平臺(tái)可能有8000*20*10*365=~5億
的價(jià)格數(shù)據(jù)(以上數(shù)據(jù)均來(lái)源網(wǎng)絡(luò),實(shí)際中的數(shù)據(jù)量不方便透露)。
OTA平臺(tái)為了能讓你更快的搜索想要的航班,會(huì)將熱門(mén)的航線價(jià)格數(shù)據(jù)從數(shù)據(jù)庫(kù)拿出來(lái)緩存在內(nèi)存中(內(nèi)存比單獨(dú)網(wǎng)絡(luò)和磁盤(pán)傳輸快的多得多,詳情見(jiàn)下圖),就取20%也大約有1億數(shù)據(jù)在內(nèi)存中。
操作 | 速度 |
---|---|
執(zhí)行指令 | 1/1,000,000,000 秒 = 1 納秒 |
從一級(jí)緩存讀取數(shù)據(jù) | 0.5 納秒 |
分支預(yù)測(cè)失敗 | 5 納秒 |
從二級(jí)緩存讀取數(shù)據(jù) | 7 納秒 |
使用Mutex加鎖和解鎖 | 25 納秒 |
從主存(RAM內(nèi)存)中讀取數(shù)據(jù) | 100 納秒 |
在1Gbps速率的網(wǎng)絡(luò)上發(fā)送2Kbyte的數(shù)據(jù) | 20,000 納秒 |
從內(nèi)存中讀取1MB的數(shù)據(jù) | 250,000 納秒 |
磁頭移動(dòng)到新的位置(代指機(jī)械硬盤(pán)) | 8,000,000 納秒 |
從磁盤(pán)中讀取1MB的數(shù)據(jù) | 20,000,000 納秒 |
發(fā)送一個(gè)數(shù)據(jù)包從美國(guó)到歐洲然后回來(lái) | 150 毫秒 = 150,000,000 納秒 |
假設(shè)我們有如下一個(gè)類,類里面有這些屬性(現(xiàn)實(shí)中要復(fù)雜的多,而且會(huì)分航線、日期等各個(gè)維度存儲(chǔ),而且不同航班有不同的售賣規(guī)則,這里演示方便忽略),那么這1億數(shù)據(jù)緩存在內(nèi)存中需要多少空間呢?
public class FlightPriceClass { /// <summary> /// 航司二字碼 如 中國(guó)國(guó)際航空股份有限公司:CA /// </summary> public string Airline { get; set; } /// <summary> /// 起始機(jī)場(chǎng)三字碼 如 上海虹橋國(guó)際機(jī)場(chǎng):SHA /// </summary> public string Start { get; set; } /// <summary> /// 抵達(dá)機(jī)場(chǎng)三字碼 如 北京首都國(guó)際機(jī)場(chǎng):PEK /// </summary> public string End { get; set; } /// <summary> /// 航班號(hào) 如 CA0001 /// </summary> public string FlightNo { get; set; } /// <summary> /// 艙位代碼 如 Y /// </summary> public string Cabin { get; set; } /// <summary> /// 價(jià)格 單位:元 /// </summary> public decimal Price { get; set; } /// <summary> /// 起飛日期 如 2017-01-01 /// </summary> public DateOnly DepDate { get; set; } /// <summary> /// 起飛時(shí)間 如 08:00 /// </summary> public TimeOnly DepTime { get; set; } /// <summary> /// 抵達(dá)日期 如 2017-01-01 /// </summary> public DateOnly ArrDate { get; set; } /// <summary> /// 抵達(dá)時(shí)間 如 08:00 /// </summary> public TimeOnly ArrTime { get; set; } }
我們可以寫(xiě)一個(gè)Benchmark,來(lái)看看100W的數(shù)據(jù)需要多少空間,然后在推導(dǎo)出1億的數(shù)據(jù)
// 隨機(jī)預(yù)先生成100W的數(shù)據(jù) 避免計(jì)算邏輯導(dǎo)致結(jié)果不準(zhǔn)確 public static readonly FlightPriceClass[] FlightPrices = Enumerable.Range(0, 100_0000 ).Select(index => new FlightPriceClass { Airline = $"C{(char)(index % 26 + 'A')}", Start = $"SH{(char)(index % 26 + 'A')}", End = $"PE{(char)(index % 26 + 'A')}", FlightNo = $"{index % 1000:0000}", Cabin = $"{(char)(index % 26 + 'A')}", Price = index % 1000, DepDate = DateOnly.FromDateTime(BaseTime.AddHours(index)), DepTime = TimeOnly.FromDateTime(BaseTime.AddHours(index)), ArrDate = DateOnly.FromDateTime(BaseTime.AddHours(3 + index)), ArrTime = TimeOnly.FromDateTime(BaseTime.AddHours(3 + index)), }).ToArray(); // 使用類來(lái)存儲(chǔ) [Benchmakr] public FlightPriceClass[] GetClassStore() { var arrays = new FlightPriceClass[FlightPrices.Length]; for (int i = 0; i < FlightPrices.Length; i++) { var item = FlightPrices[i]; arrays[i] = new FlightPriceClass { Airline = item.Airline, Start = item.Start, End = item.End, FlightNo = item.FlightNo, Cabin = item.Cabin, Price = item.Price, DepDate = item.DepDate, DepTime = item.DepTime, ArrDate = item.ArrDate, ArrTime = item.ArrTime }; } return arrays; }
來(lái)看看最終的結(jié)果,圖片如下所示。
從上面的圖可以看出來(lái)100W數(shù)據(jù)大約需要107MB的內(nèi)存存儲(chǔ),那么一個(gè)占用對(duì)象大約就是112byte了,那么一億的對(duì)象就是約等于10.4GB。這個(gè)大小已經(jīng)比較大了,那么還有沒(méi)有更多的方案可以減少一些內(nèi)存占用呢?有小伙伴就說(shuō)了一些方案。
- 可以用int來(lái)編號(hào)字符串
- 可以使用long來(lái)存儲(chǔ)時(shí)間戳
- 可以想辦法用zip之類算法壓縮一下
- 等等
我們暫時(shí)也不用這些方法,對(duì)照本文的的標(biāo)題,大家應(yīng)該能想到用什么辦法,嘿嘿,那就是使用結(jié)構(gòu)體來(lái)替代類,我們定義了一個(gè)一樣的結(jié)構(gòu)體,如下所示。
[StructLayout(LayoutKind.Auto)] public struct FlightPriceStruct { // 屬性與類一致 ...... }
我們可以使用Unsafe.SizeOf
來(lái)查看值類型所需要的內(nèi)存大小,比如像下面這樣。
可以看到這個(gè)結(jié)構(gòu)體只需要88byte,比類所需要的112byte少了27%。來(lái)實(shí)際看看能節(jié)省多少內(nèi)存。
結(jié)果很不錯(cuò)呀,內(nèi)存確實(shí)如我們計(jì)算的一樣少了27%,另外賦值速度快了57%,而且更重要的是GC發(fā)生的次數(shù)也少了。
那么為什么結(jié)構(gòu)體可以節(jié)省那么多的內(nèi)存呢?這里需要聊一聊結(jié)構(gòu)體和類存儲(chǔ)數(shù)據(jù)的區(qū)別,下圖是類數(shù)組的存儲(chǔ)格式。
我們可以看到類數(shù)組只存放指向數(shù)組引用元素的指針,不直接存儲(chǔ)數(shù)據(jù),而且每個(gè)引用類型的實(shí)例都有以下這些東西。
- 對(duì)象頭:大小為8Byte,CoreCLR上的描述是存儲(chǔ)“需要負(fù)載到對(duì)象上的所有附加信息”,比如存儲(chǔ)對(duì)象的lock值或者HashCode緩存值。
- 方法表指針:大小為8Byte,指向類型的描述數(shù)據(jù),也就是經(jīng)常提到的(Method Table),MT里面會(huì)存放GCInfo,字段以及方法定義等等。
- 對(duì)象占位符:大小為8Byte,當(dāng)前的GC要求所有的對(duì)象至少有一個(gè)當(dāng)前指針大小的字段,如果是一個(gè)空類,除了對(duì)象頭和方法表指針以外,還會(huì)占用8Byte,如果不是空類,那就是存放第一個(gè)字段。
也就是說(shuō)一個(gè)空類不定義任何東西,也至少需要24byte的空間,8byte對(duì)象頭+8byte方法表指針+8byte對(duì)象占位符。
回到本文中,由于不是一個(gè)空類,所以每個(gè)對(duì)象除了數(shù)據(jù)存儲(chǔ)外需要額外的16byte存儲(chǔ)對(duì)象頭和方法表,另外數(shù)組需要8byte存放指向?qū)ο蟮闹羔?,所以一個(gè)對(duì)象存儲(chǔ)在數(shù)組中需要額外占用24byte的空間。我們?cè)賮?lái)看看值類型(結(jié)構(gòu)體)。
從上圖中,我們可以看到如果是值類型的數(shù)組,那么數(shù)據(jù)是直接存儲(chǔ)在數(shù)組上,不需要引用。所以存儲(chǔ)相同的數(shù)據(jù),每個(gè)空結(jié)構(gòu)體都能省下24byte(無(wú)需對(duì)象頭、方法表和指向?qū)嵗闹羔槪?br />另外結(jié)構(gòu)體數(shù)組當(dāng)中的數(shù)組,數(shù)組也是引用類型,所以它也有24byte的數(shù)據(jù),它的對(duì)象占位符用來(lái)存放數(shù)組類型的第一個(gè)字段-數(shù)組大小。
我們可以使用ObjectLayoutInspector
這個(gè)Nuget包打印對(duì)象的布局信息,類定義的布局信息如下,可以看到除了數(shù)據(jù)存儲(chǔ)需要的88byte以外,還有16byte額外空間。
結(jié)構(gòu)體定義的布局信息如下,可以看到每個(gè)結(jié)構(gòu)體都是實(shí)際的數(shù)據(jù)存儲(chǔ),不包含額外的占用。
那可不可以節(jié)省更多的內(nèi)存呢?我們知道在64位平臺(tái)上一個(gè)引用(指針)是8byte,而在C#上默認(rèn)的字符串使用Unicode-16
,也就是說(shuō)2byte代表一個(gè)字符,像航司二字碼、起抵機(jī)場(chǎng)這些小于4個(gè)字符的完全可以使用char數(shù)組來(lái)節(jié)省內(nèi)存,比一個(gè)指針占用還要少,那我們修改一下代碼。
// 跳過(guò)本地變量初始化 [SkipLocalsInit] // 調(diào)整布局方式 使用Explicit自定義布局 [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)] public struct FlightPriceStructExplicit { // 需要手動(dòng)指定偏移量 [FieldOffset(0)] // 航司使用兩個(gè)字符存儲(chǔ) public unsafe fixed char Airline[2]; // 由于航司使用了4byte 所以起始機(jī)場(chǎng)偏移4byte [FieldOffset(4)] public unsafe fixed char Start[3]; // 同理起始機(jī)場(chǎng)使用6byte 偏移10byte [FieldOffset(10)] public unsafe fixed char End[3]; [FieldOffset(16)] public unsafe fixed char FlightNo[4]; [FieldOffset(24)] public unsafe fixed char Cabin[2]; // decimal 16byte [FieldOffset(28)] public decimal Price; // DateOnly 4byte [FieldOffset(44)] public DateOnly DepDate; // TimeOnly 8byte [FieldOffset(48)] public TimeOnly DepTime; [FieldOffset(56)] public DateOnly ArrDate; [FieldOffset(60)] public TimeOnly ArrTime; }
在來(lái)看看這個(gè)新結(jié)構(gòu)體對(duì)象的布局信息。
可以看到現(xiàn)在只需要68byte了,最后4byte是為了地址對(duì)齊,因?yàn)镃PU字長(zhǎng)是64bit,我們不用管。按照我們的計(jì)算能比88Byte節(jié)省了29%的空間。當(dāng)然使用unsafe fixed char
以后就不能直接賦值了,需要進(jìn)行數(shù)據(jù)拷貝才行,代碼如下。
// 用于設(shè)置string值的擴(kuò)展方法 [MethodImpl(MethodImplOptions.AggressiveInlining)] public static unsafe void SetTo(this string str, char* dest) { fixed (char* ptr = str) { Unsafe.CopyBlock(dest, ptr, (uint)(Unsafe.SizeOf<char>() * str.Length)); } } // Benchmark的方法 public static unsafe FlightPriceStructExplicit[] GetStructStoreStructExplicit() { var arrays = new FlightPriceStructExplicit[FlightPrices.Length]; for (int i = 0; i < FlightPrices.Length; i++) { ref var item = ref FlightPrices[i]; arrays[i] = new FlightPriceStructExplicit { Price = item.Price, DepDate = item.DepDate, DepTime = item.DepTime, ArrDate = item.ArrDate, ArrTime = item.ArrTime }; ref var val = ref arrays[i]; // 需要先f(wàn)ixed 然后再賦值 fixed (char* airline = val.Airline) fixed (char* start = val.Start) fixed (char* end = val.End) fixed (char* flightNo = val.FlightNo) fixed (char* cabin = val.Cabin) { item.Airline.SetTo(airline); item.Start.SetTo(start); item.End.SetTo(end); item.FlightNo.SetTo(flightNo); item.Cabin.SetTo(cabin); } } return arrays; }
再來(lái)跑一下,看看這樣存儲(chǔ)提升是不是能節(jié)省29%的空間呢。
是吧,從84MB->65MB節(jié)省了大約29%的內(nèi)存,不錯(cuò)不錯(cuò),基本可以達(dá)到預(yù)期了。
但是我們發(fā)現(xiàn)這個(gè)Gen0 Gen1 Gen2這些GC發(fā)生了很多次,在實(shí)際中的話因?yàn)檫@些都是使用的托管內(nèi)存,GC在進(jìn)行回收的時(shí)候會(huì)掃描這65MB的內(nèi)存,可能會(huì)讓它的STW變得更久;既然這些是緩存的數(shù)據(jù),一段時(shí)間內(nèi)不會(huì)回收和改變,那我們能讓GC別掃描這些嘛?答案是有的,我們可以直接使用非托管內(nèi)存,使用Marshal類就可以申請(qǐng)和管理非托管內(nèi)存,可以達(dá)到你寫(xiě)C語(yǔ)言的時(shí)候用的malloc
函數(shù)類似的效果。
// 分配非托管內(nèi)存 // 傳參是所需要分配的字節(jié)數(shù) // 返回值是指向內(nèi)存的指針 IntPtr Marshal.AllocHGlobal(int cb); // 釋放分配的非托管內(nèi)存 // 傳參是由Marshal分配內(nèi)存的指針地址 void Marshal.FreeHGlobal(IntPtr hglobal);
再修改一下Benchmark的代碼,將它改成使用非托管內(nèi)存。
// 定義了out ptr參數(shù),用于將指針傳回 public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr) { // 使用AllocHGlobal分配內(nèi)存,大小使用SizeOf計(jì)算結(jié)構(gòu)體大小乘需要的數(shù)量 var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf<FlightPriceStructExplicit>() * FlightPrices.Length); ptr = unManagerPtr; // 將內(nèi)存空間指派給FlightPriceStructExplicit數(shù)組使用 var arrays = new Span<FlightPriceStructExplicit>(unManagerPtr.ToPointer(), FlightPrices.Length); for (int i = 0; i < FlightPrices.Length; i++) { ref var item = ref FlightPrices[i]; arrays[i] = new FlightPriceStructExplicit { Price = item.Price, DepDate = item.DepDate, DepTime = item.DepTime, ArrDate = item.ArrDate, ArrTime = item.ArrTime }; ref var val = ref arrays[i]; fixed (char* airline = val.Airline) fixed (char* start = val.Start) fixed (char* end = val.End) fixed (char* flightNo = val.FlightNo) fixed (char* cabin = val.Cabin) { item.Airline.SetTo(airline); item.Start.SetTo(start); item.End.SetTo(end); item.FlightNo.SetTo(flightNo); item.Cabin.SetTo(cabin); } } // 返回長(zhǎng)度 return arrays.Length; } // 切記,非托管內(nèi)存不使用的時(shí)候 需要手動(dòng)釋放 [Benchmark] public void GetStructStoreUnManageMemory() { _ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr); // 釋放非托管內(nèi)存 Marshal.FreeHGlobal(ptr); }
再來(lái)看看Benchmark的結(jié)果。
結(jié)果非常Amazing呀,沒(méi)有在托管內(nèi)存上分配空間,賦值的速度也比原來(lái)快了很多,后面發(fā)生GC的時(shí)候也無(wú)需掃描這一段內(nèi)存,降低了GC壓力。這樣的結(jié)果基本就比較滿意了。
到現(xiàn)在的話存儲(chǔ)1億的數(shù)據(jù)差不多6.3GB,如果使用上文中提高的其它方法,應(yīng)該還能降低一些,比如像如下代碼一樣,使用枚舉來(lái)替換字符串,金額使用'分'存儲(chǔ),只存時(shí)間戳。
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)] [SkipLocalsInit] public struct FlightPriceStructExplicit { // 使用byte標(biāo)識(shí)航司 byte范圍0~255 [FieldOffset(0)] public byte Airline; // 使用無(wú)符號(hào)整形表示起抵機(jī)場(chǎng)和航班號(hào) 2^16次方 [FieldOffset(1)] public UInt16 Start; [FieldOffset(3)] public UInt16 End; [FieldOffset(5)] public UInt16 FlightNo; [FieldOffset(7)] public byte Cabin; // 不使用decimal 價(jià)格精確到分存儲(chǔ) [FieldOffset(8)] public long PriceFen; // 使用時(shí)間戳替代 [FieldOffset(16)] public long DepTime; [FieldOffset(24)] public long ArrTime; }
最后的出來(lái)的結(jié)果,每個(gè)數(shù)據(jù)只需要32byte的空間存儲(chǔ),這樣存儲(chǔ)一億的的話也不到3GB。
本文就不繼續(xù)討論這些方式了。
計(jì)算速度
那么使用結(jié)構(gòu)體有什么問(wèn)題嗎?我們來(lái)看看計(jì)算,這個(gè)計(jì)算很簡(jiǎn)單,就是把符合條件的航線篩選出來(lái),首先類和結(jié)構(gòu)體都定義了如下代碼的方法,Explicit結(jié)構(gòu)體比較特殊,我們使用Span比較。
// 類和結(jié)構(gòu)體定義的方法 當(dāng)然實(shí)際中的篩選可能更加復(fù)雜 // 比較航司 public bool EqulasAirline(string airline) { return Airline == airline; } // 比較起飛機(jī)場(chǎng) public bool EqualsStart(string start) { return Start == start; } // 比較抵達(dá)機(jī)場(chǎng) public bool EqualsEnd(string end) { return End == end; } // 比較航班號(hào) public bool EqualsFlightNo(string flightNo) { return FlightNo == flightNo; } // 價(jià)格是否小于指定值 public bool IsPriceLess(decimal min) { return Price < min; } // 對(duì)于Explicit結(jié)構(gòu)體 定義了EqualsSpan方法 [MethodImpl(MethodImplOptions.AggressiveInlining)] public static unsafe bool SpanEquals(this string str, char* dest, int length) { // 使用span來(lái)比較兩個(gè)數(shù)組 return new Span<char>(dest, length).SequenceEqual(str.AsSpan()); } // 實(shí)現(xiàn)的方法如下所示 public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline) { // 傳需要比較的長(zhǎng)度 return airline.SpanEquals(item.Airline, 2); } // 下面的方式類似,不再贅述 public static unsafe bool EqualsStart(FlightPriceStructExplicit item, string start) { return start.SpanEquals(item.Start, 3); } public static unsafe bool EqualsEnd(FlightPriceStructExplicit item, string end) { return end.SpanEquals(item.End, 3); } public static unsafe bool EqualsFlightNo(FlightPriceStructExplicit item, string flightNo) { return flightNo.SpanEquals(item.FlightNo, 4); } public static unsafe bool EqualsCabin(FlightPriceStructExplicit item, string cabin) { return cabin.SpanEquals(item.Cabin, 2); } public static bool IsPriceLess(FlightPriceStructExplicit item, decimal min) { return item.Price < min; }
最后Benchmark的代碼如下所示,對(duì)于每種存儲(chǔ)結(jié)構(gòu)都是同樣的代碼邏輯,由于100W數(shù)據(jù)一下就跑完了,每種存儲(chǔ)方式的數(shù)據(jù)量都為150W。
// 將需要的數(shù)據(jù)初始化好 避免對(duì)測(cè)試造成影響 private static readonly FlightPriceClass[] FlightPrices = FlightPriceCreate.GetClassStore(); private static readonly FlightPriceStruct[] FlightPricesStruct = FlightPriceCreate.GetStructStore(); private static readonly FlightPriceStructUninitialized[] FlightPricesStructUninitialized = FlightPriceCreate.GetStructStoreUninitializedArray(); private static readonly FlightPriceStructExplicit[] FlightPricesStructExplicit = FlightPriceCreate.GetStructStoreStructExplicit(); // 非托管內(nèi)存比較特殊 只需要存儲(chǔ)指針地址即可 private static IntPtr _unManagerPtr; private static readonly int FlightPricesStructExplicitUnManageMemoryLength = FlightPriceCreate.GetStructStoreUnManageMemory(out _unManagerPtr); [Benchmark(Baseline = true)] public int GetClassStore() { var caAirline = 0; var shaStart = 0; var peaStart = 0; var ca0001FlightNo = 0; var priceLess500 = 0; for (int i = 0; i < FlightPrices.Length; i++) { // 簡(jiǎn)單的篩選數(shù)據(jù) var item = FlightPrices[i]; if (item.EqualsAirline("CA"))caAirline++; if (item.EqualsStart("SHA"))shaStart++; if (item.EqualsEnd("PEA"))peaStart++; if (item.EqualsFlightNo("0001"))ca0001FlightNo++; if (item.IsPriceLess(500))priceLess500++; } Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}"); return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500; } [Benchmark] public int GetStructStore() { var caAirline = 0; var shaStart = 0; var peaStart = 0; var ca0001FlightNo = 0; var priceLess500 = 0; for (int i = 0; i < FlightPricesStruct.Length; i++) { var item = FlightPricesStruct[i]; if (item.EqualsAirline("CA"))caAirline++; if (item.EqualsStart("SHA"))shaStart++; if (item.EqualsEnd("PEA"))peaStart++; if (item.EqualsFlightNo("0001"))ca0001FlightNo++; if (item.IsPriceLess(500))priceLess500++; } Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}"); return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500; } [Benchmark] public int GetFlightPricesStructExplicit() { var caAirline = 0; var shaStart = 0; var peaStart = 0; var ca0001FlightNo = 0; var priceLess500 = 0; for (int i = 0; i < FlightPricesStructExplicit.Length; i++) { var item = FlightPricesStructExplicit[i]; if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++; if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++; if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++; if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++; if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++; } Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}"); return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500; } [Benchmark] public unsafe int GetFlightPricesStructExplicitUnManageMemory() { var caAirline = 0; var shaStart = 0; var peaStart = 0; var ca0001FlightNo = 0; var priceLess500 = 0; var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength); for (int i = 0; i < arrays.Length; i++) { var item = arrays[i]; if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++; if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++; if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++; if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++; if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++; } Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}"); return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500; }
Benchmark的結(jié)果如下。
我們看到單獨(dú)使用結(jié)構(gòu)體比類要慢一點(diǎn)點(diǎn),但是后面那些使用Explicit布局方式和非托管內(nèi)存的就慢很多很多了,有一倍多的差距,魚(yú)和熊掌真的不可兼得嗎?
我們來(lái)分析一下后面2種方式比較慢的原因,原因是因?yàn)橹悼截?,我們知道在C#中默認(rèn)引用類型是引用傳遞,而值類型是值傳遞。
- 引用類型調(diào)用方法傳遞時(shí)只需要拷貝一次,長(zhǎng)度為CPU字長(zhǎng),32位系統(tǒng)就是4byte,64位就是8byte
- 值類型調(diào)用方法是值傳遞,比如值需要占用4byte,那么就要拷貝4byte,在小于等于CPU字長(zhǎng)時(shí)有優(yōu)勢(shì),大于時(shí)優(yōu)勢(shì)就變?yōu)榱觿?shì)。
而我們的結(jié)構(gòu)體都遠(yuǎn)遠(yuǎn)大于CPU字長(zhǎng)64位8byte,而我們的后面的代碼實(shí)現(xiàn)發(fā)生了多次值拷貝,這拖慢了整體的速度。
那么有沒(méi)有什么辦法不發(fā)生值拷貝呢?當(dāng)然,值類型在C#中也可以引用傳遞,我們有ref
關(guān)鍵字,只需要在值拷貝的地方加上就好了,代碼如下所示。
// 改造比較方法,使其支持引用傳遞 // 加入ref public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline) { // 傳遞的是引用 需要fixed獲取指針 fixed(char* ptr = item.Airline) { return airline.SpanEquals(ptr, 2); } } // Benchmark內(nèi)部代碼也修改為引用傳遞 [Benchmark] public unsafe int GetStructStoreUnManageMemoryRef() { var caAirline = 0; var shaStart = 0; var peaStart = 0; var ca0001FlightNo = 0; var priceLess500 = 0; var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength); for (int i = 0; i < arrays.Length; i++) { // 從數(shù)組里面拿直接引用 ref var item = ref arrays[i]; // 傳參也直接傳遞引用 if (FlightPriceStructExplicit.EqualsAirlineRef(ref item,"CA"))caAirline++; if (FlightPriceStructExplicit.EqualsStartRef(ref item,"SHA"))shaStart++; if (FlightPriceStructExplicit.EqualsEndRef(ref item,"PEA"))peaStart++; if (FlightPriceStructExplicit.EqualsFlightNoRef(ref item,"0001"))ca0001FlightNo++; if (FlightPriceStructExplicit.IsPriceLessRef(ref item,500))priceLess500++; } Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}"); return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500; }
我們?cè)賮?lái)跑一下結(jié)果,我們的Explicit結(jié)構(gòu)體遙遙領(lǐng)先,比使用類足足快33%,而上一輪中使用非托管內(nèi)存表現(xiàn)也很好,排在了第二的位置。
那么同樣是引用傳遞,使用類會(huì)更慢一些呢?這就要回到更加底層的CPU相關(guān)的知識(shí)了,我們CPU里面除了基本的計(jì)算單元以外,還有L1、L2、L3這些數(shù)據(jù)緩存,如下圖所示。
這個(gè)和CPU的性能掛鉤,記得文章開(kāi)頭那一個(gè)圖嗎?CPU內(nèi)部的緩存是速度最快的,所以第一個(gè)原因就是對(duì)于結(jié)構(gòu)體數(shù)組數(shù)據(jù)是存放的連續(xù)的地址空間,非常利于CPU緩存;而類對(duì)象,由于是引用類型,需要指針訪問(wèn),對(duì)于CPU緩存不是很有利。
第二個(gè)原因是因?yàn)橐妙愋驮谠L問(wèn)時(shí),需要進(jìn)行解引用操作,也就是說(shuō)需要通過(guò)指針找到對(duì)應(yīng)內(nèi)存中的數(shù)據(jù),而結(jié)構(gòu)體不需要。
那么如何驗(yàn)證我們的觀點(diǎn)呢,其實(shí)BenchmarkDotNet
提供了這樣的指標(biāo)展示,只需要引入BenchmarkDotNet.Diagnostics.Windows
Nuget包,然后在需要評(píng)測(cè)的類上面加入以下代碼。
[HardwareCounters( HardwareCounter.LlcMisses, // 緩存未命中次數(shù) HardwareCounter.LlcReference)] // 解引用次數(shù) public class SpeedBench : IDisposable { ...... }
結(jié)果如下所示,由于需要額外的統(tǒng)計(jì)Windows ETW的信息,所以跑的會(huì)稍微慢一點(diǎn)。
我們可以從上圖看出,使用引用類型緩存未命中的次數(shù)最多,解引用的次數(shù)也很多,這些拖慢了性能。
如下圖所示,順序存儲(chǔ)的結(jié)構(gòu)體要比跳躍式的引用類型內(nèi)存訪問(wèn)效率高。另外對(duì)象的體積越小,對(duì)于緩存就越友好。
總結(jié)
在本文章中,我們討論了如何使用結(jié)構(gòu)體替換類,達(dá)到降低大量?jī)?nèi)存占用和提升幾乎一半計(jì)算性能的目的。也討論了非托管內(nèi)存在.NET中的簡(jiǎn)單使用。結(jié)構(gòu)體是我非常喜歡的東西,它有著相當(dāng)高效的存儲(chǔ)結(jié)構(gòu)和相當(dāng)優(yōu)異的性能。但是你不應(yīng)該將所有的類都轉(zhuǎn)換為結(jié)構(gòu)體,因?yàn)樗鼈冇胁煌倪m用場(chǎng)景。
那么我們?cè)谑裁磿r(shí)候需要使用結(jié)構(gòu)體,什么時(shí)候需要使用類呢?微軟官方給出了答案。
?? 如果類型的實(shí)例比較小并且通常生存期較短或者常嵌入在其他對(duì)象中,則考慮定義結(jié)構(gòu)體而不是類。
? 避免定義結(jié)構(gòu),除非具有所有以下特征:
- 它邏輯上表示單個(gè)值,類似于基元類型(
int
、double
等等)- 比如我們的緩存數(shù)據(jù),基本都是基元類型。 - 它的實(shí)例大小小于16字節(jié) - 值拷貝的代價(jià)是巨大的,不過(guò)現(xiàn)在有了
ref
能有更多的適用場(chǎng)景。 - 它是不可變的 - 在我們今天的例子中,緩存的數(shù)據(jù)是不會(huì)改變的,所以具有這個(gè)特征。
- 它不必頻繁裝箱 - 頻繁裝拆箱對(duì)性能有較大的損耗,在我們的場(chǎng)景中,函數(shù)都做了
ref
適配,所以也不存在這種情況。
在所有其他情況下,都應(yīng)將類型定義為類。
其實(shí)大家從這些方式也能看出來(lái),C#是一門(mén)入門(mén)簡(jiǎn)單但是上限很高的語(yǔ)言,平時(shí)可以利用C#的語(yǔ)法特性,快速的進(jìn)行需求變現(xiàn);而如果有了性能瓶頸,你完全可以像寫(xiě)C++代碼一樣寫(xiě)C#代碼,獲得和C++媲美的性能。
附錄
本文源碼鏈接-晚點(diǎn)會(huì)上傳
選擇結(jié)構(gòu)體還是類
結(jié)構(gòu)體設(shè)計(jì)原則
.NET Marshal類
.NET Span類
CPU不同硬件的速度
到此這篇關(guān)于.NET使用結(jié)構(gòu)體替代類提升性能優(yōu)化的技巧的文章就介紹到這了,更多相關(guān).NET性能優(yōu)化結(jié)構(gòu)體替代類內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用grpcui測(cè)試ASP.NET core的gRPC服務(wù)
這篇文章介紹了使用grpcui測(cè)試ASP.NET core gRPC服務(wù)的方法,文中通過(guò)示例代碼介紹的非常詳細(xì)。對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-07-07國(guó)產(chǎn)化中的?.NET?Core?操作達(dá)夢(mèng)數(shù)據(jù)庫(kù)DM8的兩種方式(操作詳解)
這篇文章主要介紹了國(guó)產(chǎn)化之?.NET?Core?操作達(dá)夢(mèng)數(shù)據(jù)庫(kù)DM8的兩種方式,這里提供兩種方式是傳統(tǒng)的DbHelperSQL方式和Dapper?方式,每種方式給大家介紹的非常詳細(xì),需要的朋友可以參考下2022-04-04asp.net core3.1cookie和jwt混合認(rèn)證授權(quán)實(shí)現(xiàn)多種身份驗(yàn)證方案
身份驗(yàn)證是確定用戶身份的過(guò)程。 授權(quán)是確定用戶是否有權(quán)訪問(wèn)資源的過(guò)程。本文主要介紹了asp.net core3.1cookie和jwt混合認(rèn)證授權(quán)實(shí)現(xiàn)多種身份驗(yàn)證方案,感興趣的可以了解一下2021-09-09asp.net音頻轉(zhuǎn)換之.amr轉(zhuǎn).mp3(利用七牛轉(zhuǎn)換法)
相信很多人都遇到amr格式的音頻文件不能直接在網(wǎng)頁(yè)播放的問(wèn)題,有人使用QuickTime插件的輔助,下面這篇文章主要給大家介紹了asp.net音頻轉(zhuǎn)換之利用七牛轉(zhuǎn)換法將.amr格式轉(zhuǎn).mp3格式,需要的朋友可以參考借鑒,下面來(lái)一起看看吧。2016-12-12asp.net中利用ajax獲取動(dòng)態(tài)創(chuàng)建表中文本框的值
通常在做主從表的數(shù)據(jù)錄入中,會(huì)碰到在一個(gè)頁(yè)面上同時(shí)錄入主表數(shù)據(jù)和從表數(shù)據(jù),主表的數(shù)據(jù)只有一條,從表的數(shù)據(jù)有一條到多條,這樣就要?jiǎng)討B(tài)創(chuàng)建從表數(shù)據(jù)錄入入口。2010-03-03