Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add FrozenDictionary specialization for integers / enums #111886

Merged
merged 3 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(NetCoreAppCurrent);$(NetCoreAppPrevious);$(NetCoreAppMinimum);netstandard2.0;$(NetFrameworkMinimum)</TargetFrameworks>
Expand Down Expand Up @@ -156,6 +156,10 @@ The System.Collections.Immutable library is built-in as part of the shared frame
<Compile Include="System\Collections\Frozen\String\OrdinalStringFrozenSet.AlternateLookup.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<Compile Include="System\Collections\Frozen\Integer\DenseIntegralFrozenDictionary.cs" />
</ItemGroup>

<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net9.0'))">
<Compile Include="$(CoreLibSharedDir)System\Runtime\CompilerServices\OverloadResolutionPriorityAttribute.cs" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ private static FrozenDictionary<TKey, TValue> CreateFromDictionary<TKey, TValue>
// the Equals/GetHashCode methods to be devirtualized and possibly inlined.
if (typeof(TKey).IsValueType && ReferenceEquals(comparer, EqualityComparer<TKey>.Default))
{
#if NET
if (DenseIntegralFrozenDictionary.TryCreate(source, out FrozenDictionary<TKey, TValue>? 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// 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
{
/// <summary>Provides a <see cref="FrozenDictionary{TKey, TValue}"/> for densely-packed integral keys.</summary>
internal sealed class DenseIntegralFrozenDictionary
{
/// <summary>
/// Maximum allowed ratio of the number of key/value pairs to the range between the minimum and maximum keys.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private const double CountToLengthRatio = 0.1;

public static bool TryCreate<TKey, TValue>(Dictionary<TKey, TValue> source, [NotNullWhen(true)] out FrozenDictionary<TKey, TValue>? 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<TKey, byte, TValue>(source, out result);

if (typeof(TKey) == typeof(sbyte) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(sbyte)))
return TryCreate<TKey, sbyte, TValue>(source, out result);

if (typeof(TKey) == typeof(ushort) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(ushort)))
return TryCreate<TKey, ushort, TValue>(source, out result);

if (typeof(TKey) == typeof(short) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(short)))
return TryCreate<TKey, short, TValue>(source, out result);

if (typeof(TKey) == typeof(int) || (typeof(TKey).IsEnum && typeof(TKey).GetEnumUnderlyingType() == typeof(int)))
return TryCreate<TKey, int, TValue>(source, out result);

Copy link
Contributor

Choose a reason for hiding this comment

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

What about type char?

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you have concrete examples where TKey=char is helpful?

Copy link
Contributor

Choose a reason for hiding this comment

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

For example, a frozen dictionary with keys from 'A' to 'Z'? I guess such case is fairly common.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess such case is fairly common.

Examples?

Copy link
Contributor

Choose a reason for hiding this comment

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

A dictionary of something like:
'A' -> "Alpha"
'B' -> "Bravo"
'C' -> "Charlie"
...

Why not support char considering some rare (I thought) types such as sbyte have been included.

result = null;
return false;
}

private static bool TryCreate<TKey, TKeyUnderlying, TValue>(Dictionary<TKey, TValue> source, [NotNullWhen(true)] out FrozenDictionary<TKey, TValue>? result)
where TKey : notnull
where TKeyUnderlying : unmanaged, IBinaryInteger<TKeyUnderlying>
{
// Start enumerating the dictionary to ensure it has at least one element.
Dictionary<TKey, TValue>.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<TKey, TValue> entry in source)
{
keys[i] = entry.Key;
values[i] = entry.Value;
i++;
}

if (i != keys.Length)
{
throw new InvalidOperationException(SR.CollectionModifiedDuringEnumeration);
}

// Sort the values so that we can more easily check for contiguity but also so that
// the keys/values returned from various properties/enumeration are in a predictable order.
Array.Sort(keys, values);
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorting is expensive. Is it necessary?

  1. min == 0 && length == count is enough to get the result of isFull.
  2. It seems no need to sort before creating WithOptionalValues. And similar implementation can be used when creating WithFullValues to skip sorting.

Copy link
Member Author

Choose a reason for hiding this comment

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

Is it necessary?

As noted in the comment, I wanted the keys/values in order, as we do for e.g. SmallValueTypeComparableFrozenDictionary.

Copy link
Contributor

Choose a reason for hiding this comment

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

SmallValueTypeComparableFrozenDictionary must keep keys in order because its searching relies on this. DenseIntegralFrozenDictionary doesn't have such limit, so I don't think the order of the keys makes sense. And even if we need to order the keys of DenseIntegralFrozenDictionary , there's better way to implement that without sorting.

Copy link
Member Author

Choose a reason for hiding this comment

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

And even if we need to order the keys of DenseIntegralFrozenDictionary , there's better way to implement that without sorting.

?

Copy link
Contributor

@skyoxZ skyoxZ Feb 4, 2025

Choose a reason for hiding this comment

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

For WithOptionalValues, after setting the elements of optionalValues:

int keyIndex = 0;
for (i = 0; i < optionalValues.Length; i++)
{
    if (optionalValues[i].HasValue)
    {
        keys[keyIndex] = i + min;
        values[keyIndex] = optionalValues[i].Value;
        keyIndex++;
    }
}

And for WithFullValues, use similar way but Optional is not needed.

Copy link
Contributor

Choose a reason for hiding this comment

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

But that's not the point. Users never expect FrozenDictionary.Keys are sorted.


// Determine whether all of the keys are contiguous starting at 0.
bool isFull = true;
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. This both provides faster access
// and allows the single values array to be used for lookups and for ValuesCore.
result = new WithFullValues<TKey, TKeyUnderlying, TValue>(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<TValue>[length];
for (i = 0; i < keys.Length; i++)
{
optionalValues[int.CreateTruncating((TKeyUnderlying)(object)keys[i]) - min] = new(values[i], hasValue: true);
}

result = new WithOptionalValues<TKey, TKeyUnderlying, TValue>(keys, values, optionalValues, min);
}

return true;
}
}

result = null;
return false;
}

/// <summary>Implementation used when all keys are contiguous starting at 0.</summary>
[DebuggerTypeProxy(typeof(DebuggerProxy<,,>))]
private sealed class WithFullValues<TKey, TKeyUnderlying, TValue>(TKey[] keys, TValue[] values) :
FrozenDictionary<TKey, TValue>(EqualityComparer<TKey>.Default)
where TKey : notnull
where TKeyUnderlying : IBinaryInteger<TKeyUnderlying>
{
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<TValue>();
}
}

/// <summary>Implementation used when keys are not contiguous and/or do not start at 0.</summary>
[DebuggerTypeProxy(typeof(DebuggerProxy<,,>))]
private sealed class WithOptionalValues<TKey, TKeyUnderlying, TValue>(TKey[] keys, TValue[] values, Optional<TValue>[] optionalValues, int minInclusive) :
FrozenDictionary<TKey, TValue>(EqualityComparer<TKey>.Default)
where TKey : notnull
where TKeyUnderlying : IBinaryInteger<TKeyUnderlying>
{
private readonly TKey[] _keys = keys;
private readonly TValue[] _values = values;
private readonly Optional<TValue>[] _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 index = int.CreateTruncating((TKeyUnderlying)(object)key) - _minInclusive;
Optional<TValue>[] optionalValues = _optionalValues;
if ((uint)index < (uint)optionalValues.Length)
{
ref Optional<TValue> value = ref optionalValues[index];
if (value.HasValue)
{
return ref value.Value;
}
}

return ref Unsafe.NullRef<TValue>();
}
}

private readonly struct Optional<TValue>(TValue value, bool hasValue)
{
public readonly TValue Value = value;
public readonly bool HasValue = hasValue;
}

private sealed class DebuggerProxy<TKey, TKeyUnderlying, TValue>(IReadOnlyDictionary<TKey, TValue> dictionary) :
ImmutableDictionaryDebuggerProxy<TKey, TValue>(dictionary)
where TKey : notnull;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ internal sealed class SmallValueTypeComparableFrozenDictionary<TKey, TValue> : F

internal SmallValueTypeComparableFrozenDictionary(Dictionary<TKey, TValue> source) : base(EqualityComparer<TKey>.Default)
{
Debug.Assert(default(TKey) is IComparable<TKey>);
Debug.Assert(default(TKey) is not null);
Debug.Assert(typeof(TKey).IsValueType);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
internal sealed class ImmutableDictionaryDebuggerProxy<TKey, TValue> where TKey : notnull
internal
#if !NET
sealed
#endif
class ImmutableDictionaryDebuggerProxy<TKey, TValue> where TKey : notnull
{
/// <summary>
/// The dictionary to show to the debugger.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Runtime.CompilerServices;
using Xunit;

Expand Down Expand Up @@ -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<ContiguousFromZeroEnum>
{
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<NonContiguousFromZeroEnum>
{
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<HttpStatusCode>
{
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<sbyte>
{
protected override bool AllowVeryLargeSizes => false;
Expand Down