.NET 字典插入的奇怪性能行为

10

我有两个值类型不同的字典:Dictionary<int, string[]>Dictionary<int, int[]>。假设我们在一个循环中生成随机数组并将它们插入到字典中(使用C#语言)。

var d1 = new Dictionary<int, string[]>();
var d2 = new Dictionary<int, int[]>();
var sw = Stopwatch.StartNew();
for (int i = 0; i < 40000000; i++)
{
    string[] sarr = new string[10];
    for (int j = 0; j < 10; j++)
    {
        sarr[j] = j.ToString();
    }
    int[] iarr = new int[10];
    for (int j = 0; j < 10; j++)
    {
        iarr[j] = j;
    }
    d1[i] = sarr; // (1)
    d2[i] = iarr; // (2)
}
sw.Stop();

请注意for循环的后两行。当我在我的机器上运行以上代码时,需要大约13.9秒。现在,如果我仅注释掉(1),则需要大约13.7秒。如果我仅注释掉(2),则需要大约20秒。换句话说,去除(2)后速度变得更慢了!我多次重复此操作,可以确认其一致性。
请问有人能解释这是如何可能的吗?
我进行了这个实验,因为我注意到使用相同的键在两个字典中插入string[]比插入int[]要慢,我想知道为什么插入string[]会比插入int[]慢。
所以我的问题有两个方面:(1)删除上述代码中的一行如何使事情变得更慢,(2)为什么插入string[]比插入int[]慢?
我正在使用最新的.NET 5(5.0.103)。我在Windows和Linux上尝试过该代码,行为相同。我无论使用调试模式还是发布模式,都始终看到相同的问题。
当我将已注释版本与原始版本进行IL比较时,预期已注释版本没有对字典的set_Item函数的调用。其他事情更多或更少相同。
IL_0079: ldloc.1      // dictionary2
IL_007a: ldloc.3      // key
IL_007b: ldloc.s      numArray
IL_007d: callvirt     instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32[]>::set_Item(!0/*int32*/, !1/*int32[]*/)
IL_0082: nop

当我注释掉(例如,2)时,上述部分被删除。
为了帮助重现此问题,我使用Benchmark.NET创建了一个简单的repo:https://github.com/sangkilc/TestDictionary。在这个repo中,我将迭代次数减少(从40M到4M),因为它需要太长时间。
在我的机器上,结果如下:
.NET Core SDK=5.0.103
  [Host]     : .NET Core 5.0.3 (CoreCLR 5.0.321.7212, CoreFX 5.0.321.7212), X64 RyuJIT
  DefaultJob : .NET Core 5.0.3 (CoreCLR 5.0.321.7212, CoreFX 5.0.321.7212), X64 RyuJIT


|   Method |    Mean |    Error |   StdDev |
|--------- |--------:|---------:|---------:|
| TestBoth | 1.269 s | 0.0222 s | 0.0208 s |
|  TestOne | 1.381 s | 0.0257 s | 0.0241 s |

根据 @TheodorZoulias 的观察,如果我将 d2 修改为二维数组,则差异会更加显著:
|   Method |    Mean |    Error |   StdDev |
|--------- |--------:|---------:|---------:|
| TestBoth | 1.137 s | 0.0195 s | 0.0163 s |
|  TestOne | 1.373 s | 0.0246 s | 0.0345 s |

1
我可以确认在调试模式和发布模式下都看到了一致的行为。 - skc
1
你尝试过查看生成的IL代码以找到差异吗? - Thinko
2
无法复现。在使用 .NET 5 的 Linqpad 上尝试了优化开启和关闭两种情况。 - Magnetron
1
@XouDo 你的理解是反过来了。当垃圾回收运行时,保持对象“存活”需要CPU资源(用于遍历它们的内存以查找其他引用的对象,并复制它们的数据),而在进行垃圾回收时不存活的对象不会消耗任何资源。 - Servy
1
如果您将 var d2 = new Dictionary<int, int[]>() 替换为一个简单的数组 var d2 = new int[40000000][],这种神秘的行为甚至会更加夸张。 - Theodor Zoulias
显示剩余9条评论
1个回答

0

这不是答案,只是想炫耀一些图片

我已经在发布中编译了两个场景的代码:

  • 同时包含(1)和(2)
  • 仅包含(1)

请注意,我删除了与Stopwatch相关的代码,因为我们只关心Dictionary

我的机器上安装了dotTrace,因此我获得了一些逐行分析结果。

对于both场景:

按线程树: enter image description here

按方法: enter image description here

对于(1) only场景:

通过线程树: 输入图像描述

通过方法: 输入图像描述

由于您提到模式是一致的,因此我只运行了一次分析。

从结果中,我们可以看出与Dictionary相关的函数并不是所谓的“奇怪性能行为”的主要贡献者,GC 可能 是。


你展示的性能分析明显支持GC问题;GC大约占据了 ~7%。我认为,在看到很多人的评论后,问题显然是由GC引起的,我会将其选为答案。还有一个需要注意的地方:如果减少循环迭代次数,那么奇怪的行为会消失。因此垃圾越少,性能下降越少。谢谢! - skc

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接