When .NET MAUI reached GA, we had goals of improving upon Xamarin.Forms in startup time and application size. See last release’s Performance Improvements in .NET MAUI, to learn about the performance benefits of migrating from Xamarin.Android, Xamarin.iOS, or Xamarin.Forms to .NET 6+ and .NET MAUI.
We continued with these themes in .NET 7, building upon the excellent .NET performance work described by Stephen Toub. General improvements in the .NET runtime or base class libraries (BCL) lay the foundation for the performance we are able to achieve in .NET MAUI.
For Android, our focus remains on startup performance, since application size is in a good place. For iOS, we have the opposite goal: we continued to focus on application size, since startup performance on iOS is in good shape.
From customer feedback we found the need to go beyond just startup performance, also focusing a bit on general UI performance, layout, scrolling, etc. We also improved startup time and general performance for desktop platforms. We hope to continue making improvements in these areas in future .NET releases.
For an overall comparison of startup time between .NET 6 and .NET 7,
apps in Release
mode on a Pixel 5:
Application | Version | Startup Time(ms) |
---|---|---|
dotnet new maui | .NET 6 | 568.1 |
dotnet new maui | .NET 7 | 545.4 |
.NET Podcast | .NET 6 | 814.2 |
.NET Podcast | .NET 7 | 759.7 |
In .NET 7, .NET MAUI applications should see an improvement to startup time as well as smoothing scrolling, navigation, and general UI performance.
Likewise, iOS applications have continued to get smaller in .NET 7:
Application | Version | .ipa Size (MB) |
---|---|---|
dotnet new maui | .NET 6 | 12.64 |
dotnet new maui | .NET 7 | 12.48 |
.NET Podcast | .NET 6 | 25.08 |
.NET Podcast | .NET 7 | 23.91 |
Testing a CollectionView
sample on a modest
Android device (Pixel 4a). We can see a clear improvement with
Android’s visual GPU profiler, a screenshot taken
during some “brisk” scrolling:
This sample is a CollectionView
with 10,000 rows of two data-bound
labels.
Simply updating to .NET 7 should result in smaller/faster .NET MAUI applications. Details below!
Table Of Contents
Scrolling and Layout Performance Improvements
- LOLs per second
- Avoid Repeated
View.Context
Calls - Avoid
View.Context
Calls inCollectionView
- Reduce JNI Calls During Layout
- Cache Values for RTL and Dark Mode
- Avoid Creating
IView[]
During Layout - Defer RTL Layout Calculations to Platform
- Further Notes on
CollectionView
Startup Performance Improvements
- Android NDK Compiler Flags
DateTimeOffset.Now
- Avoid
ColorStateList(int[][],int[])
- Improvements to .NET MAUI’s AOT Profile
- Better String Comparisons in Java Interop
- XAML Compilation Improvements
ReadyToRun
by Default on Windows- Dual Architectures by Default on macOS
- Notes about
RegexOptions.Compiled
- Improvements to Mono’s Interpreter
App Size Improvements
- Fix
DebuggerSupport
Trimmer Value for Android - R8 Java Code Shrinker Improvements
- Feature to Exclude Kotlin-related Files
- Improve AOT Output for Generics
Tooling and Documentation
- Profiling .NET MAUI Apps
- Measuring Startup Time
- App Size Reporting Tools
- Experimental or Advanced Options
Scrolling and Layout Performance Improvements
LOLs per Second
We have seen benchmarks of different UI frameworks that follow a pattern, such as:
-
Put text on the screen with a random rotation and color
-
Report how many times a second you can do this
The existing samples were OK, but I found starting from scratch I could avoid minor performance issues and focus on the raw numbers .NET MAUI could achieve. This also gave me the opportunity to use the word “LOL” in the text onscreen, aptly timing the number of “LOLs per second” we can achieve in .NET MAUI. LOL?
Additionally, we timed different types of apps:
- A Xamarin.Forms application (thanks @roubachof for the contribution!)
- A .NET MAUI application
- A .NET 6 Android application (using our C# bindings for Android APIs)
- An Android application written in Java
The above apps would all use the same underlying
Android.Widget.TextView
. Each app should progressively be able to
achieve better results: Xamarin.Forms to MAUI to C# TextView
to Java
TextView
(no C# to Java interop):
Overall, this concept (although quite fun!) isn’t an exact science. My
version of the sample required some duration of Thread.Sleep()
,
which will certainly vary depending on the device you are running on.
The sample needs to sleep long enough for the UI thread to be able to
keep up with a background thread. Just from visually watching the app,
I tried to pick a Thread.Sleep()
time where we got a good number of
LOLs per second and the UI appeared to be updating quickly.
However, the sample in itself is still useful:
-
We have a fun, visual comparison between .NET MAUI in .NET 6 vs .NET 7
-
It is a literal gold mine for finding performance issues!
Simply reviewing dotnet-trace
output of these apps, led to changes
to improve the performance in .NET MAUI of:
-
Each
View
added to the screen -
Layout performance
-
Scrolling performance
-
Navigation between pages
In total, the LOLs per second on a Pixel 5 went from ~327 per second to ~493 per second moving from .NET 6 to .NET 7:
For full details and source code for this sample/benchmark, see the jonathanpeppers/lols repository on GitHub.
Avoid Repeated View.Context
Calls
When reviewing dotnet-trace
output of the LOLs/second sample, we
noticed:
7.60s (14%) mono.android!Android.Views.View.get_Context()
There is an interesting Java to C# interop implication in this call. Since the beginning of Xamarin.Android we’ve had the behavior:
-
C# calls a Java method
-
A Java object is returned to C#
-
Using the
Handle
of the Java object, find out if we have a C# object alive with the sameHandle
. -
If not, create a new C# instance to wrap this object for use in managed code.
So you could see how repeated calls to .Context
would be a
performance concern, after realizing the work happening behind the
scenes.
We found layout-related code in .NET MAUI, such as:
var deviceIndependentWidth = widthMeasureSpec.ToDouble(Context);
var deviceIndependentHeight = heightMeasureSpec.ToDouble(Context);
//...
var platformWidth = Context.ToPixels(width);
var platformHeight = Context.ToPixels(height);
Where the four calls here could be replaced by a local variable, or
even a member field for the lifetime of this object. This simple
change directly translated to more Label
‘s per second as well as
faster startup and scrolling in .NET MAUI.
See dotnet/maui#8001 for details about this improvement.
Avoid View.Context
Calls in CollectionView
In .NET 6, we had some reports of poor CollectionView performance while scrolling on older Android devices.
Reviewing dotnet-trace
output, I did find some issues similar to
dotnet/maui#8001:
317.42ms (1.1%) mono.android!Android.Views.View.get_Context()
1% of the time was spent in repeated calls to View.Context
inside the
ItemContentView
class, such as:
if (pixelWidth == 0)
{
pixelWidth = (int)Context.ToPixels(measure.Width);
}
if (pixelHeight == 0)
{
pixelHeight = (int)Context.ToPixels(measure.Height);
}
//...
var x = (int)Context.ToPixels(mauiControlsView.X);
var y = (int)Context.ToPixels(mauiControlsView.Y);
var width = Math.Max(0, (int)Context.ToPixels(mauiControlsView.Width));
var height = Math.Max(0, (int)Context.ToPixels(mauiControlsView.Height));
Making a new overload for ContextExtensions.FromPixel()
, we were
able to remove all of these View.Context
calls.
After these changes, we see a huge difference in the time spent in this method:
1.30ms (0.01%) mono.android!Android.Views.View.get_Context()
We also tested these changes with the “janky frames” profiler in
Android Studio, which gives information like this on Android 12+
devices (such as a Pixel 4a). We could see several dropped frames when
scrolling a CollectionView
:
On the same Pixel 4a afterward, we only saw a single dropped frame:
We could also test these changes with Android’s visual GPU profiler, seeing a visual difference in before:
Versus afterward:
The larger bars represents time spent in code paths that attribute to
a poor framerate. These changes should improve the performance of
scrolling for any .NET MAUI CollectionView
on Android.
See dotnet/maui#8243 for details about this improvement.
Reduce JNI Calls During Layout
While profiling customer sample similar to the “LOLs per second” app,
we noticed a decent chunk of time spent measuring Android View
‘s:
932.51ms (7.4%) mono.android!Android.Views.View.Measure(int,int)
115.53ms (0.91%) mono.android!Android.Views.View.get_MeasuredWidth()
96.97ms (0.77%) mono.android!Android.Views.View.get_MeasuredHeight()
This led us to write a Java method that calls all three Java APIs and
return MeasuredWidth
and MeasuredHeight
. After a little research,
it seemed the best approach here was to “pack” two integers into a
long
. If we tried to return some Java object instead, then we’d have
the same number of JNI calls to get the integers out.
The Java side implementation arrives at:
public static long measureAndGetWidthAndHeight(View view, int widthMeasureSpec, int heightMeasureSpec) {
view.measure(widthMeasureSpec, heightMeasureSpec);
int width = view.getMeasuredWidth();
int height = view.getMeasuredHeight();
return ((long)width << 32) | (height & 0xffffffffL);
}
Which is unpacked in C# via:
var packed = PlatformInterop.MeasureAndGetWidthAndHeight(platformView, widthSpec, heightSpec);
var measuredWidth = (int)(packed >> 32);
var measuredHeight = (int)(packed & 0xffffffffL);
So instead of three transitions from C# to Java, we now have one. It appears the performance of the additional math is negligible compared to reducing the amount of interop overhead in this example.
Measuring again after the change, we were able to see an obvious savings in this method:
--783.92ms (6.2%) microsoft.maui!Microsoft.Maui.ViewHandlerExtensions.GetDesiredSizeFromHandler
++528.96ms (4.5%) microsoft.maui!Microsoft.Maui.ViewHandlerExtensions.GetDesiredSizeFromHandler
Which also translated to more Label
‘s per second in the sample app.
See dotnet/maui#8034 for details about this improvement.
Cache Values for RTL and Dark Mode
While profiling customer sample similar to the “LOLs per second” app, we noticed time spent calculating if the OS is in Dark Mode or a Right-to-Left language:
1.06s (6.3%) Microsoft.Maui.Essentials!Microsoft.Maui.ApplicationModel.AppInfoImplementation.get_RequestedLayoutDirection()
4.46ms (0.03%) Microsoft.Maui.Essentials!Microsoft.Maui.ApplicationModel.AppInfoImplementation.get_RequestedTheme()
This appears to happen for every .NET MAUI View
on Android, so this
impacts startup time, scrolling, etc.
Reviewing the AppInfoImplementation
class on Android, we could
simply use Lazy<T>
for every value that queries
Application.Context
. These values cannot change after the app
launches, so they don’t need to be computed on every call.
This should also improve subsequent calls to various Maui.Essentials APIs on Android.
See dotnet/maui#7996 for details about this improvement.
Avoid Creating IView[]
During Layout
While profiling customer sample similar to the “LOLs per second” app,
time was spent ordering views by their ZIndex
such as:
3.42s (16%) microsoft.maui!Microsoft.Maui.Handlers.LayoutHandler.Add(Microsoft.Maui.IView)
1.52s (7.3%) microsoft.maui!Microsoft.Maui.Handlers.LayoutExtensions.GetLayoutHandlerIndex()
1.50s (7.2%) microsoft.maui!Microsoft.Maui.Handlers.LayoutExtensions.OrderByZIndex(Microsoft.Maui.ILayout)
Reviewing the code in OrderByZIndex()
, it did the following:
- Make a
record ViewAndIndex(IView View, int Index)
for each child - Make a
ViewAndIndex[]
the size of the number of children - Call
Array.Sort()
- Make a
IView[]
the size of the number of children - Iterate twice over the arrays in the process
Then the GetLayoutHandlerIndex()
method, did all the same work to
create an IView[]
, get the index, then throws the array away.
To improve this process, we made the following changes:
- Removed much of the above code and used a System.Linq
OrderBy()
with “fast paths” for 0 or 1 children. - Made an
internal
EnumerateByZIndex()
method for use byforeach
loops. This avoids creating arrays in those cases.
After these changes, the time dropped substantially in dotnet-trace
output:
1.84s (13%) microsoft.maui!Microsoft.Maui.Handlers.LayoutHandler.Add(Microsoft.Maui.IView)
352.24ms (2.50%) microsoft.maui!Microsoft.Maui.Handlers.LayoutExtensions.GetLayoutHandlerIndex()
181.27ms (1.30%) microsoft.maui!Microsoft.Maui.Handlers.LayoutExtensions.<>c.<EnumerateByZIndex>b__0_0(Microsoft.Maui.IView)
2.78ms (0.02%) microsoft.maui!Microsoft.Maui.Handlers.LayoutExtensions.EnumerateByZIndex(Microsoft.Maui.ILayout)
We also saw a significant increase in the number of Label
‘s a second
in the customer sample. System.Linq
was used in the improvement,
which goes a little against traditional guidance to avoid it. In this
case, the code was implementing its own version of OrderBy()
, where
we could just use OrderBy()
instead. “Rolling your own”
System.Linq
is generally not a great idea, unless you have
benchmarks showing your implementation is better.
This change should improve layout performance of all .NET MAUI applications on any platform.
See dotnet/maui#8250 for details about this improvement.
Defer RTL Layout Calculations to Platform
When profiling our LOLs per second sample app, a percentage of time is spent calculating if each view is setup to support “Right to Left” languages or not:
290.86ms (1.4%) microsoft.maui!Microsoft.Maui.Layouts.LayoutExtensions.ShouldArrangeLeftToRight(Microsoft.Maui.IView)
In this sample app, we have a nested layout, 5 views deep, with ~100
Label
‘s on the screen. Each Label
we ended up querying the same
views for flow direction 500 times.
To solve this issue and many others, the cross-platform implementation of RTL was removed from .NET MAUI in favor of native APIs on each platform. This should improve performance and correctness in this area.
LayoutExtensions.ShouldArrangeLeftToRight()
is completely removed
from .NET MAUI in .NET 7. This should improve layout performance for
.NET MAUI on all platforms.
See dotnet/maui#9558 for details about this improvement.
Further Notes on CollectionView
As seen in issue dotnet/maui#6317, a CollectionView
with 10,000 rows contributed to poor startup time and slow scrolling
performance.
In this case, the sample app on a Pixel 5 would start in:
- .NET 6: 16s412ms
- .NET 7: 14s642ms
The root cause here appears to be the specific .xaml
layout:
<ContentPage ...>
<ScrollView>
<VerticalStackLayout>
<CollectionView
ItemsSource="{Binding Bots}"
HorizontalOptions="FillAndExpand"
VerticalOptions="FillAndExpand">
The CollectionView
is placed inside a VerticalStackLayout
that
will happily expand to an infinite height. Then subsequently placed
inside a ScrollView
(which isn’t necessary because CollectionView
supports scrolling). This effectively causes the CollectionView
to
“realize” all 10,000 rows, and voids any virtualization of the
control.
To solve this issue, we could simply remove the outer views:
<ScrollView>
<VerticalStackLayout>
Resulting with an identical look and feel on screen, and a drastically improved ~822ms (94% shorter!) startup time on a Pixel 5.
If you’re getting similar behavior in a CollectionView
, verify that
the rows are actually virtualized and not wrapped in unnecessary
views. In future .NET releases, we are working on ways to prevent
someone falling into this trap accidentally.
Startup Performance Improvements
Android NDK Compiler Flags
When we first got .NET MAUI running on early previews of .NET 7, we noticed a regression to both startup & app size:
- A ~1.5MB increase in
libmonosgen-2.0.so
, the Android native library for the Mono runtime. - A ~250ms increase in startup time
In NDK23b, Google decided to no longer pass the -O2
compiler
optimization flag for arm64 as part of the Android toolchain. Instead
the flag is decided by upstream CMake behavior, see
android/ndk#1536. Thus we needed to specify the
optimization flag going forward for the Mono runtime.
We tested three flags to compile libmonosgen-2.0.so
in a dotnet new maui
application:
-O3
: 893.7ms-O2
: 600.2ms-Oz
: 649.1ms
Interestingly, -03
(the new default!) produced a larger and slower
libmonosgen-2.0.so
. We settled on -O2
, which gave us the same
performance and size we were getting in .NET 6.
See dotnet/runtime#68354 for details about this improvement.
DateTimeOffset.Now
Reviewing a customer’s .NET 6 Android application, we found a
significant amount of time spent in the very first
DateTimeOffset.Now
call. In this case it was accidental (UtcNow
could simply be used instead), but nevertheless is a common API that
.NET developers are going to use.
Reviewing dotnet-trace
output, the first call to
DateTimeOffset.Now
took around 277ms on a Pixel 5 device:
Some of the time here was spent in the JIT, so our first idea was to record a custom AOT profile (using our experimental Mono.AotProfiler.Android package), and see if this was a good candidate for our standard profile. This got a decent improvement (~161ms), but the number still seemed like it could be improved:
It turns out, the bulk of the time here was spent loading timezone data — and then calculating the current time from the result.
Instead, we could:
-
Call an Android Java API for the current time offset. Our startup code in the Android workload is Java, so it can easily retrieve the value for use by the BCL.
-
Load timezone data on a background thread. This results in the original BCL behavior when complete, while using the offset from Java in the meantime.
This greatly improved DateTimeOffset.Now
to a mere ~17.67ms — even
without adding the code path to an AOT profile.
See dotnet/runtime#74459 and xamarin-android#7331 for details about this improvement.
Avoid ColorStateList(int[][],int[])
dotnet-trace
output of a .NET 6 dotnet new maui
application shows
time spent creating Android ColorStateList
objects:
16.63ms microsoft.maui!Microsoft.Maui.Platform.ColorStateListExtensions.CreateDefault(int)
16.63ms mono.android!Android.Content.Res.ColorStateList..ctor(int[][],int[])
11.38ms mono.android!Android.Runtime.JNIEnv.NewArray
Reviewing the C# binding for this type, reveals part of the performance impact of this constructor:
public unsafe ColorStateList (int[][]? states, int[]? colors)
: base (IntPtr.Zero, JniHandleOwnership.DoNotTransfer)
{
//...
IntPtr native_states = JNIEnv.NewArray (states);
There is a general performance problem in passing C# arrays (especially multidimensional ones) into Java. We have to create a Java array and copy each array element over to the Java array. We slightly improved upon this scenario in xamarin-android#6870, but the array duplication remains a problem.
To solve this, we can design some internal Java APIs for .NET MAUI like:
@NonNull
public static ColorStateList getDefaultColorStateList(int color)
{
return new ColorStateList(ColorStates.DEFAULT, new int[] { color });
}
@NonNull
public static ColorStateList getEditTextColorStateList(int enabled, int disabled)
{
return new ColorStateList(ColorStates.getEditTextState(), new int[] { enabled, disabled });
}
And move any C# const int
values to be completely in Java:
private static class ColorStates
{
static final int[] EMPTY = new int[] { };
static final int[][] DEFAULT = new int[][] { EMPTY };
private static int[][] editTextState;
static int[][] getEditTextState()
{
if (editTextState == null) {
editTextState = new int[][] {
new int[] { android.R.attr.state_enabled },
new int[] { -android.R.attr.state_enabled },
};
}
return editTextState;
}
}
Usage in C# then could look like:
ColorStateList defaultColors = PlatformInterop.GetDefaultColorStateList(color);
ColorStateList editTextColors = PlatformInterop.GetEditTextColorStateList(enabled, disabled);
This removes the need for marshaling various int[]
values to Java,
and we only pass in a single integer parameter for each state the
Android control supports.
Measuring startup after these changes, we instead get:
2.44ms microsoft.maui!Microsoft.Maui.Platform.ColorStateListExtensions.CreateDefault(int)
Which appears to save ~14ms of startup time on a Pixel 5 in the
dotnet new maui
template. Other apps that use Entry
, CheckBox
,
Switch
, etc. will also see an improvement to startup time.
See dotnet/maui#5654 for details about this improvement.
Improvements to .NET MAUI’s AOT Profile
Startup tracing or Profiled AOT is a feature of Xamarin.Android we brought forward to .NET 6+. This is a mechanism for AOT’ing the startup path of applications, which improves launch times significantly with only a modest app size increase.
In .NET 6, we had already recorded a pretty decent AOT profile for .NET MAUI. However, we added a couple more scenarios to the recorded app to improve things further.
First, we added flyout content to the profile, such as:
<Shell ...>
<FlyoutItem Title="Flyout Item">
<ShellContent
Title="Page 1"
ContentTemplate="{DataTemplate local:MainPage}" />
<ShellContent
Title="Page 2"
ContentTemplate="{DataTemplate local:MainPage}" />
</FlyoutItem>
</Shell>
This simple change improved the startup of the .NET Podcast sample app by around 25ms.
Secondly, we added a scenario of a non-Shell .NET MAUI app using
FlyoutPage
. Shell is the default navigation pattern in .NET MAUI,
but developers familiar with Xamarin.Forms may want to use APIs such
as FlyoutPage
, NavigationPage
, and TabPage
.
So for example, AppFlyoutPage.xaml
:
<FlyoutPage ...>
<FlyoutPage.Detail>
<local:Tabs Title="Detail"></local:Tabs>
</FlyoutPage.Detail>
<FlyoutPage.Flyout>
<ContentPage Title="Flyout">
<VerticalStackLayout>
<Label Text="Flyout Item 1"></Label>
<Label Text="Flyout Item 2"></Label>
<Label Text="Flyout Item 3"></Label>
<Label Text="Flyout Item 4"></Label>
</VerticalStackLayout>
</ContentPage>
</FlyoutPage.Flyout>
</FlyoutPage>
Then we can simply replace MainPage
during the AOT profile recording:
App.Current.MainPage = new AppFlyoutPage();
This improved startup of a template project using FlyoutPage
by
about 12ms. To go even further with your .NET MAUI application, it is
possible to record a completely custom AOT profile for your app. See
our experimental Mono.AotProfiler.Android package
for details.
See dotnet/maui#8939 and dotnet/maui#8941 for details about these improvements.
Better String Comparisons in Java Interop
dotnet-trace
output of a .NET MAUI application revealed:
21.28ms (0.45%) java.interop!Java.Interop.JniRuntime.JniTypeManager.AssertSimpleReference(string,string)
Note that changes to Android NDK Compiler Flags may have also contributed to the above timing.
Reviewing the method, it is mostly assertions around the string syntax
of jniSimpleReference
:
internal static void AssertSimpleReference (string jniSimpleReference, string argumentName = "jniSimpleReference")
{
if (jniSimpleReference == null)
throw new ArgumentNullException (argumentName);
if (jniSimpleReference != null && jniSimpleReference.IndexOf (".", StringComparison.Ordinal) >= 0)
throw new ArgumentException ("JNI type names do not contain '.', they use '/'. Are you sure you're using a JNI type name?", argumentName);
if (jniSimpleReference != null && jniSimpleReference.StartsWith ("[", StringComparison.Ordinal))
throw new ArgumentException ("Arrays cannot be present in simplified type references.", argumentName);
if (jniSimpleReference != null && jniSimpleReference.StartsWith ("L", StringComparison.Ordinal) && jniSimpleReference.EndsWith (";", StringComparison.Ordinal))
throw new ArgumentException ("JNI type references are not supported.", argumentName);
}
These assertions are needed, as there is now a way to “remap” underlying Java type names in .NET 7 as implemented in xamarin/java.interop#936. This was also compounded by how many times this method is called in a typical .NET MAUI application on Android.
However, we can simply improve the method by using the char
overload
of string.IndexOf()
and the indexer instead of string.StartsWith()
:
internal static void AssertSimpleReference (string jniSimpleReference, string argumentName = "jniSimpleReference")
{
if (string.IsNullOrEmpty (jniSimpleReference))
throw new ArgumentNullException (argumentName);
if (jniSimpleReference.IndexOf ('.') >= 0)
throw new ArgumentException ("JNI type names do not contain '.', they use '/'. Are you sure you're using a JNI type name?", argumentName);
switch (jniSimpleReference [0]) {
case '[':
throw new ArgumentException ("Arrays cannot be present in simplified type references.", argumentName);
case 'L':
if (jniSimpleReference [jniSimpleReference.Length - 1] == ';')
throw new ArgumentException ("JNI type references are not supported.", argumentName);
break;
default:
break;
}
}
This resulted in a much smaller time when reviewing these code changes
in dotnet-trace
:
1.21ms java.interop!Java.Interop.JniRuntime.JniTypeManager.AssertSimpleReference(string,string)
We found other code in Java.Interop.JniTypeSignature
‘s constructor
that validates the string syntax of Java type names, so we made
similar changes in both places.
See xamarin/java.interop#1001 and xamarin/java.interop#1002 for details about these improvements.
XAML Compilation Improvements
XAML Compilation (or XamlC) in .NET MAUI is on by default,
transforming your .xaml
files directly to IL at build time.
To illustrate this, take a couple <Image/>
elements:
<Image Source="https://picsum.photos/200/300" />
<Image Source="foo.png" />
In a Debug
build, the .xaml
is validated to give good error
messages about mistakes, but not written to disk. In a Release
build, however, this .xaml
is emitted directly into your .NET
assembly as IL.
In .NET 6, the above would .xaml
would compile to the equivalent of:
var image1 = new Image();
image1.Source = new ImageSourceConverter().ConvertFromInvariantString("foo.png");
var image2 = new Image();
image2.Source = new ImageSourceConverter().ConvertFromInvariantString("https://picsum.photos/200/300");
.NET MAUI’s type converters take string
values at runtime and
convert them into the appropriate type. These are not as performant as
if you created plain C# objects directly. Even the dotnet new maui
template with a single <Image/>
shows an impact of the above code:
1.17ms (<0.01%) microsoft.maui.controls!Microsoft.Maui.Controls.ImageSourceConverter.ConvertFrom()
XamlC has a concept of compiled type converters, when
implemented for ImageSource
generates better code:
var image1 = new Image();
image1.Source = ImageSource.FromUri(new Uri("foo.png", UriKind.Absolute));
var image2 = new Image();
image2.Source = ImageSource.FromFile("https://picsum.photos/200/300");
This treatment was applied for every type converter currently used at
runtime in the dotnet new maui
template:
This results in better/faster generated IL from .xaml
files on all
platforms .NET MAUI supports.
ReadyToRun
by Default on Windows
When the .NET Fundamentals team implemented .NET MAUI startup tracking
for Windows, we found a huge “easy win” to improve startup.
ReadyToRun
(or R2R) is not enabled by default in .NET
6 MAUI applications on Windows. R2R is form of ahead-of-time (AOT)
compilation that can be used on platforms running the CoreCLR runtime
(such as Windows).
Simply adding -p:PublishReadyToRun=true
resulted in a huge
improvement in our graphs:
Note that this graph is running the .NET MAUI application on an Azure DevOps hosted pool, so the times here are a bit worse than what we see on desktop PCs or laptops.
This made us consider making PublishReadyToRun
default for Release
builds on Windows. .NET MAUI is built on top of WinUI3
and the
Windows App SDK to leverage the latest UI framework and APIs
for Windows. WinUI3
templates already set PublishReadyToRun
by
default for Release
mode, so this was a “no-brainer” to bring to .NET
MAUI by default.
Note that PublishReadyToRun
does increase application size, so if
this is undesirable, you can simply set PublishReadyToRun
to false
in your project file.
See dotnet/maui#9357 for details about this improvement.
Dual Architectures by Default on macOS
A customer reported this behavior in their .NET MAUI application running on macOS:
- Launching the app after first install — over four seconds.
- Subsequent launches are under one second.
- A reboot causes the next launch of the application to be slow again.
It turns out, the application was built for x86_64
and running on an
M1 (arm64
) MacBook. This behavior is what you would see when
Rosetta
, Apple’s translation technology for Apple silicon
processors, is in effect:
To the user, Rosetta is mostly transparent. If an executable contains only Intel instructions, macOS automatically launches Rosetta and begins the translation process. When translation finishes, the system launches the translated executable in place of the original. However, the translation process takes time, so users might perceive that translated apps launch or run more slowly at times.
We could avoid Rosetta
translation behavior by building
the application for multiple architectures:
<RuntimeIdentifiers Condition=" $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'MacCatalyst' ">maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers>
The MacCatalyst
workload, in this case, builds for both
architectures, merging both binaries into the final app.
This led us to default to building two architectures in Release
mode
for both net7.0-maccatalyst
and net7.0-macos
applications. In most
cases, developers will want a dual-architecture application for the
App Store. If this is undesirable, you can define a single
$(RuntimeIdentifier)
in your project file to opt out.
To verify what architecture your .NET MAUI application is built for, you can run:
file /Applications/YourApp.app/Contents/MacOS/YourApp
/Applications/YourApp.app/Contents/MacOS/YourApp: Mach-O 64-bit executable x86_64
Where a dual-architecture binary would result with:
file /Applications/YourApp.app/Contents/MacOS/YourApp
/Applications/YourApp.app/Contents/MacOS/YourApp: Mach-O universal binary with 2 architectures:
- Mach-O 64-bit executable x86_64
- Mach-O 64-bit executable arm64
/Applications/YourApp.app/Contents/MacOS/YourApp (for architecture x86_64): Mach-O 64-bit executable x86_64
/Applications/YourApp.app/Contents/MacOS/YourApp (for architecture arm64): Mach-O 64-bit executable arm64
See xamarin-macios#15769 for details about this improvement.
Notes about RegexOptions.Compiled
As seen in issue dotnet/runtime#71007, a customer’s app
was spending ~258ms creating a Regex
object in the .NET 6 version of
their app, that was significantly higher than the previous
Xamarin.Android counterpart:
This showed up as a regression from Xamarin.Android to .NET 6, because
RegexOptions.Compiled
was not implemented at all in the BCL from
mono/mono. While RegexOptions.Compiled
is implemented in the BCL
from .NET Core (and .NET 6+). The new behavior is that time is spent
calling System.Reflection.Emit
to generate code at runtime to make
future Regex
calls faster.
In this example, the Regex
was only used once, so the setting was
not actually needed at all. Avoid using RegexOptions.Compiled
,
unless you really are using the Regex
many times, and are willing
to pay the startup cost for improved throughput.
However! We have an even better solution in .NET 7, as described in Regular Expression Improvements in .NET 7.
So for example, take the following usage of RegexOptions.Compiled
:
private static readonly Regex s_myCoolRegex = new Regex("abc|def", RegexOptions.Compiled | RegexOptions.IgnoreCase);
...
if (s_myCoolRegex.IsMatch(text)) { ... }
We can rewrite this in .NET 7 using [RegexGenerator]
, such as:
[RegexGenerator("abc|def", RegexOptions.IgnoreCase)]
private static partial Regex MyCoolRegex();
...
if (MyCoolRegex().IsMatch(text)) { ... }
Testing the new Regex
implementation in a sample Android
app, shows a noticeable improvement to startup time:
--Average(ms): 307.9
--Std Err(ms): 2.38723456930585
--Std Dev(ms): 7.54909854809757
++Average(ms): 214.4
++Std Err(ms): 3.06666666666667
++Std Dev(ms): 9.69765149118303
An average of 10 runs on a Pixel 5, shows a total ~93.5ms savings to
startup just by using the new Regex
APIs.
Diving deeper into dotnet-trace
output, we can see RegexOptions.Compiled
taking:
[RegexGenerator]
is now so fast that the static constructor of
MainActivity
completely disappears from the trace. We are merely
left with:
There is even a built-in refactoring inside of Visual Studio to quickly make this change in your own applications:
Improvements to Mono’s Interpreter
In .NET MAUI, the Mono runtime’s interpreter is used in two main scenarios:
-
C# Hot Reload: when debugging (or
Debug
configurations), the Mono interpreter is used by default. -
iOS (and other Apple platforms) where JIT is restricted: the interpreter enables APIs like
System.Reflection.Emit
.
In .NET 7, Mono’s interpreter has gained “tiered compilation”. Where startup is greatly improved in methods that are only called once (or very few times). When a certain threshold of calls occurs, an optimization pass takes place on the intermediate representation (IR) of the code. This lets the interpreter achieve fast performance in frequently called code-paths in your applications.
We can see the direct result of these changes in a Blazor WASM application:
However, we are also able to see a decent improvement in .NET MAUI scenarios. For example, take the following .NET MAUI applications running on a Pixel 5 device with solely the interpreter enabled:
Application | Version | Startup Time(ms) |
---|---|---|
dotnet new maui | .NET 6 | 978.0 |
dotnet new maui | .NET 7 | 775.3 |
.NET Podcast | .NET 6 | 1171.1 |
.NET Podcast | .NET 7 | 972.9 |
With these changes, you can expect .NET MAUI applications on iOS & Mac to have improved startup performance for the interpreter. We can also expect “inner development loop” improvements for Debugging and Hot Reload scenarios.
See dotnet/runtime#68823 for details about this improvement.
App Size Improvements
Fix DebuggerSupport
Trimmer Value for Android
Reviewing .NET trimmer output for .NET 6 Android applications, we found methods decorated with attributes such as:
System.Diagnostics.DebuggableAttribute
System.Diagnostics.DebuggerBrowsableAttribute
System.Diagnostics.DebuggerDisplayAttribute
System.Diagnostics.DebuggerHiddenAttribute
System.Diagnostics.DebuggerStepThroughAttribute
System.Diagnostics.DebuggerVisualizerAttribute
Were not being removed in Release
builds, attributing to larger
application sizes.
We discovered the underlying cause was an incorrect value for the
$(DebuggerSupport)
trimmer flag, after fixing this, a “Hello World”
Android application saw the following improvements:
--Java.Interop.dll 59419 bytes
++Java.Interop.dll 58642 bytes
--Mono.Android.dll 89327 bytes
++Mono.Android.dll 87877 bytes
--System.Private.CoreLib.dll 532428 bytes
++System.Private.CoreLib.dll 472318 bytes
--Overall package: 2738143 bytes
++Overall package: 2676703 bytes
This change will improve application size of all .NET 7 Android applications.
See xamarin-android#7176 for details about this improvement.
R8 Java Code Shrinker Improvements
R8 is whole-program optimization,
shrinking and minification tool that converts java byte code to
optimized Android dex code. R8
can be too aggressive, and remove
something that is called by Java reflection, etc. We don’t yet have a
good approach for making this the default across all .NET Android
applications.
However, in .NET 7, we now pass any included proguard.txt
files from
Android .aar
libraries to R8
. AndroidX libraries that are
implicitly used by .NET MAUI will use the ProGuard rules provided by
Google. This should result in less problems when enabling R8 in .NET 7
Android applications.
To opt into using R8
for Release
builds, add the following to your
.csproj
:
<!-- NOTE: not recommended for Debug builds! -->
<AndroidLinkTool Condition="'$(Configuration)' == 'Release'">r8</AndroidLinkTool>
See xamarin-android#5310 for details about this improvement.
Feature to Exclude Kotlin-related Files
Recent changes to Google’s AndroidX libraries are bringing more and
more APIs implemented in Kotlin instead of Java. One result of this
change, is additional “stuff” added to Android .apk
files.
In the case of a dotnet new maui
app:
kotlin
directory: 386,289 bytes uncompressed, 154,519 bytes compressedDebugProbesKt.bin
: 1,719 bytes uncompressed, 777 bytes compressed
These files are general metadata from Kotlin, and are not used or
needed in .NET Android applications. The kotlin
folder has files
with names such as kotlin/Error.kotlin_metadata
.
We added a new MSBuild item group to exclude custom files from the final Android application. By default we include the following exclusions:
<ItemGroup>
<AndroidPackagingOptionsExclude Include="DebugProbesKt.bin" />
<AndroidPackagingOptionsExclude Include="$([MSBuild]::Escape('*.kotlin_*')" />
</ItemGroup>
This removes the extra files from any .NET 7+ Android application. It also gives developers the flexibility to remove other files in the future if needed.
See xamarin-android#7356 for details about this improvement.
Improve AOT Output for Generics
To improve the generated AOT code size of .NET 7 iOS applications, the following cases are now implemented:
-
If a generic method or a method of a generic type has all type parameters constrained to value types – methods for reference types will not be generated.
-
If a generic method or a method of a generic type has all type parameters constrained to reference types – methods for value types will not be generated.
In both cases, we were generating AOT code for methods that were not
possible to be called. This is impactful for a large assembly like
System.Private.CoreLib.dll
.
The results of this change in a “hello world” iOS application:
File | Before | After | diff | % |
---|---|---|---|---|
HelloiOS | 19539824 | 19158672 | -381152 | -1,95% |
System.Private.CoreLib.dll-llvm.o | 6870572 | 6734416 | -136156 | -1,98% |
See dotnet/runtime#70838 for details about this improvement.
Tooling and Documentation
Profiling .NET MAUI Apps
The way to profile your .NET MAUI application varies depending on the
platform. Mobile platforms leverage dotnet-trace
and
dotnet-dsrouter
, while Windows desktop applications have access to
PerfView.
To gather instructions for various platforms, we’ve aggregated docs for:
This should assist in profiling your own code — the same way we would profile to optimize .NET MAUI itself.
Measuring Startup Time
Just like profiling, the way to measure a .NET MAUI application’s startup time is drastically different on each platform.
We’ve collected guidance such as:
- .NET MAUI, in general
- Google’s Android Documentation
- .NET Android Startup Documentation
- Apple’s iOS Documentation
- .NET iOS Startup Documentation
- Windows/.NET MAUI
Additionally, we have a measure-startup
tool for
measuring startup for desktop .NET MAUI applications on Windows or macOS.
This tool can be built from source such as:
git clone https://github.com/jonathanpeppers/measure-startup.git
cd measure-startup
dotnet run -- dotnet help
Everything after --
for dotnet run
are arguments passed to
measure-startup
, where this example times how long the dotnet
command launches and takes to print the text help
. This example
isn’t that useful, but can be easily applied to a .NET MAUI
application.
To measure a .NET MAUI app, first add a subscription to your main
Page
‘s Loaded
event:
Loaded += (sender, e) => Dispatcher.Dispatch(() => Console.WriteLine("loaded"));
Dispatcher
is used to give the app one pass for rendering/layout. In
an app using BlazorWebView
, you might consider logging this message
when the web view finishes loading.
On Windows, build the app for Release
mode, such as:
dotnet publish -f net7.0-windows10.0.19041.0 -c Release
You can then measure startup time via:
dotnet run -c Release -- bin\Release\net7.0-windows10.0.19041.0\win10-x64\publish\YourApp.exe loaded
This launches YourApp.exe
recording the time it takes for loaded
to be printed to stdout:
0:00:07.0961628
Dropping first run...
0:00:01.4743315
0:00:01.4700848
0:00:01.4834235
0:00:01.4752893
0:00:01.4695317
Average(ms): 1474.53216
Std Err(ms): 2.4945246400065977
Std Dev(ms): 5.577926666602944
This also works for .NET MAUI applications running on macOS:
dotnet publish -f net7.0-maccatalyst -c Release
dotnet run -- bin/Release/net7.0-maccatalyst/YourApp.app/Contents/MacOS/YourApp loaded
If the measure-startup
tool is useful for you,
let us know. We can release it as a .NET global tool on NuGet.org if
there is enough interest.
App Size Reporting Tools
To assist with diagnosing app size, we have two tools for Android and
iOS: apkdiff
and dotnet-appsize-report
.
apkdiff
can compare sizes between two Android .apk
files and report what changed.
To simulate what will be delivered to a user’s device from Google Play
(which now uses Android App Bundles), build an .apk
for a single architecture:
dotnet build -c Release -f net7.0-android -r android-arm64 -p:AndroidPackageFormat=apk
Google Play delivers a device-specific .apk
to users’ devices, and
so we can mimic this behavior by building for a single runtime
identifier: -r android-arm64
.
You can compare two .apk
files, using apkdiff
such as:
dotnet tool install --global apkdiff
...
apkdiff -f before.apk after.apk
Size difference in bytes ([*1] apk1 only, [*2] apk2 only):
+ 8 lib/arm64-v8a/libaot-Microsoft.Maui.Controls.Xaml.dll.so
- 608 lib/arm64-v8a/libaot-Microsoft.Maui.Controls.Compatibility.dll.so
- 3,448 lib/arm64-v8a/libaot-Microsoft.Maui.Controls.dll.so
- 4,424 lib/arm64-v8a/libaot-Microsoft.Maui.dll.so
- 5,656 lib/arm64-v8a/libaot-Microsoft.NetConf2021.Maui.dll.so
- 129,617 assemblies/assemblies.blob
Summary:
- 129,617 Other entries -1.03% (of 12,605,899)
+ 0 Dalvik executables 0.00% (of 11,756,376)
- 14,128 Shared libraries -0.10% (of 14,849,096)
- 131,072 Package size difference -0.64% (of 20,380,049)
Likewise for iOS, we have dotnet-appsize-report
:
git clone https://github.com/rolfbjarne/dotnet-appsize-report.git
cd dotnet-appsize-report
dotnet run -- -i path/to/bin/Release/net7.0-ios/YourApp.app
Which can give reports for:
App size report for YourApp.app
1) View 167 assemblies
2) View 381 namespaces
3) View 26592 types
q) Quit
And show .NET assemblies, namespaces, or types sorted by IL size. For example:
Assembly Types IL Size File size
System.Private.CoreLib 2,157 1,126,514 3,605,144 bytes
System.Private.Xml 1,667 1,378,583 3,099,800 bytes
Microsoft.iOS 14,978 5,086,414 24,121,232 bytes
Experimental or Advanced Options
Just as in .NET 6, many of the same experimental or advanced options are still available in .NET 7:
In future .NET releases, we will work toward making some of these settings the default — where it best makes sense.
Conclusion
We hope that our continued investment in performance in .NET MAUI will be helpful for your own cross-platform desktop and mobile applications. Always be profiling!
Please try out .NET MAUI, file issues, or learn more at http://dot.net/maui!
Microsoft's efforts to increase the performance of .NET 6 include rewriting the System.IO.FileStream section. This section is about working with files.
This means that the speed of working with files in .NET 6. has increased significantly.
According to the results of the benchmarks, it can be said that the work speed and performance in .NET 6. compared to all previous versions of .NET has increased and improved significantly.
Now in version 7, source by github...
I haven’t even been able to see .net 6 properly and version 7 is about to come out…
Thanks a lot for a great post like last time. This is very appreciated as it communicates the improvements and, in some cases, it also provides action points for us developers to do in our applications.
Just one minor ask - I'm trying to put together the numbers in terms of improvements so that I have a general overview of how things are going. You post numbers in terms of startup times for Android and application...
Good to see this work! Currently getting AOT on Windows in WinUI3 (which is how MAUI deploys) is dependent on https://github.com/microsoft/CsWinRT/issues/1248#issuecomment-1218778809 .
Yes, when the Windows App SDK (WinUI3) supports NativeAOT, we could use it with .NET MAUI on Windows. I think there might be some problems with System.Reflection usage and NativeAOT, though. Bindings in .NET MAUI, among other things, use a lot of System.Reflection.
That’s pretty shocking. Are bindings essential to MAUI or just one way that some users use it (with XAML markup)?
Great blog!
I have one question about "This treatment was applied for every type converter currently used at runtime in the dotnet new maui template". Is there any potential for more type converters that aren't included in the default template to be given this treatment? It sounds like the optimisations done will help the blank template (obviously this helps real world apps too), but are there more real-world gains to be made in apps that use...
There is a set of default styles in the template, that likely covered the common cases. But it's still possible your app is using something we could optimize.
If you want to check, decompile your app's main assembly in a build with a tool such as ILSpy. Looks for examples like: You should be able to find these in the method of any page or view with .