Skip to content

Commit b878df7

Browse files
Faster optimized frozen dictionary creation (3/n) (#87688)
* avoid the need of having `Action<int, int> storeDestIndexFromSrcIndex` by writing the destination indexes to the provided buffer with hashcodes and moving the responsibility to the caller (1-4% gain) * For cases where the key is an integer and we know the input us already unique (because it comes from a dictionary or a hash set) there is no need to create another hash set Also, in cases where simply all hash codes are unique, we can iterate over a span rather than a hash set +9% gain for scenarios where the key was an integer (time), 10-20% allocations drop up to +5% gain where string keys turned out to have unique hash codes Co-authored-by: Stephen Toub <[email protected]>
1 parent 4586587 commit b878df7

File tree

7 files changed

+110
-62
lines changed

7 files changed

+110
-62
lines changed

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenHashTable.cs

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,21 @@ private FrozenHashTable(int[] hashCodes, Bucket[] buckets, ulong fastModMultipli
3636
}
3737

3838
/// <summary>Initializes a frozen hash table.</summary>
39-
/// <param name="hashCodes">Pre-calculated hash codes.</param>
40-
/// <param name="storeDestIndexFromSrcIndex">A delegate that assigns the index to a specific entry. It's passed the destination and source indices.</param>
39+
/// <param name="hashCodes">Pre-calculated hash codes. When the method finishes, it assigns each value to destination index.</param>
4140
/// <param name="optimizeForReading">true to spend additional effort tuning for subsequent read speed on the table; false to prioritize construction time.</param>
41+
/// <param name="hashCodesAreUnique">True when the input hash codes are already unique (provided from a dictionary of integers for example).</param>
4242
/// <remarks>
43-
/// This method will iterate through the incoming entries and will invoke the hasher on each once.
4443
/// It will then determine the optimal number of hash buckets to allocate and will populate the
45-
/// bucket table. In the process of doing so, it calls out to the <paramref name="storeDestIndexFromSrcIndex"/> to indicate
46-
/// the resulting index for that entry. <see cref="FindMatchingEntries(int, out int, out int)"/>
47-
/// then uses this index to reference individual entries by indexing into <see cref="HashCodes"/>.
44+
/// bucket table. The caller is responsible to consume the values written to <paramref name="hashCodes"/> and update the destination (if desired).
45+
/// <see cref="FindMatchingEntries(int, out int, out int)"/> then uses this index to reference individual entries by indexing into <see cref="HashCodes"/>.
4846
/// </remarks>
4947
/// <returns>A frozen hash table.</returns>
50-
public static FrozenHashTable Create(ReadOnlySpan<int> hashCodes, Action<int, int> storeDestIndexFromSrcIndex, bool optimizeForReading = true)
48+
public static FrozenHashTable Create(Span<int> hashCodes, bool optimizeForReading = true, bool hashCodesAreUnique = false)
5149
{
5250
// Determine how many buckets to use. This might be fewer than the number of entries
5351
// if any entries have identical hashcodes (not just different hashcodes that might
5452
// map to the same bucket).
55-
int numBuckets = CalcNumBuckets(hashCodes, optimizeForReading);
53+
int numBuckets = CalcNumBuckets(hashCodes, optimizeForReading, hashCodesAreUnique);
5654
ulong fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)numBuckets);
5755

5856
// Create two spans:
@@ -100,8 +98,10 @@ public static FrozenHashTable Create(ReadOnlySpan<int> hashCodes, Action<int, in
10098
bucketStart = count;
10199
while (index >= 0)
102100
{
103-
hashtableHashcodes[count] = hashCodes[index];
104-
storeDestIndexFromSrcIndex(count, index);
101+
ref int hashCode = ref hashCodes[index];
102+
hashtableHashcodes[count] = hashCode;
103+
// we have used the hash code for the last time, now we re-use the buffer to store destination index
104+
hashCode = count;
105105
count++;
106106
bucketCount++;
107107

@@ -144,9 +144,10 @@ public void FindMatchingEntries(int hashCode, out int startIndex, out int endInd
144144
/// sizes, starting at the exact number of hash codes and incrementing the bucket count by 1 per trial,
145145
/// this is a trade-off between speed of determining a good number of buckets and maximal density.
146146
/// </remarks>
147-
private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForReading)
147+
private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForReading, bool hashCodesAreUnique)
148148
{
149149
Debug.Assert(hashCodes.Length != 0);
150+
Debug.Assert(!hashCodesAreUnique || new HashSet<int>(hashCodes.ToArray()).Count == hashCodes.Length);
150151

151152
const double AcceptableCollisionRate = 0.05; // What is a satisfactory rate of hash collisions?
152153
const int LargeInputSizeThreshold = 1000; // What is the limit for an input to be considered "small"?
@@ -159,38 +160,44 @@ private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForR
159160
}
160161

161162
// Filter out duplicate codes, since no increase in buckets will avoid collisions from duplicate input hash codes.
162-
var codes =
163+
HashSet<int>? codes = null;
164+
int uniqueCodesCount = hashCodes.Length;
165+
if (!hashCodesAreUnique)
166+
{
167+
codes =
163168
#if NETCOREAPP2_0_OR_GREATER
164-
new HashSet<int>(hashCodes.Length);
169+
new HashSet<int>(hashCodes.Length);
165170
#else
166-
new HashSet<int>();
171+
new HashSet<int>();
167172
#endif
168-
foreach (int hashCode in hashCodes)
169-
{
170-
codes.Add(hashCode);
173+
foreach (int hashCode in hashCodes)
174+
{
175+
codes.Add(hashCode);
176+
}
177+
uniqueCodesCount = codes.Count;
171178
}
172-
Debug.Assert(codes.Count != 0);
179+
Debug.Assert(uniqueCodesCount != 0);
173180

174181
// In our precomputed primes table, find the index of the smallest prime that's at least as large as our number of
175182
// hash codes. If there are more codes than in our precomputed primes table, which accommodates millions of values,
176183
// give up and just use the next prime.
177184
ReadOnlySpan<int> primes = HashHelpers.Primes;
178185
int minPrimeIndexInclusive = 0;
179-
while ((uint)minPrimeIndexInclusive < (uint)primes.Length && codes.Count > primes[minPrimeIndexInclusive])
186+
while ((uint)minPrimeIndexInclusive < (uint)primes.Length && uniqueCodesCount > primes[minPrimeIndexInclusive])
180187
{
181188
minPrimeIndexInclusive++;
182189
}
183190

184191
if (minPrimeIndexInclusive >= primes.Length)
185192
{
186-
return HashHelpers.GetPrime(codes.Count);
193+
return HashHelpers.GetPrime(uniqueCodesCount);
187194
}
188195

189196
// Determine the largest number of buckets we're willing to use, based on a multiple of the number of inputs.
190197
// For smaller inputs, we allow for a larger multiplier.
191198
int maxNumBuckets =
192-
codes.Count *
193-
(codes.Count >= LargeInputSizeThreshold ? MaxLargeBucketTableMultiplier : MaxSmallBucketTableMultiplier);
199+
uniqueCodesCount *
200+
(uniqueCodesCount >= LargeInputSizeThreshold ? MaxLargeBucketTableMultiplier : MaxSmallBucketTableMultiplier);
194201

195202
// Find the index of the smallest prime that accommodates our max buckets.
196203
int maxPrimeIndexExclusive = minPrimeIndexInclusive;
@@ -209,7 +216,7 @@ private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForR
209216
int[] seenBuckets = ArrayPool<int>.Shared.Rent((maxNumBuckets / BitsPerInt32) + 1);
210217

211218
int bestNumBuckets = maxNumBuckets;
212-
int bestNumCollisions = codes.Count;
219+
int bestNumCollisions = uniqueCodesCount;
213220

214221
// Iterate through each available prime between the min and max discovered. For each, compute
215222
// the collision ratio.
@@ -222,22 +229,48 @@ private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForR
222229
// Determine the bucket for each hash code and mark it as seen. If it was already seen,
223230
// track it as a collision.
224231
int numCollisions = 0;
225-
foreach (int code in codes)
232+
233+
if (codes is not null && uniqueCodesCount != hashCodes.Length)
226234
{
227-
uint bucketNum = (uint)code % (uint)numBuckets;
228-
if ((seenBuckets[bucketNum / BitsPerInt32] & (1 << (int)bucketNum)) != 0)
235+
foreach (int code in codes)
229236
{
230-
numCollisions++;
231-
if (numCollisions >= bestNumCollisions)
237+
uint bucketNum = (uint)code % (uint)numBuckets;
238+
if ((seenBuckets[bucketNum / BitsPerInt32] & (1 << (int)bucketNum)) != 0)
239+
{
240+
numCollisions++;
241+
if (numCollisions >= bestNumCollisions)
242+
{
243+
// If we've already hit the previously known best number of collisions,
244+
// there's no point in continuing as worst case we'd just use that.
245+
break;
246+
}
247+
}
248+
else
232249
{
233-
// If we've already hit the previously known best number of collisions,
234-
// there's no point in continuing as worst case we'd just use that.
235-
break;
250+
seenBuckets[bucketNum / BitsPerInt32] |= 1 << (int)bucketNum;
236251
}
237252
}
238-
else
253+
}
254+
else
255+
{
256+
// All of the hash codes in hashCodes are unique. In such scenario, it's faster to iterate over a span.
257+
foreach (int code in hashCodes)
239258
{
240-
seenBuckets[bucketNum / BitsPerInt32] |= 1 << (int)bucketNum;
259+
uint bucketNum = (uint)code % (uint)numBuckets;
260+
if ((seenBuckets[bucketNum / BitsPerInt32] & (1 << (int)bucketNum)) != 0)
261+
{
262+
numCollisions++;
263+
if (numCollisions >= bestNumCollisions)
264+
{
265+
// If we've already hit the previously known best number of collisions,
266+
// there's no point in continuing as worst case we'd just use that.
267+
break;
268+
}
269+
}
270+
else
271+
{
272+
seenBuckets[bucketNum / BitsPerInt32] |= 1 << (int)bucketNum;
273+
}
241274
}
242275
}
243276

@@ -247,7 +280,7 @@ private static int CalcNumBuckets(ReadOnlySpan<int> hashCodes, bool optimizeForR
247280
{
248281
bestNumBuckets = numBuckets;
249282

250-
if (numCollisions / (double)codes.Count <= AcceptableCollisionRate)
283+
if (numCollisions / (double)uniqueCodesCount <= AcceptableCollisionRate)
251284
{
252285
break;
253286
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Int32/Int32FrozenDictionary.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@ internal Int32FrozenDictionary(Dictionary<int, TValue> source) : base(EqualityCo
3535
hashCodes[i] = entries[i].Key;
3636
}
3737

38-
_hashTable = FrozenHashTable.Create(
39-
hashCodes,
40-
(destIndex, srcIndex) => _values[destIndex] = entries[srcIndex].Value);
38+
_hashTable = FrozenHashTable.Create(hashCodes, hashCodesAreUnique: true);
39+
40+
for (int srcIndex = 0; srcIndex < hashCodes.Length; srcIndex++)
41+
{
42+
int destIndex = hashCodes[srcIndex];
43+
44+
_values[destIndex] = entries[srcIndex].Value;
45+
}
4146

4247
ArrayPool<int>.Shared.Return(arrayPoolHashCodes);
4348
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/Int32/Int32FrozenSet.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ internal Int32FrozenSet(HashSet<int> source) : base(EqualityComparer<int>.Defaul
2525
int[] entries = ArrayPool<int>.Shared.Rent(count);
2626
source.CopyTo(entries);
2727

28-
_hashTable = FrozenHashTable.Create(
29-
new ReadOnlySpan<int>(entries, 0, count),
30-
static delegate { });
28+
_hashTable = FrozenHashTable.Create(new Span<int>(entries, 0, count), hashCodesAreUnique: true);
3129

3230
ArrayPool<int>.Shared.Return(entries);
3331
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/ItemsFrozenSet.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ protected ItemsFrozenSet(HashSet<T> source, bool optimizeForReading = true) : ba
3030
hashCodes[i] = entries[i] is T t ? Comparer.GetHashCode(t) : 0;
3131
}
3232

33-
_hashTable = FrozenHashTable.Create(
34-
hashCodes,
35-
(destIndex, srcIndex) => _items[destIndex] = entries[srcIndex],
36-
optimizeForReading);
33+
_hashTable = FrozenHashTable.Create(hashCodes, optimizeForReading);
34+
35+
for (int srcIndex = 0; srcIndex < hashCodes.Length; srcIndex++)
36+
{
37+
int destIndex = hashCodes[srcIndex];
38+
39+
_items[destIndex] = entries[srcIndex];
40+
}
3741

3842
ArrayPool<int>.Shared.Return(arrayPoolHashCodes);
3943
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/KeysAndValuesFrozenDictionary.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ protected KeysAndValuesFrozenDictionary(Dictionary<TKey, TValue> source, bool op
3232
hashCodes[i] = Comparer.GetHashCode(entries[i].Key);
3333
}
3434

35-
_hashTable = FrozenHashTable.Create(
36-
hashCodes,
37-
(destIndex, srcIndex) =>
38-
{
39-
_keys[destIndex] = entries[srcIndex].Key;
40-
_values[destIndex] = entries[srcIndex].Value;
41-
},
42-
optimizeForReading);
35+
_hashTable = FrozenHashTable.Create(hashCodes, optimizeForReading);
36+
37+
for (int srcIndex = 0; srcIndex < hashCodes.Length; srcIndex++)
38+
{
39+
int destIndex = hashCodes[srcIndex];
40+
41+
_keys[destIndex] = entries[srcIndex].Key;
42+
_values[destIndex] = entries[srcIndex].Value;
43+
}
4344

4445
ArrayPool<int>.Shared.Return(arrayPoolHashCodes);
4546
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/String/OrdinalStringFrozenDictionary.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,15 @@ internal OrdinalStringFrozenDictionary(
4747
hashCodes[i] = GetHashCode(keys[i]);
4848
}
4949

50-
_hashTable = FrozenHashTable.Create(
51-
hashCodes,
52-
(destIndex, srcIndex) =>
53-
{
54-
_keys[destIndex] = keys[srcIndex];
55-
_values[destIndex] = values[srcIndex];
56-
});
50+
_hashTable = FrozenHashTable.Create(hashCodes);
51+
52+
for (int srcIndex = 0; srcIndex < hashCodes.Length; srcIndex++)
53+
{
54+
int destIndex = hashCodes[srcIndex];
55+
56+
_keys[destIndex] = keys[srcIndex];
57+
_values[destIndex] = values[srcIndex];
58+
}
5759

5860
ArrayPool<int>.Shared.Return(arrayPoolHashCodes);
5961
}

src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/String/OrdinalStringFrozenSet.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,14 @@ internal OrdinalStringFrozenSet(
3838
hashCodes[i] = GetHashCode(entries[i]);
3939
}
4040

41-
_hashTable = FrozenHashTable.Create(
42-
hashCodes,
43-
(destIndex, srcIndex) => _items[destIndex] = entries[srcIndex]);
41+
_hashTable = FrozenHashTable.Create(hashCodes);
42+
43+
for (int srcIndex = 0; srcIndex < hashCodes.Length; srcIndex++)
44+
{
45+
int destIndex = hashCodes[srcIndex];
46+
47+
_items[destIndex] = entries[srcIndex];
48+
}
4449

4550
ArrayPool<int>.Shared.Return(arrayPoolHashCodes);
4651
}

0 commit comments

Comments
 (0)