Optimize ImmutableHashSet<T>.SetEquals to avoid unnecessary allocations#126309
Optimize ImmutableHashSet<T>.SetEquals to avoid unnecessary allocations#126309aw0lid wants to merge 1 commit intodotnet:mainfrom
Conversation
1eb3cee to
8c80f9d
Compare
8c80f9d to
6709a9e
Compare
6709a9e to
9910d86
Compare
9910d86 to
ff6af74
Compare
ff6af74 to
5f2749e
Compare
3c685c8 to
45c2c14
Compare
45c2c14 to
6a3ebf6
Compare
13cc045 to
1ab929a
Compare
|
Gentle ping in case this fell through the cracks |
1ab929a to
6a2294d
Compare
6a2294d to
620017b
Compare
|
@dotnet/area-system-collections for secondary review |
| { | ||
| return false; | ||
| } | ||
| foreach (T item in otherAsHashSet) |
There was a problem hiding this comment.
ImmutableHashSet uses an AVL tree which makes lookup O(log n), so this loop is (n log n). However, lookup in a HashSet is O(1) so if you flip the enumeration to be on the ImmutableHashSet instead of the HashSet, then this loop becomes O(n).
There was a problem hiding this comment.
Good catch! It's a very straightforward optimization that we missed. However, while implementing it, I found a performance regression (about 3-4ns) in the fast-path scenarios. I believe this is because the increased method complexity prevents the JIT from inlining it.
I've experimented with two approaches. The first was keeping it as a single method:
private static bool SetEquals(IEnumerable<T> other, MutationInput origin)
{
Requires.NotNull(other, nameof(other));
if (other is ICollection<T> otherAsICollectionGeneric)
{
if (otherAsICollectionGeneric.Count < origin.Count)
{
return false;
}
if (other is HashSet<T> otherAsHashSet)
{
if (otherAsHashSet.Comparer == origin.EqualityComparer)
{
if (otherAsHashSet.Count != origin.Count)
{
return false;
}
var e1 = new ImmutableHashSet<T>.Enumerator(origin.Root);
while (e1.MoveNext())
{
if (!otherAsHashSet.Contains(e1.Current))
{
return false;
}
}
return true;
}
}
else if (other is ImmutableHashSet<T> otherAsImmutableHashSet)
{
if (otherAsImmutableHashSet.KeyComparer == origin.EqualityComparer)
{
if (otherAsImmutableHashSet.Count != origin.Count)
{
return false;
}
foreach (T item in otherAsImmutableHashSet)
{
if (!Contains(item, origin))
{
return false;
}
}
return true;
}
}
}
else if (other is ICollection otherAsICollection)
{
if (otherAsICollection.Count < origin.Count)
{
return false;
}
}
var otherSet = new HashSet<T>(other, origin.EqualityComparer);
if (origin.Count != otherSet.Count)
{
return false;
}
var e = new ImmutableHashSet<T>.Enumerator(origin.Root);
while (e.MoveNext())
{
if (!otherSet.Contains(e.Current))
{
return false;
}
}
return true;
}The second was splitting the logic :
public bool SetEquals(IEnumerable<T> other)
{
Requires.NotNull(other, nameof(other));
if (object.ReferenceEquals(this, other))
{
return true;
}
if (other is ICollection<T> otherAsICollectionGeneric)
{
return SetEqualsFastPath(otherAsICollectionGeneric, this.Origin);
}
else if (other is ICollection otherAsICollection)
{
if (otherAsICollection.Count < this.Count)
{
return false;
}
}
return SetEquals(other, this.Origin);
}
private static bool SetEqualsFastPath(ICollection<T> other, MutationInput origin)
{
if (other.Count < origin.Count)
{
return false;
}
if (other is HashSet<T> otherAsHashSet)
{
if (otherAsHashSet.Comparer == origin.EqualityComparer)
{
if (otherAsHashSet.Count != origin.Count)
{
return false;
}
var e = new ImmutableHashSet<T>.Enumerator(origin.Root);
while (e.MoveNext())
{
if (!otherAsHashSet.Contains(e.Current))
{
return false;
}
}
return true;
}
}
else if (other is ImmutableHashSet<T> otherAsImmutableHashSet)
{
if (otherAsImmutableHashSet.KeyComparer == origin.EqualityComparer)
{
if (otherAsImmutableHashSet.Count != origin.Count)
{
return false;
}
foreach (T item in otherAsImmutableHashSet)
{
if (!Contains(item, origin))
{
return false;
}
}
return true;
}
}
return SetEquals(other, origin);
}
private static bool SetEquals(IEnumerable<T> other, MutationInput origin)
{
Requires.NotNull(other, nameof(other));
var otherSet = new HashSet<T>(other, origin.EqualityComparer);
if (origin.Count != otherSet.Count)
{
return false;
}
var e = new ImmutableHashSet<T>.Enumerator(origin.Root);
while (e.MoveNext())
{
if (!otherSet.Contains(e.Current))
{
return false;
}
}
return true;
}| Method Scenario | Before Splitting (No Inlining) | After Splitting (FastPath) | Regression / Overhead | Improvement % |
|---|---|---|---|---|
| BCL HashSet (Smaller Count) | 10.317 ns | 3.066 ns | 7.251 ns | 70.3% |
| HashSet (Diff Comparer - Small) | 10.340 ns | 3.039 ns | 7.301 ns | 70.6% |
| ImmutableHashSet (Larger Count) | 7.674 ns | 3.951 ns | 3.723 ns | 48.5% |
| ImmutableHashSet (Smaller Count) | 7.841 ns | 4.689 ns | 3.152 ns | 40.2% |
| Array (Smaller Count) | 10.653 ns | 8.042 ns | 2.611 ns | 24.5% |
If my assumption is correct, do you think splitting is the best approach here, or is there a way to simplify the method so the JIT can inline it while unified?
cc/ @tannergooding
Fixes #90986
Summary
ImmutableHashSet<T>.SetEqualsalways creates a new intermediateHashSet<T>for theothercollection, leading to avoidable allocations and GC pressure, especially for large datasetsOptimization Logic
falseifotheris anICollectionwith a smallerCount, avoiding any overhead.ImmutableHashSet<T>andHashSet<T>to bypass intermediate allocations.EqualityComparercompatibility before triggering fast paths to ensure logical consistency.Countwithin specialized paths for an immediate exit beforenew HashSet<T>(other)fallback.IEnumerabletypes.Click to expand Benchmark Source Code
Click to expand Benchmark Results
Benchmark Results (Before Optimization)
Benchmark Results (After Optimization)
Performance Analysis Summary (100,000 Elements)