dotnet / maui

.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.
https://dot.net/maui
MIT License
22.01k stars 1.72k forks source link

Android performance issues - unable to sustain 120 Hz updates for anything significant #20979

Closed jonmdev closed 6 months ago

jonmdev commented 6 months ago

Description

I have been very frustrated by UI performance in Android. The UI Toolkit in Unity (game development system) was much faster allowing smooth easy 120 fps performance on high end cell phones with loads of things on screen and just basic optimizations.

I struggle by contrast to get anything done at 120 fps in Maui Android. Everything I do in Maui stutters constantly on Android even though my code is far better optimized. Even if I comment out half the things I'm updating it is still stuttering and slow.

I have been looking for things to optimize further, but as I step through with Stopwatch I find everything just takes very very very long, and I don't know why.

For example, here is one simple snippet, where I am simply resizing an AbsoluteLayout that has no background and no autolayout elements around it. Any children are being manually translated within it elsewhere, so this should be as easy as can be.

Debug.WriteLine("START STOPWATCH");
Stopwatch stopwatch = Stopwatch.StartNew();
bool resized = false;
if (this.HeightRequest != entryParams.currentHeight) {
    this.HeightRequest = entryParams.currentHeight;
    resized = true;
}
if (this.WidthRequest != entryParams.fullWidth) {
    this.WidthRequest = entryParams.fullWidth;
    resized = true;
}

stopwatch.Stop();
Debug.WriteLine("==CUSTOM UPDATE " + stopwatch.Elapsed.Ticks + " ticks " + resized);

The best outcome I get when there is no "resize" in Debug mode on a Samsung S21 Ultra (a few years old but still quite high end device) is 120 ticks or as much as 290 ticks.

In other words, 120-290 ticks to compare 2 sets of doubles/floats and DO NOTHING ELSE.

How can it take so long just to do so absolutely little?

When a resize does occur (again just resizing one AbsoluteLayout with no automatically changing children or special rules, no background, no shadow), it costs as much as 230,049 ticks just for the code above (ie. one width/height request for an AbsoluteLayout).

This is on Debug mode obviously, but regardless this just seems crazy.

On Windows Debug mode (obviously Win CPU is stronger, but still) I get 16-30 ticks when no resize occurs, and 241 ticks when resize does occur.

In other words, it takes TEN TIMES LONGER for Android to compare two sets of floats/doubles and ONE THOUSAND TIMES LONGER for the Android device to resize an AbsoluteLayout than Windows.

What on earth is going on here?

The only way it seems I can optimize things is to just stop doing anything because everything costs a fortune. I had way more complex UI stuff in Unity running 120 Hz no problem on the same device. Even when I comment out 50%+ of my functions here I still get stuttering on Release mode, and it is perhaps no wonder I suppose when I see numbers like this.

Is there anything useful I can do to narrow down why basic functions are running so slowly? Is there any useful way to get this data from Release mode to reduce any confounding effect from the USB Debugging?

I am thinking I can write the stopwatch data to disk or to an onscreen label to read, but I don't know what help it will be. I can see from the stuttering that everything is not working as fast as it should be. The question is why and what to do about it.

Thanks for any help or clarification. I am happy to submit a demo project but I wanted to ask for ideas first.

Steps to Reproduce

No response

Link to public reproduction project repository

No response

Version with bug

8.0.3 GA

Is this a regression from previous behavior?

No, this is something new

Last version that worked well

Unknown/Other

Affected platforms

Android

Affected platform versions

No response

Did you find any workaround?

No response

Relevant log output

No response

jfversluis commented 6 months ago

@jonathanpeppers thoughts?

jonmdev commented 6 months ago

As for why it matters, to give some perspective:

To maintain 120 Hz function (modern cell phone) you have at MOST 1/120 = 8.3 ms per frame to do ALL your logic and UI thread updates. This is a very tight time frame.

If you are say translating some visual elements in a virtualized scroll view (where say you have 15-20 "display elements" you are updating as they go on/off screen and also translating them with scrolling, that means you have less than 1/2 ms per unit to update the scrolling translate, and update the new elements with new photos/text/sizes/etc as they go on/off screen.

Now this actually works fine on other systems. Like I said Unity's UI Toolkit (which is based off of C# VisualElement class I believe) can keep up in such situations with major room to spare.

Maui simply isn't coming close to keeping up in any practical applications. And the test data above to me is crazy. Each stopwatch.Elapsed.Ticks increment is 100 nanoseconds. So for the 280,000 ticks it took to resize a single AbsoluteLayout (again, Debug mode), this is 28 ms.

I will try to get some Release mode data tomorrow. I can say it is certainly not that bad obviously, but it is nowhere near the point where I can hit the 8.3 ms per frame target and get anything useful done in that time. I don't know why though.

jonathanpeppers commented 6 months ago

@jonmdev what does Release mode look like? See the section at the top of https://aka.ms/profile-maui for the difference between Debug and Release

You can also try setting UseInterpreter=false in your .csproj to make Debug a bit faster, if you are not using C# hot reload.

ghost commented 6 months ago

Hi @jonmdev. We have added the "s/needs-info" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

jonmdev commented 6 months ago

@jonathanpeppers Here is a demo project I spent a few hours making:

https://github.com/jonmdev/SmoothScrollBug

This is a simple pseudo endless-scroller scrollwindow. It features CycleVE objects which have a shadowed photo and a few labels in them. It runs on a Timer started in TestPage (the main ContentPage of project).

There is a DebugWindow overlay object which is being updated as yellow overlay labels with the FPS rate of the Stopwatch in the function runScroll() in TestPage which is where each incremental update occurs.

By default, I have the Timer set to 1/120f for ~120 Hz rate. This will not be precise however, as timers are not precise. I tried bumping it to 1/150f or more and it helps a bit but still can't achieve smooth action.

As the page "scrolls", it loops the CycleVE at the bottom to the top of the view and updates it with some new fake data to simulate scrolling.

Build the project on release mode to a 120 Hz device (I am using Samsung S21 Ultra) and unless your device is way more powerful, you should see the project jitters constantly and can't scroll smoothly at 120 Hz.

Screenshot_20240304_201631

What this tells me perhaps:

1) Stopwatch is useless for diagnosing the problem. Maui was built with a lot of async code and I don't think any of this is being captured by the Stopwatch. My code finishes so fast it could run at 2500 fps at worse (while scrolling) and 20,000 fps at best (when not scrolling), but yet there is no smooth motion.

2) Perhaps need some way in Android to sync update requests to screen refresh, or a more accurate timer method. Need also some accurate/timely way of measuring the deltaTime of screen refresh. Perhaps some jitter is due to my DateTime's not being correct by time of Android screen refresh.

3) I suspect the solution to point 2 is one cannot use TImers for fluid animations in Android (as Maui by default does) and instead we must somehow use Choreographer functions, though I don't know how. https://developer.android.com/reference/android/view/Choreographer.FrameCallback

Is there any way to even manually access this Callback to see if synchronizing to it helps? How might this work practically?

Any ideas or solutions?

jonathanpeppers commented 6 months ago

This repo has an example of using the FrameMetricsAggregator Android API:

This is what Android/Google docs recommend for measuring scroll performance. It is an odd API, though, it gives data like this:

05-08 14:55:47.347 31085 31085 I DOTNET  : Frame(s) that took ~9ms, count: 2
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~10ms, count: 6
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~11ms, count: 20
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~12ms, count: 2
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~15ms, count: 1
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~16ms, count: 1
05-08 14:55:47.348 31085 31085 I DOTNET  : Frame(s) that took ~19ms, count: 1
05-08 14:55:47.367 31085 31085 I DOTNET  : Frame(s) that took ~22ms, count: 17
05-08 14:55:47.367 31085 31085 I DOTNET  : Frame(s) that took ~23ms, count: 4

And so to make sense of it, I counted the "slow" frames (more than 16ms) and took an average:

05-08 14:55:47.368 31085 31085 I DOTNET  : Average frame time: 15.52ms
05-08 14:55:47.368 31085 31085 I DOTNET  : No. of slow frames: 23

If you want to try this out feel free, in the meantime I'll profile your sample app to see what I find.

jonathanpeppers commented 6 months ago

I made a few changes to the sample, you can see the commits here:

These were just to make the .speedscope / .nettrace file I recorded more accurate. DebugWindow was spending time, and I wanted to see what's happening inside MAUI.

Here are the profiles I recorded: SmoothScrollBug.zip

Visual Studio shows the "hot path":

image

The sample sets TranslationY while scrolling, does it need to do that? Could the layout be "fixed", where each row is the same height? You want to avoid additional layout passes while scrolling, and if all the Views' sizes could be static this helps.

Another thing that I see is:

image

Disabling any shadows will also help, that is a thing I've seen in the past.

jonathanpeppers commented 6 months ago

I misread a little bit how your app is using TranslationY. Are you using it to simulate scrolling? Can you use scrolling APIs instead?

Changing TranslationY on your CollectionView seems to spend a lot of time in the Android OS layer:

image

So not much we can do about that. Can we call ScrollTo() instead?

jonmdev commented 6 months ago

Thanks for your efforts, @jonathanpeppers . I have studied the situation a bit more thoroughly now and I have a few thoughts on what the limitations are and why. I appreciate your feedback as you know more than me.

First off, yes I am manually "scrolling" things using TranslationX/TranslationY. I have developed my own "scroll views", "photo carousels", "collection views" etc of this nature with my own inertia functions, mechanisms for handling past the edge, data virtualization and updating, click handling (intercepted from the OS with a catching layer like Maui's WindowOverlay), nesting capacity, etc.

This is all contingent on me manipulating the positions of my "movable objects" (ie. AbsoluteLayouts and the elements they contain). So I am kind of stuck with Translation.

In any case, I am not convinced there would be any reason the native ScrollView or CollectionView etc would be inherently faster. They presumably works by the same mechanism as translation internally? Unless they have hidden optimizations I can't access/replicate...

Main problems then:

1) Timer Is Very Inaccurate

The first and simplest issue I have found is that the Timer must be set to a much faster increment than the desired rate. Ie. If you want 120 fps always at a minimum, you need to set it to say 200-300 fps, as there is massive variation in the increment timing. ie. If you set it to 120 fps, it averages 110 fps with variation +/- 40 fps or so.

Obviously if you're not getting ticks fast enough you won't get updates fast enough.

2) Unity's UI Toolkit Had Special Optimizations Native Doesn't Allow

In retrospect, I realize I was able to get super fast custom translation behavior (manual scrolling, carousels, etc.) in Unity's UI Toolkit in part due to their usage hints which for example you could apply Dynamic Transform:

When specified, this flag hints the system to optimize rendering of the VisualElement for recurring transformation changes. The VisualElement's vertex transformation will be done by the GPU when possible on the target platform. Please note that the number of VisualElements to which this hint effectively applies can be limited by target platform capabilities. For such platforms, it is recommended to prioritize use of this hint to only the VisualElements with the highest frequency of transformation changes.

These functions allowed extremely fast specialized processing of VisualElements that needed to be transformed or translated routinely through the GPU. We have no such specifier that I am aware of in any Native platforms to do the same, right?

To the point early, do you know if Android applies any special optimizations like this to its default ScrollView or other moving objects? (I am unable to use them anyway but if so I wonder if I can manipulate Android to get the same benefit...)

3) Android is Absolutely Terrible at Loading Photos

This has been something that has been continuously evident since I started with Maui. I even designed my own custom Native image loaders to try to do better (using for example in Android: Android.Widget.ImageView & Android.Graphics.Bitmap). This would load a JPG stream from disk and convert to a native bitmap async on a different thread to try to desynchronize/cache these and minimize pressure on the main thread.

But that did not help because as soon as it must be applied to the native Android ImageView on the UI thread, again Android usually stutters trying to do this. There seems to be nothing I can do to make better, since this must happen on the main thread.

By contrast, Unity's UI Toolkit was very fast at this. You could build a Texture2D async (their version of a Bitmap) in the background and when you applied it to an Image element, it was basically zero lag and almost perfectly instant to apply.

4) Long View

Unless I am then missing anything else, it seems the only thing I can do is just wait for phones to get faster. Android and native development has advantages (built in Text Editors, etc.). But a non-native system that renders its own output like Unity's UI Toolkit can implement obviously better optimized behaviors and workarounds to boost speed in ways that Native Android does not allow.

Working in Android feels like it is a slow decrepit old outdated beast that functions poorly and inefficiently but probably can't be easily changed by Google to maintain compatibility and stability.

Very disappointing and depressing I guess but what can you do. No system is perfect it seems. This is still the best I have as I need good Native tools like the built in multi-language native Text Editors.

Any further thoughts or perspective would be appreciated. Thanks again.

jonathanpeppers commented 6 months ago

So a couple comments below:

1) Timer Is Very Inaccurate

I don't think you should be using a Timer at all to measure things. Use either dotnet-trace, FrameMetricsAggregator, or the built-in Android refresh rate counter instead.

To the point early, do you know if Android applies any special optimizations like this to its default ScrollView or other moving objects?

The Android ScrollView (and other related views) are optimized for scrolling, it's what you would use in a Java/Kotlin app. If you like you can try ScrollView directly from C#, if you are concerned about the MAUI layer. Look at the dotnet new android project template as a starting point.

3) Android is Absolutely Terrible at Loading Photos

How are you loading the images? We should verify they are AndroidResource and not byte[] or Stream. These should load the fastest as this is what Java/Kotlin apps would do.

jonmdev commented 6 months ago

So a couple comments below:

  1. Timer Is Very Inaccurate

I don't think you should be using a Timer at all to measure things. Use either dotnet-trace, FrameMetricsAggregator, or the built-in Android refresh rate counter instead.

To the point early, do you know if Android applies any special optimizations like this to its default ScrollView or other moving objects?

The Android ScrollView (and other related views) are optimized for scrolling, it's what you would use in a Java/Kotlin app. If you like you can try ScrollView directly from C#, if you are concerned about the MAUI layer. Look at the dotnet new android project template as a starting point.

  1. Android is Absolutely Terrible at Loading Photos

How are you loading the images? We should verify they are AndroidResource and not byte[] or Stream. These should load the fastest as this is what Java/Kotlin apps would do.

To be clear, I am not using Timer to time anything but rather to provide ticks for my animations and inertia or other motion behaviors to sync to. This is the same thing that Maui does.

It is just shoddy inherently. In windows there is a solution to use a media timer (winmm.dll) which is incredibly precise but nothing similar in other OS.

I will take a look at the c# scrollwindow optimization testing. That is a good idea to just make an android project in .net to play with. I didn't think of that.

As noted, image loading is terrible both with the build in method in Maui (as in this test project) or if you manually create an Android bitmap and apply it to the Android ImageView.

Removing the image setting (or allowing it to only set them once and not keep resetting them) in this demo project helps smooth out motion significantly. But at this point that seems pretty clearly an Android issue.

jonathanpeppers commented 6 months ago

As noted, image loading is terrible both with the build in method in Maui (as in this test project) or if you manually create an Android bitmap and apply it to the Android ImageView.

One suggestion is to always use the API where you pass an int like ImageView.SetImageResource(int). That will perform better than using Android.Graphics.Bitmap.

jonmdev commented 6 months ago

As noted, image loading is terrible both with the build in method in Maui (as in this test project) or if you manually create an Android bitmap and apply it to the Android ImageView.

One suggestion is to always use the API where you pass an int like ImageView.SetImageResource(int). That will perform better than using Android.Graphics.Bitmap.

Thanks again for all your advice and patience. Unfortunately I am dealing with non-embedded images like downloaded by web request so via Android Bitmap is the most I can optimize it.

Just FYI, though, I think I found the solution to optimizing what I need. It seems this is likely how Android manages this for ScrollView:

https://developer.android.com/topic/performance/hardware-accel#kotlin

They explain there how to freeze views at start/finish of animations. I will try to add some custom function that does this in Maui and perhaps that will solve most of the issue.

Thanks for brainstorming with me. Perhaps I was too hard on both Maui and Android and I will get everything to cooperate one day. đŸ™‚