Exploring Native Ahead Of Time Compilation in .NET: Pros, Cons, and Possibilities
Before code written by a programmer can be executed by a computer, it needs to go through a process called compilation. It takes what a human (well, some of us) understands, and turns it into something the computer understands.
In .NET, there are technically two steps in this compilation. The first is when the source code (in C#, say) is compiled in to Intermediary Language (IL). The second occurs at runtime, when the Common Language Runtime (CLR), the framework that your code runs on top of, translates it into the machine code that the computer can understand. This last step happens just before it needs to be run by the computer. It is called Just In Time compilation, or JIT.
Another approach is to translate it the whole way up front, before you need to run it. This is Ahead Of Time compilation, or AOT. AOT is not new. Languages like C and Pascal are compiled ahead of time.
Native AOT is now here for .NET. What does it bring to the table?
- You don't need to have the .NET runtime installed to run your app. Just package up your app and it will run.
- Native AOT apps can also run in environments that don't allow JIT, typically embedded systems.
- They are supposed to use less memory and be faster to start up. This can make a difference with applications that scale out to have multiple instances.
Writing a simple AOT app
There's a decent list of things that can't be done with Native AOT, one of which is using ASP.NET MVC, so our first AOT app will have to use minimal APIs.
We'll start by creating a new app. We'll just start with the standard Hello World app generated by the dotnet
command.
PS C:\src> dotnet new web -o AotMinimal
The template "ASP.NET Core Empty" was created successfully.
Processing post-creation actions...
Restoring C:\src\AotMinimal\AotMinimal.csproj:
Determining projects to restore...
Restored C:\src\AotMinimal\AotMinimal.csproj (in 99 ms).
Restore succeeded.
This creates our base project. The Program.cs
file looks like this:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
That just returns a simple string. Can't be simpler than this, can it?
Let's make it a bit more complicated, and add this endpoint:
app.MapGet("/object", () => new { message = "Hello World!" });
We've now got a simple app. If we run it with dotnet run
we can hit our /
endpoint and receive Hello World!
as a string. If we hit /object
we'll get a JSON string {"message":"Hello World!"}
.
Building and publishing
We're going to deploy our application into a containerised environment like Docker or Kubernetes.
Building the JIT version
Let's start with a standard JIT configuration.
FROM mcr.microsoft.com/dotnet/sdk:8.0-preview AS build-env
WORKDIR /App
# Copy everything
COPY . ./
# Restore as distinct layers
RUN dotnet restore
# Build and publish a release
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-preview
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "AotMinimal.dll"]
This is a standard .NET Dockerfile. It sets up a build environment using the SDK image, copies the source code into the container, restores NuGet packages, and builds the project, placing the output into the out
directory.
It then creates the rutime image, taking the aspnet
image as a base, copies the out
directory from the build environment, then sets the entry point to run the application.
We can build this by running:
docker build . --tag minimal:jit
And run it by running:
docker run -it --rm -p 8080:8080 minimal:jit
You can then hit http://localhost:8080
to receive the basic Hello World message, or http://localhost:8080/object
to receive the JSON object.
Building the AOT version
First up, we'll need to edit the csproj file to enable AOT. We do this by adding this setting:
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
If we try building it using the existing Dockerfile, we get errors. There are a few, but the important one for now is:
#0 16.10 /root/.nuget/packages/microsoft.dotnet.ilcompiler/8.0.0-preview.5.23280.8/build/Microsoft.NETCore.Native.Unix.targets(178,5): error : Platform linker ('clang' or 'gcc') not found in PATH. Ensure you have all the required prerequisites documented at https://aka.ms/nativeaot-prerequisites. [/App/AotMinimal.csproj]
The build environment doesn't have everything it needs to build AOT applications. We'll need to change the Dockerfile like so:
FROM mcr.microsoft.com/dotnet/sdk:8.0-preview AS build-env
WORKDIR /App
# Install NativeAOT build prerequisites
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
clang zlib1g-dev
# Copy everything
COPY . ./
# Restore as distinct layers
RUN dotnet restore
# Build and publish a release
RUN dotnet publish -c Release -r linux-x64 -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-preview
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["/App/AotMinimal"]
This one uses the same base image as the build environment, we first need to install the clang
and zlib1g-dev
packages. This installs the C compiler and the zlib compression library.
The second change is to the  run dotnet publish
command. Note the -r linux-x64
option. That's telling it to build for the linux-x64 runtime. AOT apps have to be build for the specific architecture they're running on - that's the whole point!
Finally, the runtime image has changed. It's not the .NET runtime anymore, but the (much smaller) .NET runtime deps image. This image doesn't contain the .NET runtime, just the things that .NET AOT apps need to run, which is a fairly standard Linux installation. We also need to change the entry point to be simply run the AotMinimal
command. We don't need to run it with the dotnet
command, because it doesn't need it. It's a runnable Linux executable.
We can build this by running:
docker build . --tag minimal:aot
We still get warnings here, but it is successful. Let's ignore the warnings for now (mainly because by default they get hidden in the output of the docker build
comand), and try running it by running:
docker run -it --rm -p 8080:8080 minimal:aot
It seems to start well, but what if we hit http://localhost:8080/
?
Oh no! We get an error!
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HMRGDSJK9IOC", Request id "0HMRGDSJK9IOC:00000001": An unhandled exception was thrown by the application.
System.InvalidOperationException: JsonSerializerOptions instance must specify a TypeInfoResolver setting before being marked as read-only.
And this brings us to the main thing I've discovered about .NET Native AOT! You're not going to be able to use AOT using all the tools and techniques you use today.
Why isn't it working?
Remember those compiler warnings I mentioned before? Let's take a look at them now. To make them stick around after your build completes, add --progress plain
to the end of your docker build
command. The errors are extensive!
Build errors
#12 6.230 /App/Program.cs(5,1): warning RDG004: Unable to resolve anonymous return type. Compile-time endpoint generation will skip this endpoint and the endpoint will be generated at runtime. For more information, please see https://aka.ms/aot-known-issues [/App/AotMinimal.csproj]
#12 6.230 /App/Program.cs(5,1): warning IL3050: Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder, String, Delegate)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This API may perform reflection on the supplied delegate and its parameters. These types may require generated code and aren't compatible with native AOT applications. [/App/AotMinimal.csproj]
#12 6.230 /App/Program.cs(5,1): warning IL2026: Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder, String, Delegate)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This API may perform reflection on the supplied delegate and its parameters. These types may be trimmed if not directly referenced. [/App/AotMinimal.csproj]
#12 6.230 /App/Program.cs(5,1): warning IL3050: Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder, String, Delegate)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This API may perform reflection on the supplied delegate and its parameters. These types may require generated code and aren't compatible with native AOT applications. [/App/AotMinimal.csproj]
#12 6.230 /App/Program.cs(5,1): warning IL2026: Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder, String, Delegate)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This API may perform reflection on the supplied delegate and its parameters. These types may be trimmed if not directly referenced. [/App/AotMinimal.csproj]
#12 6.713 AotMinimal -> /App/bin/Release/net8.0/linux-x64/AotMinimal.dll
#12 7.174 Generating native code
#12 7.303 /App/Program.cs(5): Trim analysis warning IL2026: Program.<Main>$(String[]): Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder,String,Delegate)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This API may perform reflection on the supplied delegate and its parameters. These types may be trimmed if not directly referenced. [/App/AotMinimal.csproj]
#12 7.303 /App/Program.cs(5): AOT analysis warning IL3050: Program.<Main>$(String[]): Using member 'Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapGet(IEndpointRouteBuilder,String,Delegate)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This API may perform reflection on the supplied delegate and its parameters. These types may require generated code and aren't compatible with native AOT applications. [/App/AotMinimal.csproj]
#12 8.093 /root/.nuget/packages/runtime.linux-x64.microsoft.dotnet.ilcompiler/8.0.0-preview.5.23280.8/framework/System.Linq.Expressions.dll : warning IL3053: Assembly 'System.Linq.Expressions' produced AOT analysis warnings. [/App/AotMinimal.csproj]
The key message here is These types may require generated code and aren't compatible with native AOT applications
. It turns out that JSON serialisation uses source generation, which is a thing that Native AOT does not support.
The /
endpoint doesn't use source generation. If you omit the /object
endpoint, it builds without warnings and runs fine. This is likely because the EndpointRoutingMiddleware
, which is throwing that exception, needs to figure out all the routes, and just can't.
As mentioned earlier, there's a significant list of things that can't be done with .NET AOT. Key ones include:
- You can't do runtime code generation, which is the problem with the JSON serialisation here
- You use dynamic loading, like using
Assembly.LoadFile
- LINQ expressions always use the slower interpreted form
Some new performance optimisations, like on-stack replacement, which I wrote about in February, also rely on the JIT nature of the runtime, where the runtime figures out a better way to run your code. If your code is already in machine code, it's not going to be able to be optimised on the fly. These features are also not able to be used with AOT.
So, what's next?
Waiting.
Current ASP.NET APIs can't really take advantage of .NET Native AOT. You could limit yourself to using language and framework features that do work with it, if you need the performance improvements that this new technology brings.
For most workloads, though, I think you're better of sticking with what works well now. But I'll definitely be keeping an eye out on what you'll be able to do with AOT in the future.
Member discussion