.NET MAUI Performance Features in .NET 9

.NET Multi-platform App UI (.NET MAUI) continues to evolve with each
release, and .NET 9 brings a focus on trimming and a new supported
runtime: NativeAOT. These features can help you reduce application
size, improve startup times, and ensure your applications run smoothly
on various platforms. Both developers looking to optimize their .NET
MAUI applications and NuGet package authors are able to take advantage
of these features in .NET 9.

We’ll also walk through the options available to you as a developer
for measuring the performance of your .NET MAUI applications. Both CPU
sampling and memory snapshots are available via dotnet-trace and
dotnet-gcdump respectively. These can give insights into performance
problems in your application, NuGet packages, or even something we
should look into for .NET MAUI.

Background

By default, .NET MAUI applications on iOS and Android use the
following settings:

  • “Self-contained”, meaning a copy of the BCL and runtime are included
    with the application.

Note

This makes .NET MAUI applications suitable for running on “app stores” as no prerequisites such as installing a .NET runtime are required.

  • Partially trimmed (TrimMode=partial), meaning that code within
    your applications or NuGet packages are not trimmed by default.

Note

This is a good default, as it is the most compatible with existing code and NuGet packages in the ecosystem.

Full Trimming

This is where full-trimming (TrimMode=full) can make an impact on
your application’s size. If you have a substantial amount of C# code
or NuGet packages, you may be missing out on a significant application
size reduction.

To opt into full trimming, you can add the following to your .csproj file:

<PropertyGroup>
  <TrimMode>full</TrimMode>
</PropertyGroup>

For an idea on the impact of full trimming:

Impact of Full Trimming on Android

Note

MyPal is a sample .NET MAUI application that is a useful comparison because of its usage of several common NuGet packages.

See our trimming .NET MAUI documentation for more
information on “full” trimming.

NativeAOT

Building upon full trimming, NativeAOT both relies on libraries being
trim-compatible and AOT-compatible. NativeAOT is a new runtime that
can improve startup time and reduce application size compared to
existing runtimes.

Note

NativeAOT is not yet supported on Android, but is available on iOS, MacCatalyst, and Windows.

To opt into NativeAOT:

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

For an idea on the impact of NativeAOT and application size:

Impact on application size of NativeAOT

And startup performance:

Impact on startup time of NativeAOT

Note

macOS on the above graphs is running on MacCatalyst, the default for .NET MAUI applications running on Mac operating systems.

See our NativeAOT deployment documentation for more
information about this newly supported runtime.

NuGet Package Authors

As a NuGet package author, you may wish for your package to run in
either fully trimmed or NativeAOT scenarios. This can be useful for
developers targeting .NET MAUI, mobile, or even self-contained ASP.NET
microservices.

To support NativeAOT, you will need to:

  1. Mark your assemblies as “trim-compatible” and “AOT-compatible”.
  2. Enable Roslyn analyzers for trimming and NativeAOT.
  3. Solve all the warnings.

Begin with modifying your .csproj file:

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

These properties will enable Roslyn analyzers as well as include
[assembly: AssemblyMetadata] information in the resulting .NET
assembly. Depending on your library’s usage of features like
System.Reflection, you could have either just a few warnings or
potentially many warnings.

See the documentation on preparing libraries for
trimming
for more information.

XAML and Trimming

Sometimes, taking advantage of NativeAOT in your app can be as easy as
adding a property to your project file. However, for many .NET MAUI
applications, there can be a lot of warnings to solve. The NativeAOT
compiler removes unnecessary code and metadata to make the app smaller
and faster. However, this requires understanding which types can be
created and which methods can and cannot be called at runtime. This is
often impossible to do in code which heavily uses System.Reflection.
There are two areas in .NET MAUI which fall into this category: XAML
and data-binding.

Compiled XAML

Loading XAML at runtime provides flexibility and enables features like
XAML hot reload. XAML can instantiate any class in the whole app, the
.NET MAUI SDK, and referenced NuGet packages. XAML can also set values
to any property.

Conceptually, loading a XAML layout at runtime requires:

  1. Parsing the XML document.
  2. Looking up the control types based on the XML element names using Type.GetType(xmlElementName).
  3. Creating new instances of the controls using Activator.CreateInstance(controlType).
  4. Converting the raw string XML attribute values into the target type of the property.
  5. Setting properties based on the names of the XML attributes.

This process can not only be slow, but it presents a great challenge
for NativeAOT. For example, the trimmer does not know which types
would be looked up using the Type.GetType method. This means that
either the compiler would need to keep all the classes from the whole
.NET MAUI SDK and all the NuGet packages in the final app, or the
method might not be able to find the types declared in the XML input
and fail at runtime.

Fortunately, .NET MAUI has a solution – XAML compilation.
This turns XAML into the actual code for the InitializeComponent()
method at build time. Once the code is generated, the NativeAOT
compiler has all the information it needs to trim your app.

In .NET 9, we implemented the last remaining XAML features that the
compiler could not handle in previous releases, especially compiling
bindings. Lastly, if your app relies on loading XAML at runtime,
NativeAOT might not be suitable for your application.

Compiled Bindings

A binding ties together a source property with a target property. When
the source changes, the value is propagated to the target.

Bindings in .NET MAUI are defined using a string “path”. This path
resembles C# expressions for accessing properties and indexers. When
the binding is applied to a source object, .NET MAUI uses
System.Reflection to follow the path to access the desired source
property. This suffers from the same problems as loading XAML at
runtime, because the trimmer does not know which properties could be
accessed by reflection and so it does not know which properties it can
safely trim from the final application.

When we know the type of the source object at build time from
x:DataType attributes, we can compile the binding path into a simple
getter method (and a setter method for two-way bindings). The compiler
will also ensure that the binding listens to any property changes
along the binding path of properties that implement
INotifyPropertyChanged.

The XAML compiler could already compile most bindings in .NET 8 and
earlier. In .NET 9 we made sure any binding in your XAML code can be
compiled. Learn more about this feature in the compiled bindings
documentation
.

Compiled bindings in C#

The only supported way of defining bindings in C# code up until .NET 8
has been using a string-based path. In .NET 9, we are adding a new
API which allows us to compile the binding using a source generator:

// .NET 8 and earlier
myLabel.SetBinding(Label.TextProperty, "Text");

// .NET 9
myLabel.SetBinding(Label.TextProperty, static (Entry nameEntry) => nameEntry.Text);

The Binding.Create() method is also an option, for when you need to
save the Binding instance for later use:

var nameBinding = Binding.Create(static (Entry nameEntry) => nameEntry.Text);

.NET MAUI’s source generator will compile the binding the same way the
XAML compiler does. This way the binding can be fully analyzed by the
NativeAOT compiler.

Even if you aren’t planning to migrate your application to NativeAOT,
compiled bindings can improve the general performance of the binding.
To illustrate the difference, let’s use BenchmarkDotNet to measure
the difference between the calls to SetBinding() on Android using the
Mono runtime:

// dotnet build -c Release -t:Run -f net9.0-android

public class SetBindingBenchmark
{
    private readonly ContactInformation _contact = new ContactInformation(new FullName("John"));
    private readonly Label _label = new();

    [GlobalSetup]
    public void Setup()
    {
        DispatcherProvider.SetCurrent(new MockDispatcherProvider());
        _label.BindingContext = _contact;
    }

    [Benchmark(Baseline = true)]
    public void Classic_SetBinding()
    {
        _label.SetBinding(Label.TextProperty, "FullName.FirstName");
    }

    [Benchmark]
    public void Compiled_SetBinding()
    {
        _label.SetBinding(Label.TextProperty, static (ContactInformation contact) => contact.FullName?.FirstName);
    }

    [IterationCleanup]
    public void Cleanup()
    {
        _label.RemoveBinding(Label.TextProperty);
    }
}

When I ran the benchmark on Samsung Galaxy S23, I got the following results:

Method Mean Error StdDev Ratio RatioSD
Classic_SetBinding 67.81 us 1.338 us 1.787 us 1.00 0.04
Compiled_SetBinding 30.61 us 0.629 us 1.182 us 0.45 0.02

The classic binding needs to first parse the string-based path and
then use System.Reflection to get the current value of the source.
Each subsequent update of the source property will also be faster with
the compiled binding:

// dotnet build -c Release -t:Run -f net9.0-android

public class UpdateValueTwoLevels
{
    ContactInformation _contact = new ContactInformation(new FullName("John"));
    Label _label = new();

    [GlobalSetup]
    public void Setup()
    {
        DispatcherProvider.SetCurrent(new MockDispatcherProvider());
        _label.BindingContext = _contact;
    }

    [IterationSetup(Target = nameof(Classic_UpdateWhenSourceChanges))]
    public void SetupClassicBinding()
    {
        _label.SetBinding(Label.TextProperty, "FullName.FirstName");
    }

    [IterationSetup(Target = nameof(Compiled_UpdateWhenSourceChanges))]
    public void SetupCompiledBinding()
    {
        _label.SetBinding(Label.TextProperty, static (ContactInformation contact) => contact.FullName?.FirstName);
    }

    [Benchmark(Baseline = true)]
    public void Classic_UpdateWhenSourceChanges()
    {
        _contact.FullName.FirstName = "Jane";
    }

    [Benchmark]
    public void Compiled_UpdateWhenSourceChanges()
    {
        _contact.FullName.FirstName = "Jane";
    }

    [IterationCleanup]
    public void Reset()
    {
        _label.Text = "John";
        _contact.FullName.FirstName = "John";
        _label.RemoveBinding(Label.TextProperty);
    }
}
Method Mean Error StdDev Ratio RatioSD
Classic_UpdateWhenSourceChanges 46.06 us 0.934 us 1.369 us 1.00 0.04
Compiled_UpdateWhenSourceChanges 30.85 us 0.634 us 1.295 us 0.67 0.03

The differences for a single binding aren’t that dramatic but they add
up. This can be noticeable on complex pages with many bindings or when
scrolling lists like CollectionView or ListView.

The full source code of the above benchmarks is available on GitHub.

Profiling .NET MAUI Applications

Attaching dotnet-trace to a .NET MAUI application, allows you to get
profiling information in formats like .nettrace and .speedscope.
These give you CPU sampling information about the time spent in each
method in your application. This is quite useful for finding where
time is spent in the startup or general performance of your .NET
applications. Likewise, dotnet-gcdump can take memory snapshots of
your application that display every managed C# object in memory.
dotnet-dsrouter is a requirement for connecting dotnet-trace to a
remote device, and so this is not needed for desktop applications.

You can install these tools with:

$ dotnet tool install -g dotnet-trace
You can invoke the tool using the following command: dotnet-trace
Tool 'dotnet-trace' was successfully installed.
$ dotnet tool install -g dotnet-dsrouter
You can invoke the tool using the following command: dotnet-dsrouter
Tool 'dotnet-dsrouter' was successfully installed.
$ dotnet tool install -g dotnet-gcdump
You can invoke the tool using the following command: dotnet-gcdump
Tool 'dotnet-gcdump' was successfully installed.

From here, instructions differ slightly for each platform, but
generally the steps are:

  1. Build your application in Release mode. For Android, toggle
    <AndroidEnableProfiler>true</AndroidEnableProfiler> in your
    .csproj file, so the required Mono diagnostic components are
    included in the application.
  2. If profiling mobile, run dotnet-dsrouter android (or
    dotnet-dsrouter ios, etc.) on your development machine.
  3. Configure environment variables, so the application can connect to
    the profiler. For example, on Android:
    $ adb reverse tcp:9000 tcp:9001
    # no output
    $ adb shell setprop debug.mono.profile '127.0.0.1:9000,nosuspend,connect'
    # no output
  4. Run your application.
  5. Attach dotnet-trace (or dotnet-gcdump) to the application,
    using the PID of dotnet-dsrouter:
    $ dotnet-trace ps
    38604  dotnet-dsrouter  ~/.dotnet/tools/dotnet-dsrouter.exe  ~/.dotnet/tools/dotnet-dsrouter.exe android
    
    $ dotnet-trace collect -p 38604 --format speedscope
    No profile or providers specified, defaulting to trace profile 'cpu-sampling'
    
    Provider Name                           Keywords            Level               Enabled By
    Microsoft-DotNETCore-SampleProfiler     0x0000F00000000000  Informational(4)    --profile 
    Microsoft-Windows-DotNETRuntime         0x00000014C14FCCBD  Informational(4)    --profile 
    
    Waiting for connection on /tmp/maui-app
    Start an application with the following environment variable: DOTNET_DiagnosticPorts=/tmp/maui-app

For iOS, macOS, and MacCatalyst, see the iOS profiling wiki
page
for more information.

Note

For Windows applications, you might just consider using Visual Studio’s built-in profiling tools, but dotnet-trace collect -- C:pathtoanexecutable.exe is also an option.

Now that you’ve collected a file containing performance information,
opening them to view the data is the next step:

  • dotnet-trace by default outputs .nettrace files, which can be
    opened in PerfView or Visual Studio.
  • dotnet-trace collect --format speedscope outputs .speedscope
    files, which can be opened in the Speedscope web app.
  • dotnet-gcdump outputs .gcdump files, which can be opened in
    PerfView or Visual Studio. Note that there is not
    currently a good option to open these files on macOS.

In the future, we hope to make profiling .NET MAUI applications easier
in both future releases of the above .NET diagnostic tooling and
Visual Studio.

Note

Note that the NativeAOT runtime does not have support for dotnet-trace and performance profiling. You can use the other supported runtimes for this, or use native profiling tools instead such as Xcode’s Instruments.

See the profiling .NET MAUI wiki page for links to
documentation on each platform or a profiling demo on
YouTube
for a full walkthrough.

Conclusion

.NET 9 introduces performance enhancements for .NET MAUI applications
through full trimming and NativeAOT. These features enable developers
to create more efficient and responsive applications by reducing
application size and improving startup times. By leveraging tools like
dotnet-trace and dotnet-gcdump, developers can gain insights into
their application’s performance.

For a full rundown on .NET MAUI trimming and NativeAOT, see the
.NET Conf 2024 session on the topic.

The post .NET MAUI Performance Features in .NET 9 appeared first on .NET Blog.

Previous Article

Function Calling is Now Enabled in GitHub Copilot

Next Article

AI system predicts protein fragments that can bind to or inhibit a target

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *