Почему хэш-наборы структур с нулевыми значениями невероятно медленные?


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

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 мс и

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

OS=Microsoft Windows NT 6.1.7601 Service Pack 1
Процессор = Intel(R) Core (TM) i7-5600U CPU 2.60 GHz, ProcessorCount=4
Частота=2536269 тиков, разрешение = 394.2799 НС, таймер=TSC
CLR=MS.NET 4.0.30319.42000, Arch=64-битный релиз [RyuJIT]
ГХ=параллельные станции
JitModules=clrjit-v4.6.1076.0

в чем причина такой низкой производительности?

2 67

2 ответа:

это происходит потому, что каждый из элементов _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;
}

он работает гораздо быстрее.

теперь очевидный вопрос: почему хэш-код каждого NullableLongWrapper то же самое.

ответ обсуждается в этой теме. Однако это не совсем отвечает на вопрос, поскольку ответ Ханса вращается вокруг структуры, имеющей два поля, из которых можно выбрать при вычислении хэш-кода , но в этом коде есть только одно поле на выбор - и это тип значения (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 происходит за кулисами, но это предположение.)

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

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

если этот комментарий был правдой, то четыре из пяти хэш-кодов в приведенном выше примере будет то же самое, так как 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. Пользовательские типы по-прежнему дают плохое поведение. источник

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