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