.NET  

Benchmarking .NET 11 NativeAOT CLI Startup and Memory Usage

Command-line applications often have a simple job: start, perform work, and exit.

For these applications, startup time and memory consumption can matter more than raw throughput. A CLI tool that runs for only a few hundred milliseconds does not have much opportunity to amortize runtime initialization costs.

This is one reason NativeAOT is interesting for .NET command-line applications.

NativeAOT compiles an application ahead of time into native code. The resulting application is self-contained for its target runtime environment and does not require the normal JIT compilation process at startup. Microsoft documents faster startup and smaller memory footprints as key NativeAOT benefits.

.NET 11 continues improving the NativeAOT experience. In .NET 11 Preview 6, the SDK added support for the NativeAOT CLI serving the full command surface, while runtime work also included faster interface dispatch.

But one important question remains:

How much does NativeAOT actually change startup time and memory usage for a real CLI application?

The answer should be measured rather than assumed.

What NativeAOT Changes

A conventional .NET application normally follows a runtime path similar to:

CLI command
    ↓
.NET host
    ↓
Runtime initialization
    ↓
JIT compilation
    ↓
Application execution
    ↓
Exit

A NativeAOT application changes the execution model:

CLI command
    ↓
Native executable
    ↓
Application execution
    ↓
Exit

The application is compiled ahead of time during publishing rather than relying on JIT compilation during execution.

For example, NativeAOT can be enabled in a project file with:

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

A platform-specific publish can then be performed with:

dotnet publish -c Release -r win-x64

The same model can target Linux:

dotnet publish -c Release -r linux-x64

NativeAOT is platform-specific, so the benchmark must always record the operating system and architecture being tested.

Why CLI Applications Are Good NativeAOT Candidates

A long-running server has time to amortize startup overhead.

A short-lived command-line tool does not.

Consider a command:

mytool generate-report

Suppose the actual business operation takes only 100 milliseconds. If process startup, runtime initialization, and JIT work add significant overhead, startup becomes a noticeable portion of the total execution time.

This becomes even more important for tools executed repeatedly:

CI/CD pipelines
Developer tooling
Build utilities
Code generators
Migration tools
Automation scripts
Cloud jobs
Container entrypoints

For these scenarios, the relevant metric is not simply application throughput.

It is:

Time-to-useful-work

NativeAOT Is Not Automatically Faster in Every Scenario

NativeAOT has strong startup characteristics, but developers should avoid turning that into an assumption that every NativeAOT application will be faster overall.

A CLI application can spend most of its execution time doing:

  • Database queries

  • Network requests

  • File processing

  • JSON serialization

  • Compression

  • CPU-intensive calculations

In those cases, startup improvements may have limited impact on total execution time.

For example:

Application A

Startup:       80 ms
Work:          20 ms
Total:        100 ms

versus:

Application B

Startup:       80 ms
Work:       2,000 ms
Total:      2,080 ms

Reducing startup from 80 ms to 20 ms is significant for Application A but relatively small for Application B.

That is why benchmarking needs realistic workloads.

Build a Benchmark CLI

Start with a small console application.

For example:

using System.Diagnostics;
using System.Text.Json;

var stopwatch = Stopwatch.StartNew();

var data = Enumerable.Range(1, 100_000)
    .Select(x => new
    {
        Id = x,
        Name = $"Item-{x}",
        Value = x * 1.25
    })
    .ToArray();

var json = JsonSerializer.Serialize(data);

stopwatch.Stop();

Console.WriteLine(
    $"Generated {data.Length} items");

Console.WriteLine(
    $"JSON size: {json.Length:N0} characters");

Console.WriteLine(
    $"Work time: {stopwatch.Elapsed.TotalMilliseconds:N2} ms");

This gives the benchmark a small amount of realistic work rather than measuring only an empty Main method.

For more meaningful testing, add the actual workload your production CLI performs.

Create Two Publish Variants

The benchmark should compare at least two builds.

Standard .NET Publish

dotnet publish -c Release -r win-x64

NativeAOT Publish

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

NativeAOT publishing can also be configured directly in the project file.

This makes the experiment easier to reproduce:

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

The important point is to compare equivalent Release builds targeting the same runtime identifier.

Measure Cold Startup

Cold startup measures the cost of launching a process without relying on recently warmed operating-system caches.

A simple external benchmark can execute the program repeatedly and record elapsed process time.

For example, a PowerShell harness can be used:

$results = @()

1..20 | ForEach-Object {
    $start = Get-Date

    & ".\mytool.exe" benchmark

    $elapsed = (Get-Date) - $start

    $results += $elapsed.TotalMilliseconds
}

$results | Measure-Object -Minimum -Maximum -Average

This is not a laboratory-grade benchmarking framework, but it is useful for an initial comparison.

For serious performance analysis, use a dedicated benchmarking methodology and control background system activity.

Measure Memory Usage Separately

Startup time and memory consumption are different measurements.

A process can start quickly but allocate substantially more memory during its workload.

Measure at least:

Working set
Private memory
Peak memory
Managed allocations

The most useful metric depends on what problem you are trying to solve.

For containerized workloads, peak memory can be especially important because memory limits can result in process termination.

For developer machines, resident memory may be more useful because it represents the application's physical memory footprint during execution.

Measure the Right Workload

A weak benchmark looks like this:

Console.WriteLine("Hello");

That may demonstrate process startup, but it does not tell you whether NativeAOT is beneficial for your actual application.

A better CLI benchmark might perform:

Parse command-line arguments
        ↓
Read configuration
        ↓
Load input file
        ↓
Deserialize JSON
        ↓
Process records
        ↓
Serialize output
        ↓
Write result

Now the comparison reflects a workload that resembles a real command-line utility.

Benchmark Multiple Execution Counts

One execution is not enough.

Run the application multiple times:

1 execution
10 executions
50 executions
100 executions

This reveals whether startup overhead becomes meaningful when the CLI is repeatedly invoked.

For example:

ScenarioStandard .NETNativeAOT
Cold startupMeasureMeasure
Warm startupMeasureMeasure
10 executionsMeasureMeasure
100 executionsMeasureMeasure
Peak memoryMeasureMeasure
Output sizeMeasureMeasure

Do not fill these values with assumptions. The purpose of the benchmark is to obtain them from the target application and environment.

Measure Binary Size Too

Startup and memory are the primary measurements for this article, but deployment size is another useful metric.

NativeAOT produces a self-contained native executable, but self-contained applications include the runtime components they need.

Record:

Published application size
Executable size
Number of deployment files
Container image size

Do not assume that NativeAOT always means the smallest possible artifact.

Compare the actual published outputs.

NativeAOT vs ReadyToRun

NativeAOT should also be distinguished from ReadyToRun.

ReadyToRun precompiles assemblies to reduce the amount of JIT work required during startup, but the application still contains IL and can still use the runtime's JIT capabilities.

A simplified comparison is:

DeploymentJIT at runtimeNative executableTypical goal
Framework-dependentYesNoSmaller deployment
Self-containedYesNoRuntime independence
ReadyToRunReducedNoFaster startup
NativeAOTNoYesFast startup and reduced runtime footprint

This makes ReadyToRun an interesting additional benchmark target.

Instead of comparing only two configurations, a more complete experiment can compare:

Framework-dependent
        ↓
Self-contained
        ↓
ReadyToRun
        ↓
NativeAOT

NativeAOT Compatibility Matters

NativeAOT has constraints that can affect existing applications.

The official NativeAOT documentation lists limitations including dynamic assembly loading, runtime code generation, and some reflection scenarios. NativeAOT also relies on trimming.

For example, code that dynamically loads an assembly:

var assembly = Assembly.LoadFile(
    "/plugins/customer-plugin.dll");

may not fit naturally into a NativeAOT deployment model.

Similarly, applications heavily dependent on runtime code generation require careful compatibility analysis.

Before benchmarking performance, make sure the application can actually be published and executed successfully as NativeAOT.

Watch for Reflection and Trimming Issues

NativeAOT changes the assumptions around runtime discoverability.

Code such as:

var type = Type.GetType(typeName);

can become problematic when the required type cannot be statically identified.

Source generation can often provide a better approach.

For JSON serialization, for example, source-generated metadata can reduce dependence on runtime reflection:

[JsonSerializable(typeof(Customer[]))]
internal partial class AppJsonContext
    : JsonSerializerContext
{
}

Then:

var json = JsonSerializer.Serialize(
    customers,
    AppJsonContext.Default.CustomerArray);

This type of design is particularly useful when building AOT-friendly applications.

Benchmark Startup Distribution, Not Just Average

Average startup time alone can hide important behavior.

Suppose 10 runs produce:

20 ms
21 ms
20 ms
22 ms
21 ms
20 ms
21 ms
20 ms
95 ms
21 ms

The average is affected by one unusually slow execution.

Record at least:

Minimum
Median
Average
P95
Maximum

The median tells you what a typical execution looks like, while P95 shows the slower end of the distribution.

For CI/CD tooling, those tail values can matter because repeated startup delays accumulate.

Avoid Benchmarking on a Busy Machine

Background processes can significantly affect process startup.

Before comparing builds:

  • Use the same machine.

  • Use the same OS version.

  • Use the same CPU architecture.

  • Use the same input.

  • Use the same configuration.

  • Use Release builds.

  • Minimize background workloads.

  • Run enough iterations.

  • Separate cold and warm measurements.

The benchmark should change only the variable being studied.

Common Benchmarking Mistakes

Comparing Debug Builds

Debug builds are not representative of production deployment performance.

Use Release builds.

Changing the Workload

If the NativeAOT version processes fewer records, the comparison is invalid.

Use identical inputs.

Measuring Only One Run

One measurement is not a benchmark.

Run multiple iterations.

Measuring Only Startup

Startup is important, but total execution time and memory consumption may matter more for your actual application.

Ignoring Deployment Size

NativeAOT can change deployment characteristics in addition to runtime performance.

Record artifact size as part of the experiment.

Assuming NativeAOT Is Always Better

NativeAOT is a deployment model, not a universal performance switch.

Measure the workload.

A Practical Benchmark Matrix

For a production-oriented experiment, use a matrix like this:

MetricStandardReadyToRunNativeAOT
Cold startupMeasureMeasureMeasure
Warm startupMeasureMeasureMeasure
Median startupMeasureMeasureMeasure
P95 startupMeasureMeasureMeasure
Peak memoryMeasureMeasureMeasure
Total execution timeMeasureMeasureMeasure
Published sizeMeasureMeasureMeasure
Container image sizeMeasureMeasureMeasure

The important word is measure.

Performance claims should come from the target application and hardware rather than generic assumptions.

Best Practices

  1. Benchmark NativeAOT using the actual production workload.

  2. Compare equivalent Release configurations.

  3. Keep the runtime identifier and hardware constant.

  4. Measure cold and warm startup separately.

  5. Record median, P95, and maximum startup time.

  6. Measure peak memory independently from startup.

  7. Include total execution time.

  8. Record published artifact size.

  9. Test NativeAOT compatibility before performance analysis.

  10. Watch for reflection, trimming, and dynamic-code dependencies.

  11. Benchmark ReadyToRun when startup optimization is the primary goal.

  12. Repeat the experiment after runtime or SDK upgrades.

Frequently Asked Questions

Does NativeAOT eliminate the .NET runtime?

NativeAOT applications are self-contained and compiled ahead of time into native code. They do not require the normal installed .NET runtime to execute. They still contain the runtime components required by the application.

Is NativeAOT always faster than JIT?

No. NativeAOT is particularly attractive for startup-sensitive workloads, but total application performance depends on the workload, APIs used, hardware, and application architecture.

Is NativeAOT suitable for CLI tools?

Yes. Short-lived command-line applications are a strong scenario to evaluate because startup overhead can represent a significant portion of total execution time.

Does NativeAOT reduce memory usage?

It can produce a smaller memory footprint, but the actual result depends on the application and workload. Measure the application rather than relying on a generic percentage.

Can every .NET application use NativeAOT without changes?

No. NativeAOT has compatibility and trimming constraints, particularly around dynamic loading, runtime code generation, and some reflection-heavy designs.

Conclusion

NativeAOT gives .NET developers another way to optimize command-line applications where startup time, memory consumption, and deployment characteristics matter.

The important change is not simply publishing with:

-p:PublishAot=true

The real engineering question is whether that deployment model improves the application that you actually run.

A useful benchmark should compare standard .NET, ReadyToRun where relevant, and NativeAOT using the same workload and environment. Measure startup distribution, total execution time, peak memory, and deployment size.

For short-lived CLI tools, the results can be particularly valuable because startup overhead represents a much larger percentage of total execution.

The best NativeAOT decision is therefore not based on the claim that AOT is faster.

It is based on evidence from your application, your workload, and your deployment environment.