当存储桶数量增加时,为什么.NET group by会慢得多

时间:2014-04-02 14:26:46

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

给出这段简单的代码和10mln随机数组:

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。

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

所以我的问题是,为什么数量的桶会影响groupBy的性能呢?

编辑: 在发布模式下运行将结果分别更改为0.55s,1.6s,6.5s。

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

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

其中数组在具有适当大小的计时器之前被初始化,结果几乎保持不变。

EDIT2: 你可以在这里看到mellamokb的工作代码pastebin.com/tJUYUhGL

5 个答案:

答案 0 :(得分:13)

我很确定这是显示内存局部性(各种级别的缓存)和alos对象分配的影响。

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

  • 改进基准测试以避免不必要的部分和测试之间的垃圾收集
  • 通过填充Dictionary删除LINQ部分(有效地GroupBy幕后工作)
  • 删除偶数Dictionary<,>并显示普通数组的相同趋势。

为了向数组显示这个,我需要增加输入大小,但确实显示了相同的增长。

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

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

我的机器上的结果:

PopulateDictionary:

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

PopulateArray:

       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.TryGetValueDictionary.Insert用于[]索引和{{1} }}。如果我们只做一个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;
}

请注意'36803159'的值。这回答了为什么最后一个结果比第一个结果更快的问题:它只需要做更少的操作 - 并且由于缓存无论如何都会失败,因此该因素不再有所作为。

答案 1 :(得分:5)

  

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

这是在您分析代码时识别的重要模式。它是软件算法中标准大小与执行时间关系之一。只是从看到行为,你可以告诉很多关于算法的实现方式。当然,从算法中可以预测出预期的执行时间。在Big Oh notation中注释的关系。

您可以获得的最快速的代码是分摊O(1),当您将问题的大小加倍时,执行时间几乎不会增加。字典&lt;&gt;正如John所证明的那样,这种行为就是这样。随着问题集的增加,时间的增加是“摊销”部分。 Dictionary的副作用必须在不断变大的桶中执行线性O(n)搜索。

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

你得到的是中间的那个,O(log n)。它是分而治之算法的标准复杂性。换句话说,每个过程将问题分成两部分,继续使用较小的集合。很常见,你会在排序算法中看到它。二进制搜索是您在教科书中找到的搜索。注意log 2(10)= 3.3,非常接近你在测试中看到的增量。由于引用的局部性差,一个cpu缓存问题总是与O(log n)算法相关联,Perf开始为非常大的集合开槽。

John回答的一件事就是他的猜测不正确,GroupBy()肯定使用Dictionary&lt;&gt;。并且它不可能通过设计,Dictionary&lt;&gt;不能提供有序的集合。如果要对GroupBy()进行订购,请在MSDN Library中说明:

  

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

不必维持秩序就是使Dictionary&lt;&gt;快速。保持订单总是花费O(log n),这是教科书中的二叉树。

长话短说,如果你真的不关心订单,你肯定不会随机数,那么你不想使用GroupBy()。您想使用词典&lt;&gt;。

答案 2 :(得分:3)

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

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

答案 3 :(得分:2)

这里有一些因素在起作用。

哈希和分组

分组的工作方式是创建哈希表。然后,每个单独的组都支持“添加”操作,该操作会向添加列表添加元素。说穿了,就像是Dictionary<Key, List<Value>>

哈希表总是被过度分配。如果向哈希添加一个元素,它会检查是否有足够的容量,如果没有,则重新创建具有更大容量的哈希表(准确地说:新容量=计数* 2,计算组数)。但是,容量越大意味着存储区索引不再正确,这意味着您必须在哈希表中重新构建条目。 Resize()中的Lookup<Key, Value>方法执行此操作。

“群组”本身就像List<T>一样。这些也是过度分配,但更容易重新分配。确切地说:简单地复制数据(使用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;
}

答案 4 :(得分:0)

执行更大的计算时,计算机上可用的物理内存越来越少,计算存储桶的速度越慢,内存越少,因为耗尽存储桶,内存将会减少。

尝试以下内容:

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次。