1. 前言
這裡先和大家介紹一下 .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 會讓程式啟動速度變快,那麼會變快多少呢?
2. 評測結果
我決定花點時間來研究一下,週末帶著上面的問題我設計了一組測試,當然時間倉促有很多不嚴謹的地方,可以說就圖一樂,望大家指出和海涵。一共設計了 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(true)這種無窮迴圈方法,分層編譯找不到時機能將低最佳化的程式碼取代成高最佳化的程式碼,所以引入了堆疊上取代,在方法執行中就可以取代成更優的方法。連結 1、連結 2。
OSA: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 次,最後取平均值。
2.1 發佈相關
在本節中,Normal 那幾項編譯參數都是一樣的,所以結果幾乎沒有差別,無需過多關注,忽略就好。
2.1.1 發佈耗時
發佈耗時這個參數,是記錄了 dotnet publish 的耗時,其中會清理 /bin、/obj 等資料夾,避免快取帶來的影響。

可以看到單檔案發佈和 AOT 發佈還是比較吃效能的,特別是 AOT 場景下簡單的 ASPNET Core 專案的發佈時間就到了接近 30 秒和一些 Rust、C++ 專案編譯速度有的一拼了,要是更大的專案估計會更長。不過正常發佈還是很快的,不會一兩秒內都能完成。
2.1.2 目錄大小
目錄大小是直接統計發佈以後的目錄所佔用的硬碟空間,注意:Normal 發佈都計算了 67.5MB 的 .NET Runtime 佔用的空間。

為什麼 AOT 的目錄大小會這麼大呢?主要就是上文中提到的用於偵錯程式的 pdb 檔案變的很大,這是因為 AOT 以後程式本身缺失很多用於偵錯的資料,只能存放在 pdb 檔案中,不過這個對於使用沒有什麼影響,發佈時也可以透過 -p:DebugType=false 和 -p:DebugSymbols=false 參數讓它不產生 pdb 檔案。
2.1.3 程式大小
程式大小統計只發佈檔案中需要執行程式的大小,這個是和分發項目息息相關的,越小的程式體積,就越容易分發。注意:Normal 發佈都計算了 67.5MB 的 .NET Runtime 佔用的空間。

如果目標平台已經預裝了 .NET Runtime,其實正常發佈的效率是最高的,只有一百多 KB 的大小;次之就是單檔案發佈+自包含執行時期+裁剪+壓縮,大小只有 20 來 MB,也比較利於分發。AOT 的表現也同樣亮眼。
2.2 程式執行相關
程式執行相關一共有三個指標,分別為啟動耗時、應用啟動耗時和記憶體佔用,這裡沒有設定 CPU 相關的指標,是因為啟動程式 CPU 基本都是 0 沒有太大的參考意義。下方流程圖展示了這幾個指標的採集時間。

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

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

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

記憶體佔用各個方式差別不大,但是也提醒到了我們,如果想讓記憶體佔用小一些,那麼可以使用 WorkstationGC 模式。引入動態 PGO 之類的 JIT 增強特性以後,相應的會多佔用一些記憶體。
2.3 效能壓力測試
機器配置:
CPU:I7 8750H 關閉超執行緒
RAM:48GB
Client:設定 CPU 親和性,綁定 3 個核心
Server:設定 CPU 親和性,綁定 2 個核心
由於筆者機器配置有限,沒有做 Client 和 Server 的環境隔離,只做了簡單的 CPU 綁核,所以得出來的資料僅供參考。
2.3.1 壓力測試 QPS

可以看到其實各個方式差別不是很大,都取得了 4.7Wqps 以上的成績,最大和最小在 4% 以內。由於這是 IO 密集型任務,JIT、PGO 的優勢沒有體現出來,後面可以試試一些計算密集型的任務,或者直接看 hez 的部落格,上文介紹 PGO 中有連結。
2.3.2 單次請求耗時
下圖中在長條圖內較大的是 單次請求耗時(MAX),在長條圖外的 0.x 的資料是 單次請求耗時(AVG)。單位是 ms.

我們發現平均耗時基本在 0.3ms 左右,AOT 和單檔案+自包含執行時期+裁剪+壓縮的表現很亮眼,只有 370ms 左右。
2.3.3 壓力測試記憶體佔用
下圖中深色代表 記憶體佔用(MAX) 而淺色代表 記憶體佔用(AVG),單位是 MB.

可以看到除了 AOT 以外的方式,記憶體佔用是大差不差的,4.7Wqps 下只需要 25MB 左右的記憶體其實很不錯了,近似的數字可以理解為誤差;另外開啟了 JIT 特性以後,就需要佔用更多的記憶體。AOT 的話記憶體佔用就比較多了,可能 GC 演算法在 AOT 環境下的最佳化還不夠。
2.3.4 壓力測試 CPU 佔用
下圖中深色代表 CPU佔用(MAX) 而淺色代表 CPU佔用(AVG)。單位為 百分比;1 個 CPU 核心是 100%,如果佔用 5 個 CPU 核心那麼就是 500%。

基本上都沒有什麼區別,但是 AOT 方式佔用率就小了很多,畢竟沒有了 JIT 這個步驟。
3. 總結
這個結論也就是圖一樂,畢竟目前 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。

原文作者:InCerry
原文標題:單檔案發佈對程式效能的影響
原文連結:https://www.cnblogs.com/InCerry/p/Single-File-And-AOT-Publish.html