日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程语言 > asp.net >内容正文

asp.net

.NET Core 3中的性能提升(译文)

發布時間:2023/12/4 asp.net 48 豆豆
生活随笔 收集整理的這篇文章主要介紹了 .NET Core 3中的性能提升(译文) 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

回顧我們準備推出.NET Core 2.0的時候,我寫了一篇博文來介紹.NET已經引入的諸多性能優化中的一部分,我很喜歡把它們放在一起講述,也收獲了很多正面反饋,因此我又給.NET Core 2.1,一個同樣高度聚焦于性能的版本,也做了一篇。經過上周的構建,以及即將到來的.NET Core 3正式版,我很高興又一次有機會去介紹她了。

(為方便,以下簡稱.NET Core為DNC)

DNC3要提供的東西堆積如山,從winform和wpf,到單體exe文件,到異步流,到平臺的intrinsics API(譯者注:用于SIMD編程),到HTTP/2,到快速的JSON讀寫,到程序集卸載,到增強的加密,又這樣又那樣的……有很多新功能值得為之慶賀,但對我來說,性能是令我樂于大清早就去工作的主要特性,而且在DNC3中,有一大堆的性能優勢。

在這個帖子里,我們將帶你領略許多已經引入DNC的運行時和核心庫的大大小小的提升,讓你的應用和服務更加輕便快速。

安裝

Benchmark.NET已經成已經成為了為.NET類庫做評估的良好工具,就像我在DNC2.1的博文里所做的那樣,我還會使用Benchmark.NET去證實這些性能的提高,通過這個帖子,我會以一些獨立小測試,介紹那些正被討論的特別增強項目,要復現這些測試,你可以按照如下步驟:

  • 確保安裝DNC3,和DNC2.1做對照;

  • 新建一個叫BlogPostBenchmarks的文件夾;

  • 在文件夾中運行dotnet new console指令;

  • 將BlogPostBenchmarks.csproj的內容換成下面代碼:

  • 5.將Program.cs文件的內容換成如下代碼:

    要執行特定的測試,除非另有說明,否則只需要復制粘貼測試代碼到上面黃色注釋位置,并執行dotnet run -c Release -f netcoreapp2.1 --runtimes netcoreapp2.1 netcoreapp3.0 --filter "*Program*"指令,這會編譯和執行2.1和3上兩個平臺的測試,并且將測試結果打印到一張表中。

    注意事項

    在我們開始之前,我們需要注意幾件事:

    1.任何涉及到微測試的結果討論都有一個前提,測量結果在不同的機器上是不同的,我已經盡力嘗試來挑選一些穩定的例子來分享(在多臺機器上以多個配置運行,以確認這一測試是有效的)。但是如果你測出來的數據不同于我展示出來的,也別太吃驚,我們仍然可以證明這些性能提高的重要性,所有的測試結果來自尚未發布的DNC 3pre6,這里是我的Windows和Linux配置項,作為使用Benchmark.NET的一個總結:

    2.除非另有說明,否則測試都運行在Windows上,在很多狀況下,性能在Windows和unix上是等同的,但是在其他平臺上,就會有不小的差異,在那些.NET依賴于系統功能的地方,而且系統本身有不同的性能表現。

    3.我提到了DNC2和2.1,但是沒提DNC2.2,2.2主要聚焦于ASP.NET,在ASP.NET層面有巨大的性能提升,這一版本主要關注運行時和核心庫提供的服務,大多數提升在2.1的帖子里就有提及,因此跳過2.2,直接說3.

    基于以上前提,讓我們來找點樂子。

    Span和它的同類

    DNC2.1引入的一個較重要的特性是Span<T>,還有它的同類ReadOnlySpan<T>,Memory<T>,和ReadOnlyMemory<T>。這些新值類型的引入帶來了上百個和它們交互的新方法,一些方法在新類型里面,一些覆寫了已有類的方法,以及JIT編譯器里的優化讓它們的工作效率大有提高。這一版本也包含了一些Span<T>的內部使用(不對外暴露),讓已有的操作更簡潔快速,但依舊保持了可維護性和安全性。在DNC3中,我們投入諸多附加工作,以提高這些方面的性能:讓運行時更好地為它們(指Span<T>等)生成代碼;在運行時內部更多地使用它們來提高其他的操作性能;并且增強和它們交互的不同類庫性能。

    要使用Span工作,應該首先拿到一個Span,已經有幾個PR讓這一進程加快了(原文and several PRs have made doing so faster)。例如,傳遞一個Memory<T>然后用它獲得一個Span<T>是一種獲得Span的常見方法;Stream.WriteAsync和ReadAsync工作的原理是接受一個ReadOnlyMemory<T>(這樣ReadOnlyMemory<T>就可以放在堆上),當實際的字節要被讀寫時進入Memory的Span屬性。這個PR移除了一個參數判斷分支以提高Span和Memory的性能(包括ReadOnlyMemory<T>.Span方法和ReadOnlySpan<T>.Slice方法),雖然移除一個判斷分支是個小事,但是在一堆有著巨量Span的代碼中(例如格式化和解析),小優化就可以聚沙成塔。

    更有影響力的是這個PR,在運行時級別上搞了些奇技淫巧來安全的去除一些運行時類型轉換檢查,而且應用了位掩碼邏輯(bit masking logic)來允許ReadOnlyMemory<T>去包裝不同的類型,像string,T[](泛型數組),和MemoryManager<T>,給這些類型提供了一個無縫的結合。這些PR的結果就是很好的加速了從Memory<T>中捕獲Span<T>的性能,也提高了所有依賴于這一機制的操作的性能。

    當然,你拿到一個Span之后肯定是要用它的,這個類型有無數種用途,其中很多在DNC3中得到了進一步的優化。

    例如數組在通過P/Invoke從Span傳遞數據到本地(native)代碼時,數據必須被固定(除非它已經不可移動了,例如當Span創建時就用來包裝一些本地分配內存或者棧上數據,而不是GC堆)。要固定一個Span,最簡單的方式就是依靠C#7.3中加入的模式,來將fixed關鍵字應用于任何Span類型。一個類型要做的是暴露一個GetPinnableReference方法(或者擴展方法)來返回一個ref T并傳遞到實例中存儲的數據,這樣它就可以用fixed了。

    ReadOnlySpan<T>準確地做到了這一點,但是即使ReadOnlySpan<T>.GetPinnableReference已經被內聯,一個內部調用的Unsafe.AsRef會阻止內聯,這個PR修復了這個問題,允許整個操作被內聯。上述代碼進而在這個PR中被魔改,來清除熱點代碼的判斷分支,兩者加在一起引發了一個可觀的加速了Span的固定:

    這一點值得注意,如果你對這種微優化感興趣,你可能避免使用默認的固定,至少是在熱點處避免。ReadOnlySpan<T>.GetPinnableReference方法是給數組和字符串的固定而設計的,null或者空輸入只會導致一個空指針,這一行為需要進行Span長度為0的非空檢查。

    如果你的代碼有構造器,確保你的Span不會是空值,你可以選擇使用MemoryMarshal.GetReference,性能相同,但沒有長度檢查:

    再一點,一個檢查會增加少量的開銷,當復讀機般的執行之后,開銷就會積羽沉舟:

    當然,有很多其他(也更受人歡迎)的方式去操作Span的數據,而不是用fixed關鍵字。比如,讓人有點吃驚的是,直到Span<T>到來之前,.NET都沒有一個內建的memcmp(memory compare,C/C++專屬)等效品,然而Span<T>的SequenceEqual 和SequenceCompareTo方法已經成為了.NET比較內存區域數據的必經之路。在DNC2.1中,這兩個方法運用了System.Numerics.Vector優化以實現向量化,但是SequenceEqual的情況使得它更容易被人利用。在這個PR中,benaadams針對AVX2和SSE2(兩個最常見的SIMD指令集)更新了SequenceCompareTo以利用DNC3中新的intrinsic API,導致了比較無論大小的Span的性能的顯著提升。(如果想查閱更多關于DNC3中intrinsic的信息,可以看這里和那里)。

    在后臺,“向量化”是單核心單指令并行執行多個操作的方法,一些優化過的編譯器能自動向量化(譯者注:例如使用LLVM做后端的Mono AOT,這一點目前的CoreCLR都沒做到),借此編譯器會分析循環來判斷是否可以利用指令來生成等效的代碼讓它跑得更快。.NET的JIT編譯器當前還不會自動向量化,但手動向量化循環是可能的,相應選項在DNC3中性能大為提高,舉個例子向量化會長啥樣,想象一下你想搜索一個byte數組的第一個非0 byte值,返回其位置,簡單的方法是迭代所有bytes的位置:

    當然,對很小的數組而言它還能有效工作,但是數組大了之后,這么做就會多出很多無用功,考慮到64位處理器會把字節數組重譯為long數組,Span<T>對此有很好的支持。我們可以一次比較8個字節而不是一個,以增加代碼復雜性為代價:只要我們找到一個非0的long值,我們就可以查看它攜帶的每一個byte,去找到第一個非0字節(雖然還有方法來改進這個操作)。類似的,數組的長度可能不會正好是8的倍數,所以我們需要處理溢出。

    對于更長的數組而言,LoopLongs方法有了9倍多的提升

    我在這里掩蓋了一些細節,但還是應該傳達核心理念。.NET添加了額外向量化機制,尤其是上述的System.Numerics.Vector類型允許開發者使用Vector編寫代碼,然后使用JIT編譯器將其編譯成當前平臺上最好的指令。

    LoopVectors方法又把性能提高了40%

    DNC3進一步擁有了新的intrinsic api,允許有興趣的開發者在受支持的硬件上發揮出最好的性能,利用AVX或SSE這樣的指令集你可以一次比較超過8個字節,DNC3中的諸多提升來自這些技術的使用。

    回到我們的例子中,復制Span的性能也有所提升,感謝banaadams提供的這個PR和那個PR,尤其是針對小的Span…

    搜索在任何程序中,都是最經常使用的操作之一,Span的搜索一般使用IndexOf方法,它的變種IndexOfAny和Contains在benaadams的這個PR中再一次被向量化,這一次提升了IndexOfAny操作字節時的性能,字節在網絡相關的解決方案里尤為普遍(例如在線下把字節解析為HTTP棧的一部分),你可以在下面的測試中看到效果:

    我挺喜歡這種優化,因為它們足夠底層以至于它們在大量代碼調用的情況下,事半功倍。以上的操作只影響到了字節,但是隨后的PR也覆蓋到了char的優化,這個PR做出了不錯的改變,將同樣的變化帶到了同樣規模的其他主數據類型中,例如我們可以將上個測試重新用在sbyte上,看到這個PR影響下,一個類似的性能提高:

    另一個例子,看下這個PR,這一變化和剛才講到的類似,使用向量化去提升ToUpper/ToLower變種的性能。

    這個PR優化了ReadOnlySpan<char>.TrimStart/TrimEnd()的性能,也是個很普遍使用的方法,得到了可喜的結果(很難看到結果中的空白部分,但是表中的結果是按照Params屬性里的參數順序排列的)

    有時候優化器僅僅在代碼管理上更聰明了些。這個PR移除了函數中一個不必要的卻在很多事關全局的代碼中起作用的層,僅僅移除那些多余的方法調用就引起了可觀的加速,例如在小Span情況下……

    當然,Span最厲害的一點在于,它是可復用的結構單元,允許很多高級操作,包括在數組和字符串上……

    數組和字符串

    性能優化作為DNC的一個主題,新功能不管在哪,不僅應該暴露給大眾使用,內部也要用上。畢竟考慮到DNC功能涉及到的深度和廣度,如果關注性能的特性連DNC本身都不能滿足的話,它多半也不會滿足客戶的需求。嚴格來說,在內部用上新特性才能證明我們的設計合格,評估它們的時候,很多額外的代碼提供了助益,這些優化有了加倍的效果。

    這部分不僅僅和新的API有關,在C#7.2和7.3中介紹的很多語法,包括C#8本身都受到了DNC自身需求的影響,并用于優化那些我們以前難以優化的地方(而不是淪落到去使用非托管的代碼,我們盡力避免去用的那玩意)。例如,這個PR通過利用C# 7.2的ref locals和7.3的ref local reassignment特性加速了Array.Reserve方法,使用新特性可以讓代碼更好地讓JIT為內部循環生成代碼,然后就是一個肉眼可見的加速:

    數組還有個例子,Clear方法在這個PR里也被優化了,處理了讓該方法依賴的隱式memset操作變慢了2倍的對齊問題。這個改變會一個個地手動清理最多幾個字節,這樣我們就可以把指針交給memset去對齊,如果你“足夠幸運”,數組剛好對齊,性能就不錯,如果沒有對齊,就會對性能有不一般的影響,這個測試模擬了不好的情況:

    也就是說,很多性能優化其實是建立在新的API上的,Span就是個好例子,它在DNC2.1中被引入,初衷是想讓它能用并暴露出足夠多的API讓它能有意義,但同時我們開始在內部使用它,一是檢查我們的設計,二是利用它帶來的優化。這些工作一部分在DNC2.1中完成了,但是影響在DNC3中依然持續,數組和字串都是這種優化的主要受益者。

    很多用在Span上的向量化優化也一樣地用在了數組上。benaadams(怎么又是這個人)的這個PR針對字節和char都優化了Array.LastIndexOf和IndexOf方法,使用了和Span類內部一樣的內部輔助方法,也得到了相似的優化結果:

    和Span一樣,感謝dschinde的這個PR,IndexOf的優化現在可以應用于相同大小的其他基元類型。

    向量化優化也用在了string上,你可以看優化帶師benaadams的這個PR帶來的效果:

    注意一下,DNC2.1因為將字符數組轉化為string有額外的分配,但是DNC3就沒了,感謝benaadams的這個PR。

    當然有些功能更偏向于string(雖然也能用于Span暴露出來的新函數),例如用多種字串比較方法計算哈希值,例如這個PR提高了執行OrdinalIgnoreCase時String.GetHashCode的性能(OrdinalIgnoreCase和Ordinal(默認)是兩個最常使用的模式)。

    OrdinalsIgnoreCase也為別的用途優化了。例如,這個PR通過向量化和移除判斷分支,用StringComparer.OrdinalIgnoreCase優化了String.Equals的性能(一次檢查兩個字符而不是一個,并從內部循環中移除了判斷分支:

    剛才的情況是String實現的功能示例,但是還有很多附加的string相關功能也被優化了,例如Char的不少操作性能都得到提升,例如這個PR和那個PR改進的Char.GetUnicodeCategory:

    那些PR還強調了另一個從語言改進中受益的例子,在C#7.3中,C#編譯器能夠優化這個形式的屬性:

    相對于照章編譯,每次調用都會分配一個新的字節數組而言,編譯器利用了兩個特征:a)數組背后的字節都是常量,b)返回了一個ReadOnlySpan,也就是說用戶不能用托管代碼去修改這個span的數據。通過這個PR,C#編譯器取締了將字節寫成二進制大對象放進元數據的做法,這個屬性將只會生成一個Span直接指向相應數據,這樣訪問數據就會極快,甚至比返回一個靜態字節數組還快:

    另一個值得關注的字串相關領域是StringBuilder(不僅僅是它自己的優化,雖然這個類的確收到過一些,例如Wraith2在這個PR中的一個重載,避免了意外的裝箱并且從一個ReadOnlyMemory<char>中創建了一個string添加到構建器中)。在很多情況下,StringBuilder用著都是方便起見,但是也增加了消耗,只需要一點小小的工作(以及某些情況下,用到DNC2.1中新的String.Create方法),我們就能消除這些開銷,不管在CPU上還是在內存分配上,例子如下……

    這個PR移除了Dns.GetHostEntry方法中的marshal操作使用的StringBuilder:

    這個PR從希伯來語數字格式化中移除了一個StringBuilder:

    這個PR從物理地址格式化中移除了一個StringBuilder:

    這個PR從X509Certificate類的若干屬性中移除了StringBuilder:

    諸如此類。

    這些PR證明了即使是小小的改動也可以大有收獲,讓已有代碼開銷更低并有效地擴展到StringBuilder之外。在DNC里面有很多地方用到了String.Substring,其中大部分可以用AsSpan和Slice替代,例如juliushardt的這個PR,或者那個PR和29539號PR,以及29227號PR,29721號PR,都從FileSystemWatcher中移除了字符串分配,延遲了這類字串的創建,只在需要的時候才初始化。

    另一個使用新API去改進已有功能的例子是String.Concat。DNC3有幾個新的String.Concat重載,一個接收ReadOnlySpan<char>代替string,這樣就很容易避免在連接其他字串的片段時帶來的子串分配和復制:我們使用了String.AsSpan和Slice來替代String.Concat和String.Substring。實際上,給這些新重載提供實現,暴露和添加測試的這個PR和那個PR也給DNC添加了幾十個調用點(call sites)。這有個例子,優化了Uri.DnsSafe的訪問:

    還有個蠣子,使用Path.ChargeExtension把一個非空擴展名(extension)換成另一個:

    最后,一個非常接近的領域是編碼。關于Encoding,一大波優化已經在DNC3中實現,不管是在通用還是特定的編碼中。例如這個PR允許Encoding.Unicode.GetString在許多地方應用一個已有的極端條件優化,又或者是這個PR從多個編碼實現中移除了一堆無用的虛擬間接尋址(其實就是移除了一些參數并加上一些sealed),還有這個PR,通過利用早些時候提到的“共同元數據-二進制大對象span“支持,來優化Encoding.Preamable;以及這個PR和那個PR大改并流水線化了UF8Encoding和AsciiEncoding的實現。

    這些例子都在強調字串本身或者應用于其周邊的改進,都很不錯,但是字串相關的改進真正影響的地方是接下來要說到的格式化和解析。

    格式化/解析

    解析和格式化是任何現代web應用或服務的命脈:線上提取數據,解析,操作,重新格式化。在DNC2.1中,伴隨著Span<T>的成熟,我們致力于實現基元類型的格式化和解析,例如從Int32到DateTime。很多這一類型的改動都能在我過去的博文中讀到,但是能實現那些優化的重要原因是把很多本機代碼遷移到托管代碼,這可能有些反直覺,畢竟C代碼比C#代碼更快是“常識”。但是除了它們(指C和C#)之間的鴻溝在縮小以外,(絕大多數)安全的C#代碼更容易去進行調測,所以雖然我們修改那些本機(native)代碼的實現看上去反復無常,但是一般公眾已經憑借于此,在一切能優化的地方深入優化。DNC3中我們仍在全力繼續這些努力,也得到了超棒的激勵。

    讓我們從核心的Integer基元類型開始吧。這個PR為整型風格的數據(如Int32或Int64)添加了一個特殊的變種,這個PR為無符號整型添加一個類似的支持,而這個PR給16進制數添加了一個差不多的,除此之外,這個PR分布在更多的優化中,例如將這些改動利用在byte一類的基元類型中,跳過無關緊要的函數分層,將一些方法調用流水線化便于內聯,進一步減少了判斷分支。最終這一版本中解析整型基元類型的性能得到了重大提升。

    這些類型的格式化也有所改進,盡管在DNC2-2.1之間它們已經被大幅優化。這個PR修改了代碼結構,以避免在不需要的時候訪問當地數字格式(例如當將一個值格式化為16進制,這個操作不需要遵守當地的文化,又何必去訪問區域設置呢?),這個PR則優化了金融數字格式化的性能,很大程度上是靠優化數據的傳遞方式(抑或是根本沒傳遞)。

    實際上,DNC3中,System.Decimal自己都被大修了,在這個PR之后,它就是個完全托管代碼實現了,還有一些別的性能工作在這個PR里面。

    回到解析和格式化上,甚至還有一些新的特殊情況的格式化,一開始可能看上去像蔡徐坤,但是代表了實事求是的優化風格,在一些大型web應用中,我們發現了托管堆上大量的字串僅僅是由0和1組成的,既然“最快的代碼就是不去執行的代碼”,那么為什么要在能緩存和復用結果的情況下,一遍遍地分配和格式化這些小數呢(其實就是實現一個自己的字串拘留池)?這就是這個PR所做的,給0-9新建一個特定的字串小緩存,不管我們在什么時候格式化一個單數字整型,只需要從緩存中拉取這些字串。

    (有一定時間效果,同時避免了空間分配)

    枚舉類型在DNC3中也得到了很大的解析和格式化性能改進,這個PR優化了Enum.Parse和Enum.TryParse的處理,不管是泛型還是非泛型的。這個PR優化了[Flags]枚舉的ToString方法,而那個PR進一步提升了其他ToString方法。最終Enum相關的性能提升也很大:

    在DNC2.1中,DateTime.TryFormat和ToString方法已經針對通常使用的“o”或“r”格式優化過,在DNC3中,等價的解析也得到了類似處理。這個PR大大提高了DateTime和DateTimeOffSet的往返“o”格式解析性能,而這個PR為RFC1123格式做了一樣的事,對任何DateTime的沉重序列化格式,這些改進都能弄個大新聞:

    說回剛才說過的StringBuilder,默認的DateTime格式化也被這個PR優化了,修改了DateTime和StringBuilder的內部交互機制,用于建立結果狀態。

    TimeSpan格式化也大有提升,通過這個PR:

    Guid類的解析在這場“優化的游戲”中也開始狂舞,通過這個優化它的PR,主要是通過避免輔助線程的開支,還有規避一些搜索,它們用來決定應用哪些線程去解析。

    和這有關的是,這個PR再一次利用了向量化,優化了Guid和byte數組以及Span之間的互相解析與構建。

    正則表達式

    正則表達式經常和解析扯到一塊。DNC3中我們對System.Text.RegularExpressions做了點微小的工作。這個PR用基于ref struct的構建器取代了內部的StringBuilder緩存,這樣就能利用棧分配的空間和池化的緩存。這個PR通過進一步利用了Span延續了這一工作,但是Alois-xx的這個PR帶來了最大的改進,修改了RegexOptions.CompiledRegex生成的代碼,以避免因為當前地域帶來無謂的thread-local訪問。當用上RegexOptions.IgnoreCase時,這一優化更具威力。為了看到實際影響,我找了一個Compiled和IgnoreCase都用過的復雜正則,并做了個測試:

    長到喪心病狂

    線程

    線程是個一直存在但是大多數應用和庫在大多數情況下都不需要顯式與其交互的東西,這使得運行時優化以盡可能減少開支越發成熟,這樣用戶代碼就更快了。上一個DNC版本展示了我們在這一領域投入的努力,DNC3延續了這個動向。這也是另一個新的API(指Span)得以暴露并作用于DNC自身的示例。

    例如,以前能排進ThreadPool隊列的東西(特指原文的work item,一個回調)只有那些運行時自帶的,也就是ThreadPool.QueueUserWorkItem和它的同類如Task和Timer創建的任務。但是在DNC3中,ThreadPool有了一個UnsafeQueueUserWorkItem方法重載,可以接受新的IThreadPoolWorkItem接口,這個接口非常簡單,只有一個Execute方法,任何實現了這個接口的對象都可以直接排進線程池隊列。這是高級的用法,大多數代碼用已有的回調就可以了。但是更多的選項提供了很多靈活性,尤其是在一個可重用的對象上實現這個接口,這樣它就能反復排進線程池,這一改進現在用在DNC3中的許多地方。

    System.Threading.Channels中就有一個這樣的例子,Channels類庫在DNC2.1中引入,已經有了一個很低的配置要求(原文是profile),但是仍然會有一些時候它會分配。例如,創建一個channel的一個選項是類庫創建的延續任務是否應該同步運行/異步運行,作為任務完成的一部分(例如當一個Channel上的TryWrite()調用,喚醒了相應的ReadAsync方法,是否ReadAsync的延續任務會被同步調用,或者被TryWrite調用排入隊列)。默認情況下延續任務從不同步調用,但是也需要分配一個對象作為將延續任務排列到隊列的一部分。在這個PR中,實現了IThreadPoolWorkItem的可重用IValueTaskSource備份了從ReadAsync返回的ValueTask,因此本身可以排進隊列,避免了分配,起到了很好的優化作用。

    IThreadPoolWorkItem現在也能用在別的地方,例如ConcurrentExclusiveSchedulerPair(一個沒多少人知道但有用的類型,提供了一個限制一次只能執行一個任務的排他調度器,一個一次執行用戶指定數量的任務的并行調度器,互相配合使得排他任務運行時,沒有并行任務在運行,就是一個讀寫鎖)現在也實現了IThreadPoolWorkItem,這樣就避免了將其排入隊列時的分配。這玩意也在ASP.NET?Core中用到,也是ASP.NET測評中每個請求(request)做到0分配的關鍵原因之一。但是到目前為止,最具影響力的實現是async/await的基礎建設。

    在DNC2.1中,運行時對async/await的支持大修過,徹底地減少了涉及到異步方法的分配,以前當一個異步方法第一次等待一個尚未完成的可等待操作時,基于結構體的狀態機將會被裝箱(就是運行時裝箱)放到堆上。但是在DNC2.1中,我們使用了一個泛型對象,結構體作為這個對象的字段而存在。這樣有諸多好處,其中之一是允許在這個對象上實現額外的接口,例如IThreadPoolWorkItem。這個PR很好的做到了這一點,并且使得另一個大范圍應用場景進一步減少了分配,尤其是用于TaskCompletionSource<T>的TaskCreationOptions.RunContinuationsAsynchronously。可以在下面的測試中看到效果:

    Gen0指第0代GC,相當于Java的Eden,可以看到我們少產生了很多垃圾

    這一改動帶來了接下來的優化,例如這個PR使用該優化進行await Task.Yield();無分配:

    它還進一步用在了Task自己身上,有個有趣的競態條件要在等待操作中處理:如果等待之后的操作在調用IsCompleted之后,卻在OnCompleted之前完成會發生什么?提醒一下,看這段代碼:

    當我們執行到IsCompleted返回false的時候,將調用AwaitedOnComplated方法然后返回。如果等待操作在調用AwaitOnCompleted時完成,我們(又)不想同步調用重入狀態機的延續,因為我們會在棧中進一步操作,如果這種事情反復發生,就會發生“潛棧(stack dive)現象”,然后爆棧。相反的是,我們強制排列這個延續。這種情況并不普遍,但是會比你期望的更加頻繁,這只需要一個快速異步完成的操作(多種網絡操作經常屬于這一類)。因為這個PR,運行時現在會利用實現了IThreadPoolWorkItem的異步狀態機避免這種情況下的分配。

    除此之外,用在async/await的IThreadPoolWorkItem允許asnyc實現將任務以一種像其他代碼那樣更加內存友好的行為排進線程池隊列,還進行了一些更改,讓線程池獲得關于狀態機裝箱的第一手資料來幫助它優化更多案例。benaadams的這個PR讓線程池把一些UnsafeQueueUserWorkItem(Action<object>, object, bool)調用在底層換成UnsafeQueueUserWorkItem(IAsyncStateMachineBox, bool),這樣更高層的類庫就可以享受到這樣分配的好處,而不必意識到裝箱機制。

    另一個異步相關的領域是Timer類型的有效優化。在DNC2.1中,System.Threading.Timers得到了一些一些重要的優化,以提高吞吐量和降低競爭時長,來應對一種普遍情況:計時器沒有觸發,相反它很快就被新建和銷毀了。雖然這些改動在計時器實際觸發時起到了一點作用,但是并沒有解決掉主要的消耗和競爭的源頭——持有鎖的時候進行了很多潛在工作(與注冊的計時器數量成比例),DNC3作出了很大的改進。這個PR將注冊計時器的內部鏈表分成兩部分:一個鏈表存儲很快就會觸發的計時器,另一個存儲一段時間不觸發的計時器。在大多數工作負載下,都會有很多計時器被注冊,大部分在任何給定的時間點上都會扔到下一個桶里,這個分區方案則允許了運行時在大多數時間只考慮觸發計時器的小桶。這樣做顯著減少了涉及觸發計時器的消耗,也引起了持有鎖帶來競爭的顯著減少。一個受無數活動計時器帶來的問題所困擾的客戶在嘗試過這些改變之后如是評價道:

    “我們昨天看到了產品的變化,結果是驚人的,減少了99%的鎖競爭,測量到了4-5%的CPU提升,更重要的是我們的服務可靠性提升了0.15%(很大了)!"

    這個解決方案的自身情況在測評中難以看出影響,所以我們做了點別的,測量了一些間接影響的東西而不是測量實際改變的參數。這些改動并不直接影響創建和銷毀計時器的性能;實際上,它們的設計目標是避免創建和銷毀(尤其是避免破壞重要的過程)。通過減少觸發計時器的消耗減少持有鎖的時間,也減少了創建銷毀計時器帶來的競爭,所以我們的測試建立了一堆計時器,測量它們的觸發時間和頻率,然后我們測試了創建和銷毀一堆計時器的時間消耗。

    100萬個計時器帶來的消耗與優化

    Timer的優化也采用了別的形式。例如,benaadams的這個PR把不用CancellationToken時,涉及Task.Delay的內存分配減少了24字節,這個PR則減少了創建計時的CancellationTokenSource的分配,對吞吐量帶來了不錯的影響:

    甚至還有更低層次的優化已經投入生產,舉個蠣子,benaadams的這個優化了Thread.CurrentThread的PR,將有意義的線程存放在ThreadStatic字段中,而不是強制CurrentThread在運行時的native部分生成一個InternalCall。

    還有些別的蠣子,這個PR“教會了”運行時要“尊重Docker的 -cpu限制”,這個PR和另一個PR優化了通過各種同步站點(原文synchronization site)帶來競爭時的自旋行為,這個PR則優化了SemaphoreSlim,當一個實例消費者把同步Wait和異步WaitAsync混合起來時。Quogu的這個PR專門為CancellationTokenSource創建了一個0延時,以避免Timer相關的損耗。

    集合

    把臭腳從線程上拿開,讓我們來看看集合帶來的優化。集合在每個程序上都有普遍的應用,因此它們在以前的DNC版本中得到了很多性能上的關注,即使這樣,仍然有提升的一席之地,下面是DNC3中的一些例子。

    ConcurrentDictionary<TKey, TValue>有一個IsEmpty屬性,標記了當前狀態下,字典是不是空的,在以前的版本中,它持有所有字典的鎖來獲取即時狀態,但實際上,只有我們認為集合可能是空的時候,鎖才需要被持有;如果集合的任何內部桶有任何元素,就不需要有鎖,而且只要找到一個有元素的散列桶,就不需要查找別的桶。因此,drewnoakes的這個PR添加了一個快速流程,首先檢查沒有鎖的散列桶,來優化字典非空這種普遍情況(字典是空的帶來的影響很小)。

    優化了24倍,是dnc3做得太好,還是dnc2做得太差?

    ConcurrentDictionary并不是唯一做出優化的并行集合。這個PR給了ConcurrentQueue<T>一個優化,這是個有趣的例子,表明性能優化通常是場景之間的權衡。在DNC2中,我們大改了ConcurrentQueue的實現,顯著提高其吞吐量,也明顯減少了內存分配,將ConcurrentQueue換成了一個循環數組鏈表,但是這一改動涉及到讓步:因為數組的生產者/消費者的天性,如果有任何需要監視鏈表分段中的數據操作(而不是將其踢出隊列),被監視的分段就會為任何接下來的排隊而被“凍結”……這一舉措是為了避免這樣的事例:一個線程在枚舉段(segment)內的元素,而另一個線程則在進行入隊和出隊,當這個隊列有很多段時對Count的訪問最后被視為觀察對象,但是那就意味著對ConcurrentQueue.Count的簡單訪問將會渲染隊列中的所有段以進一步排隊,此時我們認為這樣的權衡可以了,因為應該沒人會足夠頻繁的訪問隊列的計數,然后我們想錯了,幾個客戶報告了工作負載中顯著的遲緩,因為他們在每個入隊和出隊時都獲取了隊列計數。雖然正確的解決方法是不這樣做,但我們仍想修復這個問題。實際上,這個修復相對簡單直觀,這樣我們就可以在性能上同時得到魚和熊掌,結果在下面的測試中很明顯了:

    ImmutableDictionary<TKey, TValue>也得到了我們的注意。(譯者注:我相信這是FP開發者最喜歡的東西了)一個客戶跟我們說他們比較了ImmutableDictionary<TKey, TValue>和Dictionary<TKey, TValue>,發現前者查找的性能遠比后者慢,這事其實在意料之中,因為這兩個類型用到了大不一樣的數據結構,ImmutableDictionary的優化點在廉價地創建一個字典的可變副本,一些操作相對于Dictionary來說太昂貴;一開始的權衡就說查找會更慢一些,但是我們還是看了一下ImmutableDictionary查找的性能,然后這個PR提供了幾個提升性能的修改,將一個遞歸調用變成了非遞歸和可內聯,并去掉了一些無謂的結構體包裝。這雖然沒讓ImmutableDictionary和Dictionary的查找功能速度一樣,但是也讓ImmutableDictionary 性能大有提升,尤其是在它只有幾個元素的時候。

    另一個在DNC3中看到顯著提升的集合是BitArray。很多操作包括構造器,都在這個PR里優化了。

    這一集合的核心操作,例如Get和Set在這個omariom的PR里得到了進一步提升,通過流水線化相關的方法,并使其可以內聯。(譯者注:這個PR有些雞肋,因為BitArray屬于System.Collections命名空間,而這個命名空間應該被拋棄)

    另一個例子是SortedSet<T>。acerbusace的這個PR更改了GetViewBetween修改整個集合和子集的計數管理方式,得到了漂亮的性能加速。

    比較器在DNC3中也有漂亮的性能提升,如這個PR重寫了運行時中枚舉類型的比較器實現方式,借用了CoreRT中使用的方法。性能優化經常是添加代碼;而這是偶然發生的,優化代碼不僅更快,還更簡單更小的情況。

    網絡

    從運行在System.Net.Sockets和System.Net.Security的kestrel服務器,到通過HttpClient訪問web服務的網絡應用,System.Net已經成為了許多應用的必備之選,在DNC2.1中它收到了很多優化嘗試,3版本也是一樣。

    讓我們先來看看HttpClient。這個PR所做的優化圍繞緩沖處理方式進行,特別是在服務器提供內容長度(ContentLength)時,作為復制響應數據的一部分的大緩沖請求場合。在一次快速連接和一個大的響應數據體情況下(例子中是10MB),因為減少了系統對傳輸數據的調用,吞吐量有很大的差別。

    現在看看SslStream。以前的版本看上去把SslStream上的讀寫優化到頭了,但是在DNC3中的這兩個PR(還有一個給Unix的)讓連接的初始化更有效率,特別是在分配方面。

    在System.Net.Sockets中有個利用先前說過的IThreadPoolWorkItem的例子。在Windows上的異步操作中,我們用了“重疊I/O”,使用I/O線程池中的線程去執行socket操作的延續操作;Windows將I/O完成包隊列化,然后I/O池線程開始執行,包括調用延續。但是在Unix上機制就大不一樣,沒有“重疊I/O”,相反,System.Net.Sockets中的異步是通過epoll(或者macos上的kqueues)進行的,系統的所有socket以一個epoll文件描述符的形式注冊,有一個線程監視著epoll的變化。不管一個針對socket的異步操作什么時候完成,epoll都會被標記,上面阻塞的線程就會喚醒并執行。如果該線程繼續運行socket的延續動作,那么它最終會無限制的工作下去,阻止其他socket的處理——死鎖。因此與此相反,這個線程會把一個工作者(work item)帶進線程池隊列,然后立刻返回執行其他的socket。在DNC3之前,排隊涉及到分配,因此每個在unix上異步完成的socket操作都會有至少一個分配。在這個PR中,就再也沒有分配了,因為一個實現了IThreadPoolWorkItem,代表異步操作的緩存對象會被反復重用,直接列隊進入線程池。

    System.Net的其他領域也從剛才提到的這些工作中受益,例如Dns.GetHostName在它的marshal操作中用的是StringBuilder,但是在這個PR之后就不再這樣了。

    IPAddress.HostToNetworkOrder/NetworkToHostOrder間接從剛才說過的intrinsics推送中受益,在DNC2.1中,BinaryPrimitives.ReverseEndianness作為一個優化過的實現添加進來,IPAddress的方法被重寫成ReverseEndianness的簡單包裝,在DNC3中,這個PR將ReverseEndianness換成了JIT intrinsic實現,因為JIT能夠發出一個很有效率的BSWAP指令,使得IPAddress的吞吐量有所提高。

    提升了100多倍,夠毀三觀的

    System.IO(I/O操作)

    壓縮和網絡通信一直是“手拉手”的關系,因此壓縮操作在DNC3中也優化了。最值得注意的是,一個關鍵性的依賴被更新了。在Unix上,System.IO.Compression只用了機器上可用的zlib,而且zlib是幾乎每個unix發行版的標準部分。但是在Windows上,zlib幾乎都找不到,因此它被內置在win版的DNC里面。現在我們不包著標準的zlib了,DNC帶了一個Intel的優化魔改版(還沒有合并給上游,指標準zlib),在DNC3中,我們同步到zlib-intel的最新版,1.2.11,這個庫帶來了一些很可觀的性能優化,尤其是解壓縮上。

    也有利用了DNC上述優化的壓縮相關案例,比如同步方法Stream.CopyTo以前不是虛方法,但是重寫了異步的CopyToAsync方法并針對混合流類型(concrete stream types)優化后,CopyTo被設定為虛方法,來靠重寫得到相似的優化。這個PR在DeflateStream上覆寫了CopyTo,本質上減少了和zlib的互操作消耗。

    BrotliStream也作出了相應的改進(在DNC3中也被HttpClient用來自動解壓Brotli編碼的內容),以前每個新的BrotliStream都會分配一個很大的緩存,但是在這個PR中,緩沖被池化了,就像在DeflateStream中做的一樣(另外,這個PR重寫了BrotliStream的ReadByte和WriteByte以避免父類實現的分配)。

    把視線從壓縮上移開,應用于多場合下的格式化可比只格式化基元類型更值得介紹,例如TextWriter有很多編寫格式化字串的方法,比如public override void Write(string format, object arg0, arg1),這個PR針對StreamWriter優化了這個方法,通過提供特定的重寫使其更有效率,減少分配:

    再舉個例子,TomerWeisberg提的這個PR在BinaryReader包含MemoryStream時,通過將普遍情況特殊處理,來提高BinaryReader的基元類型解析性能。

    再來看看MarcoRossignoli提的這個PR,對StringWriter的Write{Line}{Async}方法添加了覆寫,引入了一個StringBuilder參數。StringWriter只是一個StringBuilder的包裝,而且StringBuilder知道如何把自己和另一個StringBuilder加起來,因此這些StringWriter的重寫可以直接通過。

    System.IO.Pipelines是另一個在DNC3中受到很多關注的類庫。Pipelines在DNC2.1中就引入了,作為I/O管線的一部分提供了緩沖管理,被ASP.NET?Core大量應用。不少PR用來提高它的性能,例如這玩意將普遍情況特殊處理,默認情況下,MemoryPool<byte>.Shared作為默認的Pool給一個Pipe使用。Pipe會直接訪問底層的ArrayPool<byte>.Shared,繞過Memory<byte>.Shared,移除了一個間接層,還有MemoryPool<byte>.Rent返回的IMemoryOwner<byte>對象開銷(注意這個測試,因為System.IO.Pipelines是Nuget的一部分,而不是在公共框架中,我添加了一個配置,指定了每次運行中使用哪個包版本):

    benaadams的這個PR允許Pipe使用UnsafeQueueUserWorkIte裝箱相關的優化,這個PR則避免了排入不重要的工作(work items),那個PR修改了以前的默認情況來優化一般情況下的緩存處理,35216號PR在各種pipe操作中減少了切片操作數量,benaadams的另一個PR減少了核心操作的鎖數量,35509號PR減少參數驗證(減少了判斷分支消耗),33000號PR著眼于減少作為主要交換管線的ReadOnlySequence<byte>相關的消耗,這個PR進一步優化了Pipe上GetSpan和Advance之類的操作,最后把已經很低的CPU和內存開銷再次削減:

    System.Console(控制臺)

    常人往往不會認為控制臺也是性能敏感的,但是這個版本中有兩個改動,我覺得有必要講講。

    一開始,我們聽說了很多關于控制臺性能的擔憂,顯而易見地影響到了用戶的體驗,特別是交互式控制臺應用程序在光標上做了很多操作,也涉及到查找光標在哪的問題。在Windows上,光標的獲取和設定都是很快的操作,通過kernel32.dll暴露出來的函數P/Invoke即可,但是在unix上,事情就變得復雜了,沒有標準的POSIX函數去獲得/設定一個終端的光標位置,相反有個標準的習慣,通過ANSI轉義序列去和終端交互。要設定光標位置,需要寫一些字符來輸出(例如"ESC [ 12 ; 34 H"代表12行, 34列),終端會識別并作出相應舉動。獲取光標位置更是個考驗,一個應用需要輸出一個請求 (例如“ESC [ 6 n”),終端會回應一個類似于“ESC [ 12 ; 34 R”,代表在12行和34列的光標。這一切都意味著要從輸入讀取和解析,因此在Windows上一個內部調用的事,unix上我們又得讀寫又得解析,而且要防止用戶臉滾鍵盤使用應用時不會發生問題(原句為user sitting at a keyboard),這樣的操作并不廉價,如果只是偶爾地獲取光標位置,還不是什么大問題,但是當頻繁獲取時,原本為Windows寫的操作很廉價的代碼,在遷移到其他平臺時就會發生肉眼可見的性能問題,不過好在這個問題在DNC3中已經被tmds的這個PR定位到了,這一改動緩存了當前位置,然后基于用戶交互手動處理緩存值更新,例如輸入文字或者改變窗口大小。注意一點,.NET的測試會重定向標準輸入和輸出,因此這會讓Console.CursorLeft/Top立刻就返回0,因此針對這個測試,我用StopWatch做了個小的控制臺應用,如你所見,版本之間差別很大:

    1萬多倍,毀三觀

    在另個地方,控制臺在unix和windows上性能都有提升,有趣的是這個改動一開始是因為功能(尤其是用在Windows上),但是它對所有的操作系統都有性能提升。在.NET中,我們指定緩沖區大小大多數情況下是為了性能,也代表著一種權衡:緩存越小,內存消耗就越小,但是需要更多次的操作,相反緩存越大,內存消耗也就越大,操作次數也就越少。緩存大小很少對功能造成影響,但是在控制臺里就不一樣了。在Windows上,從控制臺讀取用ReadFile或者ReadConsole都行,因為它們都是接受一個存儲讀取數據的緩存的。Windows上默認在你開新一行之前,從控制臺的讀取結果不會返回,但是Windows也需要個地方去存儲輸入的數據,所以它在提供的緩沖區這樣做。這樣,Windows不會讓用戶輸入超過緩沖區大小的字符——用戶能輸入的行長度被緩沖區所限。由于歷史原因,.NET使用了256字符的緩存區,但是這個PR把這個限制放寬到4096字符,更好地匹配了他人的編程環境,也允許更合理的行長度。但是提升緩存區大小的同時,相關的吞吐量也提高了,尤其是用管道從文件讀取到輸入(from files piped to stdin),例如,以前從stdin讀取8k的輸入數據,需要調用ReadFile32次,但是4096的緩存區域就只需要讀取兩次,帶來的性能影響可以在下面看到(這個用Benchmark.NET也不好測,所以我又用了個小控制臺應用):

    System.Diagnostics.Process

    在DNC3中,Process類迎來了很多功能提升,尤其在unix上。但是也有幾個我要說的性能提升。

    這個PR是另一個引入新的著重于性能的API,同時用在DNC里提升核心類庫性能的好例子。它是個MemoryMarshal的低層API,允許高效地從Span讀取結構體,作為System.Diagnostics.Process交互操作中不可缺少的一部分。我喜歡這個例子,不僅僅因為它帶來了巨大的性能提升,還因為它傳達了我一直在傳達的理念:添加他人可消費的API,也用這些API促進技術本身。

    另一個例子更有影響力,joshudson的這個PR將復制一個新進程的本機代碼從使用fork函數換成了vfork函數,vfork的好處在于避免了將父進程的頁表復制到子進程中,假設子進程會通過幾乎立刻執行的exec調用,來重寫一切。fork做的是copy-on-write(奶牛,寫入時復制),但是如果進程并行地調節很多狀態(例如帶GC運行),這么做代價就很高,還沒什么必要,為了這個測試,我在test.c中寫了一個沒有語句的C程序:

    用GCC編譯后的結果

    LINQ

    以前的版本,為了優化LINQ我們累死累活,在DNC3中就少了,因為很多通用范式已經被覆蓋了。但是這一版本還是有很多不錯的優化。

    向System.Linq添加新操作的事情已經很稀少了,因為任何人都可以添加擴展方法,簡單地構建和發布他們認為有用的擴展方法庫(真的有這么幾個歷史悠久的庫存在),即使這樣,DNC2仍然添加了一個TakeLast方法。在DNC3中,romasz的這個PR更新了TakeLast方法,使之和內部的IPartition<T>集成,允許幾個操作互相配合,有助于優化(有些情況下效果很大)這些操作的不同用途。

    就在最近,這個PR優化了Enumerable.Range(...).Select(…)的常見模板,關于Range生成的對象針對性地優化了Select,還允許Select操作的流跳過遍歷IEnumerable<T>,直接循環想要的數值范圍。

    Enumerable.Empty<T>()在這個PR中也被修改了,來更好地配合DNC的System.Linq中已有的優化。雖然不應該在Enumerable.Empty<T>()的結果上顯式調用額外的LINQ操作,但是IEnumerable<T>的返回值可能是一個Empty<T>()的結果也很正常,然后調用者可能在其上進行別的操作,因此這個優化很有必要:

    我們也很關注DNC的程序集大小,特別是因為它能影響AOT編譯,類似于這個的PR,在有大量泛型的LINQ里面應用了“ThrowHelper",幫助減少生成代碼的體積,對不止它自己還有別的領域的性能提升都有好處。

    互操作

    互操作是對.NET的用戶或者.NET自己都非常重要的事情之一,因為很多.NET的功能需要和操作系統的功能互操作才能正常運行。互操作的性能提升影響了很多組件。

    一個值得注意的類是SafeHandle,另一個把代碼從本機代碼遷移到托管代碼獲得性能提升的例子,SafeHandle是管理非托管資源生命周期的推薦方式,不管是Windows上的句柄,還是unix上的文件描述符,它在coreclr和corefx的所有托管庫里的內部使用方式也是如此。推薦使用它的一個原因是它會使用合適的同步機制確保這些非托管資源不會在使用時就被托管代碼關閉,那就意味著互操作層需要跟蹤SafeHandle搞出來的每一個P/Invoke,在P/Invoke之前調用DangerousAddRef,P/Invoke之后調用DangerousRelease,還需要DangerousGetHandle來提取實際的指針值,將其傳遞給本機函數。在.NET的以前版本中,這些實現的核心部分是在運行時,意味著托管代碼需要在運行時對每個這樣的操作都對本機代碼建立InternalCall。在DNC3的這個PR中,上述操作都被移動到托管代碼,移除了過渡操作帶來的開銷。

    marshal的優化也有幾個例子。我在這個帖子里說過幾種StringBuilder用于marshal和互操作的情況,鄭重聲明,我個人不喜歡在互操作中用StringBuilder,因為它增加了消耗和復雜度,卻沒有多少增益,因此在這個PR和那個PR中,我移除了coreclr和corefx的marshal中幾乎所有的StringBuilder,但是還有很多代碼建立在StringBuilder的基礎上,應該盡可能地快。這個PR避免了很多發生在StringBuilder的marshal操作時不必要的工作和分配,帶來如下優化:

    互操作和marshal的特定用途也被優化了。例如,FileSystemWatcher在macos上的互操作以前用的是MarshalAs特性,強迫運行時在每個OS回調時都做額外的marshal操作,包括分配數組。這個PR把FileSystemWatcher的互操作換成了一個更有效率的方案,不包括額外的分配,也不包括marshal命令。或者看看這個PR,System.Drawing也使用了一個優化過的marshal和互操作方案,固定了一個托管數組,直接傳遞到非托管代碼,而不是分配多余的內存然后復制進去。

    花生醬

    在這篇帖子的前半部分,我將那些影響了.NET各個領域的PR分組介紹,其中一些主流功能得到了顯著改進。但是也有一些值得關注且不限領域的PR。

    在.NET中我們把這種東西叫“花生醬”,我們有大量的代碼,對于大多數應用程序來說通常都很好,但是它有很多小的改進機會。單獨的那些小改進不會讓事情變得更好,但是它們防止了大規模代碼下的性能滑坡,這樣的問題我們修復的越多,總體性能也就越好。這兒移除個分配,那兒少個循環,還有些沒用的代碼移除了,這些就是我要說的“花生醬”。

    • 提供給Array.Copy的最低下標。調用Array.Copy(src, dst, length)需要運行時為每個src和dst調用GetLowerBound,但是傳遞T[]數組時,下標就是0,我們只需要簡單地傳遞0就可以,這個PR已經做了。

    • 復制到新數組更廉價。在很多地方,一個List<T>存儲了一些數據之后,一個數組就會基于list的長度分配出來,然后list的內容就會用CopyTo復制到數組中。這個PR中benaadams意識到了這么做有多SB,然后把它們換成了List.ToArray。

    • Nullable<T>.Value?vs?GetValueOrDefault.Nullable<T>?有兩個成員變量來訪問它的值:?Value?和?GetValueOrDefault.反直覺的是?GetValueOrDefault更廉價一些:?Value需要檢查實例是不是一個值,如果不是就拋異常,GetValueOrDefault只會返回值,如果沒有就是一個default。這個PR修改了幾處可以用GetValueOrDefault代替的調用。

    • Array.Empty<T>().?在以前的版本中,很多零長度數組都被換成Array.Empty<T>,在類庫和通過編譯器修改那些類似于params數組的東西。DNC3延續了這個策略,這個PR對corefx進行了一波清理,將更多的0長度數組換成了緩存的Array.Empty<T>()。

    • 到處避免小分配。?對于新寫的代碼,我們很關注消耗,在分配上多長了個心眼,即使分配再小再稀有,都能簡單地換成更廉價的操作。對于已經存在的代碼,影響最大的分配會出現在關鍵場景的分析中,這些分配會被盡可能的壓縮。但是還有很多小分配沒有被我們發現,直到我們因為某個原因去回顧和評估這些代碼。在每個版本中,我們都會移除很多的小分配,例如下面的這些PR,在DNC3中它們用來減少coreclr和corefx的分配:

      • In System.Collections:?dotnet/corefx#30528

      • In System.Data:?dotnet/corefx#30130

      • In System.Data.SqlClient:?dotnet/corefx#34044,?dotnet/corefx#34047,?dotnet/corefx#34234,dotnet/corefx#34999,?dotnet/corefx#35549,?dotnet/corefx#34048,?dotnet/corefx#34390, and?dotnet/corefx#34393, all from @Wraith2

      • In System.Diagnostics:?dotnet/coreclr#21752

      • In System.IO:?dotnet/corefx#30509,?dotnet/corefx#30514,?dotnet/coreclr#21760,?dotnet/corefx#37546

      • In System.Globalization:?dotnet/coreclr#18546,?dotnet/coreclr#21121

      • In System.Net:?dotnet/corefx#30521,?dotnet/corefx#30530,?dotnet/corefx#30508,?dotnet/corefx#30529,?dotnet/corefx#34356,?dotnet/corefx#36021

      • In System.Reflection:?dotnet/coreclr#21770,?dotnet/coreclr#21758

      • In System.Security:?dotnet/corefx#30512,?dotnet/corefx#29612

      • In System.Uri:?dotnet/corefx#33641,?dotnet/corefx#36056

      • In System.Xml:?dotnet/corefx#34196

    • 避免顯式靜態構造器。?任何初始化靜態字段的類型,最后都會用靜態構造器去初始化,但是如何初始化也會影響性能。尤其是當開發者顯式編寫了一個靜態構造器,而不是作為靜態字段聲明的一部分去初始化字段時,C#編譯器就不會將類型標記成beforefieldinit,beforefieldinit標記過的類型對性能有益,因為它讓運行時在執行初始化時更加靈活,繼而允許JIT可以更靈活的優化,還有訪問這個類型的靜態方法時是不是該加鎖。benadams提交的這個PR和那個PR移除了這樣的靜態構造器,這樣可以跨越大量代碼以較小的成本分層。

    • 使用更便宜且滿足功能的代替品。?字符串和Span的IndexOf返回一個給定元素的位置,Contains只返回是否包含此元素。后者稍稍有效率點,因為它不需要跟蹤元素的確切位置。因此,很多調用使用Contains而不是IndexOf,grant-d的這個PR和另一個PR指出了這一點。另一個例子,SocketsHttpHandler(HttpClient背后默認的HttpMessageHandler)在判斷一個鏈接是否要為下次請求重用時,用的是DateTime.UtcNow,但是Environment.TickCount更加廉價,也足以精確地解決這個問題,因此這個PR換上了這個方法。還有個例子,這個PR在許多地方修改了Array.Copy的重載,以避免沒用的GetLowerBound()。

    • 簡化互操作。.NET的平臺互操作機制很強大詳實,給我們留下了很多把柄來指定如何建立調用,如何傳輸數據,等等。但是,這些機制很多都有額外的消耗,例如需要運行時生成一個marshal存根來執行各種需要的轉換。這個PR和另一個PR,修改了互操作的參數以避免這種marshal代碼的分配。

    • 避免不必要的全球化。因為幾乎20年前設計的System.String API,很容易就意外用到涉及到本地文化的字符串比較。這種比較可能是錯的,而且消耗也更大,涉及到更多昂貴的操作系統或本地化類庫調用。特別是以一個char為參數的String.IndexOf方法使用了序數比較,但是以string作為參數的String.IndexOf使用本地文化執行比較。這個PR指出了System.Net中一堆的這種情況,在這里幾乎總是使用序數比較(StringComparison.Ordinal),一般在解析基于文本的協議時。

    • 避免使用不必要的ExecutionContext?流。?ExecutionContext是環境狀態“流”通過程序和異步調用的主要工具,特別是AsyncLocal <T>. 為了完成這個流,生成異步操作的代碼(例如Task.Run,Timer,等等)或者當其他操作完成時創建一個延續去運行的代碼(例如await),需要“捕獲”當前的ExecutionContext,將其掛起,之后當執行相關的工作時,使用捕獲的ExecutionContext的Run方法繼續下去。如果在執行的工作實際上不需要ExecutionContext,我們就可以避開它帶來的小分配。這個PR,33235號PR,33080號PR就是例子:它們把CancellationToken.Register換成了新的CancellationToken.UnsafeRegister,相對于Register唯一的不同就是不走ExecutionContext了。另一個例子,這個PR修改了CancellationTokenSource,當它創建Timer的時候,就不會再捕獲ExecutionContext了,或者看看這個PR,確保Task完成后,捕獲的ExecutionContext都立刻被丟棄。

    • 集中化/優化位操作。benaadams的這個PR引入了一個BitOperations類來集中一堆位操作(旋轉,前導0計數,對數等等),后來這個類型在grant-d的這些PR(22497號,22584號,22630號)里被增強了,System.Private.Corelib的每個需要位操作的地方,都可以應用這些共享的輔助代碼。這確保了所有這樣的調用(目前是大約70個)都得到了運行時可以集中的最佳實現,不管是利用當前硬件的instruction實現,還是利用軟件的。

    垃圾回收

    不談垃圾回收,就不配稱作談性能的文章。我們提到的很多優化都是減少分配的,一部分是直接減少消耗,但更多的是減少GC的負擔,縮小它要做的工作。但是提升GC自己,也是個重要問題,就像以前版本那樣,這一版本我們也對其做了工作。

    這個PR包含了不少性能提高,從鎖的優化到更好的免費list管理。mjsabby的這個PR添加了大頁面GC的支持(Linux上的“大頁面”),大型應用可以選擇這個優化,來消除轉換后備緩存(TLB)帶來的瓶頸,這個PR進一步優化了GC用的寫屏障。

    很重要的一點是優化了有很多處理器的機器的GC行為,例如這個PR。我在這里就參考Maoni0的這個博文了:

    blogs.msdn.microsoft.com.

    類似地,這一版本投入了很多努力去優化容器化環境下執行的GC(尤其是嚴重約束的環境),例如這個PR。Maoni0還能做出比我形容的還好的工作,你可以閱讀她的兩篇博文:running-with-server-gc-in-a-small-container-scenario-part-0?和?running-with-server-gc-in-a-small-container-scenario-part-1-hard-limit-for-the-gc-heap.

    JIT(動態編譯)

    DNC3中的動態編譯進行了很多優化。

    影響最大的改變之一是分層編譯(這一改動分散在很多PR中,但是這個可以作為例子)。分層編譯是MSIL高質量編譯成本機代碼耗時間問題的解決方案;分析越多,優化就越多,時間也就越長,但是對于一個在運行時生成代碼的JIT編譯器來說,這個時間就是應用啟動的直接耗時,你將陷入權衡:你希望花更長的時間生成更好的代碼,還是希望更快的生成沒那么好的代碼?分層編譯是完成兩個目標的方案。思路是方法首先快速編譯代碼,沒有多少優化,然后隨著方法一次次的執行,這些方法會被重新JIT,這一次會在代碼質量上花更多時間。

    有趣的是,分層編譯不僅僅跟啟動時間有關系。重編譯有第一次編譯所沒有的優化,例如分層編譯可以應用于可立即運行的(R2R)鏡像,這是DNC共享框架中程序集使用的一種預編譯形式。這些程序集包括了預編譯的本機代碼,但是為了版本彈性能用于本機代碼生成階段的優化是有限的,例如跨模塊內聯在R2R中沒有。所以,R2R代碼有助于快速啟動,但是經常使用的方法會被分層編譯重新編譯,利用這種優化會限制使用原始的預編譯代碼。

    首先我們可以運行接下來的測試:

    我們可以再次運行它,但是這次,通過設置COMPlus_TieredCompilation環境變量為0,分層編譯被禁用了。

    有很多配置分層編譯的環境變量,要查看更多細節,看這里。

    另一個JIT的酷斃提升在這個PR中出現。在以前的.NET中,JIT會把一些static readonly聲明的基元類型字段優化成常量,例如一個static readonly int字段被初始化成42,當一些用到了這個字段的代碼被JIT編譯時,JIT編譯器會把這個字段代替成const,并進行常量折疊和其他可能進行的優化。在DNC3中,JIT現在可以應用static readonly字段的類型來做更多優化,例如一個static readonly字段以父類聲明,初始化的卻是子類(IList<T> list=new List<T>()),JIT可能會查找存儲在字段里對象的實際類型,當調用它的虛方法時,“去虛擬化”調用,甚至潛在地內聯它。

    這很好的說明了去虛擬化做出的改進,但是還有其他的改動,例如20447,20292,20640號PR,和benaadams的PR,合在一起促進了ArrayPool<T>.Shared之類的API。

    另一個不錯的優化在局部變量的清0上。甚至在initlocals標記還沒有設定時(例如這個PR,為coreclr和corefx的所有程序集都執行了清0),JIT仍然需要將局部變量引用計數歸0,所以GC就不會發現和誤認垃圾,這種清0可以大大加快速度,尤其是大量操作Span的方法。這個PR和另一個在這件事上做了些不錯的工作。

    另一個例子和結構體有關。隨著越來越多的人認識到性能的重要性特別是在分配上之后,值類型的使用也有了很大的提升,經常一個包裝另一個。例如,等待一個ValueTask會導致在其上調用GetAwaiter,并返回一個包裝了ValueTask的ValueTaskAwaiter。這個PR通過移除不重要的復制來優化這一解決方案。

    你tm打錯了吧,哪有0.0002ns的時間?

    Go比起C#的一大優勢在于運行時很小——只有2M左右,C#自己的微型運行時項目CoreRT現在還在“試驗階段”,也許要在明年出.NET 5之前才會完善,而且CoreRT本身不支持高級語法(例如動態加載插件),因此縮小CoreCLR也是勢在必行的。

    我們可以看下這個issue:Reduce size of PublishSingleFile binary · Issue #24397 · dotnet/coreclr

    同一個hello world應用,用C++構建只要800K,用DNC構建竟然需要70M,因為將大量沒用的dll也給打包了進去——理想情況下,需要的dll應該只有1.67MB,這位同志的話語直擊心靈:

    I think there should be a native / inbuilt solution that self contained only copies the required dlls. It should work like all the native compilers. Copying the whole framework was acceptable for .NET Core 2 and previous where applications were meant to be server applications. Now that .NET Core 3 also supports end user desktop applications you cannot share a folder with over 250 files or binaries with over 70mb where the actual application code is less than 50 lines of code.

    “我覺得應該有個native/內置的解決方案讓自包含(self-contained)的應用只含有需要的dll文件。它應該像所有的native編譯器。把整個框架放進去的行為在DNC2里是可以接受的,那個時候DNC2應用就是服務器應用,但是現在DNC3支持桌面應用了,你不能發布一個有250個文件的文件夾或者一個70M的二進制程序,實際的代碼才50行”

    微軟的人稱會在DNC3 pre6中引入ILLinker(基于mono linker開發的東西),來分析不必要的dll并進行剔除,目前的mono linker可以將包體縮小60%,但還是有些不夠……好在這個issue被加入CoreCLR 3的里程碑中了,相信今年9月份的時候會迎來改善。

    下一步會怎樣?

    在我寫下這個帖子的時候,我數了29個coreclr中與性能相關卻懸而未決的PR,和corefx中的8個。其中的一些很可能在DNC3正式版中被合并——我確定還會有一些現在沒有開放的PR。簡而言之,即使是DNC2和DNC2.1,以及這篇博文提到的DNC3,還有那些提交給ASP.NET Core使之成為這顆行星上最快的web服務框架的改進加在一起,仍然有無數的讓性能越來越好的機會,你也可以幫助實現。希望這個文章讓你為DNC3的潛力而興奮,我很期盼看到你的PR,來為美好的未來一起努力!

    原文地址:https://zhuanlan.zhihu.com/p/66152703

    .NET社區新聞,深度好文,歡迎訪問公眾號文章匯總?http://www.csharpkit.com?

    總結

    以上是生活随笔為你收集整理的.NET Core 3中的性能提升(译文)的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。