为什么具有可为空值的结构的HashSets会异常慢?


69

我调查了性能下降,并将其跟踪到缓慢的HashSet。
我有带有可为空值的结构,用作主键。例如:

public struct NullableLongWrapper
{
    private readonly long? _value;

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

我注意到创建A的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);
}

结果:

           方法 中位数| 缩放比例
----------------- | ---------------- | ---------
            长裤| 22.8682我们| 0.42
    NullableLongs | 39.0337我们| 0.62
         包装纸| 62.8877我们| 1.00
 可空包装| 231,993.7278我们| 3,540.34

与具有a的结构Nullable<long>相比,使用具有a的结构long要慢3540倍!
以我为例,它使800ms和<1ms之间有所不同。

以下是BenchmarkDotNet的环境信息:

OS = Microsoft Windows NT 6.1.7601 Service Pack 1
处理器= Intel(R)CoreTM i7-5600U CPU 2.60GHz,ProcessorCount = 4
Frequency = 2536269 ticks,Resolution = 394.2799 ns,Timer = TSC
CLR = MS.NET 4.0 .30319.42000,Arch = 64位RELEASE [RyuJIT]
GC =并发工作站
JitModules = clrjit-v4.6.1076.0

表现不佳的原因是什么?


我也尝试将字段设置为非只读,这没有帮助。
科比

12
您是否正在实施GetHashCodeEquals在您的结构中?默认实现使用反射。您还应该实施IEquatable<NullableLongWrapper>以防止装箱。

@Lee-不-这是一个竞争示例。没有实现GetHashCodeEquals。不过,这是一个不错的解决方法,我没有尝试过。
科比

2
这是您的实际代码吗?因为long?已经是一个“可空的长包装器”(其实际类型为Nullable<long>),所以无需为其创建结构
BlueRaja-Danny Pflughoeft

4
@BlueRaja-不,这是一个演示问题的最小示例。我真正的结构有两个long?在IT方面。它类似于外部连接的结果,其中左或右可能为null
科比

Answers:


86

之所以发生这种情况,是因为 _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的答案围绕着具有两个字段的结构进行计算,在计算哈希代码时可以从中进行选择-但在此代码中,只有一个字段可供选择-这是一个值类型(a struct)。

但是,这个故事的寓意是:永远不要依赖默认GetHashCode()类型的值类型!


附录

我以为发生的事情可能与汉斯在我所链接的线程中的答案有关-也许是采用了结构中第一个字段(布尔值)的值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()不正确或错误:

行动:我们的返回哈希码的算法有点复杂。我们寻找第一个非静态字段并获取它的哈希码。如果类型没有非静态字段,则返回该类型的哈希码。我们不能使用静态成员的哈希码,因为如果该成员与原始类型具有相同的类型,那么我们将陷入无限循环。

如果该评论是正确的,那么上面的示例中的五个哈希码中的四个将是相同的,因为A所有这些哈希码的值都为0。(假定A是第一个字段,但是如果交换周围的值,您将得到相同的结果:两个字段显然都对哈希码有所贡献。)

然后我尝试将第一个字段更改为布尔值:

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。自定义类型仍然会产生不良行为。资源


5
我太天真了 我相信他们 谢谢!
科比

1
您为什么认为它们将具有相同的哈希码?它们应基于基础价值long

1
文件粗略地说“不要使用ValueType的默认GetHashCode”在这种特殊情况下,可能与唯一被装箱的字段有关
J0HN

1
此外,似乎struct具有第一个Nullable<T>类型字段的任何对象都将返回相同的哈希码。尽管Hans的回答没有提及可为空,但它可能与默认实现的工作方式有关
Groo

1
@MatthewWatson:但这Nullable<T>不是引用类型,它应该是一个内部struct带有附加bool字段的,不是吗?
Groo

12

这是由于struct GetHashCode()行为引起的。如果找到引用类型-它将尝试从第一个非引用类型字段中获取哈希。在您的情况下,它已经找到,并且Nullable <>也是结构,因此它只是弹出了它的私有布尔值(4个字节)


“内部布尔值”是什么意思?
马修·沃森

抱歉,我的意思是“私人”
eocron's

嗯,但是布尔值只有一个字节,但是也许它在某个地方使用了地址。
马修·沃森 Matthew Watson)2016年

1
如果不指定对齐方式,则为4个字节。机器字。此std实现出于性能目的。
eocron's
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.