【问题标题】:Inconsistent behavior of List.Contains and List.IndexOf when measuring speed测量速度时 List.Contains 和 List.IndexOf 的行为不一致
【发布时间】:2016-02-27 04:28:16
【问题描述】:

我需要使用 C# 快速处理大量字符串。为了找到最快的方法来做到这一点,我一直在使用以下基准测试功能:

delegate void Test();
static void time(Test test, int iter, string label)
    {
        Stopwatch timer = new Stopwatch();
        timer.Reset();
        timer.Start();

        int i = 0;
        while (i < iter)
        {
            test();
            i++;
        }

        Console.WriteLine(label + ": " + timer.ElapsedMilliseconds.ToString());
        timer.Reset();
    }

当我运行这段代码时:

int iter = 10000000;
string[] array = new string[] { "cat", "dog", "horse", "cow", "dimorphodon", "a", "a", "dog", "horse", "cow", "dimorphodon", "a", "a", "pig" };
List<string> list = new List<string>(array);

time(() => { int i = 0; while (i < array.Length) { if (array[i] == "cat") { return; } i++; } return; }, iter, "array search near        ");
time(() => { int i = 0; while (i < array.Length) { if (array[i] == "pig") { return; } i++; } return; }, iter, "array search far         ");
time(() => { int i = Array.IndexOf(array, "cat"); }, iter, "array IndexOf near       ");
time(() => { int i = Array.IndexOf(array, "pig"); }, iter, "array IndexOf far        ");
time(() => { list.Contains("cat"); }, iter, "list contains near        ");
time(() => { list.Contains("pig"); }, iter, "list contains far         ");
time(() => { int i = list.IndexOf("cat"); }, iter, "list IndexOf near        ");
time(() => { int i = list.IndexOf("pig"); }, iter, "list IndexOf far         ");
time(() => { int i = 0; while (i < list.Count) { if (list[i] == "cat") { return; } i++; } return; }, iter, "list search near         ");
time(() => { int i = 0; while (i < list.Count) { if (list[i] == "pig") { return; } i++; } return; }, iter, "list search far          ");

启用优化后,我一直认为迭代搜索数组是最快的选择,搜索列表的速度会稍微慢一些,而 List.IndexOfArray.IndexOf 的速度要慢得多(3-列表前部附近的值慢 4 倍,更高的索引差距缩小,在 20-30 个元素附近达到慢 2 倍)和List.Contains 是最慢的(比IndexOf 慢约 1.2 倍,与或没有优化)。

我看到了一些关于为什么 Contains 可能比迭代搜索慢的讨论here(Contains 是通用实现的,因此创建了一个在处理字符串时不需要的通用 EqualityComparer 对象),但是尽管 Contains 的实现之间存在差异并且IndexOfhere 上进行了详细说明,查看实现似乎只是确认包含应该是相同的(它们都创建了一个通用的 EqualityComparer 并使用它来比较列表内部数组的元素与参数)。

这表明问题可能出在我的基准测试功能上。 Contains 和 IndexOf 之间的差异是否存在一些我遗漏的元素,或者我的基准测试功能有问题?

编辑:我现在确定我的基准测试存在问题。执行一组略有不同的测试:

time(() => { list.Contains("cat"); }, iter, "list short contains ");
time(() => { list.Contains("cat"); }, iter, "list short indexof  ");
time(() => { list.Contains("cat"); }, iter*10, "list long contains  ");
time(() => { list.Contains("cat"); }, iter*10, "list long indexof   ");
time(() => { list.Contains("cow"); }, iter, "list short contains ");
time(() => { list.Contains("cow"); }, iter, "list short indexof  ");
time(() => { list.Contains("cow"); }, iter * 10, "list long contains  ");
time(() => { list.Contains("c"); }, iter * 10, "list long indexof   ");

导致完全不同的时间, indexof 和 contains 执行得更接近。我不知道这如何或为什么会成为可能。

再次编辑:为了清楚起见,我几乎可以肯定计时的怪异与我对计时功能的实现有关,尽管它可能与优化器有关。我经常看到, List.Contains 比 List.IndexOf 慢,但并非总是如此,并且以某种方式改变我测量时间的顺序会给我带来不同的结果。我的问题是,是什么导致上述时间不一致?

【问题讨论】:

  • @Sakura 好吧,进一步的测试证明了你的答案是正确的,尽管我仍然不知道为什么。
  • IndexOfContains 都具有基本相同的实现,因此不应在任何不同的时间内运行。它们都使用EqualityComparer&lt;T&gt;.Default,而您的迭代方法没有。
  • 你的问题到底是什么?
  • P... 我再次测试并发现IndexOfcatpig 两种情况下都更快。此外,要删除操作的第一个初始时间,请将其称为创建秒表,即 test(); Stopwatch timer = new Stopwatch();
  • @Trauer 我已经更改了标题并添加了另一个编辑,希望能更清楚地说明这一点。

标签: c# performance list benchmarking


【解决方案1】:

我会稍微介绍一下,基准测试是一门复杂的艺术,有许多微妙之处和陷阱。这种测试的更大问题是您正在分析快速的代码。我在我的 poky 笔记本电脑上将阵列搜索测试计时为 8 纳秒。这样的结果是强烈的海森堡式的,测试本身会显着影响测量。

要让如此快速的代码执行,需要做很多事情。委托目标需要即时编译,委托需要绑定到jitted代码,委托调用始终是无法优化的间接调用。重复测试 iter 次的 while() 循环本身会增加开销。所有代码的执行时间都包含在测量中,但不是您真正关心的。

测试结果的一个重要随机因素是处理器本身。现代的代码执行行为非常不确定。它们严重依赖于内存缓存的状态以及内核对代码执行方式的了解程度,分支预测器严重影响执行时间。

代码的位置会影响测试结果,这是处理器对抖动生成的机器代码所做的事情的副作用。处理器不再直接执行该代码。处理器的实际执行引擎执行一种完全不同的指令,即微操作。针对类似 RISC 的处理器,微操作是简单的指令,可以很容易地分布在各种执行单元之间并无序执行。像 Haswell 这样的处理器可以同时执行多达 8 个微操作。

这实际上在实践中并不经常发生。处理器中一个非常重要的电路是指令解码器,它是需要将 x86 或 x64 机器代码转换为微操作的逻辑。这里的心智模型是一种即时编译器,就像 .NET 使用的那样。但有一个很大的不同,这是一个抖动,需要从一个非常复杂的指令集和可变长度指令转换为一个非常饥饿的执行引擎,该引擎可以在每个时钟周期吞下 8 个微操作。。这完全可行是一项了不起的壮举,我不知道硬件设计师是如何做到这一点的。实际上,解码器并不能跟上该速率,并且如果代码多枝,通常会落后。就像在您的“近距离”测试中一样。 L1 指令高速缓存中的代码对齐起着重要作用,这就是放置很重要的原因。不是你可以调整的旋钮。

这些行为都不容易被观察到,而且是相当随机的。我使用的准则是,相差 15% 或更少的测量值没有统计学意义。

您的基准代码存在缺陷,这些缺陷在某种程度上不会影响结果。一个严重的缺陷是你没有让代码完成任何事情,你没有使用计算的结果。换句话说,您根本不使用 i 结果。抖动优化器喜欢这样的代码,最快的代码是不执行的代码。然后它可以消除此类代码。

在这个测试中不会发生这种情况,字符串比较太复杂了。默认比较使用 StringComparison.CurrentCulture 并且需要调用一个 CLR 帮助器方法,该方法咨询一个 oracle,说明应该如何根据 Thread.CurrentCulture 指示来比较字符串。该代码无法内联,因此优化器无法消除它。这发生在代码的内部循环中,因此无法消除任何内容。

所以总的来说,你得到的结果可能足够准确,编写你自己的 Array.IndexOf() 方法实际上是非常短的数组上最快的方法。如果您查看Array.IndexOf() source code,这并不奇怪。添加 null 和 Rank 测试,使其适用于不合格的数组并依赖 Object.Equals() 并不是免费的。它非常快,但您确实看到了开销,因为其余代码非常快。

Microsoft 认为 Array.IndexOf() 所做的额外工作是值得的,它确实使 更好 代码以更易诊断的方式失败并且更通用。它在实际使用中足够便宜。

这是基准测试的关键,测试越接近您程序中的实际实际使用情况,使用真实数据而不是猫和牛,测试结果越值得信赖。相当怀疑你是否到达那里。

【讨论】:

  • TL;DR:增加迭代次数,直到每次测试达到 1 秒。这样一来,所有一次性成本都会消失在噪音中。
  • 嗯,不,委托调用和 while 循环不是一次性开销成本。分析非常快速的代码的负担。
  • 我不相信委托调用与这个特定的测试负载相比是昂贵的。我可能会将循环放在委托中,但这在这里应该不会太糟糕。
  • 感谢您清晰而完整的解释!我实际上想到了您提到的代码中的缺陷(从未使用过该值),但测试使用它的替代版本并没有显着影响性能。很高兴知道这是字符串比较的一个特定功能,而不是我将来可以依赖的东西。
【解决方案2】:

除非我弄错了,List.Contains 只是简单地遍历列表以查看项目是否存在。我不认为它是为快速查找而设计的。

改用HashSetSortedList 进行测试。

【讨论】:

  • 我认为您误解了这个问题的重点。我想知道为什么 List.Contains 比 List.IndexOf 慢,以及是否是因为我的基准测试功能存在缺陷。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2015-08-03
  • 1970-01-01
  • 2013-11-04
  • 1970-01-01
  • 2014-02-08
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多