Skip to content

Faster optimized frozen dictionary creation (4/n) - #87876

Merged
stephentoub merged 2 commits into
dotnet:mainfrom
adamsitnik:frozenPerfLengthBucketsFrozenDictionary
Jun 23, 2023
Merged

Faster optimized frozen dictionary creation (4/n)#87876
stephentoub merged 2 commits into
dotnet:mainfrom
adamsitnik:frozenPerfLengthBucketsFrozenDictionary

Conversation

@adamsitnik

@adamsitnikadamsitnik commented Jun 21, 2023

Copy link
Copy Markdown
Member

Instead of creating a dictionary of lists and a multi-dimensional array we rent a single dimension array, where every bucket has five slots.
The bucket starts at (key.Length - minLength) * 5 index of the array.
Each value is an index of the key from _keys array or just -1, which represents "null".
We avoid having two copies and re-ordering of keys and values collections.

Creation time is from x2 to x10 times faster, not more than 2x slower compared to Dictionary.
Indexing is 6-12% slower, but for large inputs it's still and order of magnitude faster than Dictionary.

MethodJobCountItemsPerBucketMeanRatioAllocatedAlloc Ratio
ToDictionary#87688101102.26 ns-440 B-
ToFrozenDictionary_Optimized#87876101218.24 ns0.25488 B0.16
ToFrozenDictionary_Optimized#87688101889.59 ns1.003120 B1.00
TryGetValue_True_Dictionary#8768810197.78 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610124.48 ns1.13-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810121.69 ns1.00-NA
ToDictionary#87688105104.26 ns-440 B-
ToFrozenDictionary_Optimized#87876105223.41 ns0.43328 B0.33
ToFrozenDictionary_Optimized#87688105523.13 ns1.001000 B1.00
TryGetValue_True_Dictionary#8768810598.22 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610590.22 ns1.12-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810580.66 ns1.00-NA
ToDictionary#876881001766.64 ns-3128 B-
ToFrozenDictionary_Optimized#878761001995.67 ns0.143728 B0.12
ToFrozenDictionary_Optimized#8768810017,179.71 ns1.0030320 B1.00
TryGetValue_True_Dictionary#8768810011,761.21 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#878761001227.97 ns1.13-NA
TryGetValue_True_FrozenDictionaryOptimized#876881001201.72 ns1.00-NA
ToDictionary#876881005761.50 ns-3128 B-
ToFrozenDictionary_Optimized#878761005996.27 ns0.252128 B0.24
ToFrozenDictionary_Optimized#8768810053,959.95 ns1.008768 B1.00
TryGetValue_True_Dictionary#8768810051,042.10 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#878761005929.69 ns1.08-NA
TryGetValue_True_FrozenDictionaryOptimized#876881005864.51 ns1.00-NA
ToDictionary#87688100017,234.20 ns-31016 B-
ToFrozenDictionary_Optimized#87876100018,918.56 ns0.1236128 B0.12
ToFrozenDictionary_Optimized#876881000177,083.15 ns1.00302344 B1.00
TryGetValue_True_Dictionary#8768810001115,510.37 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100012,559.60 ns1.09-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100012,341.85 ns1.00-NA
ToDictionary#87688100057,541.47 ns-31016 B-
ToFrozenDictionary_Optimized#87876100059,438.56 ns0.2420128 B0.23
ToFrozenDictionary_Optimized#876881000539,774.73 ns1.0088040 B1.00
TryGetValue_True_Dictionary#876881000531,294.80 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100059,168.88 ns1.12-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100058,156.66 ns1.00-NA
ToDictionary#87688100001112,776.03 ns-283020 B-
ToFrozenDictionary_Optimized#87876100001263,961.31 ns0.07360130 B0.12
ToFrozenDictionary_Optimized#876881000014,023,881.49 ns1.002942142 B1.00
TryGetValue_True_Dictionary#8768810000112,874,506.46 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610000165,572.38 ns1.08-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810000160,486.95 ns1.00-NA
ToDictionary#87688100005100,764.40 ns-283090 B-
ToFrozenDictionary_Optimized#87876100005168,735.65 ns0.15200128 B0.23
ToFrozenDictionary_Optimized#876881000051,097,493.94 ns1.00871771 B1.00
TryGetValue_True_Dictionary#876881000052,732,495.46 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100005113,734.85 ns1.06-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100005107,283.76 ns1.00-NA

 we rent a single dimension array, where every bucket has five slots.
The bucket starts at (key.Length - minLength) * 5.
Each value is an index of the key from _keys array
or just -1, which represents "null".
Creation time is from 2 to 10 times faster
Indexing is 4-10% slower, but still and order of magnitude faster than Dictionary
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @dotnet/area-system-collections
See info in area-owners.md if you want to be subscribed.

Issue Details

Instead of creating a dictionary of lists and a multi-dimensional array we rent a single dimension array, where every bucket has five slots.
The bucket starts at (key.Length - minLength) * 5 index of the array.
Each value is an index of the key from _keys array or just -1, which represents "null".
We avoid having two copies and re-ordering of keys and values collections.

Creation time is from x2 to x10 times faster, not more than 2x slower compared to Dictionary.
Indexing is 4-10% slower, but still and order of magnitude faster than Dictionary.

Author:adamsitnik
Assignees:-
Labels:

area-System.Collections, tenet-performance

Milestone:-

// If there would be too much empty space in the lookup array, bail.
if (groupedByLength.Count / (double)spread < EmptyLengthsRatio)
{
// This size check prevents OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not really :) It just prevents artificial OOMs from arrays of unsupported sizes. You could still try to rent an array of length Array.MaxLength - 1 and OOM.

int arraySize = spread * MaxPerLength;
#if NET6_0_OR_GREATER
List<KeyValuePair<string, TValue>> list = CollectionsMarshal.GetValueRefOrAddDefault(groupedByLength, s.Length, out _) ??= new List<KeyValuePair<string, TValue>>(MaxPerLength);
if (arraySize >= Array.MaxLength)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why >= rather than >?

internal sealed class LengthBucketsFrozenDictionary<TValue> : FrozenDictionary<string, TValue>
{
/// <summary>Allowed ratio between buckets with values and total buckets. Under this ratio, this implementation won't be used due to too much wasted space.</summary>
private const double EmptyLengthsRatio = 0.2;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't understand why it's ok to do away with this concept. If I have one string of length 1_000_000 and one string of length 1, won't I now end up allocating an array of 5 million elements?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

If I have one string of length 1_000_000 and one string of length 1, won't I now end up allocating an array of 5 million elements?

This case is the first thing we check and I've left it untouched:

// If without even looking at the keys we know that some bucket will exceed the max per-bucket
// limit (pigeon hole principle), we can early-exit out without doing any further work.
intspread=maxLength-minLength+1;
if(keys.Length/spread>MaxPerLength)
{
returnnull;
}

I am going to benchmark whether it's beneficial to remove this check or not and get back to you.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This case is the first thing we check and I've left it untouched:

No, that's different. The check that's there is seeing whether it can be easily predetermined whether any bucket might have too many elements in it. In contrast, the EmptyLengthsRatio you deleted was used to determine whether the lengths were too spread out such that we'd be wasting a ton of space with holes in the array.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am going to benchmark whether it's beneficial to remove this check or not and get back to you.

For MaxPerLength you mean? For a given bucket, the implementation is doing a linear scan of every element. If 1000 strings were to end up in the same bucket and every look up involved comparing up to 1000 strings, it would absolutely be worse than an O(1) dictionary lookup. So there is going to be some threshold where we no longer want to use this strategy. It's certainly possible, especially with the other changes, that MaxPerLength can be increased, but it can't become infinite.

Comment on lines +78 to +82
if (buckets[index] < 0) buckets[index] = i;
else if (buckets[index + 1] < 0) buckets[index + 1] = i;
else if (buckets[index + 2] < 0) buckets[index + 2] = i;
else if (buckets[index + 3] < 0) buckets[index + 3] = i;
else if (buckets[index + 4] < 0) buckets[index + 4] = i;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's brittle to manually unroll this loop that's tied to the MaxPerLength constant (e.g. if the constant were increased or decreased, this unrolling would be wrong). Can we just make this a loop?

Comment on lines +93 to +96
// AllocateUninitializedArray is slower for small inputs, hence the size check.
int[] copy = arraySize < 1000
? new int[arraySize]
: GC.AllocateUninitializedArray<int>(arraySize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The first thing GC.AllocateUninitializedArray<int> does is a length < 512 check. Can we just let it do its thing rather than also checking here?

@danmoseley

danmoseley commented Jun 22, 2023

Copy link
Copy Markdown
Contributor

Cc @geeknoid in case he's interested in this series of PRs

@adamsitnik

Copy link
Copy Markdown
MemberAuthor

Updated perf numbers: Creation time is from x2 to x16 times faster, not more than 2x slower compared to Dictionary.
Indexing is 0-8% slower, but for large inputs it's still and order of magnitude faster than Dictionary.

MethodJobCountPerBucketMeanRatioAllocatedAlloc Ratio
ToDictionary#87688101102.98 ns-440 B-
ToFrozenDictionary_Optimized#87876101232.24 ns0.26488 B0.16
ToFrozenDictionary_Optimized#87688101908.94 ns1.003120 B1.00
TryGetValue_True_Dictionary#8768810194.47 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610122.66 ns1.04-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810121.77 ns1.00-NA
ToDictionary#87688105101.63 ns-440 B-
ToFrozenDictionary_Optimized#87876105236.91 ns0.46328 B0.33
ToFrozenDictionary_Optimized#87688105520.80 ns1.001000 B1.00
TryGetValue_True_Dictionary#8768810589.71 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610588.69 ns1.07-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810582.67 ns1.00-NA
ToDictionary#876881001772.06 ns-3128 B-
ToFrozenDictionary_Optimized#8787610011,014.77 ns0.143728 B0.12
ToFrozenDictionary_Optimized#8768810017,009.70 ns1.0030320 B1.00
TryGetValue_True_Dictionary#8768810011,749.18 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#878761001203.88 ns1.02-NA
TryGetValue_True_FrozenDictionaryOptimized#876881001200.65 ns1.00-NA
ToDictionary#876881005763.90 ns-3128 B-
ToFrozenDictionary_Optimized#8787610051,076.07 ns0.272128 B0.24
ToFrozenDictionary_Optimized#8768810053,980.54 ns1.008768 B1.00
TryGetValue_True_Dictionary#8768810051,031.84 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#878761005968.47 ns1.09-NA
TryGetValue_True_FrozenDictionaryOptimized#876881005886.03 ns1.00-NA
ToDictionary#87688100017,414.00 ns-31016 B-
ToFrozenDictionary_Optimized#87876100019,243.94 ns0.1236128 B0.12
ToFrozenDictionary_Optimized#876881000176,829.34 ns1.00302344 B1.00
TryGetValue_True_Dictionary#8768810001113,945.71 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100012,335.38 ns1.00-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100012,327.41 ns1.00-NA
ToDictionary#87688100057,124.45 ns-31016 B-
ToFrozenDictionary_Optimized#87876100059,365.89 ns0.2420128 B0.23
ToFrozenDictionary_Optimized#876881000538,455.22 ns1.0088040 B1.00
TryGetValue_True_Dictionary#876881000530,516.19 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100058,836.81 ns1.05-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100058,438.84 ns1.00-NA
ToDictionary#87688100001115,595.22 ns-283023 B1.00
ToFrozenDictionary_Optimized#87876100001253,471.88 ns0.06360129 B0.12
ToFrozenDictionary_Optimized#876881000013,989,689.41 ns1.002942141 B1.00
TryGetValue_True_Dictionary#8768810000112,715,860.97 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#8787610000163,461.63 ns1.06-NA
TryGetValue_True_FrozenDictionaryOptimized#8768810000159,874.28 ns1.00-NA
ToDictionary#8768810000595,454.42 ns-283088 B-
ToFrozenDictionary_Optimized#87876100005176,332.31 ns0.17200129 B0.23
ToFrozenDictionary_Optimized#876881000051,068,480.18 ns1.00871771 B1.00
TryGetValue_True_Dictionary#876881000052,605,945.23 ns--NA
TryGetValue_True_FrozenDictionaryOptimized#87876100005114,240.60 ns1.08-NA
TryGetValue_True_FrozenDictionaryOptimized#87688100005105,536.70 ns1.00-NA

@stephentoub
stephentoub merged commit a05537e into dotnet:mainJun 23, 2023
@ghostghost locked as resolved and limited conversation to collaborators Jul 23, 2023
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@adamsitnik@danmoseley@stephentoub