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

Storing and reading a high volume of small elements concurrently in C#

简而言之

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

更长

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

最初他们使用 Trie 数据结构来存储所有以前的状态,但我们发现它在使用多线程时表现不佳。相反,我们现在使用 HashSet with a simple lock in case we want to write to it. We found it to work extremely well with this FNV 哈希函数 "Fowler/Noll/Vo (FNV) 32-bit hash function"。与单线程参考实现相比,性能提升了约 300%。

失败的最坏情况是:

编辑 我们尝试了 System.Collections.Concurrent 中的集合,问题是我们从其中大部分获得的性能。有些提供太多,有些提供太少。理想情况下,我们只存储唯一的哈希值,这样我们就不会得到 700 万字节的数组。这就是我们使用 HashSet 的原因,它对于这个应用程序具有令人难以置信的性能,但当添加量呈指数增长时速度会大大降低。

一些实际的运行数据:

对于上述两种情况,使用 HashSet 这会产生以下结果:

所以我们在 8.43 倍的时间内大致完成了 9.49 倍的工作,这是一个不错的缩放比例(比线性略小)。还不够。

使用 ConcurrentDictionary(值为字节 0)我们得到这些结果:

使用 ConcurrentBag 我们得到这些结果:

在这种情况下,HashSet 无疑是赢家。更多运行:

在查看这些数字时要知道的重要一点是,继任者的生成可能会失败(哈哈)。以上几种情况是为了找出我们程序中可能存在的错误。

根据数据的分布情况,您可以考虑保留 Trie 方法,但根据第一个字节(或其他一些分布更好的字节,使用一些重新排序将其放在 'first' Trie), 'partition byte' 的每个值都有一个单独的锁。如果您选择的字节分布良好,这将大大减少锁争用,因为大多数时候您的各个线程将访问不同的独立尝试。

从概念上讲,HashSet 听起来很适合您要做的事情,但是 .NET 的实现有一个致命的缺陷:它不允许您设置初始容量。 (例如,不同于 C++ 的 ordered_set,它允许您在构造时指定桶计数)。因此,当您反复达到 collection 的容量时,您的大部分时间都花在了重新散列上。奇怪的是他们不允许你这样做,因为 reference source 中的评论表明调整大小会造成伤害。

那么让我们测量一下 resizing/rehashing 对您的伤害有多大(使用 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);
}

我在具有足够容量的 "warmed-up" 哈希集上显示了近 2 倍的加速:

New HashSet: 27914.5131
Warmed HashSet: 17683.5115

(顺便说一下,这是在英特尔 NUC 运行 a laptop-grade i5 上。)

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

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

...为了更大的胜利!

那么,您的应用有什么方法可以在您的 collection 上进行类似这样的预热吗?否则,您可能需要考虑 creating/finding 允许您配置初始容量的 HashSet 实现。