Going Native - Ahead of Time compilation in C#
Introduction
With .NET 8, Microsoft is further improving support for Native Ahead of Time compilation (AoT). This means that we can now compile our C# programs directly to native code, instead of IL. How does AoT differ from ’normal’ C# compilation? How do we use AoT? And what are the implications for the ecosystem?
What even is a compilation?
When we talk about compilation, we talk about translating a program from one language to another. Usually, we mean translating a higher-level programming language to code our computers can execute directly. I will refer to this type of code as ’native code’ from now on. There are many different ways to create native code from another language, and some developers make it their life’s work to make this process as efficient as possible.
Normally, C# is not directly compiled into native code. Instead, it is translated into an intermediate language (IL). This intermediate language is then compiled into native code by the Common Language Runtime (CLR). This route may seem a little bit complicated, but it is for good reasons. The CLR can store all sorts of information about the program, and use this information to do some cool stuff while the program is being executed. For example, the CLR can take care of allocating memory and cleaning up memory that is no longer in use, so developers (mostly) don’t have to worry about that anymore. It can also keep track of higher-order type information such as the methods and properties of a class. With all this information, the CLR can make targeted optimizations in real-time. Something that would be incredibly hard, if not impossible, to do if we compiled directly to native code.
This indirect model for compilation is very common in modern programming languages. JavaScript uses the V8 or SpiderMonkey engines and Java uses the JVM to perform very similar tasks to Dotnet’s CLR. More general terms for programs like this are virtual machines or language runtimes.
You can think of the CLR as a kitchen assistant. They can tell you what the next step in the recipe is, where you can find the necessary tools and ingredients, and clean up after you. Sometimes they get in your way a little bit, but now you can focus on getting food out to your guests instead of worrying about the dishes.
No free lunch
Like always, there are tradeoffs to make. The CLR is a process that needs to be running for our program to execute. This means that we need to ship the CLR with our program or use something like a docker container with the CLR pre-installed. And while the runtime is very good at what it does, it is a pretty big application and it has a noticeable impact on both the startup time and the memory usage of our program. Which can be detrimental for some use cases. In embedded scenarios, for example, there is often very limited memory and disk space available to run and store applications. So every bit that we can save is a win.
This is where AoT comes in. Already available in .NET 7 but further stabilized with the release of .NET 8, AoT allows us to directly compile our C# programs to native code. When using AoT, we don’t need the CLR to execute our C# code for us, but the computer can do it directly. This has a couple of major benefits:
- Single executable
- Faster startup times
- Smaller binary size
- Smaller memory footprint
Below you can find a graph that details the performance of AoT-compiled C# apps versus apps that are traditionally compiled:
The performance increases seem pretty massive but again, there are no free lunches in software engineering. Remember when I told you that the CLR can keep track of all sorts of information about our program? Well, we sacrifice most of that information when we compile directly to native code. For example, we can’t use runtime reflection because the compiled code doesn’t have any information about the higher-order types in our program anymore. We also can’t use dynamic code generation, or assembly loading because of this. We don’t strip out all the runtime features when AoT compiling. We still package parts of the dotnet runtime together with our application. The garbage collector still works, for example, so we don’t suddenly have to do manual memory management if we turn on AoT.
This all has pretty big implications for existing codebases. As they, or their dependencies, often rely heavily on techniques like reflection. Take ASP.NET. When we define a POST request in ASP.NET, we can specify a C# type that represents the shape of the JSON request. The framework then uses reflection to create an instance of that type from the request body. Because the framework is using runtime reflection here, this won’t work with AoT-compiled apps. To get this to work with AoT compilation, we need to have the logic to create the type from the request body in our codebase at the time of compilation. So we could, for example, write (or generate :) ) a source generator that creates this logic for us. Microsoft is doing exactly this in the background by steadily migrating systems that lean on runtime reflection to C# source generators, which create the necessary logic at compile time.
Because we can’t directly use all the features of the dotnet runtime anymore, we need to do some extra work to make our code compatible with AoT compilation. This is also the case for the third-party libraries your projects depend on. they need to explicitly support AoT compilation. For larger code bases this task can be quite non-trivial. So unfortunately we can’t just turn on AoT compilation and get all the benefits for free.
Why would you use AoT?
I briefly mentioned the performance benefits of AoT compilation earlier, but why would you actually want to use it? Well, there are a couple of use cases where AoT compilation can be very useful.
Embedded software - Embedded devices have CPUs that are often not very powerful. They also have small amounts of memory, and limited storage space, and might use instruction set architectures that are more specifically designed for what the devices are meant to do. With AoT compilation C# could be a viable option for these scenarios, as our applications get smaller footprints, become more portable, and can be specifically compiled for the target architecture.
Serverless - Serverless functions are often used to run small pieces of code in response to events. Optimizing cold starts is very important for creating useful serverless systems. Native AoT can help in this case as it significantly reduces the start-up times of an application. This makes serverless apps more responsive and can save precious compute hours (money!).
Polyglot Organizations - AoT compilation allows us to compile C# code to native code. This means that we can use C# to create libraries that can be consumed by other programming languages. This can be very useful in large organizations that use multiple programming languages across different teams. For example, we could use C# to create a library that can be consumed by an application written in C, Rust, or another native language. This way we can use the same codebase for multiple applications, and we don’t have to rewrite the same logic in multiple languages. You can find a more detailed example of this later in this blog post.
In more general terms AoT compilation can help in any situation where start-up times and application footprint are important. It can make our applications run faster, deploy easier, scale better, and use fewer cloud resources. Whether or not this is worth the extra effort of making your codebase compatible with AoT compilation is greatly dependent on your use case. So it is very important to measure the performance and cost of your application. If you want to start experimenting with AoT compilation, I suggest looking at applications that use serverless architectures first. These codebases are often smaller and less complex. This makes them easier to port to AoT compilation, so you can evaluate the benefits of AoT within a reasonable timeframe.
Hello Native C#
So how do we actually use AoT compilation? Let’s look at a couple of basic examples and start with a good old Hello World console application:
dotnet new console -o hello-world
To compile this to a native executable we need to do two things. The first is enabling AoT compilation in our project file:
<!--hello-world.csproj-->
<PropertyGroup>
<!--...-->
<PublishAoT>true</PublishAoT>
</PropertyGroup>
The second is actually compiling the application. We can do this with:
dotnet publish -c <config> -r <rid>
This will create a native executable in the bin/<configuration>/net8.0/<rid>/publish
folder. You might not be entirely familiar with the -r
flag. This flag is used to specify the runtime id of the target platform for the executable. This is important because the native code that needs to be created is different for Windows than Linux for example. At the time of writing, we can only target the same platform as the one we’re compiling the program on with AoT compilation. If you try to select a different target than your current operating system, you will get the following error message:
> error : Cross-OS native compilation is not supported
So if we’re on Windows, we can only compile for Windows. If we’re on Linux, we can only compile for Linux. We can however, cross-compile between architectures on the same operating system. So we can compile for Windows ARM on an x64 machine, or vice versa, as long as we have the right build tools installed for the target architecture. If you’re interested, you can find a list of all the available runtime IDs here. If we do want to compile for a different operating system, we could use a dockerfile to get this done. A Linux example would look something like this:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# Some extra dependencies are needed for AoT compilation on Linux
RUN apt-get update && apt-get install clang zlib1g-dev -y
WORKDIR /source
COPY . .
RUN dotnet publish -c release -r linux-x64 -o /app
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["/app/hello-world"]
There is one notable thing about this dockerfile. Namely, we can get away with using dotnet/runtime-deps
instead of the usual dotnet/runtime
image. We don’t need the full dotnet runtime anymore, because we’re compiling directly to native code. This makes our docker image much smaller.
What about libraries?
What if we want to consume a C# library from another programming language? With AoT we can also compile libraries to native code, which we can then consume in many different languages. This is slightly more involved than compiling a console application, but not by much. Let’s start with a simple class library:
dotnet new classlib -o aot-lib
Again we need to add the PublishAoT
property to our project file first. Let’s add a method to this library:
namespace aot_lib;
public class Class1
{
[UnmanagedCallersOnly(EntryPoint = "multiply")]
public static int Multiply(int a, int b)
{
return a * b;
}
}
There are a couple of things to note here. First is that all exposed methods need to be static. The second is the UnmanagedCallersOnly
attribute. ‘Unmanaged’ in this context, means something like ‘outside the influence of the dotnet runtime’. That makes sense, considering we are not using the runtime to execute functions in this library. The unmanaged caller would be another program that calls into our C# library. The EntryPoint
property in the attribute specifies the name of the function as it is exposed to consumers of the library.
We can now compile this library to native code:
dotnet publish -c <config> -r <rid> /p:NativeLib=Shared
This command will emit a ‘shared’ library (.dll on Windows, .so on Linux) in the publish folder for the target rid. We could also use NativeLib=Static
to emit a static library (.lib on Windows, .a on Linux). This is useful if we want to link the library directly to another native executable or library, but that is a little bit out of scope for this post.
To give you a quick demonstration of using the library we just built, we can use the following C program. For the sake of compactness, I will limit this example to Linux. You can find a complete example with platform-independent loading here.
// LoadLibrary.c
#include "dlfcn.h"
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#define LibPath "./bin/Release/net8.0/linux-x64/publish/libcsharp.so"
int main()
{
// Check if the library file exists
if (access(LibPath, 0) == -1)
{
puts("Couldn't find library at the specified path");
return 0;
}
// Load the library and define our multiply function
void *libhandle = dlopen(LibPath, RTLD_LAZY);
typedef int(*MultiplyFunc)(int,int);
// Lookup the multiply function in the library
// Note the lowercase 'multiply'
//This is the EntryPoint specified in UnmanagedCallersOnly
MultiplyFunc multiply = (MultiplyFunc)dlsym(libhandle, "multiply");
// Call the multiply function and output
int result = multiply(2, 8);
printf("Sum is %i\n", result);
}
Simply compile and run this program with (assuming you have clang installed):
clang LoadLibrary.c && ./a.out
You should see the following output:
Sum is 16
Conclusion
To summarize: AoT compilation allows us to compile C# programs directly to native code. It leads to faster startup times, smaller binaries, and a smaller memory footprint. But, we can’t use all the features of the dotnet runtime anymore, and so it requires a non-trivial amount of work to make existing codebases compatible with AoT compilation.
And that’s it! Now you know how to build native executables and libraries with C#. There is plenty more to talk about that I didn’t have space for in this introductory blog. Some future topics I might cover are:
- Building Native ASP.NET applications
- Native library development with C#
- Converting existing codebases to AoT
About Jesse
Jesse is a software and cloud engineer at Xebia in the Netherlands. His technical interests are currently focussed on Programming Languages, WebAssembly, and platform engineering. Learn more about him in his Manual.