给出这段简单的代码和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
答案 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.TryGetValue
,Dictionary.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次。