SoFunction
Updated on 2025-04-13

.NET NativeAOT Usage Guide

With the release of .NET 8, a new "fashionable" application model, NativeAOT, has begun to be widely used in various real-world applications.

In addition to the basic use of the NativeAOT toolchain, the term “NativeAOT” also carries all the limitations of the native world, so you have to know how to deal with these issues to use it correctly.

In this blog, I will discuss them.

Basic usage

Using NativeAOT is very simple, you only need to use MSBuild to pass a property when publishing an application.PublishAot=trueJust do it.

Usually, it can be:

dotnet publish -c Release -r win-x64 /p:PublishAot=true

inwin-x64is a runtime identifier, which can be replaced bylinux-x64osx-arm64or other platforms. You must specify it because NativeAOT needs to generate native code for the runtime identifier you specified.

Then the published application can bebin/Release/<target framework>/<runtime identifier>/publishFound in

About Compilation

Before discussing solutions to the various problems you might encounter when using NativeAOT, we need to go a little deeper and see how NativeAOT compiles the code.

We often hear that NativeAOT cuts out code that is not used. In fact, it doesn't cut unnecessary code from the assembly like IL tailoring, but compiles only what is referenced in the code.

NativeAOT compilation includes two stages:

  • Scan the IL code to build the entire program view (a dependency graph) with all the necessary dependencies that need to be compiled.
  • Perform actual compilation of each method in the dependency graph to generate code.

Note that some "delayed" dependencies may occur during the compilation process, so the above two stages may appear interlaced.

This means that nothing that is not calculated as a dependency during the analysis process will not end up being compiled.

reflection

Dependency graphs are built statically during compilation, which also means that anything that cannot be statically analyzed will not be compiled. Unfortunately, reflection, i.e. getting things at runtime without telling the compiler in advance, is exactly one thing the compiler cannot figure out.

The NativeAOT compiler has some ability to infer what a reflection call needs based on the literals at compile time.

For example:

var type = ("Foo");
(type);
class Foo
{
    public Foo() => ("Foo instantiated");
}

The reflective target above (i.e.Foo) can be figured out by the compiler, because the compiler can see that you are trying to get the typeFoo, so typeFooWill be marked as a dependency, which results inFooCompiled into the final product.

If you run this program, it will print as expectedFoo instantiated

But if we change the code to the following:

var type = (());
(type);
class Foo
{
    public Foo() => ("Foo instantiated");
}

Now let's build and run the program with NativeAOT, and then enterFooLet's create oneFooExamples. You will immediately get an exception:

Unhandled Exception: : Value cannot be null. (Parameter 'type')
   at (String) + 0x2b
   at (Type, Boolean) + 0xe7
   ...

This is because the compiler cannot see where you are using it.Foo, so it won't be forFooGenerate any code, resulting in thetypefornull

Furthermore, dependency analysis is accurate to a single method, meaning that even if a type is considered a dependency, the method will not be included in the code generation if any method in the type is not used.

Although this can be solved by adding all types and methods to the dependency graph, the compiler will generate code for them. This isTrimmerRootAssemblyThe function of: by providingTrimmerRootAssembly, the NativeAOT compiler will take everything in the assembly you specified as root.

But this is not the case when it comes to generics.

Dynamic generic instantiation

In .NET, we have generics, and the compiler generates different code for each non-shared generic type and method.

Suppose we have a typePoint<T>

struct Point<T>
{
    public T X, Y;
}

If we have a piece of code trying to usePoint<int>, the compiler will bePoint<int>Generate special code so thatandAllint. If we have onePoint<float>, the compiler will generate another special code, so thatandAllfloat

Normally this does not cause any problems, as the compiler can statically find out all instantiations you use in your code until you try to construct a generic type or a generic method using reflection:

var type = (());
var pointType = typeof(Point<>).MakeGenericType(type);

The above code will not work under NativeAOT because the compiler cannot inferPoint<T>Instantiation of  , so the compiler will neither generatePoint<int>The code of   will not be generatedPoint<float>code.

Although the compiler can beintfloat, even generic type definitionPoint<>Generate code, but if the compiler does not generatePoint<int>You can't use instantiated codePoint<int>

Even if you useTrimmerRootAssemblyLet the compiler tell the compiler to take everything in your assembly as root, and still won't be likePoint<int>orPoint<float>Such instantiation generates code because they need to be constructed separately according to type parameters.

Solution

Now that we have identified potential issues that may occur under NativeAOT, let’s talk about solutions.

Use it elsewhere

The simplest idea is that we can let the compiler know what we need by using it in our code.

For example, for code

var type = (());
var pointType = typeof(Point<>).MakeGenericType(type);

As long as we know we want to usePoint<int>andPoint<float>, we can use it once elsewhere, and the compiler will generate code for them:

// We use a permanently false condition to ensure that the code is not executed// Because we just want the compiler to know the dependencies// Note that if we simply use a `if (false)` here// This branch will be completely removed by the compiler because it is redundant// So, let's use an extraordinary but impossible condition hereif ( &lt; 0)
{
    var list = new List&lt;Type&gt;();
    (typeof(Point&lt;int&gt;));
    (typeof(Point&lt;float&gt;));
}

DynamicDependency

We have an attributeDynamicDependencyAttributeTo tell the compiler that one method depends on another type or method.

So we can use it to tell the compiler: "If A is included in the dependency graph, then add B too".

Here is an example:

class Foo
{
    readonly Type t = typeof(Bar);
    [DynamicDependency(, typeof(Bar))]
    public void A()
    {
        foreach (var prop in ())
        {
            (prop);
        }
    }
}
class Bar
{
    public int X { get; set; }
    public int Y { get; set; }
}

Now as long as the compiler finds that there is any code path calledBarAll public properties in   will be added to the dependency graph so that we canBarEach public property of  is dynamically reflected invoked.

There are many overloads for this property that can accept different parameters to suit different use cases, you can view the documentation here.

Furthermore, now we knowDynamic reflection in  will cause no problems under tailoring and NativeAOT, we can useUnconditionalSuppressMessageTo suppress warning messages, so that no warnings will be generated during the construction process.

class Foo
{
    readonly Type t = typeof(Bar);
    [DynamicDependency(, typeof(Bar))]
    [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2080",
        Justification = "The properties of Bar have been preserved by DynamicDependency.")]
    public void A()
    {
        foreach (var prop in ())
        {
            (prop);
        }
    }
}

DynamicallyAccessedMembers

Sometimes we try to dynamically access typesTMembers of  , among themTcan be a type parameter or aTypeExamples:

void Foo<T>()
{
    foreach (var prop in typeof(T).GetProperties())
    {
        (prop);
    }
}
class Bar
{
    public int X { get; set; }
    public int Y { get; set; }
}

If we callFoo<Bar>, Unfortunately, this won't work under NativeAOT. The compiler does see that you are using type parametersBarCallFoo, but inFoo<T>In the context of  , the compiler does not knowTWhat is it, and no other code is used directlyBarproperty, so the compiler will notBarProperties generation code.

We can useDynamicallyAccessedMembersLet's tell the compiler toTAll public attribute generation codes:

void Foo<[DynamicallyAccessedMembers()] T>()
{
    // ...
}

Now when the compiler compiles and callsFoo<Bar>When it knowsT(Specially, hereBarAll public properties of ) should be considered dependencies.

This property can also be applied to aTypesuperior:

Foo(typeof(Bar));
void Foo([DynamicallyAccessedMembers()] Type t)
{
    foreach (var prop in ())
    {
        (prop);
    }
}

Even in onestringsuperior:

Foo("Bar");
void Foo([DynamicallyAccessedMembers()] string s)
{
    foreach (var prop in (s).GetProperties())
    {
        (prop);
    }
}

So here you may find that we have an alternative for us inDynamicDependencyCode examples mentioned in the section:

class Foo
{
    [DynamicallyAccessedMembers()]
    readonly Type t = typeof(Bar);
    public void A()
    {
        foreach (var prop in ())
        {
            (prop);
        }
    }
}

By the way, this is also the recommended method.

TrimmerRootAssembly

If you don't own the code, but you still want the code to work under NativeAOT. You can tryTrimmerRootAssemblyTo tell the compiler to use all types and methods in an assembly as dependencies. But please note that this approach does not work with generic instantiation.

<ItemGroup>
    <TrimmerRootAssembly Include="MyAssembly" />
</ItemGroup>

TrimmerRootDescriptor

For advanced users, they may want to control what is included from an assembly. In this case, you can specify aTrimmerRootDescriptor

<ItemGroup>
    <TrimmerRootDescriptor Include="" />
</ItemGroup>

The documentation and format of the TrimmerRootDescriptor file can be found here.

Runtime Directives

For generic instantiation cases, they cannot be solved by TrimmerRootAssembly or TrimmerRootDescriptor, here a file containing runtime directives is needed to tell the compiler what needs to be compiled.

<ItemGroup>
    <RdXmlFile Include="" />
</ItemGroup>

exist, you can specify instantiation for your generic types and methods.

The documentation and format of the file can be found here.

This method is not recommended, but it can solve some of the difficulties you encounter when using NativeAOT. Please always consider using trimmer descriptor or runtime directivesDynamicallyAccessedMembersandDynamicDependencyTo comment your code to make it compatible with clipping/AOT.

Conclusion

NativeAOT is a great and powerful tool in .NET. With NativeAOT, you can build your app with predictable performance while saving resources (lower memory footprint and smaller binary size).

It also brings .NET to platforms that do not allow JIT compilers, such as iOS and hosting platforms. Additionally, it enables .NET to run on embedded devices or even bare metal devices (for example, on UEFI).

Learn about tools before using them, which will save you a lot of time.

This is all about this article about the .NET NativeAOT guide. For more related .NET NativeAOT guide, please search for my previous articles or continue browsing the related articles below. I hope you will support me in the future!