From cf0688565a4c1044d5361380d4388ae883838bbd Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 5 Jun 2022 17:34:34 +0200 Subject: [PATCH] Refactor value storage to reduce overhead and memory allocations. --- src/Avalonia.Base/Avalonia.Base.csproj | 1 + src/Avalonia.Base/AvaloniaObject.cs | 16 + src/Avalonia.Base/StyledElement.cs | 18 +- .../Utilities/AvaloniaPropertyValueStore.cs | 236 +++++++----- src/Avalonia.Base/ValueStore.cs | 12 +- .../Avalonia.Benchmarks.csproj | 3 + .../Base/ValueStoreAddRemoveBenchmarks.cs | 351 ++++++++++++++++++ .../Layout/ControlsBenchmark.cs | 33 ++ 8 files changed, 561 insertions(+), 109 deletions(-) create mode 100644 tests/Avalonia.Benchmarks/Base/ValueStoreAddRemoveBenchmarks.cs diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 16a91b10d6..15f3b67643 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 1f14ddede4..240ce80825 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -25,6 +25,7 @@ namespace Avalonia private List? _inheritanceChildren; private ValueStore? _values; private bool _batchUpdate; + private bool _isInitializing; /// /// Initializes a new instance of the class. @@ -126,6 +127,9 @@ namespace Avalonia { _values = new ValueStore(this); + _values.IsInitializing = _isInitializing; + _isInitializing = false; + if (_batchUpdate) _values.BeginBatchUpdate(); } @@ -493,6 +497,18 @@ namespace Avalonia _batchUpdate = false; _values?.EndBatchUpdate(); } + + internal void SetValueStoreIsInitializing(bool isInitializing) + { + if (_values is null) + { + _isInitializing = isInitializing; + + return; + } + + _values.IsInitializing = isInitializing; + } /// internal void AddInheritanceChild(AvaloniaObject child) diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index f98d2cdbcc..b19ef85cfa 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -306,6 +306,11 @@ namespace Avalonia public virtual void BeginInit() { ++_initCount; + + if (_initCount == 1) + { + SetValueStoreIsInitializing(true); + } } /// @@ -316,10 +321,15 @@ namespace Avalonia throw new InvalidOperationException("BeginInit was not called."); } - if (--_initCount == 0 && _logicalRoot != null) + if (--_initCount == 0) { - ApplyStyling(); - InitializeIfNeeded(); + if (_logicalRoot is not null) + { + ApplyStyling(); + InitializeIfNeeded(); + } + + SetValueStoreIsInitializing(false); } } @@ -337,11 +347,13 @@ namespace Avalonia try { BeginBatchUpdate(); + SetValueStoreIsInitializing(true); AvaloniaLocator.Current.GetService()?.ApplyStyles(this); } finally { EndBatchUpdate(); + SetValueStoreIsInitializing(false); } _styled = true; diff --git a/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs b/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs index cbe3771577..9dbe0765de 100644 --- a/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs +++ b/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace Avalonia.Utilities @@ -8,166 +7,197 @@ namespace Avalonia.Utilities /// Stores values with as key. /// /// Stored value type. - internal sealed class AvaloniaPropertyValueStore + internal struct AvaloniaPropertyValueStore { - // The last item in the list is always int.MaxValue. - private static readonly Entry[] s_emptyEntries = { new Entry { PropertyId = int.MaxValue, Value = default! } }; - - private Entry[] _entries; + private Entry[]? _entries; + private int _entryCount; public AvaloniaPropertyValueStore() { - _entries = s_emptyEntries; + _entries = null; + _entryCount = 0; + IsInitializing = false; + InitialSize = 4; } - public int Count => _entries.Length - 1; - public TValue this[int index] => _entries[index].Value; + public int Count => _entryCount; + + public bool IsInitializing { get; set; } + + public int InitialSize { get; set; } + + public TValue this[int index] => _entries![index].Value; - private (int, bool) TryFindEntry(int propertyId) + private EntryIndex LookupEntry(int propertyId) { - if (_entries.Length <= 12) + int checkIndex; + int iLo = 0; + int iHi = _entryCount; + + if (iHi <= 0) { - // For small lists, we use an optimized linear search. Since the last item in the list - // is always int.MaxValue, we can skip a conditional branch in each iteration. - // By unrolling the loop, we can skip another unconditional branch in each iteration. - - if (_entries[0].PropertyId >= propertyId) - return (0, _entries[0].PropertyId == propertyId); - if (_entries[1].PropertyId >= propertyId) - return (1, _entries[1].PropertyId == propertyId); - if (_entries[2].PropertyId >= propertyId) - return (2, _entries[2].PropertyId == propertyId); - if (_entries[3].PropertyId >= propertyId) - return (3, _entries[3].PropertyId == propertyId); - if (_entries[4].PropertyId >= propertyId) - return (4, _entries[4].PropertyId == propertyId); - if (_entries[5].PropertyId >= propertyId) - return (5, _entries[5].PropertyId == propertyId); - if (_entries[6].PropertyId >= propertyId) - return (6, _entries[6].PropertyId == propertyId); - if (_entries[7].PropertyId >= propertyId) - return (7, _entries[7].PropertyId == propertyId); - if (_entries[8].PropertyId >= propertyId) - return (8, _entries[8].PropertyId == propertyId); - if (_entries[9].PropertyId >= propertyId) - return (9, _entries[9].PropertyId == propertyId); - if (_entries[10].PropertyId >= propertyId) - return (10, _entries[10].PropertyId == propertyId); + return new EntryIndex(0, found: false); } - else + + // Do a binary search to find the value + while (iHi - iLo > 3) { - int low = 0; - int high = _entries.Length; - int id; + int iPv = (iHi + iLo) / 2; + checkIndex = _entries![iPv].PropertyId; - while (high - low > 3) + if (propertyId == checkIndex) { - int pivot = (high + low) / 2; - id = _entries[pivot].PropertyId; - - if (propertyId == id) - return (pivot, true); - - if (propertyId <= id) - high = pivot; - else - low = pivot + 1; + return new EntryIndex(iPv, found: true); } - do + if (propertyId <= checkIndex) + { + iHi = iPv; + } + else { - id = _entries[low].PropertyId; + iLo = iPv + 1; + } + } - if (id == propertyId) - return (low, true); + // Now we only have three values to search; switch to a linear search + do + { + checkIndex = _entries![iLo].PropertyId; - if (id > propertyId) - break; + if (checkIndex == propertyId) + { + return new EntryIndex(iLo, found: true); + } - ++low; + if (checkIndex > propertyId) + { + // we've gone past the targetIndex - return not found + break; } - while (low < high); - } - return (0, false); + iLo++; + } while (iLo < iHi); + + return new EntryIndex(iLo, found: false); } public bool TryGetValue(AvaloniaProperty property, [MaybeNullWhen(false)] out TValue value) { - (int index, bool found) = TryFindEntry(property.Id); - if (!found) + var entryIndex = LookupEntry(property.Id); + + if (!entryIndex.Found) { value = default; return false; } - value = _entries[index].Value; + value = _entries![entryIndex.Index].Value; + return true; } - public void AddValue(AvaloniaProperty property, TValue value) + private void InsertEntry(Entry entry, int entryIndex) { - Entry[] entries = new Entry[_entries.Length + 1]; - - for (int i = 0; i < _entries.Length; ++i) + if (_entryCount > 0) { - if (_entries[i].PropertyId > property.Id) + if (_entryCount == _entries!.Length) { - if (i > 0) + // We want to have more aggressive resizing when initializing. + var growthFactor = IsInitializing ? 2.0 : 1.2; + + var newSize = (int)(_entryCount * growthFactor); + + if (newSize == _entryCount) { - Array.Copy(_entries, 0, entries, 0, i); + newSize++; } - entries[i] = new Entry { PropertyId = property.Id, Value = value }; - Array.Copy(_entries, i, entries, i + 1, _entries.Length - i); - break; + var destEntries = new Entry[newSize]; + + Array.Copy(_entries, 0, destEntries, 0, entryIndex); + + destEntries[entryIndex] = entry; + + Array.Copy(_entries, entryIndex, destEntries, entryIndex + 1, _entryCount - entryIndex); + + _entries = destEntries; } + else + { + Array.Copy( + _entries, + entryIndex, + _entries, + entryIndex + 1, + _entryCount - entryIndex); + + _entries[entryIndex] = entry; + } + } + else + { + if (_entries is null) + { + _entries = new Entry[InitialSize]; + } + + _entries[0] = entry; } - _entries = entries; + _entryCount++; + } + + public void AddValue(AvaloniaProperty property, TValue value) + { + var propertyId = property.Id; + var index = LookupEntry(propertyId); + + InsertEntry(new Entry(propertyId, value), index.Index); } public void SetValue(AvaloniaProperty property, TValue value) { - _entries[TryFindEntry(property.Id).Item1].Value = value; + var propertyId = property.Id; + var entryIndex = LookupEntry(propertyId); + + _entries![entryIndex.Index] = new Entry(propertyId, value); } public void Remove(AvaloniaProperty property) { - var (index, found) = TryFindEntry(property.Id); + var entry = LookupEntry(property.Id); - if (found) - { - var newLength = _entries.Length - 1; - - // Special case - one element left means that value store is empty so we can just reuse our "empty" array. - if (newLength == 1) - { - _entries = s_emptyEntries; - - return; - } - - var entries = new Entry[newLength]; + if (!entry.Found) return; + + Array.Copy(_entries!, entry.Index + 1, _entries!, entry.Index, _entryCount - entry.Index - 1); - int ix = 0; + _entryCount--; + _entries![_entryCount] = default; + } - for (int i = 0; i < _entries.Length; ++i) - { - if (i != index) - { - entries[ix++] = _entries[i]; - } - } + private readonly struct EntryIndex + { + public readonly int Index; + public readonly bool Found; - _entries = entries; + public EntryIndex(int index, bool found) + { + Index = index; + Found = found; } } - private struct Entry + private readonly struct Entry { - internal int PropertyId; - internal TValue Value; + public readonly int PropertyId; + public readonly TValue Value; + + public Entry(int propertyId, TValue value) + { + PropertyId = propertyId; + Value = value; + } } } } diff --git a/src/Avalonia.Base/ValueStore.cs b/src/Avalonia.Base/ValueStore.cs index 69c644dff9..664fe6c820 100644 --- a/src/Avalonia.Base/ValueStore.cs +++ b/src/Avalonia.Base/ValueStore.cs @@ -24,7 +24,7 @@ namespace Avalonia internal class ValueStore { private readonly AvaloniaObject _owner; - private readonly AvaloniaPropertyValueStore _values; + private AvaloniaPropertyValueStore _values; private BatchUpdate? _batchUpdate; public ValueStore(AvaloniaObject owner) @@ -33,6 +33,12 @@ namespace Avalonia _values = new AvaloniaPropertyValueStore(); } + public bool IsInitializing + { + get => _values.IsInitializing; + set => _values.IsInitializing = value; + } + public void BeginBatchUpdate() { _batchUpdate ??= new BatchUpdate(this); @@ -381,7 +387,7 @@ namespace Avalonia private class BatchUpdate { - private ValueStore _owner; + private readonly ValueStore _owner; private List? _notifications; private int _batchUpdateCount; private int _iterator = -1; @@ -408,7 +414,7 @@ namespace Avalonia if (--_batchUpdateCount > 0) return false; - var values = _owner._values; + ref var values = ref _owner._values; // First call EndBatchUpdate on all bindings. This should cause the active binding to be subscribed // but notifications will still not be raised because the owner ValueStore will still have a reference diff --git a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj index 5d17808e0c..7afc0c569f 100644 --- a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj +++ b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj @@ -5,6 +5,9 @@ Exe false + + + diff --git a/tests/Avalonia.Benchmarks/Base/ValueStoreAddRemoveBenchmarks.cs b/tests/Avalonia.Benchmarks/Base/ValueStoreAddRemoveBenchmarks.cs new file mode 100644 index 0000000000..9397ea79b4 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Base/ValueStoreAddRemoveBenchmarks.cs @@ -0,0 +1,351 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Utilities; +using BenchmarkDotNet.Attributes; + +namespace Avalonia.Benchmarks.Base; + +// TODO: Remove after review together with related benchmark code. +internal sealed class AvaloniaPropertyValueStoreOld +{ + // The last item in the list is always int.MaxValue. + private static readonly Entry[] s_emptyEntries = { new Entry { PropertyId = int.MaxValue, Value = default! } }; + + private Entry[] _entries; + + public AvaloniaPropertyValueStoreOld() + { + _entries = s_emptyEntries; + } + + public int Count => _entries.Length - 1; + public TValue this[int index] => _entries[index].Value; + + private (int, bool) TryFindEntry(int propertyId) + { + if (_entries.Length <= 12) + { + // For small lists, we use an optimized linear search. Since the last item in the list + // is always int.MaxValue, we can skip a conditional branch in each iteration. + // By unrolling the loop, we can skip another unconditional branch in each iteration. + + if (_entries[0].PropertyId >= propertyId) + return (0, _entries[0].PropertyId == propertyId); + if (_entries[1].PropertyId >= propertyId) + return (1, _entries[1].PropertyId == propertyId); + if (_entries[2].PropertyId >= propertyId) + return (2, _entries[2].PropertyId == propertyId); + if (_entries[3].PropertyId >= propertyId) + return (3, _entries[3].PropertyId == propertyId); + if (_entries[4].PropertyId >= propertyId) + return (4, _entries[4].PropertyId == propertyId); + if (_entries[5].PropertyId >= propertyId) + return (5, _entries[5].PropertyId == propertyId); + if (_entries[6].PropertyId >= propertyId) + return (6, _entries[6].PropertyId == propertyId); + if (_entries[7].PropertyId >= propertyId) + return (7, _entries[7].PropertyId == propertyId); + if (_entries[8].PropertyId >= propertyId) + return (8, _entries[8].PropertyId == propertyId); + if (_entries[9].PropertyId >= propertyId) + return (9, _entries[9].PropertyId == propertyId); + if (_entries[10].PropertyId >= propertyId) + return (10, _entries[10].PropertyId == propertyId); + } + else + { + int low = 0; + int high = _entries.Length; + int id; + + while (high - low > 3) + { + int pivot = (high + low) / 2; + id = _entries[pivot].PropertyId; + + if (propertyId == id) + return (pivot, true); + + if (propertyId <= id) + high = pivot; + else + low = pivot + 1; + } + + do + { + id = _entries[low].PropertyId; + + if (id == propertyId) + return (low, true); + + if (id > propertyId) + break; + + ++low; + } + while (low < high); + } + + return (0, false); + } + + public bool TryGetValue(AvaloniaProperty property, [MaybeNullWhen(false)] out TValue value) + { + (int index, bool found) = TryFindEntry(property.Id); + if (!found) + { + value = default; + return false; + } + + value = _entries[index].Value; + return true; + } + + public void AddValue(AvaloniaProperty property, TValue value) + { + Entry[] entries = new Entry[_entries.Length + 1]; + + for (int i = 0; i < _entries.Length; ++i) + { + if (_entries[i].PropertyId > property.Id) + { + if (i > 0) + { + Array.Copy(_entries, 0, entries, 0, i); + } + + entries[i] = new Entry { PropertyId = property.Id, Value = value }; + Array.Copy(_entries, i, entries, i + 1, _entries.Length - i); + break; + } + } + + _entries = entries; + } + + public void SetValue(AvaloniaProperty property, TValue value) + { + _entries[TryFindEntry(property.Id).Item1].Value = value; + } + + public void Remove(AvaloniaProperty property) + { + var (index, found) = TryFindEntry(property.Id); + + if (found) + { + var newLength = _entries.Length - 1; + + // Special case - one element left means that value store is empty so we can just reuse our "empty" array. + if (newLength == 1) + { + _entries = s_emptyEntries; + + return; + } + + var entries = new Entry[newLength]; + + int ix = 0; + + for (int i = 0; i < _entries.Length; ++i) + { + if (i != index) + { + entries[ix++] = _entries[i]; + } + } + + _entries = entries; + } + } + + private struct Entry + { + internal int PropertyId; + internal TValue Value; + } +} + +internal class MockProperty : StyledProperty +{ + public MockProperty([JetBrains.Annotations.NotNull] string name) : base(name, typeof(object), new StyledPropertyMetadata()) + { + } +} + +internal static class MockProperties +{ + public static readonly AvaloniaProperty[] LinearProperties; + public static readonly AvaloniaProperty[] ShuffledProperties; + + static MockProperties() + { + LinearProperties = new AvaloniaProperty[32]; + ShuffledProperties = new AvaloniaProperty[32]; + + for (int i = 0; i < LinearProperties.Length; i++) + { + LinearProperties[i] = ShuffledProperties[i] = new MockProperty($"Property#{i}"); + } + + Shuffle(ShuffledProperties, 42); + } + + private static void Shuffle (T[] array, int seed) + { + var rng = new Random(seed); + + int n = array.Length; + while (n > 1) + { + int k = rng.Next(n--); + T temp = array[n]; + array[n] = array[k]; + array[k] = temp; + } + } +} + +[MemoryDiagnoser] +public class ValueStoreLookupBenchmarks +{ + [Params(2, 6, 10, 20, 30)] + public int PropertyCount; + + [Params(false, true)] + public bool UseShuffledProperties; + + public AvaloniaProperty[] Properties => UseShuffledProperties ? MockProperties.ShuffledProperties : MockProperties.LinearProperties; + + private AvaloniaPropertyValueStore _store; + private AvaloniaPropertyValueStoreOld _oldStore; + + [GlobalSetup] + public void GlobalSetup() + { + _store = new AvaloniaPropertyValueStore(); + _oldStore = new AvaloniaPropertyValueStoreOld(); + + for (int i = 0; i < PropertyCount; i++) + { + _store.AddValue(Properties[i], null); + _oldStore.AddValue(Properties[i], null); + } + } + + [Benchmark] + public void LookupProperties() + { + for (int i = 0; i < PropertyCount; i++) + { + _store.TryGetValue(Properties[i], out _); + } + } + + [Benchmark] public void LookupProperties_Old() + { + for (int i = 0; i < PropertyCount; i++) + { + _oldStore.TryGetValue(Properties[i], out _); + } + } +} + +[MemoryDiagnoser] +public class ValueStoreAddRemoveBenchmarks +{ + [Params(2, 6, 10, 20, 30)] + public int PropertyCount; + + [Params(false, true)] + public bool UseShuffledProperties; + + public AvaloniaProperty[] Properties => UseShuffledProperties ? MockProperties.ShuffledProperties : MockProperties.LinearProperties; + + [Benchmark] + [Arguments(false)] + [Arguments(true)] + public void AddValue(bool isInitializing) + { + var store = new AvaloniaPropertyValueStore { IsInitializing = isInitializing }; + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + } + } + + [Benchmark] + [Arguments(false)] + [Arguments(true)] + public void AddAndRemoveValue(bool isInitializing) + { + var store = new AvaloniaPropertyValueStore { IsInitializing = isInitializing }; + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + } + + for (int i = PropertyCount - 1; i >= 0; i--) + { + store.Remove(Properties[i]); + } + } + + [Benchmark] + [Arguments(false)] + [Arguments(true)] + public void AddAndRemoveValueInterleaved(bool isInitializing) + { + var store = new AvaloniaPropertyValueStore { IsInitializing = isInitializing }; + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + store.Remove(Properties[i]); + } + } + + [Benchmark] + public void AddValue_Old() + { + var store = new AvaloniaPropertyValueStoreOld(); + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + } + } + + [Benchmark] + public void AddAndRemoveValue_Old() + { + var store = new AvaloniaPropertyValueStoreOld(); + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + } + + for (int i = PropertyCount - 1; i >= 0; i--) + { + store.Remove(Properties[i]); + } + } + + [Benchmark] + public void AddAndRemoveValueInterleaved_Old() + { + var store = new AvaloniaPropertyValueStoreOld(); + + for (int i = 0; i < PropertyCount; i++) + { + store.AddValue(Properties[i], null); + store.Remove(Properties[i]); + } + } +} diff --git a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs index 3493dd0f53..e71330f8bd 100644 --- a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs @@ -39,6 +39,39 @@ namespace Avalonia.Benchmarks.Layout _root.LayoutManager.ExecuteLayoutPass(); } + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void CreateControl() + { + var control = new Control(); + + _root.Child = control; + + _root.LayoutManager.ExecuteLayoutPass(); + } + + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void CreateDecorator() + { + var control = new Decorator(); + + _root.Child = control; + + _root.LayoutManager.ExecuteLayoutPass(); + } + + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void CreateScrollViewer() + { + var control = new ScrollViewer(); + + _root.Child = control; + + _root.LayoutManager.ExecuteLayoutPass(); + } + [Benchmark] [MethodImpl(MethodImplOptions.NoInlining)] public void CreateButton()