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