我很确定这显示了内存局部性(不同级别的缓存)以及对象分配的影响。
为了验证这一点,我采取了三个步骤:
- 改进基准测试以避免不必要的部分并在测试之间进行垃圾收集
- 通过填充
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;
}
}
我的机器上的结果:
填充字典:
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”的值。这就回答了为什么最后一个结果比第一个结果更快的问题:它只需要执行更少的操作——而且由于缓存无论如何都会失败,所以这个因素不再有影响了。