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

Improve ImmutableArray.RemoveRange #65186

Conversation

lateapexearlyspeed
Copy link
Contributor

Fixed #65069
Will mark as ready when it is done.

@ghost ghost added the community-contribution Indicates that the PR has been added by a community member label Feb 11, 2022
@ghost
Copy link

ghost commented Feb 11, 2022

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

Issue Details

Fixed #65069
Will mark as ready when it is done.

Author: lateapexearlyspeed
Assignees: -
Labels:

area-System.Collections

Milestone: -

private struct MultiSet
{
private readonly IEqualityComparer<T> _equalityComparer;
private readonly Dictionary<int, LinkedList<(T value, int count)>> _dictionary = new();
Copy link
Member

Choose a reason for hiding this comment

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

Since you're encoding a hashtable implementation here, wouldn't it make sense to use an array of buckets directly to avoid all the linked list allocations?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it, fixed to array-based implementation.
I referred the basic concept (bucket headers array and buckets array) of generic Dictionary and HashSet and simplify something based on ImmutableArray.RemoveRange scenario, for example: no need to reuse free holes because Remove() will only happen after all Add() are called.
Please correct my understanding.

Call .TryRemove() after all Add() calls are called and not remove item entries from array, so not optimize free-holes from Remove
immutableArrray.RemoveRange should know item count so assume it is not possible to exceed initial capacity (no Resize)
@lateapexearlyspeed lateapexearlyspeed marked this pull request as ready for review February 16, 2022 13:25
@lateapexearlyspeed
Copy link
Contributor Author

lateapexearlyspeed commented Feb 17, 2022

@eiriktsarpalis it is ready for review now, also please correct my understand of MultiSet implementation for ImmutableArray.RemoveRange.

}

return self.RemoveAtRange(indicesToRemove);
ICollection<int>? indicesToRemove = items.TryGetCount(out _) ? self.FindOrderedIndicesToRemoveByMultiSet(items, equalityComparer) : self.FindOrderedIndicesToRemove(items, equalityComparer);
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure I understand why we need to fall back to the old algorithm if we can't determine the size?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See previous comment(61196)
Here we use TryGetCount to test type.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe add a comment explaining this? What about collections with very large counts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe add a comment explaining this?

Added.

What about collections with very large counts?

Not sure, I just feel the fall back code is for true enumerable data which sounds like long streaming concept. But if it is already a large reality collection data, that will mean that it was successful to allocate large memory (so same order of magnitude). Please give suggestion.

Copy link
Member

Choose a reason for hiding this comment

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

One possible approach might be to fall back to the slow implementation once the multiset exceeds a certain capacity dynamically:

        private ICollection<int>? FindOrderedIndicesToRemoveByMultiSet(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
        {
            var multiSet = new MultiSet(equalityComparer);
            int i = 0;
            foreach (T item in items)
            {
                multiSet.Add(item);
                if (++i == 100) // threshold way above what I would expect is the typical input enumerable.
                {
                      return FindOrderedIndicesToRemove(items, equalityComparer);
                }
            }

            return GetOrderedIndicesToRemoveFor(multiSet);
        }

I would strongly encourage you to make any decision based on benchmark numbers, however.

Copy link
Contributor Author

@lateapexearlyspeed lateapexearlyspeed Mar 1, 2022

Choose a reason for hiding this comment

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

          // Not check size
           public ICollection<int>? Fast_NoLimit_FindOrderedIndicesToRemoveByMultiSet(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
            {
                var multiSet = new MultiSet(equalityComparer);
                foreach (T item in items)
                {
                    multiSet.Add(item);
                }

                return GetOrderedIndicesToRemoveFor(multiSet);
            }

          // Firstly try to get collection count property directly then fall back to iterate to trigger size limit dynamically
           public ICollection<int>? Fast_TryGetCountThenIterateCheckCount(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
            {
                if (items.TryGetCount(out int count))
                {
                    if (count <= ThrottleValue)
                    {
                        return Fast_NoLimit_FindOrderedIndicesToRemoveByMultiSet(items, equalityComparer);
                    }

                    return SlowFindOrderedIndicesToRemove(items, equalityComparer);
                }

                return Fast_IterateCheckCount(items, equalityComparer);
            }

          // Firstly try to get collection count property directly then fall back to iterate to trigger size limit compared with actual dictionary.count (dedup same value purpose) dynamically
            public ICollection<int>? Fast_TryGetCountThenIterateCheckCountByDicLength(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
            {
                if (items.TryGetCount(out int count))
                {
                    if (count <= ThrottleValue)
                    {
                        return Fast_NoLimit_FindOrderedIndicesToRemoveByMultiSet(items, equalityComparer);
                    }

                    return SlowFindOrderedIndicesToRemove(items, equalityComparer);
                }

                return Fast_IterateCheckCountByDicLength(items, equalityComparer);
            }

          // Iterate to trigger size limit compared with actual dictionary.count (dedup same value purpose) dynamically
            public ICollection<int>? Fast_IterateCheckCountByDicLength(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
            {
                var multiSet = new MultiSet(equalityComparer);
                foreach (T item in items)
                {
                    if (multiSet.ItemCount > ThrottleValue)
                    {
                        return SlowFindOrderedIndicesToRemove(items, equalityComparer);
                    }
                    multiSet.Add(item);
                }

                return GetOrderedIndicesToRemoveFor(multiSet);
            }

          // Iterate to trigger size limit dynamically
            public ICollection<int>? Fast_IterateCheckCount(IEnumerable<T> items, IEqualityComparer<T>? equalityComparer)
            {
                var multiSet = new MultiSet(equalityComparer);
                int i = 0;
                foreach (T item in items)
                {
                    if (++i > ThrottleValue)
                    {
                        return SlowFindOrderedIndicesToRemove(items, equalityComparer);
                    }
                    multiSet.Add(item);
                }

                return GetOrderedIndicesToRemoveFor(multiSet);
            }

           private readonly struct MultiSet
           {
                public int ItemCount => _dictionary.Count;
           }

Benchmark condition:
ImmutableArray length = 100, Items length = 100

  1. Items is true enumerable
    image
  2. Items is array instance
    image

Result:
Pure fast method is ~1x faster than slow method
If not hit throttleValue, pure fast is almost same time as all other fast methods with throttling check
If hit throttleValue so that fall back to slow path, then all fast methods with throttling check are much slower than original slow method, except "Fast_TryGetCountThenIterateCheckCount()" and "Fast_TryGetCountThenIterateCheckCountByDicLength()" when items is Array instance because they can early find situation to fall back to slow path. However they are still slower than original slow method.

So if we need to have size limit, maybe Fast_TryGetCountThenIterateCheckCountByDicLength() is better, but not sure what throttling value is better, LOH triggering size is 85k but need to find out it after Dictionary already scales array size (EnsureCapacity). Need guidance for that, thanks.
Otherwise, pure fast method can give shortest time in all cases.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks, I think Fast_TryGetCountThenIterateCheckCountByDicLength is ok, although I would be interested in seeing the numbers for various sizes of input enumerables, particularly for low values like 1, 2 or 10 elements. It might be the case that for low enough values creating a MultiSet actually makes things slower. You can execute benchmarks for multiple sizes using BDN's ParamsAttribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Under condition of 100 length ImmutableArray, fast method is slower until items length up to ~30.
image
image

Copy link
Member

Choose a reason for hiding this comment

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

Hmmm, these numbers make me think that this optimization is likely not worth our while. I don't think it's worth optimizing for inputs > 30, it's unlikely that this method would be used with so large inputs. I'd probably just close this PR. Thanks for driving the performance investigation!

Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

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

Would it be possible to write a few benchmarks capturing performance gains (if any) brought by this change?

@stephentoub do you think this performance improvement justifies the inclusion & maintenance of yet another hashtable implementation? It should be noted that it was added since the regular Dictionary does not support null keys which is necessary here.

@stephentoub
Copy link
Member

@stephentoub do you think this performance improvement justifies the inclusion & maintenance of yet another hashtable implementation?

If no one (besides me asking whether it was an issue) has complained about the algorithmic complexity involved, probably not.

@lateapexearlyspeed
Copy link
Contributor Author

If no one (besides me asking whether it was an issue) has complained about the algorithmic complexity involved, probably not.

@eiriktsarpalis @stephentoub Then is there any chance to proceed ? Maybe put this MultiSet into ImmutableArray class to specify it is a particular hashtable implementation for ImmutableArray, or use solution of previous commit (reuse Dictionary) ?

@eiriktsarpalis
Copy link
Member

eiriktsarpalis commented Feb 23, 2022

@lateapexearlyspeed come to think of it, you might be able to work around the null key restriction using something like the following:

public readonly struct MaybeNullKeyWrapper<TKey>
{
    public TKey? Key { get; init; }
}

public class MaybeNullKeyComparer<TKey> : IEqualityComparer<MaybeNullKeyWrapper<TKey>>
{
    private readonly IEqualityComparer<TKey> _keyComparer;

    public MaybeNullKeyComparer(IEqualityComparer<TKey> keyComparer)
    {
        _keyComparer = keyComparer ?? EqualityComparer<TKey>.Default;
    }

    public int GetHashCode(MaybeNullKeyWrapper<TKey> wrapper) => _keyComparer.GetHashCode(wrapper);
    public bool Equals(MaybeNullKeyWrapper<TKey> left, MaybeNullKeyWrapper<TKey> right) => _keyComparer.Equals(left.Key, right.Key);
}

It should then be possible to use a regular dictionary without issue:

var multiset = new Dictionary<MaybeNullKeyWrapper<T>, int>(new MaybeNullKeyComparer(equalityComparer));
...


public int GetHashCode(NullableKeyWrapper obj)
{
return obj.Key == null ? 0 : _keyComparer.GetHashCode(obj.Key);
Copy link
Member

Choose a reason for hiding this comment

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

The null check would violate custom equality comparers that equate null with non-null keys.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, but struggle here is as following, please help decide:

  1. See regular HashSet, it uses same way to handle null (no matter comparer) so it will also hit issue you mentioned unless custom comparer's GetHashCode(null) == 0 also.
  2. See definition of IEqualityComparer<T>.GetHashCode([DisallowNull] T obj): interface declaration not allow null argument and compiler nullable check will also find it; although we know Default compare supports and return 0 at least.

Copy link
Member

Choose a reason for hiding this comment

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

interface declaration not allow null argument and compiler nullable check will also find it; although we know Default compare supports and return 0 at least.

I think that's fine, I would probably just add a suppression. A good guide is the current behavior of ImmutableArray.Index, which works with null elements if no equality comparer is specified and will surface any null exceptions a custom equality comparer might throw (it only exercises the Equals method, not GetHashCode).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

}

return self.RemoveAtRange(indicesToRemove);
// Avoid to allocate for true enumerable
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Avoid to allocate for true enumerable
// Avoid building a multiset for enumerables of unknown size.

{
#if NET6_0_OR_GREATER
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_dictionary, item, out _);
count++;
Copy link
Member

Choose a reason for hiding this comment

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

Might need to guard against overflow?

Suggested change
count++;
checked { count++; }

ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_dictionary, item, out _);
count++;
#else
_dictionary[item] = _dictionary.TryGetValue(item, out int count) ? count + 1 : 1;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
_dictionary[item] = _dictionary.TryGetValue(item, out int count) ? count + 1 : 1;
_dictionary[item] = _dictionary.TryGetValue(item, out int count) ? checked(count + 1) : 1;

@lateapexearlyspeed
Copy link
Contributor Author

Close this because the new implementation cannot win old implementation under low item number case.

@eiriktsarpalis
Copy link
Member

Thanks for driving this investigation @lateapexearlyspeed. It was a good learning exercise!

@ghost ghost locked as resolved and limited conversation to collaborators Apr 3, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Collections community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ImmutableArray RemoveRange methods are currently O(M*N)
3 participants