.NET 性能優化的技巧
最大化內聯
內聯是將方法體(method body)復制到調用站點的技術,這樣我們就可以避免跳轉、參數傳遞和寄存器保存/恢復等繁瑣過程。除了節省這些之外,內聯還是實現其他優化的必要條件。 不不過Roslyn(C#的編譯器)沒有內聯代碼,它是通過JIT實現的,大多數優化也是如此。
使用靜態投擲助手(static throw helper)
最近的變化涉及一個重要的重構,在序列化基準的調用持續時間上增加了大約20ns,從~130ns增加到了~150ns。
罪魁禍首是這個助手方法中添加的throw語句:
- public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
- this TBufferWriter buffer,
- SerializerSession session) where TBufferWriter : IBufferWriter<byte>
- {
- if (session == null) throw new ArgumentNullException(nameof(session));
- return new Writer<TBufferWriter>(buffer, session);
- }
當助手方法中包含throw語句時,JIT不會內聯它。解決這個問題的常見技巧是添加一個靜態的“throw helper”方法,來完成一些棘手的工作,所以最終結果如下所示:
- public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
- this TBufferWriter buffer,
- SerializerSession session) where TBufferWriter : IBufferWriter<byte>
- {
- if (session == null) ThrowSessionNull();
- return new Writer<TBufferWriter>(buffer, session);
- void ThrowSessionNull() => throw new ArgumentNullException(nameof(session));
- }
代碼庫在許多地方使用這個技巧,將throw語句放在一個單獨的方法中可能會有其他好處,例如比如改善常用代碼路徑的位置。
最小化虛擬或接口調用
虛擬調用比直接調用慢,如果你正在編寫一個關鍵系統,那么很可能會在分析器中看到虛擬調用的過程。首先,虛擬調用需要間接調用。
去虛擬化是許多JIT編譯器的一個特性,RyuJIT也不例外。然而,這是一個復雜的功能,并且RyuJIT目前可以證明(自身)方法可以被虛擬化并因此成為內聯的候選者的情況并不多。以下是利用虛擬化的一些常規技巧,但我確信還有更多。
1. 默認情況下將類標記為sealed,當一個類/方法被標記為sealed時,RyuJIT可以將其考慮在內并且可能能夠內聯一個方法調用。RyuJIT很可能成為下一代的JIT編譯器。64位計算已是大勢所趨,即使它并不總是比32位更快或更有效率。當前的.NET JIT編譯器就是一個使得64位計算機上有時導致程序速度減慢的的例子。但是,這將會被改變:一個新的,下一代x64的JIT編譯器編譯代碼的速度將加快兩倍,它將改變你對64位.NET代碼的印象。
2. 如果可能,將覆蓋(override)方法標記為sealed。override可以翻譯為覆蓋,從字面就可以知道,它是覆蓋了一個方法并且對其重寫,以求達到不同的作用。對我們來說最熟悉的覆蓋就是對接口方法的實現,在接口中一般只是對方法進行了聲明,而我們在實現時,就需要實現接口聲明的所有方法。除了這個典型的用法以外,我們在繼承中也可能會在子類覆蓋父類中的方法。
3. 使用具體類型而不是接口,具體類型為JIT提供了更多信息,因此它更有可能內聯你的調用。
4. 在同一方法中實例化和使用非sealed對象(而不是使用'create'方法),當類型明確已知時,比如構造之后,RyuJIT可以對非sealed方法調用進行虛擬化。
5. 對多態類型使用泛型類型約束,以便可以使用具體類型對它們進行專門處理,并且可以對接口調用進行非虛擬化。在Hagar中,我們的核心編寫器類型定義如下:
- public ref struct Writer<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
- {
- private TBufferWriter output;
- // --- etc ---
所有對CIL中Roslyn發出的輸出方法的調用之前都會有一條約束指令,該指令告訴JIT,該調用可以對TBufferWriter上定義的精確方法進行調用,而不是進行虛擬/接口調用。這有助于去虛擬化。結果,所有對在輸出上定義的方法的調用都被成功地去虛擬化。下面是由JIT團隊的Andy Ayers 編寫的CoreCLR線程,它詳細描述了當前和未來的去虛擬化工作。
減少分配
.NET的垃圾收集器是一項很偉大的項目, 垃圾收集器是 允許對一些無鎖數據結構進行算法優化,并且還可以刪除整個類的錯誤并減輕開發人員的認知負擔。總之,垃圾收集是一種非常成功的內存管理技術。
.NET使用bump分配器,其中每個線程通過找到各自的指針來從每個線程上下文中分配對象。因此,當在同一線程上分配和使用短期分配時,可以更好的實現局部緩存(cache locality)機制。
有關.NET 垃圾收集器的更多信息,請點此了解。
對象池(Object Pool) 或緩沖池(Buffer Pool)
Hagar本身并不管理緩沖區,而是將責任轉移給用戶。這聽起來可能很麻煩,但實際上并不麻煩,因為它與System.IO.Pipelines兼容。因此,我們可以利用默認管道通過System.Buffers.ArrayPool
一般來說,重復使用緩沖區可以減輕垃圾收集器的壓力。
避免裝箱
盡可能不要通過將值類型轉換為引用類型來裝箱值類型。這是常見的建議,但在API設計中需要考慮一些因素。在Hagar中,可以接受值類型的接口和方法定義是通用的,以便它們可以專門用于精確類型并避免裝箱/拆箱成本。結果,沒有熱路徑拳擊。在某些情況下仍然存在拳擊,例如異常方法的字符串格式。可以通過對參數的顯式. tostring()調用來刪除那些特定的裝箱分配。
減少關閉分配
只分配閉包一次,并存儲結果,就可供多次重復使用。例如,通常將委托傳遞給ConcurrentDictionary. getoradd。與其將委托編寫為內聯lambda,還不如將define定義為類中的私有字段。下面是來自Hagar中可選ISerializable支持包的一個例子:
- private readonly Func<Type, Action<object, SerializationInfo, StreamingContext>> createConstructorDelegate;
- public ObjectSerializer(SerializationConstructorFactory constructorFactory)
- {
- // Other parameters/statements omitted.
- this.createConstructorDelegate = constructorFactory.GetSerializationConstructorDelegate;
- }
- // Later, on a hot code path:
- var constructor = this.constructors.GetOrAdd(info.ObjectType, this.createConstructorDelegate);
盡量減少復制
.NET Core 2.0和2.1以及最近的C#版本,在刪除數據復制過程的方面取得了相當大的進步。最值得注意的是Span
使用Span
一個Span
對于.NET Core來說,Span
Hagar廣泛使用Span
通過ref傳遞結構以最小化堆棧上的副本
Hagar使用兩個主要結構,Reader 和Writer
在沒有干預的情況下,使用這些結構進行的每個方法調用都會帶來很大的影響,因為每個調用都需要將整個結構復制到堆棧中。
我們可以通過將這些結構作為ref參數傳遞來避免副本的產生,另外,C#還支持使用ref this作為擴展方法的目標,這非常方便。據我所知,沒有辦法確保特定的結構類型總是由ref傳遞,如果你不小心在調用的參數列表中省略了ref,這可能會導致運行錯誤。
避免保護性拷貝(defensive copy)
Roslyn有時需要做一些工作來保證一些語言不變量,當結構存儲在只讀字段中時,編譯器將插入一些指令,以避免復制該字段,然后再將其包含到任何能保證不會對其進行修改的操作中。通常,這意味著調用在結構類型本身上定義的方法,因為將結構作為參數傳遞給在另一類型上定義的方法已經需要將結構復制到堆棧上(除非通過ref或in傳遞)。
如果將
有時,如果你無法將其定義為只讀結構,則最好在其他不可變結構字段上省略readonly修飾符。
以Jon Skeet的NodaTime庫為例,在這個示例中,Jon使大多數結構變為只讀,因此能夠將readonly修飾符添加到包含這些結構的字段中,而不會對性能產生負面影響。
減少分支和分支錯誤預測
現代cpu依賴于長pipeline的指令,這些指令通過并發性進行處理。這涉及到CPU分析指令,以確定哪些指令不依賴于前面的指令,還涉及猜測將采用哪些條件跳轉語句。為此,CPU使用一個名為分支預測器(branch predictor)的組件,該組件負責猜測將采用哪個分支。它通常通過讀取和寫入表中的條目來實現這一點,并根據上次執行條件跳轉時發生的情況修改其預測。
當預測正確時,就會加快進程,否則就需要把預測分支的指令排空,重新獲取正確分支的指令進入pipeline繼續執行。
所以加快進程的最好辦法就是減少分支和分支錯誤預測,首先嘗試最小化分支數量,如果無法消除分支,請盡量減少錯誤預測率,這可能涉及使用排序數據或重構代碼,可以用查找的辦法來代替分支預測。
其他雜項提示
1. 避免使用LINQ,LINQ在應用程序代碼方面很出色,但在庫/框架代碼中很少被用于路徑。LINQ很難對JIT進行優化(IEnumerable
2. 使用具體類型而不是接口或抽象類型,也許最常見的是,如果你在List
3. 反射在庫代碼中特別有用,緩存反射結果,考慮使用IL或Roslyn為訪問器生成委托,或者更好的方法是使用現有的庫,如Microsoft.Extensions.ObjectMethodExecutor.Sources,Microsoft.Extensions.PropertyHelper.Sources或FastMember。
特定于庫的優化
優化生成的代碼
Hagar使用Roslyn為要序列化的POCO生成C#代碼,這個C#代碼在編譯時包含在你的項目中。我們可以對生成的代碼執行一些優化,以加快速度。
通過跳過對已知類型的編解碼器查找來避免虛擬調用
當復雜對象包含眾所周知的字段(如int,Guid,string)時,代碼生成器將直接插入對這些類型的手動編碼編解碼器的調用,而不是調用CodecProvider來檢索該類型的IFieldCodec
在運行時專門化泛型類型
與上面類似,代碼生成器可以生成在運行時使用專門化的代碼。
預先計算常數值以消除某些分支
在序列化期間,每個字段都帶有一個標頭,通常是一個字節。它會告訴解串器哪個字段是編碼的。此字段標題包含3條信息:字段的規格(固定寬度、長度前綴、標記分隔、引用等),字段的模式類型(預期、眾所周知、以前定義的、編碼)用于多態,并將最后3位專用于編碼字段id(如果它小于7)。在許多情況下,可以確切地知道在編譯時這個標頭字節是什么。如果字段具有值類型,那么我們就知道運行時類型永遠不能與字段類型不同,并且始終知道字段id。
因此,我們通常可以保存計算標頭值所需的所有工作,并可以直接將其作為常量嵌入到代碼中。這樣可以節省分支并且通常會消除大量的中間語言代碼。
選擇適當的數據結構
通過切換到結構數組,很大程度上消除了索引和維護集合的成本,并且參考跟蹤不再出現在基準測試中。這有一個缺點,對于大型對象圖,這種新方法可能較慢。
選擇合適的算法
Hagar花費大量時間對可變長度整數進行編碼/解碼,這種方法被稱為varints,varints是用一個或多個字節序列化整形的一種方法,以減小有效載荷的大小。許多二進制序列化器使用這種技術,包括協議緩沖區。甚至.NET的BinaryWriter也使用這種編碼。下面是參考資料的一小段:
- protected void Write7BitEncodedInt(int value) {
- // Write out an int 7 bits at a time. The high bit of the byte,
- // when on, tells reader to continue reading more bytes.
- uint v = (uint) value; // support negative numbers
- while (v >= 0x80) {
- Write((byte) (v | 0x80));
- v >>= 7;
- }
- Write((byte)v);
- }
我想指出ZigZag編碼對于包含負值的有符號整數可能更有效,而不是強制轉換為uint。
這些序列化器中的變量使用稱為Little Endian Base-128或LEB128的算法,該算法每字節編碼多達7位。它使用每個字節的最高有效位來指示是否跟隨另一個字節(1 =是,0 =否)。這是一種簡單的格式,但可能不是最快的。不過PrefixVarint更快,使用PrefixVarint,所有來自LEB128的1都是在有效載荷的開頭一次性寫入的。這可能讓我們使用硬件內在函數來提高這種編碼和解碼的速度。通過將大小信息往前移,我們也可以從有效載荷中一次讀取更多字節,從而減少內部壓力并提高性能。