.NET8 极致性能优化 AOT

发布时间 2023-12-05 12:58:20作者: lzhdim

前言

.NET8 对于性能的优化是方方面面的,所以 AOT 预编译机器码也是不例外的。本篇来看下对于 AOT 的优化。

概述

首先要明确一个概念,.NET 里面的 AOT 它是原生的。什么意思呢?也就是说通过 ILC 编译器 (AOT 编译器,参考:.Net 7 新编译器 ILC 简析) 编译出来的代码是各个平台上可以直接运行的二进制代码。比如 MacOS 的二进制,Linux 二进制等等。所以称之为原生。

C# 源码被 ILC 编译之后,生成了一个完全原生态代码的可执行文件。在执行的时候不需要 JIT 来编译任何东西,因为 JIT 已经在 ILC 里面被充分利用过了。实际上 AOT 里面也没有包含 JIT。那么它如何优化呢?只能是在 ILC 里面调用 JIT 的时候了。所以它这个优化依然依靠 JIT。.NET8 里面优化 AOT 的一个典型的例子,就是 ASP.NET 应用程序在使用 AOT 的时候表现不错,同时也降低了总成本。

在.NET8 里面优化 AOT 的一个重要的目标就是减少 AOT 可执行文件的大小,关于这点的效果。我们现在就可以看到

下面创建一个控制台应用程序

dotnet new console -o nativeaotexample -f net7.0

由于上面是通过.NET7.0 创建的,我们把这个控制台的 csproj 更改下

<TargetFramework>net7.0</TargetFramework>改为<TargetFrameworks>net7.0;net8.0</TargetFrameworks>

可以轻松的构建.NET7.0 或者.NET8.0 的程序

继续​​​​​​​

<PropertyGroup>...</PropertyGroup>项中添加如下<PublishAot>true</PublishAot>编译成AOT文件

下面我们就可以通过 dotnet publish 发布它了,linux 如下:

dotnet publish -f net7.0 -r linux-x64 -c Release

现在它生成了一个.NET7.0 版本的独立可执行文件,可通过 ls/dir 输出目录以查看生成的二进制大小

12820K /home/stoub/nativeaotexample/bin/Release/net7.0/linux-x64/publish/nativeaotexample

这个大约是 13M 左右,我们再来看下.NET8.0

dotnet publish -f net8.0 -r linux-x64 -c Release

生成的可执行文件大小如下:

1536K /home/stoub/nativeaotexample/bin/Release/net8.0/linux-x64/publish/nativeaotexample

1.5M 的大小,这个优化的力度不可不大啊。整整优化了将近 10 倍的体积。这就是.NET8.0 的优化魔力。

但是优化的情况远不止如此,比如说我们可以配置 csproj 使 AOT 的体积更小​​​​​​​

csproj添加如下size表示要生成的AOT大小<OptimizationPreference>Size</OptimizationPreference>

如果我们不需要全球化代码和数据,需要特定的代码和数据,并且使用不变模式,可以 csproj 添加如下选项

<InvariantGlobalization>true</InvariantGlobalization>

如果你不想在 AOT 异常的时候抛出堆栈,那么你也可以在 csproj 里面添加如下

<StackTraceSupport>false</StackTraceSupport>

重新通过 dotnet publish net8.0 发布了之后,它的体积还可以继续减小

1248K /home/stoub/nativeaotexample/bin/Release/net8.0/linux-x64/publish/nativeaotexample

再次缩小了 0.3M 大小。

然而,你以为到此优化就为止了吗?并没有,.NET8 不仅对 AOT 编译器内部进行了改进,而且还对单个库也进行了性能优化和改进。比如 HttpClient。

当然除了体积的优化之外,还有其它的优化,比如避免了在读取静态字段时的辅助调用,再比如 BenchmarkDotNet 也是支持 AOT 化的,也就是性能测试上面的支持。我们可以只使用 --runtimes nativeaot7.0 nativeaot8.0,而不使用 --runtimes net7.0 net8.0,如下代码​​​​​​​

// dotnet run -c Release -f net7.0 --filter "*" --runtimes nativeaot7.0 nativeaot8.0
using BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]public class Tests{    private static readonly int s_configValue = 42;
    [Benchmark]    public int GetConfigValue() => s_configValue;}

上面代码可以通过如下 AOT 化运行

dotnet run -c Release -f net7.0 --filter "*" --runtimes nativeaot7.0 nativeaot8.0

BenchmarkDotNet 输出如下

MethodRuntimeMeanRatio
GetConfigValue NativeAOT 7.0 1.1759 ns 1.000
GetConfigValue NativeAOT 8.0 0.0000 ns 0.000

可以看到即使是性能测试的 Benchmark,AOT 优化也是不放过的。

另外还值得一提的地方就是分层,因为 AOT 里面没有分层的概念。但是即时编译也就是不是 AOT 编译的时候,一个方法从 tier0 提升到 tier1, 方法里面的静态字段必须被初始化过了。AOT 里面添加了一个快速路径检查字段是否初始化,避免一些不必要的开销。

其它的一些改进,比如 AOT 锁的实现方式。使用了一种混合方式,开始使用轻量级自旋锁,后面升级到使用 System.Threading.Lock 类型,这个应该会在.NET9.0 里面释放出来。