在C#中同时存储和读取大量小元素

时间:2015-12-15 22:07:38

标签: c# arrays multithreading concurrency hashset

简而言之

如果已经看到很多小的Byte数组,如果没有存储它们并移动到下一批,则需要检查它们。这同时发生。当元素超过100万时,HashSet会产生奇迹,但会完全崩溃(每个数组都可以产生0,1或n个后继者)。我们对删除元素不感兴趣,只是跟踪。什么数据结构足够灵活,具有良好的性能并可由多个线程使用?

对于这个项目,我们需要存储大量某些状态的字节数组,以便跟踪我们看到的数组和不看的数组。该项目是在.NET框架的帮助下在C#中完成的。实际的程序是一个控制台应用程序。挑战在于使单线程参考解决方案成为更快的多线程参考解决方案。

最初他们使用Trie数据结构来存储所有先前的状态,但我们发现它在使用多个线程时表现不佳。相反,我们现在使用带有简单锁定的HashSet,以防我们想要写入它。我们发现它与this FNV散列函数,#34; Fowler / Noll / Vo(FNV)32位散列函数"一起工作得非常好。与单线程参考实现相比,性能提升了大约300%。

失败的最坏情况是:

  • 认为6600万字节数组
  • 740万人最终进入我们的HashSet(其余的都是骗局)
  • 这产生了700万个小字节数组的哈希值,而6600万个检查是否先前已经考虑了一个数组(通过对它们进行哈希并检查该哈希是否已经存在)。

修改 我们在System.Collections.Concurrent中尝试了这些集合,问题在于我们在大多数集合中获得的性能。有些提供太多,有些提供太少。理想情况下,我们只存储唯一的哈希,因此我们最终不会有700万字节的数组。这就是我们使用HashSet的原因,它为这个应用程序提供了令人难以置信的性能,但是当增加成倍增长时,速度会慢下来。

一些实际运行数据:

  • 考虑了7001535个字节数组,找到977689个重复项并将6023846添加到HashSet(第二个最复杂的数组)。
  • 考虑了66478557个字节数组,发现7460501重复,并将59018056添加到HashSet(最坏情况)。

使用HashSet,可以为上述两种情况产生以下结果:

  • 经过时间2017毫秒
  • 经过时间17010毫秒

所以我们在8.43倍的时间内完成了大约9.49倍的工作量,这是一些好的缩放(略小于线性)。但不够。

使用ConcurrentDictionary(值为字节0),我们得到以下结果:

  • 经过时间2898毫秒
  • 经过时间32155毫秒

使用ConcurrentBag我们得到以下结果:

  • 40000 ms后终止
  • 没打扰

在这种情况下,HashSet是明显的赢家。还有一些运行:

  • 考虑了704字节数组,找到了85个重复数据并将619添加到HashSet:经过时间799毫秒
  • 考虑了9931个字节数组,发现了1183个重复项并将其添加到HashSet中;经过时间294毫秒
  • 考虑了3890个字节数组,找到了603个重复项,并将3287添加到HashSet中;经过时间319毫秒
  • 考虑64字节数组,找到8个重复数据并向HashSet添加56;经过时间288毫秒

重要的是要知道,在查看这些数字时,后续者的生成可能会失败(哈哈)。上述情况旨在发现我们计划中可能存在的错误。

2 个答案:

答案 0 :(得分:1)

从概念上讲,HashSet听起来像是你想要做的很好的匹配,但.NET的实现有一个致命的缺陷:它不会让你设置你的初始容量。 (例如,与C ++的ordered_set不同,它允许您在构造时指定存储桶计数)。因此,当您反复使用该系列的容量时,您的大部分时间都花在了重复上。奇怪的是他们不允许你这样做,因为reference source中的评论表明调整大小会伤害。

因此,让我们测量调整大小/重新散列对您造成多大伤害(使用8字节数组,粗略估计最坏情况):

static void Main(string[] args)
{
    const int COUNT = 66478557;
    const int UNIQUE_COUNT = 59018056;

    // create a bunch of 8-byte arrays:
    var arrays = new List<byte[]>(COUNT);
    for (long i = 0; i < COUNT; ++i)
        arrays.Add(BitConverter.GetBytes(i % UNIQUE_COUNT));

    // the HashSet we'll be abusing (i'll plug in a better comparer later):
    var hs = new HashSet<byte[]>(EqualityComparer<byte[]>.Default);
    //var hs = new HashSet<byte[]>(new ByteArrayComparer());

    var sw = Stopwatch.StartNew();

    for (int i = 0; i < COUNT; ++i)
        hs.Add(arrays[i]);
    sw.Stop();

    Console.WriteLine("New HashSet: " + sw.Elapsed.TotalMilliseconds);

    // clear the collection (doesn't reset capacity):
    hs.Clear();

    // Do the adds again, now that the HashSet has suitable capacity:
    sw.Restart();
    for (int i = 0; i < COUNT; ++i)
        hs.Add(arrays[i]);
    sw.Stop();

    Console.WriteLine("Warmed HashSet: " + sw.Elapsed.TotalMilliseconds);
}

我在具有足够容量的“热身”哈希集上显示了近2倍的加速:

New HashSet: 27914.5131
Warmed HashSet: 17683.5115

(顺便说一句,这是在运行笔记本电脑级i5的英特尔NUC上。)

好的,现在让我们加快哈希实现:

class ByteArrayComparer : IEqualityComparer<byte[]>
{
    public int GetHashCode(byte[] obj)
    {
        long myLong = BitConverter.ToInt64(obj, 0);
        // just XOR's upper and lower 4 bytes:
        return myLong.GetHashCode();
    }

    private EqualityComparer<byte[]> _defaultComparer = EqualityComparer<byte[]>.Default;
    public bool Equals(byte[] a1, byte[] a2)
    {
        return _defaultComparer.Equals(a1, a2);
    }
}

结果:

New HashSet: 5397.449
Warmed HashSet: 2013.0509

......获得更大的胜利!

那么你的应用程序有什么方法可以在你的收藏中做这样的热身吗?否则,您可能需要考虑创建/查找允许您配置初始容量的HashSet实现。

答案 1 :(得分:0)

根据数据的分布情况,您可能会考虑保留Trie方法,但基于第一个字节(或其他更好分布的字节进行分区,使用一些重新排序将其置于第一个&#39; in Trie),对“分区字节”的每个值都有一个单独的锁。如果您选择的字节分布均匀,这将大大减少锁争用,因为大多数情况下您的各种线程将访问不同的独立Tries。