AOT和單文件發布對程式性能的影響

前言

這裡先和大家介紹一下.NET一些發布的歷史,以前的.NET框架原生並不支援最終編譯結果的單文件發布(需要依賴第三方工具),我這裡新建了一個簡單的ASP.NET Core項目,發布以後的目錄就會像下圖這樣,裡面包含很多*.dll文件和其它各類的文件。

在.NET Core 2.1時代,引入了單文件發布的功能,只需要在發布命令上,增加-p:PublishSingleFile=true參數就可以使用,從這以後就無需發布的文件夾就再也沒有那麼多的文件,只有一個*.exe文件和對應的配置文件和用於調試*.pdb的文件,如下所示:

不過此時的.NET還是需要安裝一個大小為50~130MB左右的.NET Runtime才能運行,這個其實不利於在客戶端場景下程式的分發,大家應該能回憶起在安裝一些軟體之前,必須安裝.NET Framework的場景。

在單文件發布推出的同時,也可以通過--self-contained true的參數,將運行時也包含在發布文件內,這樣的話就無需在目標機器上再安裝.NET Runtime。不過由於它自帶運行時,整個發布文件夾的大小就變得很大了,可以說比安裝.NET Runtime還要大一些(足足82.4MB)。

程式本質上也就是文件,我們也可以通過壓縮程式的方式,讓它的大小變小,只需要加上-p:EnableCompressionInSingleFile=true參數。就可以將80MB的程式壓縮至44MB左右。

單文件發布體積大的原因就是包括了所有運行可能用到的依賴,不過有很多依賴是我們程式中用不到的,所以發布的時候可以加-p:PublishTrimmed=true參數,發布的時候移除掉沒有使用的依賴,這樣體積就可以降低很多(從44MB到35MB)。

當然,移除沒有使用的依賴和壓縮是可以同時使用的,這樣發布以後,體積就可以變得更小了,只需要20MB左右。

此時.NET運行還是需要自帶運行時,在運行.NET程式的時候需要JIT來參與,這樣的話在應用啟動時需要一定的時間讓JIT將MSIL編譯到對應平台機器碼,隨後.NET推出了預覽版的Native-AOT,可以在編譯時直接將程式碼編譯成對應平台的機器碼,以加快啟動速度;另外由於不需要自帶運行時,它整個的體積大小也變得很小。

用於調試的pdb文件就會變得很大,不過真實發布的話也用不到這個文件,可以捨棄。AOT以後的大小也就20MB左右。不過AOT也不是銀彈,由於沒有了JIT,很多編譯時優化就不能做了,Java的GraalVm發布的時候就有一張五邊形圖,充分的說明了JIT和AOT之間的取捨。

AOT擁有更快的啟動速度、更低的記憶體佔用和更小的程式體積;當然它的吞吐量和最大延時表現的就沒那麼好(另外也會失去很多動態的特性,降低一些編程效率)。

心中會有一個疑問,這樣的發布方式會對程式的性能有影響嘛?都說AOT會讓程式啟動速度變快,那麼會變快多少呢?

評測結果

我決定花點時間來研究一下,周末帶著上面的問題我設計了一組測試,當然時間倉促有很多不嚴謹的地方,可以說就圖一樂,望大家指出和海涵。一共設計了12個組,主要是對比單文件發布、AOT發布和普通發布的區別;另外我也加入了PGO、TC、OSR和OSA等JIT參數,來看看不同JIT參數的影響。

PGO:PGO 即 Profile Guided Optimization(配置引導優化),通過收集運行時資訊來指導 JIT 如何優化程式碼,相比以前沒有 PGO 時可以做更多以前難以完成的優化。可以參考hez大佬的部落格,還有一些鏈接1鏈接2鏈接3.

TC:TC 即 Tiered Compilation(分層編譯),是一種運行時優化程式碼的技術,每個C#函數都會由JIT編譯成目標平台的機器碼,為了讓方法能快點運行,JIT一般會很粗獷(並不是最優,生成程式碼效率比較低)的編譯,所以JIT就引入了TC,當某一個方法頻繁被調用時,JIT就會為它編譯一份更優的程式碼,這樣下一次方法被調用時,它執行的會更有效率。想了解更多關於.NET分層編譯可以戳這個鏈接

OSR:OSR 即 On-Stack Replacement(棧上替換),OSR是一種在運行時替換正在運行的函數/方法的棧幀的技術。這個是為了分層編譯引入的,因為有時候我們運行的方法是一個while(ture)這種死循環方法,分層編譯找不到時機能把低優化的程式碼替換成高優化的程式碼,所以引入了棧上替換,在方法運行中就可以替換成更優的方法。鏈接1鏈接2

OSR:OSA 即 Object Stack Allocation (對象棧上分配),在.NET中的引用對象默認是分配在堆上的,回收時需要垃圾回收器介入,而且分配對象時必須初始化記憶體(全部初始化為0),如果對象的生命周期可控,那麼可以將它分配在棧上。這樣做的好處就是能降低GC壓力(方法棧結束,對象自動釋放了),提升性能(可以進行標量替換,訪問更快)。鏈接1

每個組的命名和參數如下所示。

項目 備註
Normal 正常發布,對照組
Normal-WksGC 正常方式,使用WorkStationGC
Normal_PGO 正常發布,使用PGO
Normal_PGO_OSR 正常發布,使用OSR
Normal_PGO_OSR_OSA 正常發布,使用PGO+OSR+OSA
SingleFilePublish 普通單文件發布
SingleFilePublish-SelfContained 包含運行時單文件發布
SingleFilePublish-SelfContained-Trim 包含運行時單文件發布+剪裁程式集
SingleFilePublish-SelfContained-Compress 包含運行時單文件發布+壓縮程式集
SingleFilePublish-SelfContained-Trim-Compress 包含運行時單文件發布+剪裁+壓縮程式集
AOT-Size AOT編譯,使用Size模式
AOT-Speed AOT編譯,使用Speed模式

下方的小標題是評測項的方式和評測的結果,每個項我們都會跑5次,最後取平均值。

發布相關

在本節中,Normal那幾項編譯參數都是一樣的,所以結果幾乎沒有差別,無需過多關注,忽略就好。

發布耗時

發布耗時這個參數,是記錄了dotnet publish的耗時,其中會清理/bin、/obj等文件夾,避免快取帶來的影響。

可以看到單文件發布和AOT發布還是比較吃性能的,特別是AOT場景下簡單的ASPNET Core項目的發布時間就到了接近30秒和一些Rust、C++項目編譯速度有的一拼了,要是更大的項目估計會更長。不過正常發布還是很快的,不會一兩秒內都能完成。

目錄大小

目錄大小是直接統計發布以後的目錄所佔用的硬碟空間,注意:Normal發布都計算了67.5MB的.NET Runtime佔用的空間

為什麼AOT的目錄大小會這麼大呢?主要就是上文中提到的用於調試程式的pdb文件變的很大,這是因為AOT以後程式本身缺失很多用於調試的數據,只能存放在pdb文件中,不過這個對於使用沒有什麼影響,發布時也可以通過-p:DebugType=false-p:DebugSymbols=false參數讓它不生成pdb文件。

程式大小

程式大小統計只發布文件中需要運行程式的大小,這個是和分發項目息息相關的,越小的程式體積,就越容易分發。注意:Normal發布都計算了67.5MB的.NET Runtime佔用的空間

如果目標平台已經預裝了.NET Runtime,其實正常發布的效率是最高的,只有一百多KB的大小;次之就是單文件發布+自包含運行時+裁剪+壓縮,大小只有20來MB,也比較利於分發。AOT的表現也同樣亮眼。

程式運行相關

程式運行相關一共有三個指標,分別為啟動耗時、應用啟動耗時和記憶體佔用,這裡沒有設置CPU相關的指標,是因為啟動程式CPU基本都是0沒有太大的參考意義。下方流程圖展示了這幾個指標的採集時間。
啟動配圖

啟動耗時

程式的啟動耗時結果如下所示。

我們可以看到兩個極值,最大的單文件+自包含運行時+壓縮啟動耗時到170ms,因為沒有剪裁程式集,需要解壓縮的依賴很大,所以啟動耗時會比較長一點。最小的AOT-Speed模式只需要16.8ms就能啟動程式,看來沒有了JIT編譯和程式集載入的過程,果然快很多。

應用啟動耗時

應用啟動耗時和程式啟動耗時排列基本一致,像單文件+自包含運行時+壓縮啟動耗時需要0.5s+才能啟動程式,而AOT模式只需要70ms,中間差了七八倍。不過正常發布啟動速度也很快,只需要200ms不到的時間。

記憶體佔用


記憶體佔用各個方式差別不大,但是也提醒到了我們,如果想讓記憶體佔用小一些,那麼可以使用WorkstationGC模式。引入動態PGO之類的JIT增強特性以後,相應的會多佔用一些記憶體。

性能壓測

機器配置:

CPU:I7 8750H 關閉超執行緒
RAM:48GB
Client:設置CPU親和性,綁定3個核心
Server:設置CPU親和性,綁定2個核心

由於筆者機器配置有限,沒有做ClientServer的環境隔離,只做了簡單的CPU綁核,所以的出來的數據僅供參考。

壓測QPS


可以看到其實各個方式差別不是很大,都取得了4.7Wqps以上的成績,最大和最小在4%以內。由於這是IO密集型任務,JIT、PGO的優勢沒有體現出來,後面可以試試一些計算密集型的任務,或者直接看hez的部落格,上文介紹PGO中有鏈接。

單次請求耗時

下圖中在條形圖內較大的是單次請求耗時(MAX),在條形圖外的0.x的數據是單次請求耗時(AVG)。單位是ms.

我們發現平均耗時基本在0.3ms左右,AOT和單文件+自包含運行時+剪裁+壓縮的表現很亮眼,只有370ms左右。

壓測記憶體佔用

下圖中深色代表記憶體佔用(MAX)而淺色代表記憶體佔用(AVG),單位是MB.

可以看到除了AOT以外的方式,記憶體佔用是大差不差的,4.7Wqps下只需要25MB左右的記憶體其實很不錯了,近似的數字可以理解為誤差;另外開啟了JIT特性以後,就需要佔用更多的記憶體。AOT的話記憶體佔用就比較多了,可能GC演算法在AOT環境下的優化還不夠。

壓測CPU佔用

下圖中深色代表CPU佔用(MAX)而淺色代表CPU佔用(AVG)。單位為百分比;1個CPU核心是100%,如果佔用5個CPU核心那麼就是500%

基本上都沒有啥區別,但是AOT方式佔用率就小了很多,畢竟沒有了JIT這個步驟。

總結

這個結論也就是圖一樂,畢竟目前AOT還沒有正式發布(已經合併主分支.NET7會正式發布),還有很多值得優化的地方。另外像OSR、OSA這些特性也還沒有完全定下來,下面是一些和對照組比較的百分比數據,原始數據和測試程式碼見Github。後續.NET7正式發布了,再跑一下試試。

回答開始提到的問題,總得來說AOT對縮小軟體大小,提升應用啟動速度有著很大的作用,但是目前需要很長的發布時間和佔用更多的記憶體
另外PGO等一些JIT特性需要比正常情況下佔用更多的記憶體,其性能的優勢在這個IO密集的場景沒有很好的表現出來。

最後在多說幾句,我一直覺得C#是一個很好的語言,.NET是一個很好的平台。從2002年一路走來,今年是.NET的第20個年頭,各種新特性相繼加入,性能也已經站在了第一梯隊,希望以後能有更多的發展吧。
PS:在前幾天更新的Benchmarks Game數據裡面,C# .NET已經是帶JIT語言裡面跑的最快的了,僅次於C、C++、Rust等編譯型語言,詳情可見鏈接1鏈接2

Tags: