From 9f8df77deca05afe0937405d804963c536dd6232 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 16:42:10 -0300 Subject: [PATCH 01/21] add benchmark --- .../PropertyChangePropagationBenchmarker.cs | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs diff --git a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs new file mode 100644 index 000000000000..395a8e3dafa9 --- /dev/null +++ b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs @@ -0,0 +1,302 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Benchmarks +{ + /// + /// Benchmarks that measure the time and allocations caused by property-change + /// propagation on the three most commonly used text-based controls: Label, Button + /// and Entry. Only properties that exist on all three controls are exercised so + /// the numbers are directly comparable. + /// + /// Common properties tested: + /// - Text (string) + /// - TextColor (Color) + /// - FontSize (double – triggers InvalidateMeasure internally) + /// - FontAttributes (enum) + /// - IsEnabled (bool – coerced through the visual tree) + /// - Opacity (double – coerced to [0,1]) + /// + /// Each benchmark group is run both without and with a PropertyChanged subscriber + /// so you can isolate the cost of the notification-dispatch leg. + /// + [MemoryDiagnoser] + public class PropertyChangePropagationBenchmarker + { + // Enough iterations to keep BenchmarkDotNet happy (>100ms per benchmark). + const int Iterations = 1_000; + + // Pre-allocate controls outside the benchmark methods so construction cost + // is excluded and only the property-change propagation is measured. + Label _label; + Button _button; + Entry _entry; + + Label _labelWithSubscriber; + Button _buttonWithSubscriber; + Entry _entryWithSubscriber; + + // Two alternating values per property type keep the BindableObject from + // short-circuiting the change via its value-equality check. + static readonly Color _colorA = Colors.Red; + static readonly Color _colorB = Colors.Blue; + + [GlobalSetup] + public void Setup() + { + _label = new Label(); + _button = new Button(); + _entry = new Entry(); + + _labelWithSubscriber = new Label(); + _buttonWithSubscriber = new Button(); + _entryWithSubscriber = new Entry(); + + // Attach a lightweight subscriber to simulate real-world usage where + // the UI (or a binding) listens to property changes. + static void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { } + + _labelWithSubscriber.PropertyChanged += OnPropertyChanged; + _buttonWithSubscriber.PropertyChanged += OnPropertyChanged; + _entryWithSubscriber.PropertyChanged += OnPropertyChanged; + } + + // ------------------------------------------------------------------------- + // Text property (string) + // ------------------------------------------------------------------------- + + [Benchmark] + public void Label_SetText() + { + for (int i = 0; i < Iterations; i++) + { + _label.Text = "Hello World A"; + _label.Text = "Hello World B"; + } + } + + [Benchmark] + public void Button_SetText() + { + for (int i = 0; i < Iterations; i++) + { + _button.Text = "Hello World A"; + _button.Text = "Hello World B"; + } + } + + [Benchmark] + public void Entry_SetText() + { + for (int i = 0; i < Iterations; i++) + { + _entry.Text = "Hello World A"; + _entry.Text = "Hello World B"; + } + } + + // ------------------------------------------------------------------------- + // TextColor property (Color) + // ------------------------------------------------------------------------- + + [Benchmark] + public void Label_SetTextColor() + { + for (int i = 0; i < Iterations; i++) + { + _label.TextColor = _colorA; + _label.TextColor = _colorB; + } + } + + [Benchmark] + public void Button_SetTextColor() + { + for (int i = 0; i < Iterations; i++) + { + _button.TextColor = _colorA; + _button.TextColor = _colorB; + } + } + + [Benchmark] + public void Entry_SetTextColor() + { + for (int i = 0; i < Iterations; i++) + { + _entry.TextColor = _colorA; + _entry.TextColor = _colorB; + } + } + + // ------------------------------------------------------------------------- + // FontSize property (double – triggers InvalidateMeasure on Button/Label) + // ------------------------------------------------------------------------- + + [Benchmark] + public void Label_SetFontSize() + { + for (int i = 0; i < Iterations; i++) + { + _label.FontSize = 16; + _label.FontSize = 18; + } + } + + [Benchmark] + public void Button_SetFontSize() + { + for (int i = 0; i < Iterations; i++) + { + _button.FontSize = 16; + _button.FontSize = 18; + } + } + + [Benchmark] + public void Entry_SetFontSize() + { + for (int i = 0; i < Iterations; i++) + { + _entry.FontSize = 16; + _entry.FontSize = 18; + } + } + + // ------------------------------------------------------------------------- + // Multiple common properties in one pass (composite benchmark) + // ------------------------------------------------------------------------- + + [Benchmark] + public void Label_SetCommonProperties() + { + for (int i = 0; i < Iterations; i++) + { + _label.Text = "A"; + _label.TextColor = _colorA; + _label.FontSize = 14; + _label.FontAttributes = FontAttributes.Bold; + _label.IsEnabled = false; + _label.Opacity = 0.5; + + _label.Text = "B"; + _label.TextColor = _colorB; + _label.FontSize = 16; + _label.FontAttributes = FontAttributes.None; + _label.IsEnabled = true; + _label.Opacity = 1.0; + } + } + + [Benchmark] + public void Button_SetCommonProperties() + { + for (int i = 0; i < Iterations; i++) + { + _button.Text = "A"; + _button.TextColor = _colorA; + _button.FontSize = 14; + _button.FontAttributes = FontAttributes.Bold; + _button.IsEnabled = false; + _button.Opacity = 0.5; + + _button.Text = "B"; + _button.TextColor = _colorB; + _button.FontSize = 16; + _button.FontAttributes = FontAttributes.None; + _button.IsEnabled = true; + _button.Opacity = 1.0; + } + } + + [Benchmark] + public void Entry_SetCommonProperties() + { + for (int i = 0; i < Iterations; i++) + { + _entry.Text = "A"; + _entry.TextColor = _colorA; + _entry.FontSize = 14; + _entry.FontAttributes = FontAttributes.Bold; + _entry.IsEnabled = false; + _entry.Opacity = 0.5; + + _entry.Text = "B"; + _entry.TextColor = _colorB; + _entry.FontSize = 16; + _entry.FontAttributes = FontAttributes.None; + _entry.IsEnabled = true; + _entry.Opacity = 1.0; + } + } + + // ------------------------------------------------------------------------- + // Same composite benchmark – with a PropertyChanged subscriber attached. + // Compares the propagation overhead vs. the no-subscriber variants above. + // ------------------------------------------------------------------------- + + [Benchmark] + public void Label_SetCommonProperties_WithSubscriber() + { + for (int i = 0; i < Iterations; i++) + { + _labelWithSubscriber.Text = "A"; + _labelWithSubscriber.TextColor = _colorA; + _labelWithSubscriber.FontSize = 14; + _labelWithSubscriber.FontAttributes = FontAttributes.Bold; + _labelWithSubscriber.IsEnabled = false; + _labelWithSubscriber.Opacity = 0.5; + + _labelWithSubscriber.Text = "B"; + _labelWithSubscriber.TextColor = _colorB; + _labelWithSubscriber.FontSize = 16; + _labelWithSubscriber.FontAttributes = FontAttributes.None; + _labelWithSubscriber.IsEnabled = true; + _labelWithSubscriber.Opacity = 1.0; + } + } + + [Benchmark] + public void Button_SetCommonProperties_WithSubscriber() + { + for (int i = 0; i < Iterations; i++) + { + _buttonWithSubscriber.Text = "A"; + _buttonWithSubscriber.TextColor = _colorA; + _buttonWithSubscriber.FontSize = 14; + _buttonWithSubscriber.FontAttributes = FontAttributes.Bold; + _buttonWithSubscriber.IsEnabled = false; + _buttonWithSubscriber.Opacity = 0.5; + + _buttonWithSubscriber.Text = "B"; + _buttonWithSubscriber.TextColor = _colorB; + _buttonWithSubscriber.FontSize = 16; + _buttonWithSubscriber.FontAttributes = FontAttributes.None; + _buttonWithSubscriber.IsEnabled = true; + _buttonWithSubscriber.Opacity = 1.0; + } + } + + [Benchmark] + public void Entry_SetCommonProperties_WithSubscriber() + { + for (int i = 0; i < Iterations; i++) + { + _entryWithSubscriber.Text = "A"; + _entryWithSubscriber.TextColor = _colorA; + _entryWithSubscriber.FontSize = 14; + _entryWithSubscriber.FontAttributes = FontAttributes.Bold; + _entryWithSubscriber.IsEnabled = false; + _entryWithSubscriber.Opacity = 0.5; + + _entryWithSubscriber.Text = "B"; + _entryWithSubscriber.TextColor = _colorB; + _entryWithSubscriber.FontSize = 16; + _entryWithSubscriber.FontAttributes = FontAttributes.None; + _entryWithSubscriber.IsEnabled = true; + _entryWithSubscriber.Opacity = 1.0; + } + } + } +} From e6a017e2ae445da4da6b446e962045b2f85398d7 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 19:14:21 -0300 Subject: [PATCH 02/21] implement caches --- .../src/Core/Internals/CacheWithSwitch.cs | 43 ++++++++++ src/Controls/src/Core/Internals/ICache.cs | 6 ++ .../src/Core/Internals/LRUBrushCache.cs | 79 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Controls/src/Core/Internals/CacheWithSwitch.cs create mode 100644 src/Controls/src/Core/Internals/ICache.cs create mode 100644 src/Controls/src/Core/Internals/LRUBrushCache.cs diff --git a/src/Controls/src/Core/Internals/CacheWithSwitch.cs b/src/Controls/src/Core/Internals/CacheWithSwitch.cs new file mode 100644 index 000000000000..ff9ec5ff8a12 --- /dev/null +++ b/src/Controls/src/Core/Internals/CacheWithSwitch.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Controls.Internals; + +sealed class CacheWithSwitch : ICache +{ + ICache _cache; + + public CacheWithSwitch(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + + _cache = new SimpleCache(capacity); + } + + private class SimpleCache(int capacity) : ICache + { + readonly Dictionary _dict = []; + public bool IsAtCapacity => _dict.Count == capacity; + + public ImmutableBrush Get(Color key) + { + ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(_dict, key, out _); + value ??= new ImmutableBrush(key); + return value; + } + + public LRUBrushCache Promote() => new(capacity, _dict); + } + + public ImmutableBrush Get(Color key) + { + if (_cache is SimpleCache { IsAtCapacity: true} simple) + { + _cache = simple.Promote(); + } + + return _cache.Get(key); + } +} diff --git a/src/Controls/src/Core/Internals/ICache.cs b/src/Controls/src/Core/Internals/ICache.cs new file mode 100644 index 000000000000..2cb9a24103a2 --- /dev/null +++ b/src/Controls/src/Core/Internals/ICache.cs @@ -0,0 +1,6 @@ +namespace Microsoft.Maui.Controls.Internals; + +interface ICache +{ + TValue Get(TKey key); +} diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs new file mode 100644 index 000000000000..9a2ff47402c1 --- /dev/null +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Controls.Internals; + +/// +/// Provides a small, fixed-capacity least-recently-used (LRU) cache for instances, +/// keyed by . +/// + +sealed class LRUBrushCache : ICache +{ + /// + /// Creates a new instance of + /// + /// /// + /// This cache helps reduce allocations by reusing instances for frequently used colors. + /// When the cache exceeds , the least-recently accessed entry is evicted. + /// This type is not thread-safe. + /// + /// The maximum number of cached brushes to keep. + /// throws if argument is zero or negative. + public LRUBrushCache(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + + _capacity = capacity; + } + + public LRUBrushCache(int capacity, Dictionary brushes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + ArgumentNullException.ThrowIfNull(brushes); + + if (brushes.Count > capacity) + { + throw new ArgumentException("Brush count must not exceed capacity.", nameof(brushes)); + } + + _capacity = capacity; + _cache = new Dictionary>(capacity); + _lru = []; + + foreach (var (color, brush) in brushes) + { + var node = _lru.AddFirst(brush); + _cache.Add(color, node); + } + } + + readonly Dictionary> _cache = []; + readonly LinkedList _lru = []; + readonly int _capacity; + + public ImmutableBrush Get(Color key) + { + if (_cache.TryGetValue(key, out var node)) + { + _lru.Remove(node); + _lru.AddFirst(node); + return node.Value; + } + + var brush = new ImmutableBrush(key); + + if (_cache.Count >= _capacity) + { + var last = _lru.Last!; + _lru.RemoveLast(); + _cache.Remove(last.Value.Color); + } + + var newNode = _lru.AddFirst(brush); + _cache[key] = newNode; + + return brush; + } +} From 62f7add486f366c80699d6a61979796592fe5f80 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 19:25:34 -0300 Subject: [PATCH 03/21] use cache to return a new ImmutableBrush --- src/Controls/src/Core/Brush/Brush.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index d218e4fe01e1..ec3c674e9c6f 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -1,4 +1,5 @@ #nullable disable +using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using GraphicsGradientStop = Microsoft.Maui.Graphics.PaintGradientStop; @@ -11,10 +12,15 @@ namespace Microsoft.Maui.Controls [System.ComponentModel.TypeConverter(typeof(BrushTypeConverter))] public abstract partial class Brush : Element { + static readonly ICache _cache = new CacheWithSwitch(50); + public static implicit operator Brush(Paint paint) { if (paint is SolidPaint solidPaint) - return new SolidColorBrush { Color = solidPaint.Color }; + { + return _cache.Get(solidPaint.Color); + } + if (paint is GradientPaint gradientPaint) { @@ -100,7 +106,7 @@ public static implicit operator Paint(Brush brush) /// public static Brush Default => defaultBrush ??= new(null); - public static implicit operator Brush(Color color) => new SolidColorBrush(color); + public static implicit operator Brush(Color color) => _cache.Get(color); /// /// When overridden in a derived class, indicates whether the given brush represents the empty brush. From 4cde161783799544bebb6cc6d0b1f53a679548df Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 21:17:38 -0300 Subject: [PATCH 04/21] remove Iterations on benchmark code --- .../PropertyChangePropagationBenchmarker.cs | 243 +++++++----------- 1 file changed, 100 insertions(+), 143 deletions(-) diff --git a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs index 395a8e3dafa9..4ac2e35a70cd 100644 --- a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs +++ b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs @@ -24,8 +24,6 @@ namespace Microsoft.Maui.Benchmarks [MemoryDiagnoser] public class PropertyChangePropagationBenchmarker { - // Enough iterations to keep BenchmarkDotNet happy (>100ms per benchmark). - const int Iterations = 1_000; // Pre-allocate controls outside the benchmark methods so construction cost // is excluded and only the property-change propagation is measured. @@ -53,13 +51,14 @@ public void Setup() _buttonWithSubscriber = new Button(); _entryWithSubscriber = new Entry(); - // Attach a lightweight subscriber to simulate real-world usage where - // the UI (or a binding) listens to property changes. - static void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { } - _labelWithSubscriber.PropertyChanged += OnPropertyChanged; _buttonWithSubscriber.PropertyChanged += OnPropertyChanged; _entryWithSubscriber.PropertyChanged += OnPropertyChanged; + + // Attach a lightweight subscriber to simulate real-world usage where + // the UI (or a binding) listens to property changes. + static void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { } } // ------------------------------------------------------------------------- @@ -69,31 +68,22 @@ static void OnPropertyChanged(object sender, System.ComponentModel.PropertyChang [Benchmark] public void Label_SetText() { - for (int i = 0; i < Iterations; i++) - { - _label.Text = "Hello World A"; - _label.Text = "Hello World B"; - } + _label.Text = "Hello World A"; + _label.Text = "Hello World B"; } [Benchmark] public void Button_SetText() { - for (int i = 0; i < Iterations; i++) - { - _button.Text = "Hello World A"; - _button.Text = "Hello World B"; - } + _button.Text = "Hello World A"; + _button.Text = "Hello World B"; } [Benchmark] public void Entry_SetText() { - for (int i = 0; i < Iterations; i++) - { - _entry.Text = "Hello World A"; - _entry.Text = "Hello World B"; - } + _entry.Text = "Hello World A"; + _entry.Text = "Hello World B"; } // ------------------------------------------------------------------------- @@ -103,7 +93,7 @@ public void Entry_SetText() [Benchmark] public void Label_SetTextColor() { - for (int i = 0; i < Iterations; i++) + // for (int i = 0; i < Iterations; i++) { _label.TextColor = _colorA; _label.TextColor = _colorB; @@ -113,21 +103,15 @@ public void Label_SetTextColor() [Benchmark] public void Button_SetTextColor() { - for (int i = 0; i < Iterations; i++) - { - _button.TextColor = _colorA; - _button.TextColor = _colorB; - } + _button.TextColor = _colorA; + _button.TextColor = _colorB; } [Benchmark] public void Entry_SetTextColor() { - for (int i = 0; i < Iterations; i++) - { - _entry.TextColor = _colorA; - _entry.TextColor = _colorB; - } + _entry.TextColor = _colorA; + _entry.TextColor = _colorB; } // ------------------------------------------------------------------------- @@ -137,31 +121,22 @@ public void Entry_SetTextColor() [Benchmark] public void Label_SetFontSize() { - for (int i = 0; i < Iterations; i++) - { - _label.FontSize = 16; - _label.FontSize = 18; - } + _label.FontSize = 16; + _label.FontSize = 18; } [Benchmark] public void Button_SetFontSize() { - for (int i = 0; i < Iterations; i++) - { - _button.FontSize = 16; - _button.FontSize = 18; - } + _button.FontSize = 16; + _button.FontSize = 18; } [Benchmark] public void Entry_SetFontSize() { - for (int i = 0; i < Iterations; i++) - { - _entry.FontSize = 16; - _entry.FontSize = 18; - } + _entry.FontSize = 16; + _entry.FontSize = 18; } // ------------------------------------------------------------------------- @@ -171,64 +146,55 @@ public void Entry_SetFontSize() [Benchmark] public void Label_SetCommonProperties() { - for (int i = 0; i < Iterations; i++) - { - _label.Text = "A"; - _label.TextColor = _colorA; - _label.FontSize = 14; - _label.FontAttributes = FontAttributes.Bold; - _label.IsEnabled = false; - _label.Opacity = 0.5; - - _label.Text = "B"; - _label.TextColor = _colorB; - _label.FontSize = 16; - _label.FontAttributes = FontAttributes.None; - _label.IsEnabled = true; - _label.Opacity = 1.0; - } + _label.Text = "A"; + _label.TextColor = _colorA; + _label.FontSize = 14; + _label.FontAttributes = FontAttributes.Bold; + _label.IsEnabled = false; + _label.Opacity = 0.5; + + _label.Text = "B"; + _label.TextColor = _colorB; + _label.FontSize = 16; + _label.FontAttributes = FontAttributes.None; + _label.IsEnabled = true; + _label.Opacity = 1.0; } [Benchmark] public void Button_SetCommonProperties() { - for (int i = 0; i < Iterations; i++) - { - _button.Text = "A"; - _button.TextColor = _colorA; - _button.FontSize = 14; - _button.FontAttributes = FontAttributes.Bold; - _button.IsEnabled = false; - _button.Opacity = 0.5; - - _button.Text = "B"; - _button.TextColor = _colorB; - _button.FontSize = 16; - _button.FontAttributes = FontAttributes.None; - _button.IsEnabled = true; - _button.Opacity = 1.0; - } + _button.Text = "A"; + _button.TextColor = _colorA; + _button.FontSize = 14; + _button.FontAttributes = FontAttributes.Bold; + _button.IsEnabled = false; + _button.Opacity = 0.5; + + _button.Text = "B"; + _button.TextColor = _colorB; + _button.FontSize = 16; + _button.FontAttributes = FontAttributes.None; + _button.IsEnabled = true; + _button.Opacity = 1.0; } [Benchmark] public void Entry_SetCommonProperties() { - for (int i = 0; i < Iterations; i++) - { - _entry.Text = "A"; - _entry.TextColor = _colorA; - _entry.FontSize = 14; - _entry.FontAttributes = FontAttributes.Bold; - _entry.IsEnabled = false; - _entry.Opacity = 0.5; - - _entry.Text = "B"; - _entry.TextColor = _colorB; - _entry.FontSize = 16; - _entry.FontAttributes = FontAttributes.None; - _entry.IsEnabled = true; - _entry.Opacity = 1.0; - } + _entry.Text = "A"; + _entry.TextColor = _colorA; + _entry.FontSize = 14; + _entry.FontAttributes = FontAttributes.Bold; + _entry.IsEnabled = false; + _entry.Opacity = 0.5; + + _entry.Text = "B"; + _entry.TextColor = _colorB; + _entry.FontSize = 16; + _entry.FontAttributes = FontAttributes.None; + _entry.IsEnabled = true; + _entry.Opacity = 1.0; } // ------------------------------------------------------------------------- @@ -239,64 +205,55 @@ public void Entry_SetCommonProperties() [Benchmark] public void Label_SetCommonProperties_WithSubscriber() { - for (int i = 0; i < Iterations; i++) - { - _labelWithSubscriber.Text = "A"; - _labelWithSubscriber.TextColor = _colorA; - _labelWithSubscriber.FontSize = 14; - _labelWithSubscriber.FontAttributes = FontAttributes.Bold; - _labelWithSubscriber.IsEnabled = false; - _labelWithSubscriber.Opacity = 0.5; - - _labelWithSubscriber.Text = "B"; - _labelWithSubscriber.TextColor = _colorB; - _labelWithSubscriber.FontSize = 16; - _labelWithSubscriber.FontAttributes = FontAttributes.None; - _labelWithSubscriber.IsEnabled = true; - _labelWithSubscriber.Opacity = 1.0; - } + _labelWithSubscriber.Text = "A"; + _labelWithSubscriber.TextColor = _colorA; + _labelWithSubscriber.FontSize = 14; + _labelWithSubscriber.FontAttributes = FontAttributes.Bold; + _labelWithSubscriber.IsEnabled = false; + _labelWithSubscriber.Opacity = 0.5; + + _labelWithSubscriber.Text = "B"; + _labelWithSubscriber.TextColor = _colorB; + _labelWithSubscriber.FontSize = 16; + _labelWithSubscriber.FontAttributes = FontAttributes.None; + _labelWithSubscriber.IsEnabled = true; + _labelWithSubscriber.Opacity = 1.0; } [Benchmark] public void Button_SetCommonProperties_WithSubscriber() { - for (int i = 0; i < Iterations; i++) - { - _buttonWithSubscriber.Text = "A"; - _buttonWithSubscriber.TextColor = _colorA; - _buttonWithSubscriber.FontSize = 14; - _buttonWithSubscriber.FontAttributes = FontAttributes.Bold; - _buttonWithSubscriber.IsEnabled = false; - _buttonWithSubscriber.Opacity = 0.5; - - _buttonWithSubscriber.Text = "B"; - _buttonWithSubscriber.TextColor = _colorB; - _buttonWithSubscriber.FontSize = 16; - _buttonWithSubscriber.FontAttributes = FontAttributes.None; - _buttonWithSubscriber.IsEnabled = true; - _buttonWithSubscriber.Opacity = 1.0; - } + _buttonWithSubscriber.Text = "A"; + _buttonWithSubscriber.TextColor = _colorA; + _buttonWithSubscriber.FontSize = 14; + _buttonWithSubscriber.FontAttributes = FontAttributes.Bold; + _buttonWithSubscriber.IsEnabled = false; + _buttonWithSubscriber.Opacity = 0.5; + + _buttonWithSubscriber.Text = "B"; + _buttonWithSubscriber.TextColor = _colorB; + _buttonWithSubscriber.FontSize = 16; + _buttonWithSubscriber.FontAttributes = FontAttributes.None; + _buttonWithSubscriber.IsEnabled = true; + _buttonWithSubscriber.Opacity = 1.0; } [Benchmark] public void Entry_SetCommonProperties_WithSubscriber() { - for (int i = 0; i < Iterations; i++) - { - _entryWithSubscriber.Text = "A"; - _entryWithSubscriber.TextColor = _colorA; - _entryWithSubscriber.FontSize = 14; - _entryWithSubscriber.FontAttributes = FontAttributes.Bold; - _entryWithSubscriber.IsEnabled = false; - _entryWithSubscriber.Opacity = 0.5; - - _entryWithSubscriber.Text = "B"; - _entryWithSubscriber.TextColor = _colorB; - _entryWithSubscriber.FontSize = 16; - _entryWithSubscriber.FontAttributes = FontAttributes.None; - _entryWithSubscriber.IsEnabled = true; - _entryWithSubscriber.Opacity = 1.0; - } + _entryWithSubscriber.Text = "A"; + _entryWithSubscriber.TextColor = _colorA; + _entryWithSubscriber.FontSize = 14; + _entryWithSubscriber.FontAttributes = FontAttributes.Bold; + _entryWithSubscriber.IsEnabled = false; + _entryWithSubscriber.Opacity = 0.5; + + _entryWithSubscriber.Text = "B"; + _entryWithSubscriber.TextColor = _colorB; + _entryWithSubscriber.FontSize = 16; + _entryWithSubscriber.FontAttributes = FontAttributes.None; + _entryWithSubscriber.IsEnabled = true; + _entryWithSubscriber.Opacity = 1.0; } } } From ff4b34913967aeba1ddb0da3d335e470bb9eda8d Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 21:26:13 -0300 Subject: [PATCH 05/21] adjust benchmark to run over the cache --- .../PropertyChangePropagationBenchmarker.cs | 215 +++++------------- 1 file changed, 58 insertions(+), 157 deletions(-) diff --git a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs index 4ac2e35a70cd..36694c2d3385 100644 --- a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs +++ b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs @@ -1,3 +1,4 @@ +using System; using BenchmarkDotNet.Attributes; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; @@ -6,35 +7,27 @@ namespace Microsoft.Maui.Benchmarks { /// /// Benchmarks that measure the time and allocations caused by property-change - /// propagation on the three most commonly used text-based controls: Label, Button - /// and Entry. Only properties that exist on all three controls are exercised so - /// the numbers are directly comparable. + /// propagation on the three most commonly used controls: Label, Button and Entry. + /// All benchmarks attach a PropertyChanged subscriber to simulate real-world scenarios + /// where the UI or bindings listen to property changes. /// - /// Common properties tested: - /// - Text (string) - /// - TextColor (Color) - /// - FontSize (double – triggers InvalidateMeasure internally) - /// - FontAttributes (enum) - /// - IsEnabled (bool – coerced through the visual tree) + /// Properties tested: + /// - HeightRequest (double) + /// - Background (Brush – exercises color-to-brush conversion and caching) + /// - IsEnabled (bool – coerced through the visual tree, uses boxed value caching) /// - Opacity (double – coerced to [0,1]) + /// - FontSize (double – Button only) /// - /// Each benchmark group is run both without and with a PropertyChanged subscriber - /// so you can isolate the cost of the notification-dispatch leg. + /// The Background property is particularly interesting as it exercises implicit Color-to-Brush + /// conversion, which can benefit from brush instance caching. IsEnabled tests boxed bool reuse. /// [MemoryDiagnoser] public class PropertyChangePropagationBenchmarker { - - // Pre-allocate controls outside the benchmark methods so construction cost - // is excluded and only the property-change propagation is measured. Label _label; Button _button; Entry _entry; - Label _labelWithSubscriber; - Button _buttonWithSubscriber; - Entry _entryWithSubscriber; - // Two alternating values per property type keep the BindableObject from // short-circuiting the change via its value-equality check. static readonly Color _colorA = Colors.Red; @@ -47,213 +40,121 @@ public void Setup() _button = new Button(); _entry = new Entry(); - _labelWithSubscriber = new Label(); - _buttonWithSubscriber = new Button(); - _entryWithSubscriber = new Entry(); - - _labelWithSubscriber.PropertyChanged += OnPropertyChanged; - _buttonWithSubscriber.PropertyChanged += OnPropertyChanged; - _entryWithSubscriber.PropertyChanged += OnPropertyChanged; - // Attach a lightweight subscriber to simulate real-world usage where // the UI (or a binding) listens to property changes. static void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) - { } - } - - // ------------------------------------------------------------------------- - // Text property (string) - // ------------------------------------------------------------------------- - - [Benchmark] - public void Label_SetText() - { - _label.Text = "Hello World A"; - _label.Text = "Hello World B"; - } + { - [Benchmark] - public void Button_SetText() - { - _button.Text = "Hello World A"; - _button.Text = "Hello World B"; - } + } - [Benchmark] - public void Entry_SetText() - { - _entry.Text = "Hello World A"; - _entry.Text = "Hello World B"; + _label.PropertyChanged += OnPropertyChanged; + _button.PropertyChanged += OnPropertyChanged; + _entry.PropertyChanged += OnPropertyChanged; } // ------------------------------------------------------------------------- - // TextColor property (Color) + // HeightRequest property (double) // ------------------------------------------------------------------------- [Benchmark] - public void Label_SetTextColor() + public void Label_HeightRequest() { - // for (int i = 0; i < Iterations; i++) - { - _label.TextColor = _colorA; - _label.TextColor = _colorB; - } + _label.HeightRequest = 444; + _label.HeightRequest = 445; } [Benchmark] - public void Button_SetTextColor() + public void Button_HeightRequest() { - _button.TextColor = _colorA; - _button.TextColor = _colorB; + _button.HeightRequest = 444; + _button.HeightRequest = 445; } [Benchmark] - public void Entry_SetTextColor() + public void Entry_HeightRequest() { - _entry.TextColor = _colorA; - _entry.TextColor = _colorB; + _entry.HeightRequest = 444; + _entry.HeightRequest = 445; } // ------------------------------------------------------------------------- - // FontSize property (double – triggers InvalidateMeasure on Button/Label) + // Background property (Color) // ------------------------------------------------------------------------- [Benchmark] - public void Label_SetFontSize() + public void Label_SetBackground() { - _label.FontSize = 16; - _label.FontSize = 18; + _label.Background = _colorA; + _label.Background = _colorB; } [Benchmark] - public void Button_SetFontSize() + public void Button_SetBackground() { - _button.FontSize = 16; - _button.FontSize = 18; + _button.Background = _colorA; + _button.Background = _colorB; } [Benchmark] - public void Entry_SetFontSize() + public void Entry_SetBackground() { - _entry.FontSize = 16; - _entry.FontSize = 18; + _entry.Background = _colorA; + _entry.Background = _colorB; } - // ------------------------------------------------------------------------- - // Multiple common properties in one pass (composite benchmark) - // ------------------------------------------------------------------------- - [Benchmark] - public void Label_SetCommonProperties() + public void Label_SetCommonProperties_WithSubscriber() { - _label.Text = "A"; - _label.TextColor = _colorA; - _label.FontSize = 14; - _label.FontAttributes = FontAttributes.Bold; + _label.HeightRequest = 444; + _label.Background = _colorA; _label.IsEnabled = false; _label.Opacity = 0.5; - _label.Text = "B"; - _label.TextColor = _colorB; - _label.FontSize = 16; - _label.FontAttributes = FontAttributes.None; + _label.HeightRequest = 445; + _label.Background = _colorB; _label.IsEnabled = true; _label.Opacity = 1.0; } [Benchmark] - public void Button_SetCommonProperties() + public void Button_SetCommonProperties_WithSubscriber() { - _button.Text = "A"; - _button.TextColor = _colorA; + _button.HeightRequest = 444; + _button.Background = _colorA; _button.FontSize = 14; - _button.FontAttributes = FontAttributes.Bold; - _button.IsEnabled = false; _button.Opacity = 0.5; - _button.Text = "B"; - _button.TextColor = _colorB; - _button.FontSize = 16; - _button.FontAttributes = FontAttributes.None; + _button.HeightRequest = 445; + _button.Background = _colorB; _button.IsEnabled = true; _button.Opacity = 1.0; } [Benchmark] - public void Entry_SetCommonProperties() + public void Entry_SetCommonProperties_WithSubscriber() { - _entry.Text = "A"; - _entry.TextColor = _colorA; - _entry.FontSize = 14; - _entry.FontAttributes = FontAttributes.Bold; + _entry.HeightRequest = 444; + _entry.Background = _colorA; _entry.IsEnabled = false; _entry.Opacity = 0.5; - _entry.Text = "B"; - _entry.TextColor = _colorB; - _entry.FontSize = 16; - _entry.FontAttributes = FontAttributes.None; + _entry.HeightRequest = 445; + _entry.Background = _colorB; _entry.IsEnabled = true; _entry.Opacity = 1.0; } - // ------------------------------------------------------------------------- - // Same composite benchmark – with a PropertyChanged subscriber attached. - // Compares the propagation overhead vs. the no-subscriber variants above. - // ------------------------------------------------------------------------- - [Benchmark] - public void Label_SetCommonProperties_WithSubscriber() + public void SetSameValueOnDifferentControls() { - _labelWithSubscriber.Text = "A"; - _labelWithSubscriber.TextColor = _colorA; - _labelWithSubscriber.FontSize = 14; - _labelWithSubscriber.FontAttributes = FontAttributes.Bold; - _labelWithSubscriber.IsEnabled = false; - _labelWithSubscriber.Opacity = 0.5; + _entry.Background = _colorA; + _button.Background = _colorA; + _label.Background = _colorA; - _labelWithSubscriber.Text = "B"; - _labelWithSubscriber.TextColor = _colorB; - _labelWithSubscriber.FontSize = 16; - _labelWithSubscriber.FontAttributes = FontAttributes.None; - _labelWithSubscriber.IsEnabled = true; - _labelWithSubscriber.Opacity = 1.0; - } - - [Benchmark] - public void Button_SetCommonProperties_WithSubscriber() - { - _buttonWithSubscriber.Text = "A"; - _buttonWithSubscriber.TextColor = _colorA; - _buttonWithSubscriber.FontSize = 14; - _buttonWithSubscriber.FontAttributes = FontAttributes.Bold; - _buttonWithSubscriber.IsEnabled = false; - _buttonWithSubscriber.Opacity = 0.5; - - _buttonWithSubscriber.Text = "B"; - _buttonWithSubscriber.TextColor = _colorB; - _buttonWithSubscriber.FontSize = 16; - _buttonWithSubscriber.FontAttributes = FontAttributes.None; - _buttonWithSubscriber.IsEnabled = true; - _buttonWithSubscriber.Opacity = 1.0; - } - - [Benchmark] - public void Entry_SetCommonProperties_WithSubscriber() - { - _entryWithSubscriber.Text = "A"; - _entryWithSubscriber.TextColor = _colorA; - _entryWithSubscriber.FontSize = 14; - _entryWithSubscriber.FontAttributes = FontAttributes.Bold; - _entryWithSubscriber.IsEnabled = false; - _entryWithSubscriber.Opacity = 0.5; - _entryWithSubscriber.Text = "B"; - _entryWithSubscriber.TextColor = _colorB; - _entryWithSubscriber.FontSize = 16; - _entryWithSubscriber.FontAttributes = FontAttributes.None; - _entryWithSubscriber.IsEnabled = true; - _entryWithSubscriber.Opacity = 1.0; + _entry.Background = null; + _button.Background = null; + _label.Background = null; } } } From 1622d0c9881ec67bc80b123017e3d19ac280baca Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:26:55 -0300 Subject: [PATCH 06/21] unit tests --- .../CacheWithSwitchUnitTests.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs diff --git a/src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs b/src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs new file mode 100644 index 000000000000..f6839f66e7d2 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class CacheWithSwitchUnitTests : BaseTestFixture + { + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void CacheWithSwitchCtorThrowsWhenCapacityIsNotPositive(int capacity) + { + Assert.Throws(() => new CacheWithSwitch(capacity)); + } + + [Fact] + public void CacheWithSwitchReturnsSameBrushForSameColor() + { + var cache = new CacheWithSwitch(5); + + var first = cache.Get(Colors.Red); + var second = cache.Get(Colors.Red); + + Assert.Same(first, second); + } + + [Fact] + public void CacheWithSwitchPromotesToLruWhenAtCapacity() + { + var cache = new CacheWithSwitch(2); + + cache.Get(Colors.Red); + cache.Get(Colors.Green); + + Assert.Equal("SimpleCache", GetInnerCache(cache).GetType().Name); + + cache.Get(Colors.Red); + + Assert.IsType(GetInnerCache(cache)); + } + + [Fact] + public void CacheWithSwitchPromotionPreservesEntriesAndAppliesLruEviction() + { + var cache = new CacheWithSwitch(2); + + var red = cache.Get(Colors.Red); + var green = cache.Get(Colors.Green); + + var redAfterPromotion = cache.Get(Colors.Red); + Assert.Same(red, redAfterPromotion); + + var blue = cache.Get(Colors.Blue); + var blueAgain = cache.Get(Colors.Blue); + Assert.Same(blue, blueAgain); + Assert.Same(red, cache.Get(Colors.Red)); + + var greenAfterEviction = cache.Get(Colors.Green); + + Assert.NotSame(green, greenAfterEviction); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void LruBrushCacheCtorThrowsWhenCapacityIsNotPositive(int capacity) + { + Assert.Throws(() => new LRUBrushCache(capacity)); + } + + [Fact] + public void LruBrushCacheSeededCtorThrowsWhenBrushesAreNull() + { + Assert.Throws(() => new LRUBrushCache(1, null)); + } + + [Fact] + public void LruBrushCacheSeededCtorThrowsWhenBrushCountExceedsCapacity() + { + var brushes = new Dictionary + { + [Colors.Red] = new ImmutableBrush(Colors.Red), + [Colors.Green] = new ImmutableBrush(Colors.Green), + }; + + Assert.Throws(() => new LRUBrushCache(1, brushes)); + } + + [Fact] + public void LruBrushCacheSeededCtorReusesSeededBrushInstances() + { + var red = new ImmutableBrush(Colors.Red); + var green = new ImmutableBrush(Colors.Green); + + var brushes = new Dictionary + { + [Colors.Red] = red, + [Colors.Green] = green, + }; + + var cache = new LRUBrushCache(2, brushes); + + Assert.Same(red, cache.Get(Colors.Red)); + Assert.Same(green, cache.Get(Colors.Green)); + } + + [Fact] + public void LruBrushCacheEvictsLeastRecentlyUsedEntry() + { + var cache = new LRUBrushCache(2); + + var red = cache.Get(Colors.Red); + var green = cache.Get(Colors.Green); + + cache.Get(Colors.Red); + var blue = cache.Get(Colors.Blue); + + Assert.Same(red, cache.Get(Colors.Red)); + Assert.Same(blue, cache.Get(Colors.Blue)); + Assert.NotSame(green, cache.Get(Colors.Green)); + } + + static object GetInnerCache(CacheWithSwitch cache) + { + var field = typeof(CacheWithSwitch).GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); + return field?.GetValue(cache) ?? throw new InvalidOperationException("Cache backing field was not found."); + } + } +} From 96f7a903bc837bbc9954191107177d6989f31aac Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:36:53 -0300 Subject: [PATCH 07/21] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Controls/src/Core/Internals/LRUBrushCache.cs | 4 ++-- .../Benchmarks/PropertyChangePropagationBenchmarker.cs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs index 9a2ff47402c1..d68daf023d34 100644 --- a/src/Controls/src/Core/Internals/LRUBrushCache.cs +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -14,13 +14,13 @@ sealed class LRUBrushCache : ICache /// /// Creates a new instance of /// - /// /// + /// /// This cache helps reduce allocations by reusing instances for frequently used colors. /// When the cache exceeds , the least-recently accessed entry is evicted. /// This type is not thread-safe. /// /// The maximum number of cached brushes to keep. - /// throws if argument is zero or negative. + /// Thrown when is zero or negative. public LRUBrushCache(int capacity) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); diff --git a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs index 36694c2d3385..b8bf8e8ee094 100644 --- a/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs +++ b/src/Core/tests/Benchmarks/Benchmarks/PropertyChangePropagationBenchmarker.cs @@ -121,11 +121,13 @@ public void Button_SetCommonProperties_WithSubscriber() { _button.HeightRequest = 444; _button.Background = _colorA; + _button.IsEnabled = false; _button.FontSize = 14; _button.Opacity = 0.5; _button.HeightRequest = 445; _button.Background = _colorB; + _button.FontSize = 15; _button.IsEnabled = true; _button.Opacity = 1.0; } From 77b3c48395b65384617e34ebc14e560ecf736c33 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:39:48 -0300 Subject: [PATCH 08/21] handle null --- src/Controls/src/Core/Brush/Brush.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index ec3c674e9c6f..8be72204ca43 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -18,9 +18,10 @@ public static implicit operator Brush(Paint paint) { if (paint is SolidPaint solidPaint) { - return _cache.Get(solidPaint.Color); + var color = solidPaint.Color; + return color is null ? defaultBrush : _cache.Get(solidPaint.Color); } - + if (paint is GradientPaint gradientPaint) { @@ -106,7 +107,7 @@ public static implicit operator Paint(Brush brush) /// public static Brush Default => defaultBrush ??= new(null); - public static implicit operator Brush(Color color) => _cache.Get(color); + public static implicit operator Brush(Color color) => color is null ? defaultBrush : _cache.Get(color); /// /// When overridden in a derived class, indicates whether the given brush represents the empty brush. From 1c9c30852d79d65df07dc9b4566e1fe86b7cb3a2 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:41:09 -0300 Subject: [PATCH 09/21] increase capacity into one This will trigger the promote as soon the cache reaches 51 items. Which is fine, since we want the dictionary implementation to be until 50 --- src/Controls/src/Core/Brush/Brush.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index 8be72204ca43..f91f67fc0835 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -12,7 +12,7 @@ namespace Microsoft.Maui.Controls [System.ComponentModel.TypeConverter(typeof(BrushTypeConverter))] public abstract partial class Brush : Element { - static readonly ICache _cache = new CacheWithSwitch(50); + static readonly ICache _cache = new CacheWithSwitch(51); public static implicit operator Brush(Paint paint) { From d98ec21c88421f364088c5021f0b6209a213f4a6 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:41:46 -0300 Subject: [PATCH 10/21] use the property instead --- src/Controls/src/Core/Brush/Brush.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index f91f67fc0835..97c6b776a531 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -19,7 +19,7 @@ public static implicit operator Brush(Paint paint) if (paint is SolidPaint solidPaint) { var color = solidPaint.Color; - return color is null ? defaultBrush : _cache.Get(solidPaint.Color); + return color is null ? Default : _cache.Get(solidPaint.Color); } @@ -107,7 +107,7 @@ public static implicit operator Paint(Brush brush) /// public static Brush Default => defaultBrush ??= new(null); - public static implicit operator Brush(Color color) => color is null ? defaultBrush : _cache.Get(color); + public static implicit operator Brush(Color color) => color is null ? Default : _cache.Get(color); /// /// When overridden in a derived class, indicates whether the given brush represents the empty brush. From 201e243da3cb8c018160d2c99f90f625f58271f5 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:44:21 -0300 Subject: [PATCH 11/21] add requested test --- .../BrushTypeConverterUnitTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/Controls/tests/Core.UnitTests/BrushTypeConverterUnitTests.cs b/src/Controls/tests/Core.UnitTests/BrushTypeConverterUnitTests.cs index 1098669d2a6e..6c5320aaaf75 100644 --- a/src/Controls/tests/Core.UnitTests/BrushTypeConverterUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/BrushTypeConverterUnitTests.cs @@ -111,5 +111,37 @@ public void InvalidOperationExceptionWhenSettingParentOnImmutableBrush() { Assert.Throws(() => SolidColorBrush.Green.Parent = new Grid()); } + + [Fact] + public void ImplicitConversionFromNullColorReturnsEmptyBrush() + { + var exception = Record.Exception(() => + { + Brush brush = (Color)null; + + Assert.NotNull(brush); + Assert.True(brush.IsEmpty); + Assert.True(Brush.IsNullOrEmpty(brush)); + Assert.Null(((SolidColorBrush)brush).Color); + }); + + Assert.Null(exception); + } + + [Fact] + public void ImplicitConversionFromSolidPaintWithNullColorReturnsEmptyBrush() + { + var exception = Record.Exception(() => + { + Brush brush = new SolidPaint { Color = null }; + + Assert.NotNull(brush); + Assert.True(brush.IsEmpty); + Assert.True(Brush.IsNullOrEmpty(brush)); + Assert.Null(((SolidColorBrush)brush).Color); + }); + + Assert.Null(exception); + } } } \ No newline at end of file From cbb9bfd98833556a469b16b432390fa17010fc9d Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Sun, 5 Jul 2026 23:46:44 -0300 Subject: [PATCH 12/21] init the dictionary with capacity --- src/Controls/src/Core/Internals/CacheWithSwitch.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Internals/CacheWithSwitch.cs b/src/Controls/src/Core/Internals/CacheWithSwitch.cs index ff9ec5ff8a12..36a853e4ee1f 100644 --- a/src/Controls/src/Core/Internals/CacheWithSwitch.cs +++ b/src/Controls/src/Core/Internals/CacheWithSwitch.cs @@ -18,7 +18,7 @@ public CacheWithSwitch(int capacity) private class SimpleCache(int capacity) : ICache { - readonly Dictionary _dict = []; + readonly Dictionary _dict = new (capacity); public bool IsAtCapacity => _dict.Count == capacity; public ImmutableBrush Get(Color key) From ed00d5789a8714edb1a19aac1ebcaf05421d8fb1 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Wed, 8 Jul 2026 01:30:31 -0300 Subject: [PATCH 13/21] fixes for netstandard --- .../src/Core/Internals/CacheWithSwitch.cs | 15 +++++++++++++-- src/Controls/src/Core/Internals/LRUBrushCache.cs | 12 ++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Internals/CacheWithSwitch.cs b/src/Controls/src/Core/Internals/CacheWithSwitch.cs index 36a853e4ee1f..11e53488ff31 100644 --- a/src/Controls/src/Core/Internals/CacheWithSwitch.cs +++ b/src/Controls/src/Core/Internals/CacheWithSwitch.cs @@ -11,7 +11,10 @@ sealed class CacheWithSwitch : ICache public CacheWithSwitch(int capacity) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } _cache = new SimpleCache(capacity); } @@ -23,8 +26,16 @@ private class SimpleCache(int capacity) : ICache public ImmutableBrush Get(Color key) { +#if NETSTANDARD + if (!_dict.TryGetValue(key, out var value)) + { + value = new ImmutableBrush(key); + _dict[key] = value; + } +#else ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(_dict, key, out _); value ??= new ImmutableBrush(key); +#endif return value; } @@ -33,7 +44,7 @@ public ImmutableBrush Get(Color key) public ImmutableBrush Get(Color key) { - if (_cache is SimpleCache { IsAtCapacity: true} simple) + if (_cache is SimpleCache { IsAtCapacity: true } simple) { _cache = simple.Promote(); } diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs index d68daf023d34..00414c1a5c10 100644 --- a/src/Controls/src/Core/Internals/LRUBrushCache.cs +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -23,16 +23,24 @@ sealed class LRUBrushCache : ICache /// Thrown when is zero or negative. public LRUBrushCache(int capacity) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } _capacity = capacity; } public LRUBrushCache(int capacity, Dictionary brushes) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + ArgumentNullException.ThrowIfNull(brushes); + if (brushes.Count > capacity) { throw new ArgumentException("Brush count must not exceed capacity.", nameof(brushes)); From 17519beeeca57bcba6e0d100849722afd3548768 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Wed, 8 Jul 2026 01:31:41 -0300 Subject: [PATCH 14/21] ThreadStatic solution --- src/Controls/src/Core/Brush/Brush.cs | 9 ++++++--- src/Controls/src/Core/Internals/LRUBrushCache.cs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index 97c6b776a531..b7a9e76e8a86 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -1,4 +1,5 @@ #nullable disable +using System; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using GraphicsGradientStop = Microsoft.Maui.Graphics.PaintGradientStop; @@ -12,14 +13,16 @@ namespace Microsoft.Maui.Controls [System.ComponentModel.TypeConverter(typeof(BrushTypeConverter))] public abstract partial class Brush : Element { - static readonly ICache _cache = new CacheWithSwitch(51); + [System.ThreadStatic] + static ICache _cache; + static ICache Cache => _cache ??= new CacheWithSwitch(51); public static implicit operator Brush(Paint paint) { if (paint is SolidPaint solidPaint) { var color = solidPaint.Color; - return color is null ? Default : _cache.Get(solidPaint.Color); + return color is null ? Default : Cache.Get(solidPaint.Color); } @@ -107,7 +110,7 @@ public static implicit operator Paint(Brush brush) /// public static Brush Default => defaultBrush ??= new(null); - public static implicit operator Brush(Color color) => color is null ? Default : _cache.Get(color); + public static implicit operator Brush(Color color) => color is null ? Default : Cache.Get(color); /// /// When overridden in a derived class, indicates whether the given brush represents the empty brush. diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs index 00414c1a5c10..7db789677863 100644 --- a/src/Controls/src/Core/Internals/LRUBrushCache.cs +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -38,7 +38,7 @@ public LRUBrushCache(int capacity, Dictionary brushes) throw new ArgumentOutOfRangeException(nameof(capacity)); } - ArgumentNullException.ThrowIfNull(brushes); + _ = brushes ?? throw new ArgumentNullException(nameof(brushes)); if (brushes.Count > capacity) From 0d562df5694f244158ce5aa8d81324e396a6acdf Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Wed, 8 Jul 2026 02:02:27 -0300 Subject: [PATCH 15/21] use lock to make it thread-safe for write and read --- src/Controls/src/Core/Brush/Brush.cs | 8 +++----- .../src/Core/Internals/CacheWithSwitch.cs | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index b7a9e76e8a86..27a7dbc9d9b3 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -13,16 +13,14 @@ namespace Microsoft.Maui.Controls [System.ComponentModel.TypeConverter(typeof(BrushTypeConverter))] public abstract partial class Brush : Element { - [System.ThreadStatic] - static ICache _cache; - static ICache Cache => _cache ??= new CacheWithSwitch(51); + static readonly ICache _cache = new CacheWithSwitch(51); public static implicit operator Brush(Paint paint) { if (paint is SolidPaint solidPaint) { var color = solidPaint.Color; - return color is null ? Default : Cache.Get(solidPaint.Color); + return color is null ? Default : _cache.Get(solidPaint.Color); } @@ -110,7 +108,7 @@ public static implicit operator Paint(Brush brush) /// public static Brush Default => defaultBrush ??= new(null); - public static implicit operator Brush(Color color) => color is null ? Default : Cache.Get(color); + public static implicit operator Brush(Color color) => color is null ? Default : _cache.Get(color); /// /// When overridden in a derived class, indicates whether the given brush represents the empty brush. diff --git a/src/Controls/src/Core/Internals/CacheWithSwitch.cs b/src/Controls/src/Core/Internals/CacheWithSwitch.cs index 11e53488ff31..657eef53ca78 100644 --- a/src/Controls/src/Core/Internals/CacheWithSwitch.cs +++ b/src/Controls/src/Core/Internals/CacheWithSwitch.cs @@ -9,6 +9,12 @@ sealed class CacheWithSwitch : ICache { ICache _cache; +#if NETSTANDARD + readonly object _lock = new(); +#else + readonly System.Threading.Lock _lock = new(); +#endif + public CacheWithSwitch(int capacity) { if (capacity <= 0) @@ -44,11 +50,14 @@ public ImmutableBrush Get(Color key) public ImmutableBrush Get(Color key) { - if (_cache is SimpleCache { IsAtCapacity: true } simple) + lock (_lock) { - _cache = simple.Promote(); - } + if (_cache is SimpleCache { IsAtCapacity: true } simple) + { + _cache = simple.Promote(); + } - return _cache.Get(key); + return _cache.Get(key); + } } } From 317b1a9d2939554c7401f9bd3609d36fec0323b2 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Wed, 8 Jul 2026 02:02:51 -0300 Subject: [PATCH 16/21] add benchmark for cache implementations --- .../Benchmarks/BrushCacheBenchmarker.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs diff --git a/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs new file mode 100644 index 000000000000..2a6534b79400 --- /dev/null +++ b/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs @@ -0,0 +1,128 @@ +using System; +using BenchmarkDotNet.Attributes; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Handlers.Benchmarks; + +[MemoryDiagnoser] +public class FortyColorBrushCacheBenchmarks +{ + private const int ColorCount = 40; + private static readonly Color[] Colors = BrushCacheBenchmarkData.CreateColors(ColorCount); + + [Benchmark(Baseline = true, OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object LruCache() + => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object SwitchCache() + => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object NullCache() + => BrushCacheBenchmarkData.Run(new NullCache(), Colors); +} + +[MemoryDiagnoser] +public class SixtyColorBrushCacheBenchmarks +{ + private const int ColorCount = 60; + private static readonly Color[] Colors = BrushCacheBenchmarkData.CreateColors(ColorCount); + + [Benchmark(Baseline = true, OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object LruCache() + => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object SwitchCache() + => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] + public object NullCache() + => BrushCacheBenchmarkData.Run(new NullCache(), Colors); +} + +[MemoryDiagnoser] +public class WeightedColorBrushCacheBenchmarks +{ + private const int CommonColorCount = 20; + private const int ColdColorCount = 40; + private const int CommonColorWeight = 10; + private const int AccessCount = CommonColorCount * CommonColorWeight + ColdColorCount; + + private static readonly Color[] Colors = BrushCacheBenchmarkData.CreateWeightedColors( + CommonColorCount, ColdColorCount, CommonColorWeight); + + [Benchmark(Baseline = true, OperationsPerInvoke = AccessCount * BrushCacheBenchmarkData.LoopCount)] + public object LruCache() + => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = AccessCount * BrushCacheBenchmarkData.LoopCount)] + public object SwitchCache() + => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + + [Benchmark(OperationsPerInvoke = AccessCount * BrushCacheBenchmarkData.LoopCount)] + public object NullCache() + => BrushCacheBenchmarkData.Run(new NullCache(), Colors); +} + +internal static class BrushCacheBenchmarkData +{ + public const int CacheCapacity = 50; + public const int LoopCount = 10; + + public static ImmutableBrush Run(ICache cache, Color[] colors) + { + ImmutableBrush lastBrush = null; + for (var iteration = 0; iteration < LoopCount; iteration++) + foreach (var color in colors) + lastBrush = cache.Get(color); + return lastBrush; + } + + public static Color[] CreateColors(int count) + { + var colors = new Color[count]; + for (var i = 0; i < colors.Length; i++) + colors[i] = new Color( + red: ((i * 67) % 256) / 255f, + green: ((i * 97) % 256) / 255f, + blue: ((i * 131) % 256) / 255f, + alpha: 1.0f); + return colors; + } + + public static Color[] CreateWeightedColors(int commonColorCount, int coldColorCount, int commonColorWeight) + { + var uniqueColors = CreateColors(commonColorCount + coldColorCount); + var accesses = new Color[commonColorCount * commonColorWeight + coldColorCount]; + var index = 0; + + for (var colorIndex = 0; colorIndex < commonColorCount; colorIndex++) + for (var repeat = 0; repeat < commonColorWeight; repeat++) + accesses[index++] = uniqueColors[colorIndex]; + + for (var colorIndex = commonColorCount; colorIndex < uniqueColors.Length; colorIndex++) + accesses[index++] = uniqueColors[colorIndex]; + + Shuffle(accesses, seed: 42); + return accesses; + } + + static void Shuffle(Color[] colors, int seed) + { + var random = new Random(seed); + for (var i = colors.Length - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (colors[i], colors[j]) = (colors[j], colors[i]); + } + } +} + +internal sealed class NullCache : ICache +{ + public ImmutableBrush Get(Color key) => new ImmutableBrush(key); +} From e351aa4ff043adde8ba909fe0344fbafb0acf7e0 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Thu, 30 Jul 2026 22:41:53 -0300 Subject: [PATCH 17/21] refactor --- src/Controls/src/Core/Internals/LRUBrushCache.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs index 7db789677863..cea9c0de7ef7 100644 --- a/src/Controls/src/Core/Internals/LRUBrushCache.cs +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -50,8 +50,10 @@ public LRUBrushCache(int capacity, Dictionary brushes) _cache = new Dictionary>(capacity); _lru = []; - foreach (var (color, brush) in brushes) + foreach (var pair in brushes) { + var color = pair.Key; + var brush = pair.Value; var node = _lru.AddFirst(brush); _cache.Add(color, node); } From 42877ed84072ed1ce710cb736610f2eb7cd5eb8f Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Thu, 30 Jul 2026 23:26:08 -0300 Subject: [PATCH 18/21] implement Lru64 cache --- .../Core/Internals/InlineLRUCache/Lru64.cs | 299 ++++++++++++++++++ .../Lru64ColorVectorInlineBrushCache.cs | 64 ++++ 2 files changed, 363 insertions(+) create mode 100644 src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs create mode 100644 src/Controls/src/Core/Internals/InlineLRUCache/Lru64ColorVectorInlineBrushCache.cs diff --git a/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs new file mode 100644 index 000000000000..50c66d2fba87 --- /dev/null +++ b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs @@ -0,0 +1,299 @@ +#nullable disable + +// This fixed-capacity (max 64) LRU building block relies on [InlineArray] and +// Vector span APIs that are only available on .NET 8+. It is intentionally +// excluded from the netstandard2.0/2.1 targets of Controls.Core. +#if !NETSTANDARD + +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Controls.Internals; + +internal interface ILru64LinkStore +{ + byte GetPrevious(byte slot, byte head); + byte GetNext(byte slot, byte tail); + void SetPrevious(byte slot, byte previous); + void SetNext(byte slot, byte next); +} + +internal struct InlineArrayLinkStore : ILru64LinkStore +{ + PreviousBuffer _previous; + NextBuffer _next; + + public byte GetPrevious(byte slot, byte head) => slot == head ? Lru64Constants.None : _previous[slot]; + public byte GetNext(byte slot, byte tail) => slot == tail ? Lru64Constants.None : _next[slot]; + public void SetPrevious(byte slot, byte previous) => _previous[slot] = previous; + public void SetNext(byte slot, byte next) => _next[slot] = next; + + [InlineArray(Lru64Constants.MaxCapacity)] + struct PreviousBuffer + { + byte _element0; + } + + [InlineArray(Lru64Constants.MaxCapacity)] + struct NextBuffer + { + byte _element0; + } +} + +internal static class Lru64Constants +{ + public const int MaxCapacity = 64; + public const byte None = byte.MaxValue; + + public static byte ValidateCapacity(int capacity) + { + if (capacity <= 0 || capacity > MaxCapacity) + { + throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be between 1 and 64"); + } + + return (byte)capacity; + } +} + +internal struct Lru64ColorVectorInline +{ + Lru64ColorVector _cache; + + public Lru64ColorVectorInline(int capacity) => _cache = new(capacity); + public TValue GetOrAdd(Color key, Func factory) => _cache.GetOrAdd(key, factory); + public int Count => _cache.Count; + public bool ContainsKey(Color key) => _cache.ContainsKey(key); + internal void AssertInvariants() => _cache.AssertInvariants(); +} + +internal struct Lru64ColorVector + where TLinks : struct, ILru64LinkStore +{ + readonly byte _capacity; + byte _count; + byte _head; + byte _tail; + UIntKeyBuffer _keys; + ValueBuffer _values; + TLinks _links; + + public Lru64ColorVector(int capacity) + { + _capacity = Lru64Constants.ValidateCapacity(capacity); + _count = 0; + _head = Lru64Constants.None; + _tail = Lru64Constants.None; + _keys = default; + _values = default; + _links = default; + } + + public int Count => _count; + + public TValue GetOrAdd(Color key, Func factory) + { + var keyValue = key.ToUint(); + var slot = FindSlot(keyValue); + if (slot != Lru64Constants.None) + { + MoveToHead(slot); + return _values[slot]; + } + + slot = GetSlotForInsert(); + _keys[slot] = keyValue; + _values[slot] = factory(key); + InsertAtHead(slot); + return _values[slot]; + } + + public bool ContainsKey(Color key) => FindSlot(key.ToUint()) != Lru64Constants.None; + + byte FindSlot(uint key) + { + var count = _count; + if (count == 0) + { + return Lru64Constants.None; + } + + ref var first = ref _keys[0]; + var keys = MemoryMarshal.CreateReadOnlySpan(ref first, count); + var target = new Vector(key); + var vectorWidth = Vector.Count; + var index = 0; + + for (; index <= count - vectorWidth; index += vectorWidth) + { + var matches = Vector.Equals(new Vector(keys.Slice(index, vectorWidth)), target); + + if (!Vector.EqualsAll(matches, Vector.Zero)) + { + for (var lane = 0; lane < vectorWidth; lane++) + { + if (matches[lane] != 0) + { + return (byte)(index + lane); + } + } + } + } + + for (; index < count; index++) + { + if (keys[index] == key) + { + return (byte)index; + } + } + + return Lru64Constants.None; + } + + byte GetSlotForInsert() + { + if (_count < _capacity) + { + return _count++; + } + + var slot = _tail; + Detach(slot); + return slot; + } + + void MoveToHead(byte slot) + { + if (slot == _head) + { + return; + } + + Detach(slot); + InsertAtHead(slot); + } + + void Detach(byte slot) + { + var previous = _links.GetPrevious(slot, _head); + var next = _links.GetNext(slot, _tail); + + if (previous != Lru64Constants.None) + { + _links.SetNext(previous, next); + } + else + { + _head = next; + } + + if (next != Lru64Constants.None) + { + _links.SetPrevious(next, previous); + } + else + { + _tail = previous; + } + } + + void InsertAtHead(byte slot) + { + var oldHead = _head; + _links.SetPrevious(slot, Lru64Constants.None); + _links.SetNext(slot, oldHead); + _head = slot; + + if (oldHead != Lru64Constants.None) + { + _links.SetPrevious(oldHead, slot); + } + else + { + _tail = slot; + } + } + + internal void AssertInvariants() => Lru64InvariantHelpers.AssertInvariants(_count, _capacity, _head, _tail, ref _links); + + [InlineArray(Lru64Constants.MaxCapacity)] + struct UIntKeyBuffer + { + uint _element0; + } + + [InlineArray(Lru64Constants.MaxCapacity)] + struct ValueBuffer + { + TValue _element0; + } +} + +internal static class Lru64InvariantHelpers +{ + public static void AssertInvariants(byte count, byte capacity, byte head, byte tail, ref TLinks links) + where TLinks : struct, ILru64LinkStore + { + if (count == 0) + { + if (head != Lru64Constants.None || tail != Lru64Constants.None) + { + throw new InvalidOperationException("Empty cache should not have head or tail."); + } + + return; + } + + if (head == Lru64Constants.None || tail == Lru64Constants.None) + { + throw new InvalidOperationException("Non-empty cache must have head and tail."); + } + + var visited = 0UL; + var visitedCount = 0; + var slot = head; + var previous = Lru64Constants.None; + + while (slot != Lru64Constants.None) + { + var mask = 1UL << slot; + if ((visited & mask) != 0) + { + throw new InvalidOperationException("Active list contains a cycle."); + } + + visited |= mask; + visitedCount++; + + if (links.GetPrevious(slot, head) != previous) + { + throw new InvalidOperationException("Previous link is inconsistent."); + } + + previous = slot; + slot = links.GetNext(slot, tail); + } + + if (previous != tail) + { + throw new InvalidOperationException("Tail is not the last active node."); + } + + if (visitedCount != count) + { + throw new InvalidOperationException("Active list length does not match count."); + } + + if (count > capacity) + { + throw new InvalidOperationException("Count exceeds capacity."); + } + } +} + +#endif diff --git a/src/Controls/src/Core/Internals/InlineLRUCache/Lru64ColorVectorInlineBrushCache.cs b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64ColorVectorInlineBrushCache.cs new file mode 100644 index 000000000000..b90bd448403d --- /dev/null +++ b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64ColorVectorInlineBrushCache.cs @@ -0,0 +1,64 @@ +#nullable disable + +using System; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Controls.Internals; + +/// +/// Thread-safe, fixed-capacity least-recently-used cache of instances keyed by +/// . +/// +/// +/// On .NET the cache is backed by Lru64ColorVectorInline<TValue>, which stores up to 64 colors as +/// packed values in an inline array and matches them with a SIMD scan, keeping all bookkeeping +/// in a single struct with no per-entry heap allocations. When the cache is full the least-recently-used color is +/// evicted to make room for a new one. +/// +/// On netstandard targets the [InlineArray] and span APIs +/// used by that struct are unavailable, so the cache falls back to , which offers the +/// same LRU semantics using a dictionary and a linked list. +/// +/// +/// All access is guarded by a single lock. A cache hit still mutates LRU order (it moves the entry to the head), +/// so every is effectively a write; a plain lock — rather than a reader/writer lock — is +/// therefore both correct and the fastest option. The guarded section is only a short SIMD scan plus a few +/// pointer swaps over inline, cache-friendly memory. +/// +/// +sealed class Lru64ColorVectorInlineBrushCache : ICache +{ +#if NETSTANDARD + readonly object _lock = new(); + readonly LRUBrushCache _cache; +#else + readonly System.Threading.Lock _lock = new(); + Lru64ColorVectorInline _cache; +#endif + + /// The maximum number of cached brushes to keep. On .NET this must be between 1 and 64. + public Lru64ColorVectorInlineBrushCache(int capacity) + { +#if NETSTANDARD + _cache = new LRUBrushCache(capacity); +#else + _cache = new Lru64ColorVectorInline(capacity); +#endif + } + + public ImmutableBrush Get(Color key) + { + lock (_lock) + { +#if NETSTANDARD + return _cache.Get(key); +#else + return _cache.GetOrAdd(key, CreateBrush); +#endif + } + } + +#if !NETSTANDARD + static ImmutableBrush CreateBrush(Color color) => new(color); +#endif +} From 3c80b8abee9a778bf9bd4cf186d1b1ad4a9784db Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Thu, 30 Jul 2026 23:26:14 -0300 Subject: [PATCH 19/21] add unit test --- ...u64ColorVectorInlineBrushCacheUnitTests.cs | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/Controls/tests/Core.UnitTests/Lru64ColorVectorInlineBrushCacheUnitTests.cs diff --git a/src/Controls/tests/Core.UnitTests/Lru64ColorVectorInlineBrushCacheUnitTests.cs b/src/Controls/tests/Core.UnitTests/Lru64ColorVectorInlineBrushCacheUnitTests.cs new file mode 100644 index 000000000000..d9c54a449a53 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/Lru64ColorVectorInlineBrushCacheUnitTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class Lru64ColorVectorInlineBrushCacheUnitTests : BaseTestFixture + { + // Builds a color whose ToUint() is 0xFF000000 | index, guaranteeing a distinct, stable key per index. + static Color DistinctColor(int index) => Color.FromUint(0xFF000000u | (uint)index); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(65)] + public void CtorThrowsWhenCapacityIsOutOfRange(int capacity) + { + Assert.Throws(() => new Lru64ColorVectorInlineBrushCache(capacity)); + } + + [Fact] + public void ReturnsSameBrushInstanceForSameColor() + { + var cache = new Lru64ColorVectorInlineBrushCache(5); + + var first = cache.Get(Colors.Red); + var second = cache.Get(Colors.Red); + + Assert.Same(first, second); + } + + [Fact] + public void ReturnsDifferentBrushInstancesForDifferentColors() + { + var cache = new Lru64ColorVectorInlineBrushCache(5); + + var red = cache.Get(Colors.Red); + var green = cache.Get(Colors.Green); + + Assert.NotSame(red, green); + } + + [Fact] + public void ReturnedBrushHasRequestedColor() + { + var cache = new Lru64ColorVectorInlineBrushCache(5); + + var brush = cache.Get(Colors.Purple); + + Assert.Equal(Colors.Purple, brush.Color); + } + + [Fact] + public void EvictsLeastRecentlyUsedEntryWhenFull() + { + var cache = new Lru64ColorVectorInlineBrushCache(2); + + var red = cache.Get(Colors.Red); + var green = cache.Get(Colors.Green); + + // Cache is full (Red, Green). Adding Blue must evict the LRU entry (Red). + var blue = cache.Get(Colors.Blue); + + Assert.Same(green, cache.Get(Colors.Green)); + Assert.Same(blue, cache.Get(Colors.Blue)); + Assert.NotSame(red, cache.Get(Colors.Red)); + } + + [Fact] + public void RecentlyUsedColorSurvivesEviction() + { + var cache = new Lru64ColorVectorInlineBrushCache(2); + + var red = cache.Get(Colors.Red); + cache.Get(Colors.Green); + + // Touch Red so Green becomes the least-recently-used entry. + cache.Get(Colors.Red); + + // Adding Blue must now evict Green, not Red. + cache.Get(Colors.Blue); + + Assert.Same(red, cache.Get(Colors.Red)); + Assert.NotSame(cache.Get(Colors.Green), red); + } + + [Fact] + public void RetainsIdentityForAllColorsUpToCapacity() + { + const int capacity = 40; + var cache = new Lru64ColorVectorInlineBrushCache(capacity); + + var brushes = new ImmutableBrush[capacity]; + for (int i = 0; i < capacity; i++) + { + brushes[i] = cache.Get(DistinctColor(i)); + } + + // Nothing was evicted, so every color must still map to its original brush instance. + for (int i = 0; i < capacity; i++) + { + Assert.Same(brushes[i], cache.Get(DistinctColor(i))); + } + } + + [Fact] + public void SupportsMaximumCapacityOf64() + { + const int capacity = 64; + var cache = new Lru64ColorVectorInlineBrushCache(capacity); + + var first = cache.Get(DistinctColor(0)); + for (int i = 1; i < capacity; i++) + { + cache.Get(DistinctColor(i)); + } + + // Color 0 is the least-recently-used; the cache is exactly full and it should still be present. + Assert.Same(first, cache.Get(DistinctColor(0))); + } + + [Fact] + public void ConcurrentAccessReturnsStableBrushIdentity() + { + const int distinctColors = 40; // under capacity, so no eviction races + var cache = new Lru64ColorVectorInlineBrushCache(51); + var expected = new ImmutableBrush[distinctColors]; + + for (int i = 0; i < distinctColors; i++) + { + expected[i] = cache.Get(DistinctColor(i)); + } + + Parallel.For(0, 50_000, i => + { + int colorIndex = i % distinctColors; + var brush = cache.Get(DistinctColor(colorIndex)); + Assert.Same(expected[colorIndex], brush); + }); + } + } + + public class Lru64ColorVectorInlineUnitTests : BaseTestFixture + { + static Color DistinctColor(int index) => Color.FromUint(0xFF000000u | (uint)index); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(65)] + public void CtorThrowsWhenCapacityIsOutOfRange(int capacity) + { + Assert.Throws(() => new Lru64ColorVectorInline(capacity)); + } + + [Fact] + public void GetOrAddInvokesFactoryOnlyOnMiss() + { + var cache = new Lru64ColorVectorInline(4); + int factoryCalls = 0; + int Factory(Color c) { factoryCalls++; return (int)c.ToUint(); } + + var first = cache.GetOrAdd(Colors.Red, Factory); + var second = cache.GetOrAdd(Colors.Red, Factory); + + Assert.Equal(first, second); + Assert.Equal(1, factoryCalls); + } + + [Fact] + public void CountTracksInsertsAndSaturatesAtCapacity() + { + const int capacity = 5; + var cache = new Lru64ColorVectorInline(capacity); + + for (int i = 0; i < 20; i++) + { + cache.GetOrAdd(DistinctColor(i), c => (int)c.ToUint()); + Assert.Equal(Math.Min(i + 1, capacity), cache.Count); + } + } + + [Fact] + public void ContainsKeyReflectsPresenceAndEviction() + { + var cache = new Lru64ColorVectorInline(2); + + cache.GetOrAdd(DistinctColor(0), c => 0); + cache.GetOrAdd(DistinctColor(1), c => 1); + + Assert.True(cache.ContainsKey(DistinctColor(0))); + Assert.True(cache.ContainsKey(DistinctColor(1))); + + // Inserting a third color evicts the least-recently-used (color 0). + cache.GetOrAdd(DistinctColor(2), c => 2); + + Assert.False(cache.ContainsKey(DistinctColor(0))); + Assert.True(cache.ContainsKey(DistinctColor(1))); + Assert.True(cache.ContainsKey(DistinctColor(2))); + } + + [Fact] + public void MaintainsInvariantsUnderChurn() + { + const int capacity = 8; + var cache = new Lru64ColorVectorInline(capacity); + + // Access a mix of repeated and new colors to exercise move-to-head, insert, and eviction. + for (int i = 0; i < 500; i++) + { + int key = (i * 7) % 25; // 25 distinct colors churning through an 8-slot cache + cache.GetOrAdd(DistinctColor(key), c => (int)c.ToUint()); + cache.AssertInvariants(); + } + + Assert.Equal(capacity, cache.Count); + } + + [Fact] + public void EvictsLeastRecentlyUsedAcrossSimdBoundary() + { + // A capacity larger than the SIMD width exercises both the vectorized scan and its scalar tail. + const int capacity = 40; + var cache = new Lru64ColorVectorInline(capacity); + + for (int i = 0; i < capacity; i++) + { + cache.GetOrAdd(DistinctColor(i), c => (int)c.ToUint()); + } + + // Cache is full; color 0 is the LRU entry. A new color evicts it and nothing else. + cache.GetOrAdd(DistinctColor(capacity), c => (int)c.ToUint()); + cache.AssertInvariants(); + + Assert.False(cache.ContainsKey(DistinctColor(0))); + Assert.True(cache.ContainsKey(DistinctColor(capacity))); + for (int i = 1; i < capacity; i++) + { + Assert.True(cache.ContainsKey(DistinctColor(i))); + } + } + } +} From 01981004b93954069fab89d55cb8e2ad5bd3d696 Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Thu, 30 Jul 2026 23:40:37 -0300 Subject: [PATCH 20/21] use the new implementation and adjustments --- src/Controls/src/Core/Brush/Brush.cs | 2 +- .../src/Core/Internals/CacheWithSwitch.cs | 63 ------------------- .../Core/Internals/InlineLRUCache/Lru64.cs | 2 +- ...UnitTests.cs => LRUBrushCacheUnitTests.cs} | 61 ++---------------- .../Benchmarks/BrushCacheBenchmarker.cs | 12 ++-- 5 files changed, 13 insertions(+), 127 deletions(-) delete mode 100644 src/Controls/src/Core/Internals/CacheWithSwitch.cs rename src/Controls/tests/Core.UnitTests/{CacheWithSwitchUnitTests.cs => LRUBrushCacheUnitTests.cs} (54%) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index 27a7dbc9d9b3..7857181349a6 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -13,7 +13,7 @@ namespace Microsoft.Maui.Controls [System.ComponentModel.TypeConverter(typeof(BrushTypeConverter))] public abstract partial class Brush : Element { - static readonly ICache _cache = new CacheWithSwitch(51); + static readonly ICache _cache = new Lru64ColorVectorInlineBrushCache(64); public static implicit operator Brush(Paint paint) { diff --git a/src/Controls/src/Core/Internals/CacheWithSwitch.cs b/src/Controls/src/Core/Internals/CacheWithSwitch.cs deleted file mode 100644 index 657eef53ca78..000000000000 --- a/src/Controls/src/Core/Internals/CacheWithSwitch.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using Microsoft.Maui.Graphics; - -namespace Microsoft.Maui.Controls.Internals; - -sealed class CacheWithSwitch : ICache -{ - ICache _cache; - -#if NETSTANDARD - readonly object _lock = new(); -#else - readonly System.Threading.Lock _lock = new(); -#endif - - public CacheWithSwitch(int capacity) - { - if (capacity <= 0) - { - throw new ArgumentOutOfRangeException(nameof(capacity)); - } - - _cache = new SimpleCache(capacity); - } - - private class SimpleCache(int capacity) : ICache - { - readonly Dictionary _dict = new (capacity); - public bool IsAtCapacity => _dict.Count == capacity; - - public ImmutableBrush Get(Color key) - { -#if NETSTANDARD - if (!_dict.TryGetValue(key, out var value)) - { - value = new ImmutableBrush(key); - _dict[key] = value; - } -#else - ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(_dict, key, out _); - value ??= new ImmutableBrush(key); -#endif - return value; - } - - public LRUBrushCache Promote() => new(capacity, _dict); - } - - public ImmutableBrush Get(Color key) - { - lock (_lock) - { - if (_cache is SimpleCache { IsAtCapacity: true } simple) - { - _cache = simple.Promote(); - } - - return _cache.Get(key); - } - } -} diff --git a/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs index 50c66d2fba87..5dcaec815147 100644 --- a/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs +++ b/src/Controls/src/Core/Internals/InlineLRUCache/Lru64.cs @@ -51,7 +51,7 @@ internal static class Lru64Constants public static byte ValidateCapacity(int capacity) { - if (capacity <= 0 || capacity > MaxCapacity) + if ((uint)(capacity - 1) >= (uint)MaxCapacity) { throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be between 1 and 64"); } diff --git a/src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs b/src/Controls/tests/Core.UnitTests/LRUBrushCacheUnitTests.cs similarity index 54% rename from src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs rename to src/Controls/tests/Core.UnitTests/LRUBrushCacheUnitTests.cs index f6839f66e7d2..b8ece3d29a80 100644 --- a/src/Controls/tests/Core.UnitTests/CacheWithSwitchUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/LRUBrushCacheUnitTests.cs @@ -1,26 +1,25 @@ using System; using System.Collections.Generic; -using System.Reflection; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Xunit; namespace Microsoft.Maui.Controls.Core.UnitTests { - public class CacheWithSwitchUnitTests : BaseTestFixture + public class LRUBrushCacheUnitTests : BaseTestFixture { [Theory] [InlineData(0)] [InlineData(-1)] - public void CacheWithSwitchCtorThrowsWhenCapacityIsNotPositive(int capacity) + public void LruBrushCacheCtorThrowsWhenCapacityIsNotPositive(int capacity) { - Assert.Throws(() => new CacheWithSwitch(capacity)); + Assert.Throws(() => new LRUBrushCache(capacity)); } [Fact] - public void CacheWithSwitchReturnsSameBrushForSameColor() + public void LruBrushCacheReturnsSameBrushForSameColor() { - var cache = new CacheWithSwitch(5); + var cache = new LRUBrushCache(5); var first = cache.Get(Colors.Red); var second = cache.Get(Colors.Red); @@ -28,50 +27,6 @@ public void CacheWithSwitchReturnsSameBrushForSameColor() Assert.Same(first, second); } - [Fact] - public void CacheWithSwitchPromotesToLruWhenAtCapacity() - { - var cache = new CacheWithSwitch(2); - - cache.Get(Colors.Red); - cache.Get(Colors.Green); - - Assert.Equal("SimpleCache", GetInnerCache(cache).GetType().Name); - - cache.Get(Colors.Red); - - Assert.IsType(GetInnerCache(cache)); - } - - [Fact] - public void CacheWithSwitchPromotionPreservesEntriesAndAppliesLruEviction() - { - var cache = new CacheWithSwitch(2); - - var red = cache.Get(Colors.Red); - var green = cache.Get(Colors.Green); - - var redAfterPromotion = cache.Get(Colors.Red); - Assert.Same(red, redAfterPromotion); - - var blue = cache.Get(Colors.Blue); - var blueAgain = cache.Get(Colors.Blue); - Assert.Same(blue, blueAgain); - Assert.Same(red, cache.Get(Colors.Red)); - - var greenAfterEviction = cache.Get(Colors.Green); - - Assert.NotSame(green, greenAfterEviction); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void LruBrushCacheCtorThrowsWhenCapacityIsNotPositive(int capacity) - { - Assert.Throws(() => new LRUBrushCache(capacity)); - } - [Fact] public void LruBrushCacheSeededCtorThrowsWhenBrushesAreNull() { @@ -123,11 +78,5 @@ public void LruBrushCacheEvictsLeastRecentlyUsedEntry() Assert.Same(blue, cache.Get(Colors.Blue)); Assert.NotSame(green, cache.Get(Colors.Green)); } - - static object GetInnerCache(CacheWithSwitch cache) - { - var field = typeof(CacheWithSwitch).GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); - return field?.GetValue(cache) ?? throw new InvalidOperationException("Cache backing field was not found."); - } } } diff --git a/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs index 2a6534b79400..2a41358bd004 100644 --- a/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs +++ b/src/Core/tests/Benchmarks/Benchmarks/BrushCacheBenchmarker.cs @@ -17,8 +17,8 @@ public object LruCache() => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] - public object SwitchCache() - => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + public object InlineLruCache() + => BrushCacheBenchmarkData.Run(new Lru64ColorVectorInlineBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] public object NullCache() @@ -36,8 +36,8 @@ public object LruCache() => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] - public object SwitchCache() - => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + public object InlineLruCache() + => BrushCacheBenchmarkData.Run(new Lru64ColorVectorInlineBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = ColorCount * BrushCacheBenchmarkData.LoopCount)] public object NullCache() @@ -60,8 +60,8 @@ public object LruCache() => BrushCacheBenchmarkData.Run(new LRUBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = AccessCount * BrushCacheBenchmarkData.LoopCount)] - public object SwitchCache() - => BrushCacheBenchmarkData.Run(new CacheWithSwitch(BrushCacheBenchmarkData.CacheCapacity), Colors); + public object InlineLruCache() + => BrushCacheBenchmarkData.Run(new Lru64ColorVectorInlineBrushCache(BrushCacheBenchmarkData.CacheCapacity), Colors); [Benchmark(OperationsPerInvoke = AccessCount * BrushCacheBenchmarkData.LoopCount)] public object NullCache() From 06aacbf7902fafa528b5a72549846fb3a36a114b Mon Sep 17 00:00:00 2001 From: Pedro Jesus Date: Fri, 31 Jul 2026 00:08:04 -0300 Subject: [PATCH 21/21] code review --- src/Controls/src/Core/Brush/Brush.cs | 3 +-- src/Controls/src/Core/Internals/LRUBrushCache.cs | 10 ++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Controls/src/Core/Brush/Brush.cs b/src/Controls/src/Core/Brush/Brush.cs index 7857181349a6..9e4f5003faeb 100644 --- a/src/Controls/src/Core/Brush/Brush.cs +++ b/src/Controls/src/Core/Brush/Brush.cs @@ -1,5 +1,4 @@ #nullable disable -using System; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using GraphicsGradientStop = Microsoft.Maui.Graphics.PaintGradientStop; @@ -20,7 +19,7 @@ public static implicit operator Brush(Paint paint) if (paint is SolidPaint solidPaint) { var color = solidPaint.Color; - return color is null ? Default : _cache.Get(solidPaint.Color); + return color is null ? Default : _cache.Get(color); } diff --git a/src/Controls/src/Core/Internals/LRUBrushCache.cs b/src/Controls/src/Core/Internals/LRUBrushCache.cs index cea9c0de7ef7..3ab2119539d8 100644 --- a/src/Controls/src/Core/Internals/LRUBrushCache.cs +++ b/src/Controls/src/Core/Internals/LRUBrushCache.cs @@ -11,6 +11,10 @@ namespace Microsoft.Maui.Controls.Internals; sealed class LRUBrushCache : ICache { + + readonly Dictionary> _cache; + readonly LinkedList _lru; + readonly int _capacity; /// /// Creates a new instance of /// @@ -29,6 +33,8 @@ public LRUBrushCache(int capacity) } _capacity = capacity; + _cache = new Dictionary>(capacity); + _lru = []; } public LRUBrushCache(int capacity, Dictionary brushes) @@ -59,10 +65,6 @@ public LRUBrushCache(int capacity, Dictionary brushes) } } - readonly Dictionary> _cache = []; - readonly LinkedList _lru = []; - readonly int _capacity; - public ImmutableBrush Get(Color key) { if (_cache.TryGetValue(key, out var node))