Skip to content

Add a buffer cache to containers in VirtualizingStackPanel #18646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

gentledepp
Copy link
Contributor

@gentledepp gentledepp commented Apr 14, 2025

What does the pull request do?

Uses 3 strategies to optimize scrolling performance of a VirtualizedStackPanel

Instead of measuring and arranging every element for every bit of scrolling as depicted here:

e50e4e59-b5c1-4a46-bf91-d769c159844f

We keep additional elements in memory and only measure and arrange when we reach the extended viewport
Also, we only measure and arrange elements that have been changed!

582e5fb9-7a37-48fa-9bb5-77f7da7f354c

The downside of this approach is, that we use more memory (as we need to keep additional items in memory)
The upside is, that re greatly reduce the number of measure & arrange cycles which reduces the stress on the GC

What is the current behavior?

Scrolling on Android is very edgy (non-smooth) since many Measure/Arrange calls put a lot of pressure on the Garbage Collector:

perf_1

What is the updated/expected behavior with this PR?

Smooth scrolling on all platforms, even with more complex/deeper nested list item layouts.

perf_2

How was the solution implemented (if it's not obvious)?

The default implementation of the VirtualizingStackPanel only keeps enough visual elements in memory to fill its current bounds. This uses the least amount of memory.

On devices such as Android, however, it leads to rough/edgy (non-smooth) scrolling.
This is because for every bit of scrolling, all elements of the VirtualizingStackPanel need to be measured and arranged, which causes extensive GC/memory pressure on Android.

The approach implemented here tries to keep the number of Measure/Arrange cycles as low as possible.

This is achieved by applying two strategies:

1 - Buffer more items (the first commit of this PR)

In order to not have to recycle elements every time the user scrolls "a bit", we extended the viewport by a BufferFactor.
By default this is 0.5 - so if the VirtualizingStackPanel scrolls vertically and can show 800 px, we do not only render elements for 800 px, but also 400 (800*BufferFactor) above and below
Only when reaching the edge of these additional elements, we detect and call InvalidateMeasure.
This causes more memory to be used (since twice as many UI elements are kept in memory), but greatly reduces the number of Measure calls.

The second commit of this PR handles a corner case:
When we scroll near the end or start of the list, the VirtualizingStackPanel keeps trying to fill the available space.
Since no more element is there to be rendered, however, this space cannot be filled. Not being aware of this causes the panel to call InvalidateMeasure on every scroll (towards the start and end of the list)
You can see this in the log output below since the last 4 logs call InvalidateMeasure

	old xVP:374,8.-1675,6 - new VP:649,6-1300
	old xVP:374,8.-1675,6 - new VP:600-1250,4
	old xVP:374,8.-1675,6 - new VP:550,4-1200,8
	old xVP:374,8.-1675,6 - new VP:500-1150,4
	old xVP:374,8.-1675,6 - new VP:449,6-1100
	old xVP:374,8.-1675,6 - new VP:400-1050,4
	old xVP:374,8.-1675,6 - new VP:350,4-1000,8 Case 1a => InvalidateMeasure
	old xVP:25,2.-1326 - new VP:300-950,4
	old xVP:25,2.-1326 - new VP:249,6-900
	old xVP:25,2.-1326 - new VP:200-850,4
	old xVP:25,2.-1326 - new VP:150,4-800,8 Case 1b UP => InvalidateMeasure    
	old xVP:0.-1126 - new VP:100-750,4 Case 1b UP => InvalidateMeasure
	old xVP:0.-1075,6 - new VP:49,6-700 Case 1b UP => InvalidateMeasure
	old xVP:0.-1025,2 - new VP:0-650,4 Case 1b UP => InvalidateMeasure

After implementing the strategy for this edge case, it looks like this:

	old xVP:399,2.-2350,4 - new VP:600-1250,4
	old xVP:399,2.-2350,4 - new VP:550,4-1200,8
	old xVP:399,2.-2350,4 - new VP:500-1150,4
	old xVP:399,2.-2350,4 - new VP:449,6-1100
	old xVP:399,2.-2350,4 - new VP:400-1050,4
	old xVP:399,2.-2350,4 - new VP:350,4-1000,8 Case 1a => InvalidateMeasure
	old xVP:0.-1651,2 - new VP:300-950,4 Case 1b UP
	old xVP:0.-1651,2 - new VP:249,6-900 Case 1b UP
	old xVP:0.-1651,2 - new VP:200-850,4 Case 1b UP
	old xVP:0.-1651,2 - new VP:150,4-800,8 Case 1b UP
	old xVP:0.-1651,2 - new VP:100-750,4 Case 1b UP
	old xVP:0.-1651,2 - new VP:49,6-700 Case 1b UP
	old xVP:0.-1651,2 - new VP:0-650,4 Case 1b UP

You can see that InvalidateMeasure was called and then the effective viewport changed 7 consecutive times without any InvalidateMeasure call.
This is because the first call detected that it is a t the start of the list and re-measuring would have no effect at all.

2 - Avoiding measuring realized elements when only the scroll position changed

The third commit of this PR implements another optimization:
When we only scroll, it makes no sense to call Measure on any elements that will be kept in the viewport since they did not change at all.
Therefore we try to detect

  • that only the scroll position changed and
  • whether a ui element was kept (not created or re-used)
    ...and in this case skipt the Measure call.

NOTE as I am not a 100% "Avalonia native developer", please check this code carefully since I do not know if all edge-cases are handled correctly!!

However, when implemented, the output can look something like:

	old xVP:0.-1300,8 - new VP:49,6-700
	old xVP:0.-1300,8 - new VP:100-750,4
	old xVP:0.-1300,8 - new VP:150,4-800,8
	old xVP:0.-1300,8 - new VP:200-850,4
	old xVP:0.-1300,8 - new VP:249,6-900
	old xVP:0.-1300,8 - new VP:300-950,4
	old xVP:0.-1300,8 - new VP:350,4-1000,8
	old xVP:0.-1300,8 - new VP:400-1050,4
	old xVP:0.-1300,8 - new VP:449,6-1100
	old xVP:0.-1300,8 - new VP:500-1150,4
	old xVP:0.-1300,8 - new VP:550,4-1200,8
	old xVP:0.-1300,8 - new VP:600-1250,4
	old xVP:0.-1300,8 - new VP:649,6-1300
	old xVP:0.-1300,8 - new VP:700-1350,4 Case 1a => InvalidateMeasure
	Skipped measuring Item 1
	Skipped measuring Item 2
	Skipped measuring Item 3
	Skipped measuring Item 4
	Skipped measuring Item 5
	Skipped measuring Item 6
	Skipped measuring Item 7
	Skipped measuring Item 8
	Skipped measuring Item 9
	Skipped measuring Item 10
	Skipped measuring Item 11
	Skipped measuring Item 12
	Skipped measuring Item 13
	Skipped measuring Item 14
	Skipped measuring Item 15
	Skipped measuring Item 16
	Skipped measuring Item 17
	Skipped measuring Item 18
	Skipped measuring Item 19
	Skipped measuring Item 20
	Skipped measuring Item 21
	Skipped measuring Item 22
	Skipped measuring Item 23
	Skipped measuring Item 24
	Skipped measuring Item 25
	Skipped measuring Item 26
	Skipped measuring Item 27
	Skipped measuring Item 28
	Skipped measuring Item 29
	Skipped measuring Item 30
	Skipped measuring Item 31
	Skipped measuring Item 32
	old xVP:49,5999999999999.-2000,8 - new VP:750,4-1400,8

Finally I thought: Why not also cache the "arranged" elements if we only scroll?
So I added that too. ;-)

NOTE I also introduced a readonly variable private readonly bool _traceVirtualization
If you set it to true you get the same debug output as above which will help you understand the implementation.

Checklist

  • Added unit tests (if possible)? - I don't know where and how. Please advise!
  • Added XML documentation to any related classes?
  • Consider submitting a PR to https://github.com/AvaloniaUI/avalonia-docs with user documentation - I'd happily do so. But only BufferFactor needs to be documented and I'd like to have the implementation and naming accepted first

Breaking changes

None - we can set the default BufferFactor to 0 and the implementation will essentially be the same as before this PR.

Obsoletions / Deprecations

Fixed issues

Implements #18626

@gentledepp gentledepp marked this pull request as ready for review April 14, 2025 12:48
@timunie timunie added needs-api-review The PR adds new public APIs that should be reviewed. enhancement area-perf labels Apr 14, 2025
@MrJul
Copy link
Member

MrJul commented Apr 14, 2025

First of all, thank you for your contribution!

I haven't looked at the code at all yet, but there's a bunch of failing tests after this PR: https://dev.azure.com/AvaloniaUI/AvaloniaUI/_build/results?buildId=56039&view=ms.vss-test-web.build-test-results-tab. Some existing tests might need adjustments while others look like real failures that should be addressed.

Moreover, the new feature should come with its own unit tests validating the new expected behaviors regarding realization, scrolling and measuring. VirtualizingStackPanel has complex logic, and we shouldn't add anything new here that would be easily broken in the future without tests.

@cla-avalonia
Copy link
Collaborator

cla-avalonia commented Apr 14, 2025

  • All contributors have signed the CLA.

@gentledepp
Copy link
Contributor Author

gentledepp commented Apr 15, 2025

@MrJul you are right.
I fixed most of the the tests yesterday night.
They failed because the default BufferFactor was set to 0.5, which obviously changed the panels behavior.

Could you please assist me in fixing one more test?
Avalonia.Controls.UnitTests.VirtualizingStackPanelTests.Recycling_A_Hidden_Control_Shows_It

        [Fact]
        public void Recycling_A_Hidden_Control_Shows_It()
        {
            using var app = App();
            var style = CreateIsVisibleBindingStyle();
            var items = Enumerable.Range(0, 3).Select(x => new ItemWithIsVisible(x)).ToList();
            var (target, scroll, itemsControl) = CreateTarget(items: items, styles: new[] { style });
            var container = target.ContainerFromIndex(2)!;

            Assert.True(container.IsVisible);
            Assert.Equal(20, container.Bounds.Top);

            items[2].IsVisible = false;
            Layout(target);

            Assert.False(container.IsVisible);

            items.RemoveAt(2);
            items.Add(new ItemWithIsVisible(3));
            Layout(target); //THIS DOES NOT CALL MEASUREOVERRIDE ANYMORE! 

            Assert.True(container.IsVisible);
        }

For some reason, the MeasureOverride call of the VirtualizingStackPanel is not called anymore after applying my first commit.
I honestly do not understand why that is the case.

But I also do not understand the test.
If an item changes, it should re-render the container.
OK.
But the ItemsSource is a simple List - so withotu any NotifyingCollectionChanged.

Would it be feasible if I changed this to be an ObservableCollection
because if I change it that way, it works fine:

[Fact]
        public void Recycling_A_Hidden_Control_Shows_It()
        {
            using var app = App();
            var style = CreateIsVisibleBindingStyle();
            var itemsList = Enumerable.Range(0, 3).Select(x => new ItemWithIsVisible(x)).ToList();
            var items = new ObservableCollection<ItemWithIsVisible>(itemsList);
            var (target, scroll, itemsControl) = CreateTarget(items: items, styles: new[] { style });
            var container = target.ContainerFromIndex(2)!;

            Assert.True(container.IsVisible);
            Assert.Equal(20, container.Bounds.Top);

            items[2].IsVisible = false;
            Layout(target);

            Assert.False(container.IsVisible);

            items.RemoveAt(2);
            items.Add(new ItemWithIsVisible(3));
            Layout(target);

            Assert.True(container.IsVisible);
        }
        

I will add the tests for the buffer today then!

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056055-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@gentledepp
Copy link
Contributor Author

@MrJul please wait with review - I am still adding specialized tests for the bufferfactor!

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from b8b1506 to 29d4dd6 Compare April 15, 2025 13:51
@gentledepp
Copy link
Contributor Author

ok... so I added a bunch of tests.

VirtualizingStackPanelTests.Buffered.cs

  • is a copy of VirtualitingStackPanelTests, but altered to test with BufferFactor = 0.5
  • additionally, contains tests for the ExtendedViewPort calculation
  • and tests for handling the extended viewport (including counting the number of measure and arrange calls)

I guess I've got everything covered to be honest.

The only thing that bothers me still is this test case: Recycling_A_Hidden_Control_Shows_It that I had to change (swapping the List with an ObservableCollection) and I am not 100% sure if I broke something with that.

@gentledepp
Copy link
Contributor Author

@cla-avalonia agree

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056057-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@gentledepp
Copy link
Contributor Author

@MrJul some test are failing, but it seems to be an appium issue (not related to my changes - i guess?)

OpenQA.Selenium.WebDriverException : An unknown server-side error occurred while processing the command. Original error: 'POST /element' cannot be proxied to Mac2 Driver server because its process is not running (probably crashed)

Please advise

@gentledepp
Copy link
Contributor Author

gentledepp commented Apr 16, 2025

If you look at my original PR description now, you'll see I added two simple animations which (I hope) show how my PR works and what improvements it can bring

@MrJul
Copy link
Member

MrJul commented Apr 16, 2025

Thank you for adding the new tests.

@MrJul some test are failing, but it seems to be an appium issue (not related to my changes - i guess?)

Yes, Appium sometimes just randomly crashes while running integration tests.
You can ignore this for now, or force a push to re-trigger tests.

@gentledepp
Copy link
Contributor Author

Are there any plans for reviewing this?

Alternatively, is there any guide on how to create your own nuget package of avalonia locally?
I do not want to spam you with pseudo PRs, just to get a usable package :-D

@grokys
Copy link
Member

grokys commented May 9, 2025

Hi @gentledepp - I definitely plan to review this, the only problem is a lack of time - sorry. Thank you for the great PR writeup, it should make reviewing it much easier!

@grokys grokys self-requested a review May 9, 2025 08:37
@grokys
Copy link
Member

grokys commented May 15, 2025

Hi @gentledepp - I hope to review this today.

Alternatively, is there any guide on how to create your own nuget package of avalonia locally? I do not want to spam you with pseudo PRs, just to get a usable package :-D

Yes, you can create local nuget packages quite easily using the BuildToNuGetCache nuke target which will push a set of 9999.0.0-localbuild nuget packages to your nuget cache: #14446

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056498-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Copy link
Member

@grokys grokys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @gentledepp!

First of all sorry that it took me so long to review, you caught me at a time where I was busy with some other stuff and I'm the only one who really knows this part of the codebase well enough to give a review.

Secondly, thank you so much for the fantastic PR description - it's one of the best I've ever seen and was a joy to read ;)

Thirdy, thank you for the code - it's a really useful feature. I've tried to dig into the code as best I can and I think I understand what it's doing reasonably well now. The only real concerns I have about this PR relate to the duplication of tests and whether _unchangedElements is really needed. The rest of the comments are more nits, and I ask forgiveness for these; I'd be quite happy to tidy these up myself before merge (let me know) ;)


return CalculateDesiredSize(orientation, items.Count, viewport);
}
finally
{
_cacheElementMeasurements = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took me a little which to understand why _cacheElementMeasurements is reset here, but if I remove this line, then Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change_Causes_It_To_Be_Moved_Into_Visible_Viewport fails, so I think that the reason for it is that it's only set to true after a viewport change due to scrolling, and if a measure is invoked for any other reason then it needs to be false? If so might be worth adding a quick comment here to explain it (as the rest of the code is so well commented).

Copy link
Contributor Author

@gentledepp gentledepp May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that is exactly the idea.
My problem was, that I wanted to make scrolling fast in a virtualizingstackpanel (as fast as it is in a normal stackpanel) so I tried caching as much as possible.

Doesn't a call to "InvalidateMeasure" invalidate all measurements down the visual tree? At least I thought it did.
One reason why the normal "VirtualizingStackPanel" is so slow (for complex item layouts) is that it measures everything anytime a new item is shown.
So I assume the caching you are talking about is at least not working? (if it is, I am happy to remove that code and the complexity that comes with it)

But: I'm glad you ran into my code coverage when trying to remove this ;-)

/// <summary>
/// Defines the <see cref="BufferFactor"/> property.
/// </summary>
public static readonly StyledProperty<double> BufferFactorProperty =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to consider: WinUI/UWP has a similar concept as this in ItemsRepeater and the properties which control this behavior there are called HorizontalCacheLength and VerticalCacheLength.

As VirtualizingStackPanel can only virtualize on one axis, we don't need two properties but we should consider whether we want to adopt similar naming? We'll do an API review on this at the end anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not have a preference on the naming.
I thing you guys are the owners and should therefore tell me how to name stuff.

That being said: I wanted to use a relative value, therefore called "factor"
Length sounds like a fixed pixel length that is used for caching.

So, we could call it VirtualizationFactor or CachingFactor?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure just yet - we probably need to do a quick API review with the core team.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please do :-)

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch 2 times, most recently from c1d8ee5 to 882fb17 Compare May 15, 2025 11:01
@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056510-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@grokys grokys changed the title Feature/18626 virtualizingstackpanelperf2 Add a buffer cache to containers in VirtualizingStackPanel May 15, 2025
@gentledepp
Copy link
Contributor Author

@grokys I incorporated your feedback.
What is missing:

  1. deciding on the naming of the "BufferFactor" property
  2. if caching the measure-arrange calls really makes sense. Otherwise I will remove these calls

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056512-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056514-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@maxkatz6
Copy link
Member

We are going to have next api review early next month most at earliest.

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from 6c8184c to 6432902 Compare May 21, 2025 10:10
@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056628-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from 6432902 to d2ae1c6 Compare May 21, 2025 11:22
@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056630-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@grokys
Copy link
Member

grokys commented May 22, 2025

Hi @gentledepp - we did a quick mini API review and the best name we could collectively think of was CacheLength:

  • Mainly because of the precedent of HorizontalCacheLength and VerticalCacheLength in WinUI/UWP
  • It's true that "factor" is a nice descriptor, but we prioritize consistency with other XAML frameworks where their naming isn't completely off

What do you think? If you'd like a second opinion we can wait until @MrJul gets back from his vacation and do one of our proper reviews.

@gentledepp
Copy link
Contributor Author

I totally disagree.
Basically, keeping names consistent makes total sense.
However, in WinUI/UWP those Lengths are defined as:

Gets or sets a value that indicates the size of the buffer used to realize items when panning or scrolling vertically.
So this is a size in pixels I assume.

My implementation, on the other hand, uses a factor to relatively calculate the area of additionally cached items.
So CacheFactor seems to be a more suitable name.

Why would you even keep a consistent naming? I guess: To make it easier to developers coming from WinUI and UWP to get used to Avalonia.
Now, using CacheLength will cause the opposite: Developers will enter a fixed number (e.g. 100) and be "surprised" that it behaves differently.

Should I be, however, wrong and the VerticalCacheLength and HorizontalCacheLength properties are also factors in UWP and WinUI, your name is absolutely the best choice.

That being said: It is your framework - so you decide. ;-)

@grokys
Copy link
Member

grokys commented May 22, 2025

Should I be, however, wrong and the VerticalCacheLength and HorizontalCacheLength properties are also factors in UWP and WinUI, your name is absolutely the best choice.

They are factors unless I'm mistaken?

https://learn.microsoft.com/en-us/windows/winui/api/microsoft.ui.xaml.controls.itemsrepeater.verticalcachelength?view=winui-2.8

A non-negative value that indicates the size of the buffer as a multiple of the viewport size. The default value is determined by the system. (emphasis added)

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from d2ae1c6 to aa418f5 Compare May 27, 2025 06:28
@gentledepp
Copy link
Contributor Author

thanks for clarifying... a terrible name still in my humble opinion, but it means the names should align.
So I renamed all mentions of BufferFactor to CacheLength. ;-)

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0056733-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added api-approved The new public APIs have been approved. and removed needs-api-review The PR adds new public APIs that should be reviewed. labels May 31, 2025
@gentledepp
Copy link
Contributor Author

@grokys has been a while already. Any plans to merge this PR? 😅

@danwalmsley
Copy link
Member

@gentledepp I noticed the code dealing with “UnchangedElements” to skip measuring for items that were previously visible was removed, was this not needed in the end?

@gentledepp
Copy link
Contributor Author

Yes it was not needed. I assumed that since the ItemsControl re-measures and arranges all its visible children when the scroll offset changes, that this might cause a performance impact.

However, I was told (and I also re-checked) by the makers of avalonia, that those items cache their measurements in case the available size does not change (which it doesn't when you scroll)

@danwalmsley
Copy link
Member

danwalmsley commented Jun 11, 2025

@gentledepp is my understanding correct that the performance improvement here is because previously it would do recycling as each item moved passed the viewport, but now it doesnt even have to do any recycling until it scrolls 25% of the items (if 25% were before and 25% after) meaning its doing the recycling in say chunks of say 10 items at once. meaning that you have to run that code less often. (Essentially it ends up doing the recycling in small could you call them pages?)

I understand the measure is cached… but why cant the non-visible element arrange be skipped?

// Always arrange the element, even if it's outside the visible viewport
                         ArrangeElement(e, rect);

BTW im asking these questions with a view to port your optimizations over to TreeDataGrid, its code is more or less based on this same code too.

Copy link
Member

@danwalmsley danwalmsley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found an unused field?

@gentledepp
Copy link
Contributor Author

gentledepp commented Jun 11, 2025

@gentledepp is my understanding correct that the performance improvement here is because previously it would do recycling as each item moved passed the viewport, but now it doesnt even have to do any recycling until it scrolls 25% of the items (if 25% were before and 25% after) meaning its doing the recycling in say chunks of say 10 items at once. meaning that you have to run that code less often. (Essentially it ends up doing the recycling in small could you call them pages?)

yes it is.
Especially on Android I noted that every measure/arrange causes a lot of GC activity which in the end kills the scrolling performance. So I tried to reduce these calls as much as possible

I understand the measure is cached… but why cant the non-visible element arrange be skipped?

// Always arrange the element, even if it's outside the visible viewport
                         ArrangeElement(e, rect);

BTW im asking these questions with a view to port your optimizations over to TreeDataGrid, its code is more or less based on this same code too.

Interesting question. Would that make a huge difference?
If so, skipping would not be hard to implement.
I still have a branch containing those "measure/arrange" cachings

@TomEdwardsEnscape
Copy link
Contributor

I have two points I'd like to raise.

Caching strategies

I implemented caching in a panel I created for my product. But it doesn't work like this:

582e5fb9-7a37-48fa-9bb5-77f7da7f354c

Instead it continues to realise controls one-by-one. It just does so outside the viewport area. This is because each time an item is realised in this panel we start asynchronously loading data from an external source (either a web service or a local cache), and this always takes at least one frame to complete, often many more. With the caching strategy I implemented we can generally avoid the user seeing items before they have finished their async loading process.

The strategy demonstrated in the image above is is of little use for the problem my product needs to solve because the cache "buffer" frequently runs out. Then the user starts seeing items before they have fully loaded. You can reduce how frequently the buffer runs out by increasing the cache length, but this is costly as more and more data need to be loaded as the cache lengthens, and it will still hit zero every so often.

Is it not enough to avoid re-measuring controls that weren't created/recycled? Do we really reduce overhead within each individual measure call by executing more of them in the same layout pass?

Cache Length Unit

WPF lets us configure the unit of cache length. You can use Item, set an integer cache length, and know that this means that exactly N items will be cached. This is very useful when using logical scrolling. Can support for this be added?

@grokys
Copy link
Member

grokys commented Jun 25, 2025

@TomEdwardsEnscape re: your comments:

Caching strategies

I think you meant to share a screenshot but nothing is showing up. Anyway I think what you're describing addresses a different use-case. This PR is meant to solve the common problem of scrolling stuttering in a fully-loaded list of items.

Is it not enough to avoid re-measuring controls that weren't created/recycled?

I believe the problem is not re-measuring of controls that weren't created/recycled, and instead the fact that every e.g. 25 pixels, a new container is realised, which triggers a layout pass. From a previous comment from the author: "Especially on Android I noted that every measure/arrange causes a lot of GC activity which in the end kills the scrolling performance."

Cache Length Unit

What do you mean by logical scrolling here? This feature is implemented on VirtualizingStackPanel which handles its own virtualization and doesn't implement ILogicalScrollable.

@gentledepp
Copy link
Contributor Author

@TomEdwardsEnscape this sound a bit confusing to me.
Why not do the obvious:

  • for create viewmodels, and asynchronously ensure their data is loaded compeltely
  • thereafter add it to the "ItemsSource"

am I missing something? 😅

Anyways, this is about something completely different as @grokys pointed out. 😅

@gentledepp
Copy link
Contributor Author

@gentledepp is my understanding correct that the performance improvement here is because previously it would do recycling as each item moved passed the viewport, but now it doesnt even have to do any recycling until it scrolls 25% of the items (if 25% were before and 25% after) meaning its doing the recycling in say chunks of say 10 items at once. meaning that you have to run that code less often. (Essentially it ends up doing the recycling in small could you call them pages?)

I understand the measure is cached… but why cant the non-visible element arrange be skipped?

// Always arrange the element, even if it's outside the visible viewport
                         ArrangeElement(e, rect);

BTW im asking these questions with a view to port your optimizations over to TreeDataGrid, its code is more or less based on this same code too.

So initially, ma plan was to cache any measure arrange calls on all the items that have not been changed (please look at the animated gif in the description)

@grokys pointed out, that caching "Measure" calls makes no sense, since the visuals themselves cache their measurements in case the size provided does not change.
Also, Arrange calls are cached to if the size does not change

Still, I re-added the tests to verify that, so that we cannot accidentally change the VirtualizingStackPanel in a way that causes its items to need re-measuring and re-arranging while scrolling

@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from aa418f5 to d00dd90 Compare June 27, 2025 09:28
…kPanel.cs by reducing Measure/Arrange calls since they cause heavy GC pressure on constrained devices (Android, iOS) especially with complex item views
@gentledepp gentledepp force-pushed the feature/18626_virtualizingstackpanelperf2 branch from d00dd90 to ae456a1 Compare June 27, 2025 09:31
@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0057349-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@TomEdwardsEnscape
Copy link
Contributor

@TomEdwardsEnscape this sound a bit confusing to me. Why not do the obvious:

  • for create viewmodels, and asynchronously ensure their data is loaded compeltely
  • thereafter add it to the "ItemsSource"

am I missing something? 😅

This defeats the purpose of virtualisation. We don't want to download 50 query response pages and 5000 thumbnails from the internet before we display anything to the user, any more than we want to immediately generate 5000 containers.

It seems from your other comments that "not measuring" the other containers isn't actually doing anything, so the performance gains do indeed come entirely from batching up container generation. I don't see a way to unify behaviour to satisfy both sets of requirements given this. We'll just have to use different panels for the different scenarios.

What do you mean by logical scrolling here? This feature is implemented on VirtualizingStackPanel which handles its own virtualization and doesn't implement ILogicalScrollable.

Logical scrolling is just an example of a scenario in which caching by item becomes more important. But it's always useful IMO.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved The new public APIs have been approved. area-perf enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants