7 min read

Exploring Native Ahead Of Time Compilation in .NET: Pros, Cons, and Possibilities

Exploring Native Ahead Of Time Compilation in .NET: Pros, Cons, and Possibilities
Photo by Alexander Schimmeck / Unsplash

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!"}.

🤔
Hmmm... I wonder if adding this bit of complication might bite us in the butt later on?

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.

🚀
Check out the code examples from this article at https://github.com/arunstephens/aot-minimal.

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 buildcommand. 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.