Почему HashSets структур с нулевыми значениями невероятно медленны?

Я исследовал ухудшение производительности и отследил его до медленных HashSets.
У меня есть структуры с нулевыми значениями, которые используются в качестве первичного ключа. Например:

public struct NullableLongWrapper
{
    private readonly long? _value;

    public NullableLongWrapper(long? value)
    {
        _value = value;
    }
}

Я заметил, что создание HashSet<NullableLongWrapper> исключительно медленное.

Вот пример использования BenchmarkDotNet: (Install-Package BenchmarkDotNet)

using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

public class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<HashSets>();
    }
}

public class Config : ManualConfig
{
    public Config()
    {
        Add(Job.Dry.WithWarmupCount(1).WithLaunchCount(3).WithTargetCount(20));
    }
}

public struct NullableLongWrapper
{
    private readonly long? _value;

    public NullableLongWrapper(long? value)
    {
        _value = value;
    }

    public long? Value => _value;
}

public struct LongWrapper
{
    private readonly long _value;

    public LongWrapper(long value)
    {
        _value = value;
    }

    public long Value => _value;
}

[Config(typeof (Config))]
public class HashSets
{
    private const int ListSize = 1000;

    private readonly List<long?> _nullables;
    private readonly List<long> _longs;
    private readonly List<NullableLongWrapper> _nullableWrappers;
    private readonly List<LongWrapper> _wrappers;

    public HashSets()
    {
        _nullables = Enumerable.Range(1, ListSize).Select(i => (long?) i).ToList();
        _longs = Enumerable.Range(1, ListSize).Select(i => (long) i).ToList();
        _nullableWrappers = Enumerable.Range(1, ListSize).Select(i => new NullableLongWrapper(i)).ToList();
        _wrappers = Enumerable.Range(1, ListSize).Select(i => new LongWrapper(i)).ToList();
    }

    [Benchmark]
    public void Longs() => new HashSet<long>(_longs);

    [Benchmark]
    public void NullableLongs() => new HashSet<long?>(_nullables);

    [Benchmark(Baseline = true)]
    public void Wrappers() => new HashSet<LongWrapper>(_wrappers);

    [Benchmark]
    public void NullableWrappers() => new HashSet<NullableLongWrapper>(_nullableWrappers);
}

Результат:

           Method |          Median |   Scaled
----------------- |---------------- |---------
            Longs |      22.8682 us |     0.42
    NullableLongs |      39.0337 us |     0.62
         Wrappers |      62.8877 us |     1.00
 NullableWrappers | 231,993.7278 us | 3,540.34

Использование структуры с Nullable<long> по сравнению с структурой с long в 3540 раз медленнее!
В моем случае это делало разницу между 800 мс и < 1 мс.

Вот информация об окружающей среде от BenchmarkDotNet:

ОС = Microsoft Windows NT 6.1.7601 Пакет обновления 1
Процессор = Intel (R) Core (TM) i7-5600U CPU 2,60 ГГц, ProcessorCount = 4
Частота = 2536269 тиков, разрешение = 394,2799 нс, таймер = TSC
CLR = MS.NET 4.0.30319.42000, Arch = 64-разрядный RELEASE [RyuJIT]
GC = параллельная рабочая станция
JitModules = clrjit-v4.6.1076.0

Какова причина, по которой это бедность?

Ответ 1

Это происходит потому, что каждый из элементов _nullableWrappers имеет тот же хэш-код, который возвращается GetHashCode(), что приводит к тому, что хеширование вырождается в O (N), а не O (1).

Вы можете проверить это, распечатав все хэш-коды.

Если вы измените свою структуру следующим образом:

public struct NullableLongWrapper
{
    private readonly long? _value;

    public NullableLongWrapper(long? value)
    {
        _value = value;
    }

    public override int GetHashCode()
    {
        return _value.GetHashCode();
    }

    public long? Value => _value;
}

он работает намного быстрее.

Теперь очевидным вопросом является WHY - это хэш-код для всех NullableLongWrapper.

Ответ на этот вопрос обсуждается в этом разделе. Тем не менее, он не совсем отвечает на вопрос, так как ответ Ганса вращается вокруг структуры, имеющей TWO-поля, из которых следует выбирать при вычислении хеш-кода, - но в этом коде есть только одно поле для выбора - и это тип значения (a struct).

Однако мораль этой истории: Никогда не полагайтесь на значения по умолчанию GetHashCode() для типов значений!


Добавление

Я думал, что, возможно, то, что происходило, было связано с ответом Ганса в связанной с ним нитью - возможно, это принимало значение первого поля (bool) в структуре Nullable<T>), и мои эксперименты показывают, что это могут быть связаны - но это осложнилось:

Рассмотрим этот код и его вывод:

using System;

public class Program
{
    static void Main()
    {
        var a = new Test {A = 0, B = 0};
        var b = new Test {A = 1, B = 0};
        var c = new Test {A = 0, B = 1};
        var d = new Test {A = 0, B = 2};
        var e = new Test {A = 0, B = 3};

        Console.WriteLine(a.GetHashCode());
        Console.WriteLine(b.GetHashCode());
        Console.WriteLine(c.GetHashCode());
        Console.WriteLine(d.GetHashCode());
        Console.WriteLine(e.GetHashCode());
    }
}

public struct Test
{
    public int A;
    public int B;
}

Output:

346948956
346948957
346948957
346948958
346948959

Обратите внимание, что второй и третий хеш-коды (для 1/0 и 0/1) одинаковы, но все остальные. Я нахожу это странным, потому что, очевидно, изменение A меняет хэш-код, как и изменение B, но, учитывая два значения X и Y, тот же хэш-код генерируется для A = X, B = Y и A = Y, B = X.

(Похоже, что некоторые вещи XOR происходят за кулисами, но это догадывается.)

Кстати, такое поведение, при котором поля BOTH могут отображаться в качестве хеш-кода, доказывает, что комментарий в исходном источнике для ValueType.GetHashType() является неточным или неправильным:

Действие: Наш алгоритм для возврата хэш-кода немного сложнее. Мы ищем первое нестатическое поле и получаем его hashcode. Если тип не имеет нестатических полей, мы возвращаем хэш-код типа. Мы не можем взять хэш-код статического члена, потому что если этот элемент имеет тот же тип, что и исходный тип, мы закончим бесконечный цикл.

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

Затем я попытался изменить первое поле как bool:

using System;

public class Program
{
    static void Main()
    {
        var a = new Test {A = false, B = 0};
        var b = new Test {A = true,  B = 0};
        var c = new Test {A = false, B = 1};
        var d = new Test {A = false, B = 2};
        var e = new Test {A = false, B = 3};

        Console.WriteLine(a.GetHashCode());
        Console.WriteLine(b.GetHashCode());
        Console.WriteLine(c.GetHashCode());
        Console.WriteLine(d.GetHashCode());
        Console.WriteLine(e.GetHashCode());
    }
}

public struct Test
{
    public bool A;
    public int  B;
}

Output

346948956
346948956
346948956
346948956
346948956

Ничего себе! Поэтому, делая первое поле bool, все хэш-коды выходят одинаково независимо от значений любого из полей!

Это все еще выглядит для меня некоторой ошибкой.

Ошибка была исправлена ​​в .NET 4, но только для Nullable. Пользовательские типы по-прежнему приводят к плохому поведению. источник

Ответ 2

Это связано с поведением структуры GetHashCode(). Если он находит ссылочные типы - он пытается получить хэш из первого поля без ссылки. В вашем случае он был найден, а Nullable < > также является структурой, поэтому он просто вынул его частное логическое значение (4 байта)