【问题标题】:Why .NET group by is (much) slower when the number of buckets grows为什么当存储桶数量增加时 .NET group by 会(非常)慢
【发布时间】:2014-04-02 14:26:46
【问题描述】:

鉴于这段简单的代码和 1000 万个随机数数组:

static int Main(string[] args)
    {
        int size = 10000000;
        int num =  10; //increase num to reduce number of buckets
        int numOfBuckets = size/num;
        int[] ar = new int[size];
        Random r = new Random(); //initialize with randum numbers
        for (int i = 0; i < size; i++)
            ar[i] = r.Next(size);

        var s = new Stopwatch();
        s.Start();
        var group = ar.GroupBy(i => i / num);
        var l = group.Count();
        s.Stop();

        Console.WriteLine(s.ElapsedMilliseconds);
        Console.ReadLine();
        return 0;
    }

我在分组上做了一些性能,所以当桶数为 10k 时,估计执行时间为 0.7s,100k 桶为 2s,1m 桶为 7.5s。

我想知道为什么会这样。我想如果 GroupBy 是使用 HashTable 实现的,那么可能会出现冲突问题。例如,最初哈希表准备好为 1000 个组工作,然后当组数增加时,它需要增加大小并进行重新散列。如果是这种情况,我可以编写自己的分组,在其中使用预期的存储桶数初始化 HashTable,我这样做了,但它只是稍微快了一点。

所以我的问题是,为什么桶的数量对 groupBy 的性能影响如此之大?

编辑: 在release模式下运行,结果分别为0.55s、1.6s、6.5s。

我还将 group.ToArray 更改为下面的一段代码,只是为了强制执行分组:

foreach (var g in group)
    array[g.Key] = 1;  

如果数组在定时器之前以适当的大小初始化,结果几乎保持不变。

编辑2: 您可以在这里 pastebin.com/tJUYUhGL 看到来自 mellamokb 的工作代码

【问题讨论】:

  • 使用表达式i / numOfBuckets,您实际上得到更多 个桶,因为numOfBuckets 变小了。你是说i % numOfBuckets吗?
  • 我在代码中添加了 cmets 以进行澄清。
  • 向我们展示您正在测试的代码,包括时序代码。此外,请告诉我们您是如何进行测试的。除非您在没有附加调试器的情况下为发布构建计时,否则您的计时将受到高度怀疑。
  • 添加了发布时间和时间代码。
  • 您需要向我们展示您的完整测试程序,包括您用于计时的代码(即Stopwatch)。我应该能够将您的方法复制到我的测试程序中并运行它。您对“桶数”的讨论一开始就令人困惑,而您的“更正”只是让它变得更加混乱。我认为我知道你的问题是什么,但很难确定,因为你的术语不一致。如果您需要帮助解决这个问题,您需要提供SSCCE,并确保您的问题文本与示例一致。

标签: c# .net performance group-by grouping


【解决方案1】:

我很确定这显示了内存局部性(不同级别的缓存)以及对象分配的影响。

为了验证这一点,我采取了三个步骤:

  • 改进基准测试以避免不必要的部分并在测试之间进行垃圾收集
  • 通过填充 Dictionary 删除 LINQ 部分(这实际上是 GroupBy 在幕后所做的)
  • 甚至删除Dictionary&lt;,&gt; 并为普通数组显示相同的趋势。

为了在数组中显示这一点,我需要增加输入大小,但它确实显示出同样的增长。

这是一个简短但完整的程序,可用于测试字典和数组端 - 只需翻转中间注释掉的行:

using System;
using System.Collections.Generic;
using System.Diagnostics;

class Test
{    
    const int Size = 100000000;
    const int Iterations = 3;
    
    static void Main()
    {
        int[] input = new int[Size];
        // Use the same seed for repeatability
        var rng = new Random(0);
        for (int i = 0; i < Size; i++)
        {
            input[i] = rng.Next(Size);
        }
        
        // Switch to PopulateArray to change which method is tested
        Func<int[], int, TimeSpan> test = PopulateDictionary;
        
        for (int buckets = 10; buckets <= Size; buckets *= 10)
        {
            TimeSpan total = TimeSpan.Zero;
            for (int i = 0; i < Iterations; i++)
            {
                // Switch which line is commented to change the test
                // total += PopulateDictionary(input, buckets);
                total += PopulateArray(input, buckets);
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }
            Console.WriteLine("{0,9}: {1,7}ms", buckets, (long) total.TotalMilliseconds);
        }
    }
    
    static TimeSpan PopulateDictionary(int[] input, int buckets)
    {
        int divisor = input.Length / buckets;
        var dictionary = new Dictionary<int, int>(buckets);
        var stopwatch = Stopwatch.StartNew();
        foreach (var item in input)
        {
            int key = item / divisor;
            int count;
            dictionary.TryGetValue(key, out count);
            count++;
            dictionary[key] = count;
        }
        stopwatch.Stop();
        return stopwatch.Elapsed;
    }

    static TimeSpan PopulateArray(int[] input, int buckets)
    {
        int[] output = new int[buckets];
        int divisor = input.Length / buckets;
        var stopwatch = Stopwatch.StartNew();
        foreach (var item in input)
        {
            int key = item / divisor;
            output[key]++;
        }
        stopwatch.Stop();
        return stopwatch.Elapsed;
    }
}

我的机器上的结果:

填充字典:

       10:   10500ms
      100:   10556ms
     1000:   10557ms
    10000:   11303ms
   100000:   15262ms
  1000000:   54037ms
 10000000:   64236ms // Why is this slower? See later.
100000000:   56753ms 

填充数组:

       10:    1298ms
      100:    1287ms
     1000:    1290ms
    10000:    1286ms
   100000:    1357ms
  1000000:    2717ms
 10000000:    5940ms
100000000:    7870ms

早期版本的PopulateDictionary 使用Int32Holder 类,并为每个存储桶创建一个(当在字典中查找失败时)。当存储桶数量较少时,这更快(大概是因为我们每次迭代只通过字典查找路径一次而不是两次),但速度明显变慢,最终耗尽内存。当然,这也会导致碎片化的内存访问。请注意,PopulateDictionary 指定了开始时的容量,以避免测试中数据复制的影响。

使用PopulateArray 方法的目的是去除尽可能多的框架代码,留下更少的想象空间。我还没有尝试过使用自定义结构的数组(具有各种不同的结构大小),但这可能是您也想尝试的。

编辑:无论测试顺序如何,我都可以随意重现 10000000 比 100000000 慢的结果的奇怪之处。我还不明白为什么。它可能特定于我正在使用的确切处理器和缓存...

--编辑--

10000000 比 100000000 结果慢的原因与散列的工作方式有关。更多测试可以解释这一点。

首先,让我们看看操作。有Dictionary.FindEntry,用于[] 索引和Dictionary.TryGetValue,还有Dictionary.Insert,用于[] 索引和Dictionary.Add。如果我们只是做一个FindEntry,时间会按我们的预期上升:

static TimeSpan PopulateDictionary1(int[] input, int buckets)
{
    int divisor = input.Length / buckets;
    var dictionary = new Dictionary<int, int>(buckets);
    var stopwatch = Stopwatch.StartNew();
    foreach (var item in input)
    {
        int key = item / divisor;
        int count;
        dictionary.TryGetValue(key, out count);
    }
    stopwatch.Stop();
    return stopwatch.Elapsed;
}

这是实现不必处理哈希冲突(因为没有),这使得行为符合我们的预期。一旦我们开始处理碰撞,时间就会开始下降。如果我们有和元素一样多的桶,那么碰撞显然会更少......确切地说,我们可以通过这样做来准确计算出有多少碰撞:

static TimeSpan PopulateDictionary(int[] input, int buckets)
{
    int divisor = input.Length / buckets;
    int c1, c2;
    c1 = c2 = 0;
    var dictionary = new Dictionary<int, int>(buckets);
    var stopwatch = Stopwatch.StartNew();
    foreach (var item in input)
    {
        int key = item / divisor;
        int count;
        if (!dictionary.TryGetValue(key, out count))
        {
            dictionary.Add(key, 1);
            ++c1;
        }
        else
        {
            count++;
            dictionary[key] = count;
            ++c2;
        }
    }
    stopwatch.Stop();
    Console.WriteLine("{0}:{1}", c1, c2);
    return stopwatch.Elapsed;
}

结果是这样的:

10:99999990
       10:    4683ms
100:99999900
      100:    4946ms
1000:99999000
     1000:    4732ms
10000:99990000
    10000:    4964ms
100000:99900000
   100000:    7033ms
1000000:99000000
  1000000:   22038ms
9999538:90000462       <<-
 10000000:   26104ms
63196841:36803159      <<-
100000000:   25045ms

注意“36803159”的值。这就回答了为什么最后一个结果比第一个结果更快的问题:它只需要执行更少的操作——而且由于缓存无论如何都会失败,所以这个因素不再有影响了。

【讨论】:

  • 也许它只是更快,因为英特尔 Speedstep 启动了,提高了单个 CPU 内核的频率。在我自己的一些测试中,我注意到了同样的行为。
  • @StefandeBruijn:虽然这是可能的,但我怀疑在这种情况下不太可能 - 我已经运行了很多测试,所以如果它会的。我会看看我是否可以重现它 - 这很有趣。
  • 刚刚在我的 FixBucketTest 实现上进行了测试;我无法在这里重现您的结果。我确实使用了不同的字典调用,但 afaik 不应该有所作为,因为它们都以相同的 FindEntry/Insert 调用结束。不过,这很有趣,所以如果你发现了什么,请告诉我。
  • @StefandeBruijn:您可以尝试使用我发布的确切代码吗?无论顺序如何,我都可以重现这三个慢速测试的时间 - 具有 10000000 个存储桶的测试始终约为 65 秒,而具有 1000000 和 100000000 个存储桶的测试始终约为 55 秒。这很奇怪。
  • 很奇怪。刚刚用您的确切代码进行了测试;在我的电脑上,它只是在使用 .NET 4 x64 JIT'ter 时上升。 (英特尔 i5,8 GB 内存)。计时:64s、77s、109s。 .NET 4.5 x64 JIT'ter 给出了完全不同的时间:71s、101s、107s。之前我已经注意到几次 4.5 JIT'ter 做了一些奇怪的事情(而且通常更慢);你能用 4.0 JIT'ter 确认一下吗?
【解决方案2】:

10k 估计执行时间为 0.7s,100k 桶为 2s,1m 桶为 7.5s。

这是在分析代码时要识别的重要模式。它是软件算法中的标准大小与执行时间关系之一。仅从行为中,您就可以了解算法的实现方式。当然反过来,您可以从算法中预测预期的执行时间。在Big Oh notation 中注释的关系。

您可以获得的最快代码是摊销 O(1),当您将问题规模扩大一倍时,执行时间几乎不会增加。正如 John 所演示的, Dictionary 类的行为就是这样。随着问题集变大,时间的增加是“摊销”部分。 Dictionary 的副作用是必须在不断变大的桶中执行线性 O(n) 搜索。

一个非常常见的模式是O(n)。这告诉您在迭代集合的算法中有一个 for() 循环。 O(n^2) 告诉你有两个嵌套的 for() 循环。 O(n^3) 有三个,等等。

你得到的是介于两者之间的 O(log n)。它是分治算法的标准复杂度。换句话说,每次通过将问题一分为二,继续处理较小的集合。很常见,您可以在排序算法中看到它。二进制搜索是您在教科书中找到的搜索。注意 log₂(10) = 3.3,非常接近您在测试中看到的增量。由于引用的局部性较差,性能开始对非常大的集合有所下降,这是一个始终与 O(log n) 算法相关的 cpu 缓存问题。

John 的回答证明的一件事是他的猜测不可能是正确的,GroupBy() 确实 使用字典。而且这是不可能的,Dictionary 无法提供有序集合。 GroupBy() 必须在哪里被订购,它在 MSDN 库中这样说:

IGrouping 对象的生成顺序基于 source 中生成每个 IGrouping 的第一个键的元素的顺序。分组中的元素按照它们在源代码中出现的顺序产生。

无需维护顺序是 Dictionary 快速的原因。保持秩序总是花费 O(log n),这是你教科书中的二叉树。

长话短说,如果您实际上并不关心顺序,而且您肯定不会关心随机数,那么您就不想使用 GroupBy()。你想使用字典。

【讨论】:

  • 所以没有人注意到的一件事是 GroupBy 必须返回有序的值,这很重要!然而,即使对 100 万个整数随机元素进行排序也只需要不到 100 毫秒,因此与我们看到的几秒钟的增加相比,它是相当小的。那么是元素的大小导致排序如此缓慢吗? (又可能与缓存有关?)
  • 我不会根据我看不到的基准做出判断。你肯定太不欣赏 O(log n) 复杂性。拥有它是一种很好的方式。你把问题放大一千倍,程序只慢十倍。这很好,没有很多算法可以做到这一点。当然不是排序,它是摊销 O(n x log n)。
  • @HansPassant 你错了,分组使用基于哈希的匹配。它们不必排序,只需按照添加时的相同顺序存储在组中。分组的摊销时间肯定是 O(1)。
  • 尝试解释它是如何工作的:想象有一个哈希表 (int[]) 和一个单独的键值数组 (keyvaluepair[])。您可以通过枚举散列或枚举存储键/值对的数组来枚举字典。只要将元素添加到数组中的顺序与它们在源中出现的顺序相同,条件就成立。 Add 摊销 O(1),因为它需要在散列和键值数组中插入,这两个都是 O(1)。二叉树或排序与它无关。
  • @HansPassant 我明白保持顺序非常感谢您:-) 您只是将“按插入顺序保持键”与“按键排序”相混淆。前者用于分组(或者如果您喜欢Lookup&lt;TKey, TElement&gt;.GetGrouping),而您正在谈论后者。简单地说:group by 不排序也不保持键按任何比较操作排序,它只是将一个键附加到一个列表中,如果该键是新的。如果不清楚,我很乐意添加一些代码来说明它是如何工作的。
【解决方案3】:

有(至少)两个影响因素:首先,如果你有一个完美的散列函数,散列表查找只需要 O(1),它不存在。因此,您有哈希冲突。

不过,我想更重要的是缓存效果。现代 CPU 具有较大的缓存,因此对于较小的桶数,哈希表本身可能适合缓存。由于哈希表被频繁访问,这可能会对性能产生很大影响。如果有更多存储桶,则可能需要对 RAM 进行更多访问,这与缓存命中相比会很慢。

【讨论】:

  • 我也认为这可能与缓存有关。
【解决方案4】:

这里有几个因素在起作用。

哈希和分组

分组的工作方式是创建一个哈希表。然后,每个单独的组都支持“添加”操作,该操作将元素添加到添加列表。说白了就是Dictionary&lt;Key, List&lt;Value&gt;&gt;

哈希表总是过度分配。如果将元素添加到哈希中,它会检查是否有足够的容量,如果没有,则重新创建具有更大容量的哈希表(准确地说:新容量 = 计数 * 2 与计数组数)。但是,更大的容量意味着桶索引不再正确,这意味着您必须重新构建哈希表中的条目。 Lookup&lt;Key, Value&gt; 中的 Resize() 方法就是这样做的。

“组”本身就像List&lt;T&gt;。这些也被过度分配,但更容易重新分配。准确地说:数据被简单地复制(在 Array.Resize 中使用 Array.Copy)并添加了一个新元素。由于不涉及重新散列或计算,这是一个相当快的操作。

分组的初始容量为 7。这意味着,对于 10 个元素,您需要重新分配 1 次,对于 100 个元素需要重新分配 4 次,对于 1000 个元素需要重新分配 8 次,依此类推。因为您每次都必须重新散列更多元素,所以每次存储桶数量增加时,您的代码都会变慢一些。

我认为随着存储桶数量的增加,这些过度分配是导致时间小幅增长的最大因素。检验这一理论的最简单方法是完全不进行过度分配(测试 1),只需将计数器放入数组中即可。结果可以在下面FixArrayTest 的代码中显示(或者如果您喜欢FixBucketTest,它更接近于分组的工作方式)。可以看到,# buckets = 10...10000 的时间是一样的,按照这个理论是正确的。

缓存和随机

缓存和随机数生成器不是朋友。

我们的小测试还表明,当存储桶的数量超过某个阈值时,内存就会发挥作用。在我的计算机上,数组大小约为 4 MB(4 * 存储桶数)。因为数据是随机的,随机的 RAM 块将被加载和卸载到缓存中,这是一个缓慢的过程。这也是速度上的一大飞跃。要查看实际情况,请将随机数更改为一个序列(称为“测试 2”),并且 - 因为现在可以缓存数据页面 - 整体速度将保持不变。

请注意,哈希分配过多,因此您将在分组中有一百万个条目之前达到目标。

测试代码

static void Main(string[] args)
{
    int size = 10000000;
    int[] ar = new int[size];

    //random number init with numbers [0,size-1]
    var r = new Random();
    for (var i = 0; i < size; i++)
    {
        ar[i] = r.Next(0, size);
        //ar[i] = i; // Test 2 -> uncomment to see the effects of caching more clearly
    }

    Console.WriteLine("Fixed dictionary:");
    for (var numBuckets = 10; numBuckets <= 1000000; numBuckets *= 10)
    {
        var num = (size / numBuckets);
        var timing = 0L;
        for (var i = 0; i < 5; i++)
        {
            timing += FixBucketTest(ar, num);
            //timing += FixArrayTest(ar, num); // test 1
        }
        var avg = ((float)timing) / 5.0f;

        Console.WriteLine("Avg Time: " + avg + " ms for " + numBuckets);
    }

    Console.WriteLine("Fixed array:");
    for (var numBuckets = 10; numBuckets <= 1000000; numBuckets *= 10)
    {
        var num = (size / numBuckets);
        var timing = 0L;
        for (var i = 0; i < 5; i++)
        {
            timing += FixArrayTest(ar, num); // test 1
        }
        var avg = ((float)timing) / 5.0f;

        Console.WriteLine("Avg Time: " + avg + " ms for " + numBuckets);
    }
}

static long FixBucketTest(int[] ar, int num)
{
    // This test shows that timings will not grow for the smaller numbers of buckets if you don't have to re-allocate
    System.Diagnostics.Stopwatch s = new Stopwatch();
    s.Start();
    var grouping = new Dictionary<int, List<int>>(ar.Length / num + 1); // exactly the right size
    foreach (var item in ar)
    {
        int idx = item / num;
        List<int> ll;
        if (!grouping.TryGetValue(idx, out ll))
        {
            grouping.Add(idx, ll = new List<int>());
        }
        //ll.Add(item); //-> this would complete a 'grouper'; however, we don't want the overallocator of List to kick in
    }
    s.Stop();
    return s.ElapsedMilliseconds;
}

// Test with arrays
static long FixArrayTest(int[] ar, int num)
{
    System.Diagnostics.Stopwatch s = new Stopwatch();
    s.Start();

    int[] buf = new int[(ar.Length / num + 1) * 10];
    foreach (var item in ar)
    {
        int code = (item & 0x7FFFFFFF) % buf.Length;
        buf[code]++;
    }

    s.Stop();
    return s.ElapsedMilliseconds;
}

【讨论】:

    【解决方案5】:

    当执行更大的计算时,计算机上可用的物理内存更少,内存越少,计算存储桶的速度就越慢,随着存储桶的消耗,你的内存会减少。

    尝试以下方法:

    int size = 2500000; //10000000 divided by 4
    int[] ar = new int[size];
    //random number init with numbers [0,size-1]
    System.Diagnostics.Stopwatch s = new Stopwatch();
    s.Start();
    
    for (int i = 0; i<4; i++)
    {
    var group = ar.GroupBy(i => i / num); 
    //the number of expected buckets is size / num.
    var l = group.ToArray();
    }
    
    s.Stop();
    

    用较小的数字计算 4 次。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2012-10-23
      • 2017-04-16
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-04-04
      相关资源
      最近更新 更多