web-dev-qa-db-ja.com

null許容値を持つ構造体のHashSetが非常に遅いのはなぜですか?

パフォーマンスの低下を調査し、HashSetが遅くなるまで追跡しました。
主キーとして使用されるnull値を許容する値を持つ構造体があります。例えば:

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);
}

結果:

メソッド|中央値|スケーリングされた
 ----------------- | ---------------- | --------- 
 Longs | 22.8682 us | 0.42 
 NullableLongs | 39.0337 us | 0.62 
ラッパー| 62.8877 us | 1.00 
 NullableWrappers | 231,993.7278 us | 3,540.34 

Nullable<long>を持つ構造体を使用すると、longを持つ構造体と比較して3540倍遅くなります!
私の場合、800msと<1msの間に差が生じました。

BenchmarkDotNetからの環境情報は次のとおりです。

OS = Microsoft Windows NT 6.1.7601 Service Pack 1
Processor = Intel(R)Core(TM)i7-5600U CPU 2.60GHz、ProcessorCount = 4
周波数= 2536269ティック、解像度= 394.2799 ns、タイマー= TSC
CLR = MS.NET 4.0.30319.42000、Arch = 64ビットリリース[RyuJIT]
GC =同時ワークステーション
JitModules = clrjit-v4.6.1076.0

パフォーマンスがこれほど低い理由は何ですか?

69
Kobi

これは、__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のハッシュコードが同じである理由です。

その答えは このスレッドで説明 です。ただし、Hansの答えは、ハッシュコードを計算するときに選択できる2つのフィールドを持つ構造体を中心に展開するため、質問にはまったく答えません。ただし、このコードでは、選択できるフィールドは1つだけです。 (struct)。

ただし、この話の教訓は次のとおりです:値の型については、デフォルトのGetHashCode()に依存しないでください!


補遺

私はおそらく、起こっていたことはリンクしたスレッドのハンスの答えに関連していると思った-多分それは_Nullable<T>_ struct)の最初のフィールド(bool)の値をとっていたので、私の実験はそれが関連する-しかしそれは複雑です:

このコードとその出力を検討してください。

_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
_

2番目と3番目のハッシュコード(1/0と0/1)は同じですが、他のハッシュコードはすべて異なることに注意してください。 Aを明確に変更するとハッシュコードが変更され、Bも変更されるため、この奇妙なことに気づきますが、2つの値XおよびYが与えられると、同じハッシュコードがA = X、B = YおよびA = Y、B = Xに対して生成されます。

(XORが舞台裏で起こっているように聞こえますが、それは推測です。)

ちなみに、両方のフィールドがハッシュコードに寄与することが示されるこの動作は、ValueType.GetHashType()の参照ソースのコメントが不正確または間違っていることを証明します。

アクション:ハッシュコードを返すためのアルゴリズムは少し複雑です。最初の非静的フィールドを探し、ハッシュコードを取得します。タイプに非静的フィールドがない場合、タイプのハッシュコードを返します。静的メンバーのハッシュコードを取得することはできません。そのメンバーが元の型と同じ型である場合、無限ループに陥るからです。

そのコメントが真の場合、Aの値はすべて0であるため、上記の例の5つのハッシュコードのうち4つは同じになります。 (つまり、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
_

うわー!したがって、最初のフィールドをブール値にすると、フィールドの値に関係なく、すべてのハッシュコードが同じになります。

これはまだ私には何らかのバグのように見えます。

このバグは.NET 4で修正されましたが、Nullableのみです。カスタム型は依然として悪い動作をもたらします。 ソース

87
Matthew Watson

これは、GetHashCode()構造体の動作によるものです。参照型が見つかった場合-最初の非参照型フィールドからハッシュを取得しようとします。あなたの場合、それは見つかりました、そしてNullable <>も構造体なので、それはただプライベートなブール値(4バイト)をポップしました

12
eocron