.Net Posted yesterday at 18:05 Posted yesterday at 18:05 .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. NoteThis 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. NoteThis 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: 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. NoteNativeAOT 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: And startup performance: NotemacOS 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: Mark your assemblies as “trim-compatible” and “AOT-compatible”. Enable Roslyn analyzers for trimming and NativeAOT. 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: Parsing the XML document. Looking up the control types based on the XML element names using Type.GetType(xmlElementName). Creating new instances of the controls using Activator.CreateInstance(controlType). Converting the raw string XML attribute values into the target type of the property. 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: 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. If profiling mobile, run dotnet-dsrouter android (or dotnet-dsrouter ios, etc.) on your development machine. 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 Run your application. 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. NoteFor 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. NoteNote 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 Quote
Recommended Posts
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.