Skip to content

Trimming and Native AOT

lemming104 edited this page Oct 6, 2024 · 9 revisions

Recent versions of the .NET SDK support trimming for self-contained deployments. At the cost of longer compile times and some restrictions discussed below, trimming allows developers to ship .NET executables that are smaller and start more quickly. However, not all code is "trimmable" and there are numerous patterns that need to be addressed to support trimming.

Trim-compliant code can also be published using Native AOT. This generates fully native code, with only a minimal runtime (CoreRT) compiled into the executable to provide support for basic .NET functionality such as memory management/garbage collection. Native AOT imposes a few additional requirements on top of trimming (mostly related to native type marshaling and file paths in compiled assemblies), but starting with an executable that supports trimming is a necessary first step and typically covers most (or sometimes even all) of the AOT requirements. AOT executables are not necessarily any faster than ones that are just trimmed (and may even be slower in cases that use a lot of Linq, since AOT is incapable of code generation at runtime). The main benefits are typically:

  1. Smaller file size compared to an executable with a self-contained runtime (at least, unless the self-contained file is also compressed, which slows startup)
  2. Instant startup
  3. Absolutely no JIT compilation

Note that AOT also requires a native toolchain (LLVM on Linux, the VS C/C++ compiler on Windows), and cannot generate cross-platform executables. Trimming alone does not impose these requirements.

This document focuses on coding pattern accommodations for trimmed and AOT deployment. For documentation on enabling trimming, trim analyzers, or AOT, please see the Microsoft documentation.

Example Repository

The coding patterns discussed in this document are implemented in a simple project in this repository.

Serialization and Deserialization

The popular Newtonsoft.Json library includes many features, such as support for .NET DataTables, that are not amenable to trimming. Using this library will produce a large volume of trim warnings. We recommend using System.Text.Json instead, as this provides trim-friendly methods for serialization and deserialization. Note that System.Text.Json is not a 100% drop-in replacement for Newtonsoft.Json--it has different default behaviors when serializing and deserializing some types, and there are cases where we may need to write custom converters. Adequate test coverage is important here.

To support trimmable serialization and deserialization, all types must be known at compile time. System.Text.Json recommends the use of a "serialization context" so that the compiler can generate the necessary code for the types we expect to see.

For example, if we have a model class that looks like this:

    public class JsonSerializableClass
    {
        public string? StringField;

        public int IntField;
    }

We can create a serialization context for it that looks like this:

    using System.Text.Json.Serialization;

    [JsonSourceGenerationOptions(
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | JsonIgnoreCondition.WhenWritingDefault,
    PropertyNameCaseInsensitive = true,
    IncludeFields = true)]
    [JsonSerializable(typeof(JsonSerializableClass), TypeInfoPropertyName = "JsonSerializableClass")]

    public partial class JsonSerializableClass_SerializationContext : JsonSerializerContext
    {
    }

Then, we can serialize an instance as follows:

string serialized = JsonSerializer.Serialize(objectToSerialize, typeof(JsonSerializableClass), JsonSerializableClass_SerializationContext.Default);

We can also deserialize using the same context:

JsonSerializableClass? deserialized = JsonSerializer.Deserialize(serialized, typeof(JsonSerializableClass), JsonSerializableClass_SerializationContext.Default) as JsonSerializableClass;

Since the types required are all known at compile time, this program can be trimmed or compiled ahead-of-time without any issues.

Enums

The type of an enumeration must be known at compile time. If we need to get all of the possible values of an enum, we must specify its type in a generic type constraint. The following code will generate a trim warning:

Enum.GetValues(typeof(Enum1))

This code, however, fully specifies the type and will not generate a trim warning:

Enum.GetValues<Enum1>();

To support code that needs to get all of the possible values of an enumeration in a context where we cannot write a generic method or class, we may need to pre-compute a static dictionary containing all of known enumerations and their possible values, keyed by Type.

Reflection

Helion's configuration settings are stored in a complex class hierarchy. They are serialized to, and deserialized from, an .ini file that is organized as a set of key-value pairs. This poses some similar concerns to Json serialization, except we need to solve many of the problems ourselves.

Assume we have the following class hierarchy, and we want to recursively list out all of the field values, perhaps to store them in a file or dictionary:

    public class SelfReflectingClass1
    {
        public string? StringValue;
        public int IntValue;
        public SelfReflectingClass1? RecursiveMember;
        public SelfReflectingClass2? SelfReflectingClass2Member;
    }

    public class SelfReflectingClass2
    {
        public double DoubleValue;
    }

We cannot just use reflection across SelfReflectingClass1 and its children, because the compiler is not smart enough to know the types at compile time. However, we can write something like this, in which a generic type is annotated with an attribute that tells the compiler to preserve type information (the [DynamicallyAccessedMembers] attribute):

    public abstract class SelfReflector<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T> : ISelfReflector
        where T : SelfReflector<T>
    {
        public List<string> GetFieldValuesRecursively()
        {
            List<string> fieldValues = new();
            FieldInfo[] fields = typeof(T).GetFields();

            foreach (FieldInfo f in fields)
            {
                object? value = f.GetValue(this);
                if (value is ISelfReflector)
                {
                    // Recursive step
                    fieldValues.AddRange((value as ISelfReflector)!.GetFieldValuesRecursively());
                }
                else if (value != null)
                {
                    fieldValues.Add(value?.ToString() ?? "Unknown value");
                }
            }

            return fieldValues;
        }
    }

    public interface ISelfReflector
    {
        List<string> GetFieldValuesRecursively();
    }

Then, we can make our other classes inherit from this generic type:

    public class SelfReflectingClass1 : SelfReflector<SelfReflectingClass1>
    { 
        // Contents unchanged from previous example
    }

    public class SelfReflectingClass2 : SelfReflector<SelfReflectingClass2>
    {
        // Contents unchanged from previous example
    }

We can then call GetFieldValuesRecursively() on an object based upon one of these types:

SelfReflectingClass1 c1 = new SelfReflectingClass1();
// Perhaps set some values here

List<string> valuesAsStrings = c1.GetFieldValuesRecursively();

Similar techniques can be used to set field values, or enumerate methods on types. The main complication is that all types need to be known at compile time, so any reflection must happen on typeof(SomeSpecificClassType), not upon the output of someObject.GetType().