Jump to content
Xtreme .Net Talk

Recommended Posts

Posted

.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:\path\to\an\executable.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.

View the full article

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...