From c5bbc11354175c296c8bfa7360363c49ab267423 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jan 2025 00:59:54 -0500 Subject: [PATCH 1/2] Add FrozenDictionary specialization for integers / enums This adds a specialized dictionary implementation for when TKey is an integer (some integer types) or an enum backed by an integer. It employs an array that can be indexed into directly, handling both the cases where the values are contiguous from zero and where they're either non-contiguous or not from zero. A density threshold is used to decide when to fallback to another implementation rather than expending more memory on unused array slots. --- .../src/System.Collections.Immutable.csproj | 6 +- .../Collections/Frozen/FrozenDictionary.cs | 7 + .../Integer/DenseIntegralFrozenDictionary.cs | 239 ++++++++++++++++++ ...mallValueTypeComparableFrozenDictionary.cs | 1 - .../ImmutableEnumerableDebuggerProxy.cs | 6 +- .../tests/Frozen/FrozenDictionaryTests.cs | 37 +++ 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs diff --git a/src/libraries/System.Collections.Immutable/src/System.Collections.Immutable.csproj b/src/libraries/System.Collections.Immutable/src/System.Collections.Immutable.csproj index 3f4bdabb2e37b6..635a3217a35c0f 100644 --- a/src/libraries/System.Collections.Immutable/src/System.Collections.Immutable.csproj +++ b/src/libraries/System.Collections.Immutable/src/System.Collections.Immutable.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent);$(NetCoreAppPrevious);$(NetCoreAppMinimum);netstandard2.0;$(NetFrameworkMinimum) @@ -156,6 +156,10 @@ The System.Collections.Immutable library is built-in as part of the shared frame + + + + diff --git a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenDictionary.cs b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenDictionary.cs index 0f757b9d1203eb..29c6a0a1fdfd2c 100644 --- a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenDictionary.cs +++ b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenDictionary.cs @@ -122,6 +122,13 @@ private static FrozenDictionary CreateFromDictionary // the Equals/GetHashCode methods to be devirtualized and possibly inlined. if (typeof(TKey).IsValueType && ReferenceEquals(comparer, EqualityComparer.Default)) { +#if NET + if (DenseIntegralFrozenDictionary.TryCreate(source, out FrozenDictionary? result)) + { + return result; + } +#endif + if (source.Count <= Constants.MaxItemsInSmallValueTypeFrozenCollection) { // If the key is a something we know we can efficiently compare, use a specialized implementation diff --git a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs new file mode 100644 index 00000000000000..3e6262c43ea8be --- /dev/null +++ b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace System.Collections.Frozen +{ + /// Provides a for densely-packed integral keys. + internal sealed class DenseIntegralFrozenDictionary + { + /// + /// Maximum allowed ratio of the number of key/value pairs to the range between the minimum and maximum keys. + /// + /// + /// + /// This is dialable. The closer the value gets to 0, the more likely this implementation will be used, + /// and the more memory will be consumed to store the values. The value of 0.1 means that up to 90% of the + /// slots in the values array may be unused. + /// + /// + /// As an example, DaysOfWeek's min is 0, its max is 6, and it has 7 values, such that 7 / (6 - 0 + 1) = 1.0; thus + /// with a threshold of 0.1, DaysOfWeek will use this implementation. But SocketError's min is -1, its max is 11004, and + /// it has 47 values, such that 47 / (11004 - (-1) + 1) = 0.004; thus, SocketError will not use this implementation. + /// + /// + private const double CountToLengthRatio = 0.1; + + public static bool TryCreate(Dictionary source, [NotNullWhen(true)] out FrozenDictionary? result) + where TKey : notnull + { + // Int32 and integer types that fit within Int32. This is to minimize difficulty later validating that + // inputs are in range of int: we can always cast everything to Int32 without loss of information. + + if (typeof(TKey) == typeof(byte) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(byte))) + return TryCreate(source, out result); + + if (typeof(TKey) == typeof(sbyte) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(sbyte))) + return TryCreate(source, out result); + + if (typeof(TKey) == typeof(ushort) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(ushort))) + return TryCreate(source, out result); + + if (typeof(TKey) == typeof(short) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(short))) + return TryCreate(source, out result); + + if (typeof(TKey) == typeof(int) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(int))) + return TryCreate(source, out result); + + result = null; + return false; + } + + private static bool TryCreate(Dictionary source, [NotNullWhen(true)] out FrozenDictionary? result) + where TKey : notnull + where TKeyUnderlying : unmanaged, IBinaryInteger + { + // Start enumerating the dictionary to ensure it has at least one element. + Dictionary.Enumerator e = source.GetEnumerator(); + if (e.MoveNext()) + { + // Get that element and treat it as the min and max. Then continue enumerating the remainder + // of the dictionary to count the number of elements and track the full min and max. + int count = 1; + int min = int.CreateTruncating((TKeyUnderlying)(object)e.Current.Key); + int max = min; + while (e.MoveNext()) + { + count++; + int key = int.CreateTruncating((TKeyUnderlying)(object)e.Current.Key); + if (key < min) + { + min = key; + } + else if (key > max) + { + max = key; + } + } + + // Based on the min and max, determine the spread. If the range fits within a non-negative Int32 + // and the ratio of the number of elements in the dictionary to the length is within the allowed + // threshold, create the new dictionary. + long length = (long)max - min + 1; + Debug.Assert(length > 0); + if (length <= int.MaxValue && + (double)count / length >= CountToLengthRatio) + { + // Create arrays of the keys and values, sorted ascending by key. + var keys = new TKey[count]; + var values = new TValue[keys.Length]; + int i = 0; + foreach (KeyValuePair entry in source) + { + keys[i] = entry.Key; + values[i] = entry.Value; + i++; + } + + if (i != keys.Length) + { + throw new InvalidOperationException(SR.CollectionModifiedDuringEnumeration); + } + + Array.Sort(keys, values); + + // Determine whether all of the keys are contiguous starting at 0. + bool isFull = true; + if (isFull) + { + for (i = 0; i < keys.Length; i++) + { + if (int.CreateTruncating((TKeyUnderlying)(object)keys[i]) != i) + { + isFull = false; + break; + } + } + } + + if (isFull) + { + // All of the keys are contiguous starting at 0, so we can use an implementation that + // just stores all the values in an array indexed by key. + result = new WithFullValues(keys, values); + } + else + { + // Some of the keys in the length are missing, so create an array to hold optional values + // and populate the entries just for the elements we have. The 0th element of the optional + // values array corresponds to the element with the min key. + var optionalValues = new Optional[length]; + for (i = 0; i < keys.Length; i++) + { + optionalValues[int.CreateTruncating((TKeyUnderlying)(object)keys[i]) - min] = new(values[i], hasValue: true); + } + + result = new WithOptionalValues(keys, values, optionalValues, min); + } + + return true; + } + } + + result = null; + return false; + } + + /// Implementation used when all keys are contiguous starting at 0. + [DebuggerTypeProxy(typeof(DebuggerProxy<,,>))] + private sealed class WithFullValues(TKey[] keys, TValue[] values) : + FrozenDictionary(EqualityComparer.Default) + where TKey : notnull + where TKeyUnderlying : IBinaryInteger + { + private readonly TKey[] _keys = keys; + private readonly TValue[] _values = values; + + private protected override TKey[] KeysCore => _keys; + + private protected override TValue[] ValuesCore => _values; + + private protected override int CountCore => _keys.Length; + + private protected override Enumerator GetEnumeratorCore() => new Enumerator(_keys, _values); + + private protected override ref readonly TValue GetValueRefOrNullRefCore(TKey key) + { + int index = int.CreateTruncating((TKeyUnderlying)(object)key); + TValue[] values = _values; + if ((uint)index < (uint)values.Length) + { + return ref values[index]; + } + + return ref Unsafe.NullRef(); + } + } + + /// Implementation used when keys are not contiguous and/or do not start at 0. + [DebuggerTypeProxy(typeof(DebuggerProxy<,,>))] + private sealed class WithOptionalValues(TKey[] keys, TValue[] values, Optional[] optionalValues, int minInclusive) : + FrozenDictionary(EqualityComparer.Default) + where TKey : notnull + where TKeyUnderlying : IBinaryInteger + { + private readonly TKey[] _keys = keys; + private readonly TValue[] _values = values; + private readonly Optional[] _optionalValues = optionalValues; + private readonly int _minInclusive = minInclusive; + + private protected override TKey[] KeysCore => _keys; + + private protected override TValue[] ValuesCore => _values; + + private protected override int CountCore => _keys.Length; + + private protected override Enumerator GetEnumeratorCore() => new Enumerator(_keys, _values); + + private protected override ref readonly TValue GetValueRefOrNullRefCore(TKey key) + { + int keyInt32 = int.CreateTruncating((TKeyUnderlying)(object)key); + int minInclusive = _minInclusive; + if (keyInt32 >= minInclusive) + { + int index = keyInt32 - minInclusive; + Optional[] optionalValues = _optionalValues; + if ((uint)index < (uint)optionalValues.Length) + { + ref Optional value = ref optionalValues[index]; + if (value.HasValue) + { + return ref value.Value; + } + } + } + + return ref Unsafe.NullRef(); + } + } + + private readonly struct Optional(TValue value, bool hasValue) + { + public readonly TValue Value = value; + public readonly bool HasValue = hasValue; + } + + private sealed class DebuggerProxy(IReadOnlyDictionary dictionary) : + ImmutableDictionaryDebuggerProxy(dictionary) + where TKey : notnull; + } +} diff --git a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/SmallValueTypeComparableFrozenDictionary.cs b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/SmallValueTypeComparableFrozenDictionary.cs index 884cb7aeca807c..e75c581a693caa 100644 --- a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/SmallValueTypeComparableFrozenDictionary.cs +++ b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/SmallValueTypeComparableFrozenDictionary.cs @@ -24,7 +24,6 @@ internal sealed class SmallValueTypeComparableFrozenDictionary : F internal SmallValueTypeComparableFrozenDictionary(Dictionary source) : base(EqualityComparer.Default) { - Debug.Assert(default(TKey) is IComparable); Debug.Assert(default(TKey) is not null); Debug.Assert(typeof(TKey).IsValueType); diff --git a/src/libraries/System.Collections.Immutable/src/System/Collections/Immutable/ImmutableEnumerableDebuggerProxy.cs b/src/libraries/System.Collections.Immutable/src/System/Collections/Immutable/ImmutableEnumerableDebuggerProxy.cs index b3258e35eb921c..849cd2b4ba279b 100644 --- a/src/libraries/System.Collections.Immutable/src/System/Collections/Immutable/ImmutableEnumerableDebuggerProxy.cs +++ b/src/libraries/System.Collections.Immutable/src/System/Collections/Immutable/ImmutableEnumerableDebuggerProxy.cs @@ -16,7 +16,11 @@ namespace System.Collections.Immutable /// This class should only be used with immutable dictionaries, since it /// caches the dictionary into an array for display in the debugger. /// - internal sealed class ImmutableDictionaryDebuggerProxy where TKey : notnull + internal +#if !NET + sealed +#endif + class ImmutableDictionaryDebuggerProxy where TKey : notnull { /// /// The dictionary to show to the debugger. diff --git a/src/libraries/System.Collections.Immutable/tests/Frozen/FrozenDictionaryTests.cs b/src/libraries/System.Collections.Immutable/tests/Frozen/FrozenDictionaryTests.cs index 86a6f3539278f8..84b251349365d3 100644 --- a/src/libraries/System.Collections.Immutable/tests/Frozen/FrozenDictionaryTests.cs +++ b/src/libraries/System.Collections.Immutable/tests/Frozen/FrozenDictionaryTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Net; using System.Runtime.CompilerServices; using Xunit; @@ -523,6 +524,42 @@ public class FrozenDictionary_Generic_Tests_byte_byte : FrozenDictionary_Generic protected override byte Next(Random random) => (byte)random.Next(byte.MinValue, byte.MaxValue); } + public class FrozenDictionary_Generic_Tests_ContiguousFromZeroEnum_byte : FrozenDictionary_Generic_Tests_base_for_numbers + { + protected override bool AllowVeryLargeSizes => false; + + protected override ContiguousFromZeroEnum Next(Random random) => (ContiguousFromZeroEnum)random.Next(); + } + + public class FrozenDictionary_Generic_Tests_NonContiguousFromZeroEnum_byte : FrozenDictionary_Generic_Tests_base_for_numbers + { + protected override bool AllowVeryLargeSizes => false; + + protected override NonContiguousFromZeroEnum Next(Random random) => (NonContiguousFromZeroEnum)random.Next(); + } + + public enum ContiguousFromZeroEnum + { + A1, B1, C1, D1, E1, F1, G1, H1, I1, J1, K1, L1, M1, N1, O1, P1, Q1, R1, S1, T1, U1, V1, W1, X1, Y1, Z1, + A2, B2, C2, D2, E2, F2, G2, H2, I2, J2, K2, L2, M2, N2, O2, P2, Q2, R2, S2, T2, U2, V2, W2, X2, Y2, Z2, + A3, B3, C3, D3, E3, F3, G3, H3, I3, J3, K3, L3, M3, N3, O3, P3, Q3, R3, S3, T3, U3, V3, W3, X3, Y3, Z3, + A4, B4, C4, D4, E4, F4, G4, H4, I4, J4, K4, L4, M4, N4, O4, P4, Q4, R4, S4, T4, U4, V4, W4, X4, Y4, Z4, + } + + public enum NonContiguousFromZeroEnum + { + A1 = 1, B1, C1, D1, E1, F1, G1, H1, I1, J1, K1, L1, M1, N1, O1, P1, Q1, S1, T1, U1, V1, W1, X1, Y1, Z1, + A2, B2, C2, D2, E2, F2, G2, H2, I2, J2, K2, L2, M2, N2, O2, P2, Q2, R2, S2, T2, U2, V2, W2, X2, Y2, Z2, + A3, B3, C3, D3, E3, F3, G3, H3, I3, J3, K3, L3, M3, N3, O3, P3, Q3, R3, S3, T3, U3, V3, W3, X3, Y3, Z3, + A4, B4, C4, D4, E4, F4, G4, H4, I4, J4, K4, L4, M4, N4, O4, P4, Q4, R4, S4, T4, U4, V4, W4, X4, Y4, Z4, + } + + public class FrozenDictionary_Generic_Tests_HttpStatusCode_byte : FrozenDictionary_Generic_Tests_base_for_numbers + { + protected override bool AllowVeryLargeSizes => false; + protected override HttpStatusCode Next(Random random) => (HttpStatusCode)random.Next(); + } + public class FrozenDictionary_Generic_Tests_sbyte_sbyte : FrozenDictionary_Generic_Tests_base_for_numbers { protected override bool AllowVeryLargeSizes => false; From a84a6f1b1631ce17f353f51dd3b66af391463e73 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jan 2025 08:39:45 -0500 Subject: [PATCH 2/2] Address PR feedback --- .../Integer/DenseIntegralFrozenDictionary.cs | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs index 3e6262c43ea8be..926225aadb1391 100644 --- a/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs +++ b/src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Integer/DenseIntegralFrozenDictionary.cs @@ -109,26 +109,26 @@ private static bool TryCreate(Dictionary(keys, values); } else @@ -206,19 +206,14 @@ private sealed class WithOptionalValues(TKey[] key private protected override ref readonly TValue GetValueRefOrNullRefCore(TKey key) { - int keyInt32 = int.CreateTruncating((TKeyUnderlying)(object)key); - int minInclusive = _minInclusive; - if (keyInt32 >= minInclusive) + int index = int.CreateTruncating((TKeyUnderlying)(object)key) - _minInclusive; + Optional[] optionalValues = _optionalValues; + if ((uint)index < (uint)optionalValues.Length) { - int index = keyInt32 - minInclusive; - Optional[] optionalValues = _optionalValues; - if ((uint)index < (uint)optionalValues.Length) + ref Optional value = ref optionalValues[index]; + if (value.HasValue) { - ref Optional value = ref optionalValues[index]; - if (value.HasValue) - { - return ref value.Value; - } + return ref value.Value; } }