Skip to content

Commit

Permalink
[controls] improve perf of "merged" ResourceDictionary lookups (#21334)
Browse files Browse the repository at this point in the history
Applies to: #18505
Context: https://github.com/dotnet/maui/files/13251041/MauiCollectionView.zip

I profiled the above sample with `dotnet-trace` with the following PRs
applied locally:

* #21229
* #21291

While scrolling, a lot of time is spent in `ResourceDictionary`
lookups on an Android Pixel 5 device:

    2.0% Microsoft.Maui.Controls!Microsoft.Maui.Controls.ResourceDictionary.TryGetValue(string,object&)

Drilling in, I can see System.Linq's `Reverse()` method:

    0.56% System.Linq!System.Linq.Enumerable.ReverseIterator<TSource_REF>.MoveNext()
    0.14% System.Linq!System.Linq.Enumerable.Reverse(System.Collections.Generic.IEnumerable`1<TSource_REF>)
    0.04% System.Linq!System.Linq.Enumerable.ReverseIterator<TSource_REF>..ctor(System.Collections.Generic.IEnumerable`1<TSource_REF>)
    0.04% System.Linq!System.Linq.Enumerable.ReverseIterator<TSource_REF>.Dispose()

`Reverse()` can be problematic as it can sometimes create a copy of
the entire collection, in order to sort in reverse. We can juse use a
reverse `for`-loop instead.

The indexer, we can also avoid a double-lookup:

    if (dict.ContainsKey(index))
        return dict[index];

And instead do:

    if (dict.TryGetValue(index, out var value))
        return value;

The MAUI project template seems to setup a few "merged"
`ResourceDictionary` as it contains `Styles.xaml`, so this is why this
code path is being hit.

I wrote a BenchmarkDotNet benchmark, and it indicates the collection
is being copied, as the 872 bytes of allocation occur:

| Method      | key         | Mean      | Error    | StdDev   | Gen0   | Allocated |
|------------ |------------ |----------:|---------:|---------:|-------:|----------:|
| TryGetValue | key0        |  11.45 ns | 0.026 ns | 0.023 ns |      - |         - |
| Indexer     | key0        |  24.72 ns | 0.133 ns | 0.118 ns |      - |         - |
| TryGetValue | merged99,99 | 117.06 ns | 2.334 ns | 2.497 ns | 0.1042 |     872 B |
| Indexer     | merged99,99 | 145.60 ns | 2.737 ns | 2.286 ns | 0.1042 |     872 B |

With these changes in place, I see less time spent inside:

    0.91% Microsoft.Maui.Controls!Microsoft.Maui.Controls.ResourceDictionary.TryGetValue(string,object&)

The benchmark no longer allocates either:

| Method      | key         | Mean      | Error     | StdDev    | Allocated |
|------------ |------------ |----------:|----------:|----------:|----------:|
| TryGetValue | key0        |  11.92 ns |  0.094 ns |  0.084 ns |         - |
| Indexer     | merged99,99 |  23.12 ns |  0.418 ns |  0.391 ns |         - |
| Indexer     | key0        |  24.20 ns |  0.485 ns |  0.453 ns |         - |
| TryGetValue | merged99,99 |  29.09 ns |  0.296 ns |  0.262 ns |         - |

This should improve the performance "parenting" of any MAUI view on
all platforms -- as well as scrolling `CollectionView`.
  • Loading branch information
jonathanpeppers committed Mar 27, 2024
1 parent 83960b0 commit 8ca7648
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 4 deletions.
20 changes: 16 additions & 4 deletions src/Controls/src/Core/ResourceDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,17 @@ public bool ContainsKey(string key)
if (_mergedInstance != null && _mergedInstance.ContainsKey(index))
return _mergedInstance[index];
if (_mergedDictionaries != null)
foreach (var dict in MergedDictionaries.Reverse())
if (dict.ContainsKey(index))
return dict[index];
{
var dictionaries = (ObservableCollection<ResourceDictionary>)MergedDictionaries;
for (int i = dictionaries.Count - 1; i >= 0 ; i--)
{
if (dictionaries[i].TryGetValue(index, out var value))
{
return value;
}
}
}

throw new KeyNotFoundException($"The resource '{index}' is not present in the dictionary.");
}
set
Expand Down Expand Up @@ -286,12 +294,16 @@ internal bool TryGetValueAndSource(string key, out object value, out ResourceDic

bool TryGetMergedDictionaryValue(string key, out object value, out ResourceDictionary source)
{
foreach (var dictionary in MergedDictionaries.Reverse())
var dictionaries = (ObservableCollection<ResourceDictionary>)MergedDictionaries;
for (int i = dictionaries.Count - 1; i >= 0 ; i--)
{
var dictionary = dictionaries[i];
if (dictionary.TryGetValue(key, out value))
{
source = dictionary;
return true;
}
}

value = null;
source = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using Microsoft.Maui.Controls;

namespace Microsoft.Maui.Handlers.Benchmarks
{
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class ResourceDictionaryBenchmarker
{
const int Size = 100;
readonly ResourceDictionary _resourceDictionary;

public ResourceDictionaryBenchmarker()
{
_resourceDictionary = new ResourceDictionary();
for (var i = 0; i < Size; i++)
{
_resourceDictionary.Add($"key{i}", i);
}

for (var j = 0; j < Size; j++)
{
var merged = new ResourceDictionary();
for (var i = 0; i < Size; i++)
{
merged.Add($"merged{i},{j}", i);
}
_resourceDictionary.MergedDictionaries.Add(merged);
}
}

[Benchmark]
[Arguments("key0")]
[Arguments("merged50,50")]
[Arguments("merged99,99")]
public void TryGetValue(string key) => _resourceDictionary.TryGetValue(key, out _);

[Benchmark]
[Arguments("key0")]
[Arguments("merged50,50")]
[Arguments("merged99,99")]
public void Indexer(string key) => _ = _resourceDictionary[key];
}
}

0 comments on commit 8ca7648

Please sign in to comment.