Почему HashSet <Point> намного медленнее, чем HashSet <string>?

Я хотел хранить некоторые пиксельные местоположения, не допуская дубликатов, поэтому первое, что приходит в голову, - это HashSet<Point> или подобные классы. Однако это кажется очень медленным по сравнению с чем-то вроде HashSet<string>.

Например, этот код:

HashSet<Point> points = new HashSet<Point>();
using (Bitmap img = new Bitmap(1000, 1000))
{
    for (int x = 0; x < img.Width; x++)
    {
        for (int y = 0; y < img.Height; y++)
        {
            points.Add(new Point(x, y));
        }
    }
}

занимает около 22,5 секунд.

В то время как следующий код (который не является хорошим выбором по очевидным причинам) занимает всего 1,6 секунды:

HashSet<string> points = new HashSet<string>();
using (Bitmap img = new Bitmap(1000, 1000))
{
    for (int x = 0; x < img.Width; x++)
    {
        for (int y = 0; y < img.Height; y++)
        {
            points.Add(x + "," + y);
        }
    }
}

Итак, мои вопросы:

  • Есть ли причина для этого? Я проверил этот ответ, но 22,5 сек - это больше, чем числа, показанные в этом ответе.
  • Есть ли лучший способ хранить точки без дубликатов?

Ответ 1

Существуют две перфомансы, вызванные структурой Point. Что-то, что вы можете увидеть, добавив Console.WriteLine(GC.CollectionCount(0)); к тестовому коду. Вы увидите, что для теста Point требуется ~ 3720 коллекций, но для тестирования строки требуется ~ 18 коллекций. Не бесплатно. Когда вы видите тип значения, вызываете так много коллекций, тогда вам нужно заключить "uh-oh, слишком много бокса".

Проблема заключается в том, что для HashSet<T> требуется IEqualityComparer<T> выполнить свою работу. Так как вы его не предоставили, он должен вернуться к возврату EqualityComparer.Default<T>(). Этот метод может хорошо работать для строки, он реализует IEquatable. Но не для Point, это тип, который вызывается из .NET 1.0 и никогда не получал дженериков. Все, что он может сделать, это использовать методы Object.

Другая проблема заключается в том, что Point.GetHashCode() не выполняет звездную работу в этом тесте, слишком много коллизий, поэтому он сильно ударяет Object.Equals(). String имеет отличную реализацию GetHashCode.

Вы можете решить обе проблемы, предоставив HashSet хороший компаратор. Как этот:

class PointComparer : IEqualityComparer<Point> {
    public bool Equals(Point x, Point y) {
        return x.X == y.X && x.Y == y.Y;
    }

    public int GetHashCode(Point obj) {
        // Perfect hash for practical bitmaps, their width/height is never >= 65536
        return (obj.Y << 16) ^ obj.X;
    }
}

И используйте его:

HashSet<Point> list = new HashSet<Point>(new PointComparer());

И это примерно в 150 раз быстрее, легко избивая строковый тест.

Ответ 2

Основной причиной падения производительности является весь бокс (как уже объяснялось в ответе Hans Passant).

Кроме того, алгоритм хеш-кода усугубляет проблему, потому что он вызывает больше вызовов Equals(object obj), увеличивая при этом количество конверсий бокса.

Также обратите внимание, что хэш-код Point вычисляется x ^ y. Это дает очень мало дисперсии в вашем диапазоне данных, и поэтому ведра HashSet перенаселены — что не происходит с string, где дисперсия хэшей намного больше.

Вы можете решить эту проблему, реализовав собственную структуру Point (тривиально) и используя лучший алгоритм хеширования для вашего ожидаемого диапазона данных, например. сдвигая координаты:

(x << 16) ^ y

Для некоторых хороших советов, когда дело доходит до хэш-кодов, прочитайте сообщение в блоге Эрика Липперта по теме.