How to develop Native AOT applications in .NET

.NET 8 brings comprehensive support for ahead-of-time compilation, also known as Native AOT. Such applications usually start faster and consume less memory than managed solutions.

The .NET AOT publishing produces trimmed, self-contained applications. Such applications:

  1. Can run on a machine that does not have the .NET runtime installed.
  2. Target a specific runtime environment, such as Windows x64.
  3. Do not contain unused code.

Deploy .NET AOT applications

We develop Docotic.Pdf library for PDF processing. The AOT compatibility is one of the most popular feature requests in 2024. This article describes our journey to achieving AOT compatibility. You will learn how to find and fix AOT issues in .NET projects.

.NET AOT publishing basics

To publish a Native AOT application, add the following property to your project file:

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>

You might also need to install some prerequisites. Then, you can publish your app from Visual Studio. Or, you can publish the project from the command line using the dotnet publish command:

dotnet publish -r linux-x64 -c Release

AOT compatibility for .NET libraries

The Native AOT publishing is available in .NET 7 and .NET 8. In .NET 7, you can only publish console applications. In .NET 8, you can additionally publish ASP.NET Core applications.

But the Docotic.Pdf core library targets .NET Standard 2.0. Can we make it AOT friendly? Of course.

You can still find and fix AOT compatibility issues in .NET libraries targeting .NET Standard, .NET 5, or .NET 6. Native AOT applications can rely on the library then.

Find AOT issues

The first support request about the AOT compatibility sounds like this:

We are considering using Docotic.Pdf compiled to WebAssembly. It produces trim warnings when we compile it to AOT:
warning IL2104: Assembly 'BitMiracle.Docotic.Pdf' produced trim warnings. For more information see https://aka.ms/dotnet-illink/libraries

Not very informative. Let's reproduce the issue and get more detail about these trim warnings.

Get publishing warnings

Publishing a test application is the most flexible way to find .NET AOT issues. You need to:

  1. Create a .NET 8 console project with PublishAot = true
  2. Add a reference to your project and use some of its types

Here is the csproj configuration we use for Docotic.Pdf:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <PublishAot>true</PublishAot>
        <TrimmerSingleWarn>false</TrimmerSingleWarn>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <WarningsAsErrors />
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\Docotic\Docotic.csproj" />
    </ItemGroup>
</Project>

The TrimmerSingleWarn = false allows you to get the detailed information about warnings. Otherwise, you will only get the "Assembly 'X' produced trim warnings" message.

And here is the C# code in the Program.cs:

using BitMiracle.Docotic.Pdf;

using var pdf = new PdfDocument();

Add some code to force the loading of the assembly being checked. It is not required to cover all functionality of your project to get publishing warnings. Using a single type is enough. But you need to write more code to catch runtime errors.

The dotnet publish -r win-x64 -c Release command for this project returned trim and AOT warnings. Here is the short version of the list:

AOT analysis warning IL3050: Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): Using member 'System.Enum.GetValues(Type)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling.

Trim analysis warning IL2026: LogProviders.Log4NetLogProvider.Log4NetLogger.GetCreateLoggingEvent(ParameterExpression,UnaryExpression,ParameterExpression,UnaryExpression,Type): Using member 'System.Linq.Expressions.Expression.Property(Expression,String)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.

Trim analysis warning IL2057: LogProviders.LogProviderBase.FindType(String,String[]): Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'.

Trim analysis warning IL2070: LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The parameter 'loggerType' of method 'LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String)' does not have matching annotations. The source value must declare at least the same requirements as those declared on the target location it is assigned to.

Trim analysis warning IL2075: LogProviders.Log4NetLogProvider.GetOpenNdcMethod(): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The return value of method 'LogProviders.LogProviderBase.FindType(String,String)' does not have matching annotations. The source value must declare at least the same requirements as those declared on the target location it is assigned to.

We found the root cause. Before fixing the issues, let's check alternative options to find AOT compatibility issues.

Use AOT-compatibility analyzers

Microsoft provides Roslyn analyzers to find AOT issues in .NET 7 or .NET 8 projects. They can highlight AOT problems when you write the code. But the publishing of a test application detects more issues.

To use static analysis, add the following property to the .csproj file:

<PropertyGroup>
    <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

You need to target the .NET 7 or .NET 8 framework to use such analysis in a .NET Standard 2.0 project. The project configuration may look like this:

<PropertyGroup>
    <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
    <IsAotCompatible Condition="'$(TargetFramework)'=='net8.0'">true</IsAotCompatible>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsAsErrors />
</PropertyGroup>

We applied the above configuration to our large core project and got 1036 build errors.

Many build errors

Did they all relate to the AOT compatibility? We had rebuilt the project without the IsAotCompatible property. Still, 1036 errors.

All errors related to the improvements in .NET 8 compared to .NET Standard 2.0. In our case, most of the problems were about the better support for nullable reference types. There were also many CA1500 and CA1512 errors about new ArgumentOutOfRangeException.ThrowIfLessThan and ArgumentNullException.ThrowIfNull helper methods.

We fixed all errors and turned on the IsAotCompatible property. The project build finished without AOT errors. The static analysis did not catch any of the warnings found during the publishing process 🙁

To confirm, we had added the following test code to our code base:

Type t = typeof(int);
t = typeof(List<>).MakeGenericType(t);
Console.WriteLine(Activator.CreateInstance(t));

string s = Console.ReadLine() ?? string.Empty;
Type? type = Type.GetType(s);
if (type != null)
{
    foreach (var m in type.GetMethods())
        Console.WriteLine(m.Name);
}

This C# code is 100% incompatible with .NET AOT and trimming. And the static analysis identified the following errors:

error IL3050: Using member 'System.Type.MakeGenericType(params Type[])' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The native code for this instantiation might not be available at runtime.

error IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type.

So, Roslyn analyzers work and can find some problems. In our case, they did not find anything.

Catch runtime errors

The How to make libraries compatible with native AOT article states the following principle:

If an application has no warnings when being published for AOT, it will behave the same after AOT as it does without AOT.

On practice, the version after AOT can still work differently. We had fixed all AOT and trim warnings. All automatic tests passed. The test application processed PDF documents properly. And the AOT version of the same application produced incorrect documents.

The AOT application still did not work

AOT publishing mistakenly removed some required code. We created the Optimization removes necessary code issue. The .NET team quickly confirmed and fixed it. And we added a workaround to our C# code until the fix is available.

The moral of this story is that publishing and static analysis warnings may not be enough. Use the key features of your project in the test application. And run the test application after deployment.

The Docotic.Pdf test application uses PDF compression, text extraction, and other key functionality. We use it like this:

AotCompatibility.exe compress "C:\compressed.pdf" "C:\input.pdf" "optional-password"

Manual testing of the published application is not convenient. It's worth automating the process.

Detect AOT issues automatically

Docotic.Pdf development is based on automatic tests. There are tests for every feature or bug. The .NET AOT compatibility is not an exception.

There are two ways to automate the testing of Native AOT applications.

Place tests outside the AOT application

Here, you use a regular NUnit/xUnit/MSTest project. Before building this project, you publish the AOT test application. The tests run the published app using the Process.Start method.

The primary advantage is the separation of the test infrastructure from the AOT app. You can use the full capabilities of unit testing frameworks. Any AOT compatibility issues in the testing framework do not matter. It is also easier to reuse existing test cases.

The sample configuration of the test project is available on GitHub. The Docotic.Tests project contains automatic tests for both managed and AOT versions. The AotCompatibility is a test application for AOT publishing.

The AotCompatibility.csproj publishes the test application on a post-build event in the Release configuration. The '$(PublishProtocol)'=='' condition disables this step when you use the "Publish" wizard in Visual Studio.

<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition="'$(Configuration)' == 'Release' And '$(PublishProtocol)'==''">
    <Exec Command="&quot;$(SolutionDir)Scripts\publish.bat&quot;" />
</Target>

The publish.bat script is simple:

@echo off

SET AOT_TEST_DIR=%~dp0\..\AotCompatibility
dotnet publish "%TRIM_TEST_DIR%\AotCompatibility.csproj" --no-build /warnaserror /p:PublishProfile="%AOT_TEST_DIR%\Properties\PublishProfiles\win-x64-installer.pubxml"

Note that we use the --no-build argument here. The project is already built before the post-build event. We do not need to build it again.

The tests in NativeAot.cs run the published AotCompatibility.exe with different arguments. Docotic.Tests and AotCompatibility projects share the same tested code. That allows us to check managed and AOT versions without duplicating code.

Place tests inside the AOT application

An alternative approach is to write tests inside the AOT test application. In July 2024, you can only use the MS Test early preview. Read the Testing your native AOT Applications article for more detail.

The key problem is that any AOT issue in the testing framework will make your tests unreliable. And only a limited set of test framework features is available. That's why we prefer to put test infrastructure outside the AOT application.

Fix AOT and trim warnings

Avoid suppressing publishing warnings. Usually, suppressing is an incorrect solution. When you suppress AOT or trim warnings, you say that your project is AOT compatible. However, the root cause of the warning is still there. Any suppressed warning might finally lead to a run-time failure of your application.

Try to remove or rewrite the code associated with each warning. Updating third-party components might also help.

Let's review how we fixed AOT and trim warnings in Docotic.Pdf. You already saw some of them in the Get publishing warnings section.

Trim warnings from LibLog

We used the LibLog library for logging. LibLog relies on reflection to detect and use popular logging frameworks. It is incompatible with AOT by design.

Currently, The Microsoft.Extensions.Logging package is the standard for logging in .NET. And the LibLog development is now frozen.

Given that, we had completely removed LibLog from our code. Instead, we had released the Docotic.Pdf.Logging add-on. It depends on Microsoft.Extensions.Logging.Abstractions interfaces. That reduced the core library size and fixed all related trim warnings.

No trim warnings

A debugging code in the Docotic.Layout add-on

In the layout add-on, we use reflection for internal debugging. That led to the trim analysis warning IL2075.

The corresponding code exists in the code base, but clients cannot use it via the public API. The solution is to exclude the code from Release configuration. It is now used in the Debug configuration only.

AOT warning IL3050 from BouncyCastle

The BouncyCastle library helps us to sign PDF documents. The following warning comes from its code:

Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): Using member 'System.Enum.GetValues(Type)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. It might not be possible to create an array of the enum type at runtime. Use the GetValues<TEnum> overload or the GetValuesAsUnderlyingType method instead.

BouncyCastle uses the Enum.GetValues(Type) method, which is not AOT compatible.

The simplest solution would be to use the Enum.GetValues<T>() overload instead. The latest BouncyCastle version already uses it. Unfortunately, this overload is only available in .NET 5 or newer. This is not an option for .NET Standard 2.0.

We dug deeper and analyzed the BouncyCastle code. It turned out that BouncyCastle uses it to prevent the obfuscation of enum constants. Instead, we disabled the obfuscation for corresponding enums using the [System.Reflection.Obfuscation(Exclude = true)] attribute. And removed no longer necessary usages of Enum.GetValues(Type).

A weird issue in the obfuscated build

We use obfuscation to protect production builds. Surprisingly, the .NET AOT deployment for the obfuscated build returned similar warnings:

<unknown>:0: error: symbol '__GenericLookupFromType_BitMiracle_Docotic_Pdf___4<System___Canon__System___Canon__System___Canon__Int32>_TypeHandle___System___Canon' is already defined

Such errors did not have an associated IL30## or IL2### codes. It was unclear how to identify the associated code.

Errors happened in the obfuscated version only. It might be a bug in the obfuscator. We had updated the obfuscator, but errors were still present.

We needed to narrow down the scope of the problem. We used the following process, based on a binary search:

  1. Disable the obfuscation for half of the namespaces. Check whether errors occur. Continue until finding the namespaces that lead to the errors.
  2. Disable the obfuscation for half of the types in the namespace from the step one. Check whether errors occur. Continue until finding the types that lead to the errors.
  3. Disable the obfuscation for half of the members in the type from the step two. Check whether errors occur. Continue until finding the members that lead to the errors.
  4. Review the code of the member from the step three. Comment out unusual parts and check whether errors occur.

We ended up in the step one 🙂 After disabling the obfuscation for all namespaces, errors still occurred.

We analyzed the generated assembly with ILSpy. Found a few unexpected compiler-generated types. The ILSpy showed their usages from a similar C# code:

interface X
{
    void f();
}

X obj = ..;
if (obj.f != null)
    ..

This weird code came from the migration of old-style C code to C#. The if (obj.f != null) condition is completely redundant. We removed such conditions and errors had gone away.

Do not use GetCallingAssembly

We caught another runtime issue. The LicenseManager.AddLicenseData(string) method call failed after the AOT publishing with the following error:

Assembly.GetCallingAssembly() throws System.PlatformNotSupportedException: Operation is not supported on this platform.

The Assembly.GetCallingAssembly method should be implemented for Native AOT in .NET 9. Until the fix is ready, we refactored our code to minimize the negative impact. Now, we use the calling assembly attributes for validating the Application License only. Other license types are compatible with AOT.

Mark the code with special attributes

Fortunately, we have been able to fix all AOT and trim warnings in the core Docotic.Pdf library and most add-ons. But, you might not be able to rewrite all the AOT incompatible code.

.NET provides special attributes for such situations. You mark the API with attributes to inform clients about known AOT issues. Users of your API will get a warning when calling the marked method.

The key attributes for marking AOT incompatible .NET code are:

  • RequiresDynamicCode
  • RequiresUnreferencedCode
  • DynamicallyAccessedMembers

You can find more information about these attributes in the official Introduction to AOT warnings and Prepare .NET libraries for trimming articles.

Conclusion

The Native AOT deployment is a big step forward in the .NET world. You can write regular C# code and get a native application or library. Such applications are usually faster and can run without the .NET runtime installed. You can even use published DLLs from C, Rust, or other non-.NET programming languages.

Publish a test .NET 8 application to find AOT compatibility problems. There are also Roslyn analyzers, but they catch fewer issues.

The core Docotic.Pdf library is now compatible with AOT and trimming. The following add-ons are compatible as well:

  • Layout add-on
  • Gdi add-on
  • Logging add-on

The HTML to PDF add-on still contains trim warnings. This is our next goal on the Native AOT roadmap.

Feel free to ask us questions about the Native AOT or PDF processing.