1. 程式人生 > 其它 >AOT和單檔案釋出對程式效能的影響

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