Skip to main content

Native AOT and allocations

Native AOT compiles a .NET program ahead of time into a native executable. There is no just-in-time compiler at run time, and the compiler removes code that it cannot see being used, which is called trimming. The result starts fast and is small, but a library has to cooperate. Code that finds types or members by name at run time, which is reflection, can lose the very things it looks for.

Minos.NET is written for this. This page says what that covers, where it is checked, and what the client allocates, because a program that avoids the just-in-time compiler usually cares about memory as well.

What is Native AOT compatible​

  • The client. Minos.NET sets IsAotCompatible, which turns on the trimming and Native AOT analyzers for the library's own build. Sending requests, mapping every failure to a DecisionError, retries, logging and telemetry all run under Native AOT.
  • The generated question sets. The [Questions] generator writes the question JSON and the answer parsing as ordinary C# at compile time. The set uses no reflection at run time, and neither does reading an answer.
  • Dependency injection. Minos.NET.DependencyInjection, the AddSystemOneDecisionClient registrations in Minos.NET.SystemOne and the AddOpenAIDecisionClient registrations in Minos.NET.OpenAI, whose provider name is openai, are Native AOT compatible too. Both bind options from configuration with the configuration binding source generator, so binding uses no reflection and needs nothing set up in your program. Dependency injection covers the options.
  • The pipeline stages. UseCaching in Minos.NET.Caching and UseRateLimiting in Minos.NET.RateLimiting run under Native AOT. The cache stage stores answers in the distributed cache as compact JSON, written and read with Utf8JsonWriter and Utf8JsonReader, so it uses no reflection.

The one use of reflection​

A question set built at run time can take a Choice or a Score from an enum. To read the enum's members, the builder reads its public fields. This is the only reflection in the library, and it is declared to the trimmer with DynamicallyAccessedMembers, so the trimmer keeps those fields and the code is safe under trimming and Native AOT. You do not need to do anything for it.

There is one case that does ask something of you. A method of your own that passes its own generic parameter on to Choice<T>, Score<T> or QuestionSetAnswers.Get needs the same annotation on that parameter, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)]. Question sets at run time says where.

State types need JSON metadata​

A typed state, such as a support ticket record, is written as JSON through System.Text.Json. Under Native AOT that needs source-generated metadata, which a JsonSerializerContext gives. Pass the context's JsonTypeInfo to the EvaluateAsync<T, TState> overload, and nothing is found by reflection. A state you pass as a string, a JsonElement or UTF-8 JSON needs no metadata at all, and DecisionContent.FromValue takes the same JsonTypeInfo.

Publishing your own application​

Add PublishAot to the project that you publish, and publish for a runtime identifier:

<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -c Release -r linux-x64

Publishing for Native AOT needs the native toolchain of the platform. Microsoft's Native AOT deployment guide lists it. A publish that reports trimming or AOT warnings, whose codes start with IL2 or IL3, is telling you that something in the program will not survive, and the warning names it.

Where it is checked​

Three checks guard it, and each answers a different question.

The smoke application: does it run​

The repository holds a smoke application, samples/Minos.NET.AotSmoke. The aot-smoke job of the CI workflow publishes it with PublishAot and runs the native executable. The project treats every warning as an error, including the whole IL2xxx and IL3xxx range, so a trimming or AOT warning anywhere in what the application uses fails the build. The program exits with a failure if any check fails.

The checks run the real client over a canned HTTP handler. They cover:

  • raw, typed and built-set evaluation, and a typed state;
  • failures: a rejected request, an unreadable response, an overloaded service that is retried, and the unsupported model listing on OpenRouter;
  • logging through a real LoggerFactory, and the telemetry spans and metrics through real listeners;
  • registering clients with dependency injection, and binding their options from configuration;
  • the allocation budgets in the next section.

The whole-assembly check: is anything unsafe​

A publish only analyses what the program reaches, so the smoke application cannot vouch for a member that no check calls. The aot-surface job closes that gap. It publishes samples/Minos.NET.AotSurface with PublishAot and full trimming, and roots Minos.NET, Minos.NET.SystemOne, Minos.NET.OpenAI, Minos.NET.DependencyInjection, Minos.NET.Caching, Minos.NET.RateLimiting and Minos.NET.Evaluation, the labelled dataset loader, whole through TrimmerRootAssembly.

Rooting analyses every non-generic member of these assemblies, called or not, and every generic member the compiler can share across reference types. A value-type generic has no such shared form, so rooting alone skips it. That covers Choice<T>, Score<T> and the other generics over an enum, whose type parameter is where T : struct, Enum. The host therefore instantiates every public generic type and generic method of these assemblies over its own types, and the compiler analyses them through those instantiations. A test in tests/Minos.NET.AotSmoke.Tests fails when a public generic is not instantiated in the host. The host is rooted as well. Its [Questions] sets, one with a Noul, a Choice and a Score and one with a typed state, mean the code the generator writes is analysed in full too.

Every IL2xxx and IL3xxx warning is an error, so a trimming or AOT hazard in the public API fails the job. The host is only published and never run, because the publish is the check. A hazard that nothing calls fails the build all the same. That was confirmed by looking a type up by name in a never-called method, and again inside a generic method and a member of a generic type over an enum.

The coverage rule: does the smoke application leave anything out​

The smoke application has to call everything a user can call, or its own pass says too little. The test project tests/Minos.NET.AotSmoke.Tests enforces that, and it runs with the rest of the test suite.

An entry point is a public or protected method or constructor that you call. The list is read from the PublicAPI files of the seven library packages, so a new public member is an entry point the day it is added. These are not entry points, and the test says why for each:

  • type declarations, which are called through their constructors and methods;
  • property and indexer accessors, fields, constants and enum members, which are covered through their types;
  • operators, including implicit conversions, which are syntax over methods that count;
  • the Equals(object), GetHashCode() and ToString() overrides;
  • the members the compiler writes for a record, such as <Clone>$, Deconstruct, PrintMembers and Equals(T). The test recognises them by the [CompilerGenerated] attribute the compiler puts on them, not by name, so a record member written by hand, and the Equals(T) of a struct, do count.

Every other entry point has to be declared. A smoke check carries one [Covers] attribute per entry point it calls, spelled as its line in the PublicAPI file. The test fails in four cases:

  • an entry point that no check declares;
  • a declaration that names no entry point, such as a stale line or a typo;
  • a check that declares something its code never calls. The test binds the check's code with the compiler's semantic model, follows it into the smoke application's own helpers, and compares the symbols it finds;
  • a declaring check that Main does not run.

A default interface method is an entry point too, and the rule for it is stricter. Calling it through a client resolved from a container proves nothing, because that client may override it and the default body would never run. A check counts for a default interface method only when the receiver is known from the code to be a type that does not override it: either its own type, or the type its local was created as, when nothing assigns that local again. Since 0.8, IDecisionClient has no default methods, because its typed calls became extension methods, so the rule applies to the next interface that gets one.

A protected override of a library member, such as a stage's Dispose(bool), counts as called when a check creates the smoke type that declares it. Only the library's own code can call such a member, as DelegatingDecisionClient's Dispose() calls Dispose(bool).

The rule is about reach, not about correctness: it shows that the code binds to the entry point and that Main runs the check, not that the call asserts the right thing.

What the client allocates​

An allocation is memory the garbage collector must later reclaim. A client that allocates little causes few collections, which keeps the pauses short in a program that makes many calls. Minos is built to allocate little. The typed and built-set calls write the request into a pooled buffer and read the response from one, and reading an answer creates no object.

  • Reading an answer allocates nothing. Noul, Choice<T> and Score<T> are read as structs, and so are the Probabilities, the confidence helpers and QuestionSetAnswers.Get for a set built at run time.
  • Parsing a typed answer set allocates the result. That is the result record, plus the shared buffer that holds the probabilities of its answers, and nothing else.
  • A whole call allocates a few kilobytes. Over the canned handler, a typed call measures 3248 B under Native AOT. Performance has the measurements for the other paths.
  • Building a question set allocates the set. It measures 2648 B, so build it once and share it, as the run-time page advises.
  • Logging and telemetry add nothing until something listens. With no logger, or every level off, a call allocates nothing extra. With nothing listening to the source or the meter, telemetry adds nothing either. Before the pipeline, it added 211 B, measured under the JIT, to a typed or built-set call that completed asynchronously. Logging, traces and metrics says what each adds when it is on.

These claims are enforced, not only measured. The smoke application runs each path below repeatedly, mostly under AllocationGate from the ZeroAlloc.TestHelpers package, and fails if the path allocates more than its budget. The calls run over a canned in-memory handler, so the budgets measure the client's own work and not the network.

The allocation budgets​

A budget is set a little above what the path measures under Native AOT, usually the measurement plus about 10 percent, rounded up to the next 64 bytes. Where the measurement is zero the budget is zero, and the first gates have looser budgets that Performance notes. A gate that fails fails the aot-smoke job, so an allocation regression cannot reach a release unnoticed.

GateWhat it measuresBudget in bytes
AnswersGetReading answers of a built set through its handles.0
PatternHelpersThe confidence and normalization helpers of the patterns.0
NoulEqualsComparing two Noul answers, directly and through EqualityComparer<Noul>.Default.0
AnswerSlotAccessorsReading a Noul, a Choice<T> and a Score<T> from the answer slots of a typed set.0
GeneratedCreateThe generated Create of a typed set building its result from the answer slots, which replaces the old GeneratedParse gate of 192. The protocol's probability buffer is counted by the typed round trips and by a test in the unit suite, which holds the whole read to 192.128
EvaluateRoundTripA raw EvaluateAsync call.4352
TypedEvaluateRoundTripA typed EvaluateAsync<T> call.3328
NeutralEvaluateRoundTripA neutral EvaluateAsync(DecisionRequest) call on the standard pipeline.3392
BareTransportRoundTripA neutral call with UseStandardPipeline off, the transport alone.3392
PassThroughStageA DelegatingDecisionClient that overrides nothing, over an inner call that completes synchronously.0
CacheHitA CachingDecisionClient answering from its in-process cache; the 120 B measured is the key string a hit builds per call.192
GrantedPermitA RateLimitingDecisionClient granting a token-bucket permit, over an inner call that allocates nothing.0
Utf8StateEvaluateRoundTripA typed EvaluateUtf8Async<T> call, which copies the caller's bytes once and sends them as written.3712
TypedStateEvaluateRoundTripA typed EvaluateAsync<T, TState> call, which serializes its state once into an array.3136
SystemOneImageRoundTripA typed EvaluateAsync<T, TState> call with one 64 B image, sent as a data URL beside the state.3200
OpenAITypedRoundTripA typed EvaluateAsync<T> call over an OpenAIDecisionClient, over a three-kind response.3776
OpenAIImageRoundTripA typed EvaluateAsync<T, TState> call over an OpenAIDecisionClient with one 64 B image.3072
EvaluateBuiltSetRoundTripAn EvaluateAsync call over a built set.3648
BuildQuestionSetBuilding a question set.7296
ContentFromValueDecisionContent.FromValue.320
ContentFromUtf8JsonDecisionContent.FromUtf8Json.320
EvaluateRoundTripWithNullLoggerFactoryA raw call with a logger factory that logs nothing.4352
TypedEvaluateRoundTripWithEveryLevelFilteredA typed call with every log level filtered out.3328
EvaluateRoundTripWithDiscardingLoggerA raw call with every log level on.4352
TypedEvaluateRoundTripWithDiscardingLoggerA typed call with every log level on.3328
EvaluateRoundTripThroughDependencyInjectionA raw call through a client resolved from the container, and no more than a hand-built client's own measurement.4416
EvaluateRoundTripWhileListeningA raw call with a span and metric listener attached.5888
TypedEvaluateRoundTripWhileListeningA typed call with the listeners attached.5056
NeutralEvaluateRoundTripWhileListeningA neutral call with the listeners attached.4928
EvaluateBuiltSetRoundTripWhileListeningA built-set call with the listeners attached.5376
EvaluateRoundTripThroughBoundConfigurationA raw call through a client bound from configuration, equal to a hand-built client's own measurement.same as the hand-built client
DisabledLoggerAddsNothingWhereAnEnabledOneDoesAsynchronous calls with no factory, a null factory and an enabled logger. Checks the disabled ones add no more than 64 B per call, a tolerance for the noise of a counter that sees every thread, set over 60 runs; an enabled logger adds 196 B or more.no byte budget
TelemetryOffAsynchronousTypedEvaluationA typed call that completes asynchronously, with nothing listening. The least of three windows of 500 calls, with no tolerance.4608

The two logging rows with a logger that does nothing keep the budgets of the calls without one, because the client takes the unlogged path. The two rows with every level on keep them as well, because an enabled logger adds nothing to a call that completes synchronously, and the listening rows show what a span and the metrics cost. Each budget has a section in Performance that explains the measurement behind it. The phases there are 3.1 for logging, 3.2 for telemetry, 3.3 for dependency injection and 6.3 for the client pipeline.

A call that completes asynchronously, as every real network call does, pays for a state machine that a synchronous call does not. The canned handler completes synchronously, so most gates do not see that cost, and the last gate in the table exists to measure it.

Next​