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(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 次,最後取平均值。
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