【问题标题】:Filtering a List .NET C# - best performance - strange results [closed]过滤列表 .NET C# - 最佳性能 - 奇怪的结果 [关闭]
【发布时间】:2021-09-19 01:13:07
【问题描述】:

所以我正在尝试过滤 c# .NET5 中的列表,并且我想专注于该过程的性能。目前该程序正在为此使用 ForEach 循环,这很糟糕。

无论如何,这就是示例,我得到一个字符串列表,其中可以包含任何类型的字符串,包括 null 或空。有 10k 个条目。

我找到了使用 LINQ 的过滤方法,例如.Where 或 .RemoveAll,但您喜欢过滤。

首先我认为最好和最快的方法是制作一个哈希集并使用 List.RemoveAll(i => hashset.Contains(i)) 因为我所有的研究都指出使用 hashset.Contains 迭代 List 是最快的方法。我还认为 RemoveAll 会更快,因为 Contains-Method 的检查会在它返回一个“真”案例时立即停止。

我创建了一个fiddle 来对所有这些方法进行基准测试,但问题是,我得到了这些结果:

.Where & HashSet contains: 0.7018 MS
.RemoveAll & HashSet contains: 0.4803 MS
.Where & List contains: 0.0072 MS
.RemoveAll & List contains: 0.4504 MS
.Where 0.1234 MS
.RemoveAll 0.0006 MS
ForEach 41.8379 MS

这些结果提出了新的问题,希望我能得到解释或只是一个明确的答案。

问题:

  1. 不出所料,.RemoveAll & HashSet 比 .Where & HashSet 快,但这不适用于 List.Contains,为什么?
  2. 为什么通过 List.Contains 得到的结果比两种 HashSet 方法都快?尤其是 .Where & List
  3. 为什么是普通的 List.Where,它总是比两种 HashSet 方法更快地检查 4 个语句是否为真?
  4. 我预计 List.RemoveAll 会很快,但没想到会那么快...我查找的大多数搜索和问题,人们总是建议在这种情况下使用 HashSet。

我希望有人能解释一下,至少部分解释一下。

编辑:

我发现,最后一个 .RemoveAll 方法是我做的一个错误,我使用了 .Where 而不是 .RemoveAll,现在事情变得更加清晰了。我还删除了 ForEach 循环中的 .ToLower 函数

现在我得到了这些结果:

.Where & HashSet contains: 0.6769 MS
.RemoveAll & HashSet contains: 0.4603 MS
.Where & List contains: 0.0052 MS
.RemoveAll & List contains: 0.449 MS
.Where 0.1103 MS
.RemoveAll 0.305 MS
ForEach 0.4445 MS

但我仍然要问,为什么在使用 .Where 时 HashSet 比 List 花费的时间更长。 还有为什么在使用 HashSet 时 RemoveAll 比 Where 快,而 List 却相反?

【问题讨论】:

  • 物化这些列表。在您实际枚举它们的结果之前,大多数 LINQ 函数都不会被评估。话虽如此:设置一个微基准比这更复杂(至少你必须重复每个测试数千次,然后平均 - 在去除异常值之后)
  • 你在比较苹果和橘子。 RemoveAll 实际上修改了当前列表,而Where & List contains 除了创建查询之外什么都不做。追加ToList并将其分配给原始列表,您可以比较结果(但由于您创建了一个新列表,结果仍然不同)。
  • 也使用 BenchmarkDotnet,而不是更可靠的数字。
  • 另外,RemoveAll 改变了input 列表,因此所有后续测试都是垃圾
  • 您根本无法将WhereRemoveAll 进行比较,因为Where 正在使用延迟执行,所以它只是创建一个查询而不是一个列表(附加ToList 以获得一个有意义的结果)。所以最后一个问题是为什么Where(HashSet.Contains) 似乎比Where(List.Contains) 慢得多。这就是为什么我说你应该使用BenchmarkDotNet。反转这两个测试,突然List.Contains 会变慢。您需要对基准进行热身。无论如何,如果没有ToList,这些Where 测试毫无价值。

标签: c# performance linq collections .net-5


【解决方案1】:

在我看来,您的测试具有误导性。 您在 for each 循环(2x ToLower)中进行了更昂贵的比较,而且在大多数 Linq 语句中,您甚至不执行查询(它们处于延迟/未执行状态)。

在这种情况下,列表击败哈希集的原因是它们都只包含几个元素 (5),而且您比较的列表将只包含 5 个不同的值。 因此,在大多数情况下,您调用 List.Contains 的迭代次数少于 3 次 - 在这种情况下,与 hashet 相比,您需要为每个元素至少额外调用一次 GetHashCode。

如果我重构您的测试,ForEach 会比 Where 高出大约 1% 到 10%。 如果我改变不同元素的数量,HashSet 远远胜过 List(快 5 倍)! 我没有使用 RemoveAll,因为它是不同的操作。

/*

Result of Benchmark.RunAllTests():

Test 'where hashset' took 42,71700 msec
Test 'where list' took 285,44390 msec
Test 'foreach hashset' took 41,99030 msec
Test 'foreach list' took 277,24130 msec

*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace ConsoleApp1
{
    class Benchmark
    {
        Random _random = new Random();

        public void RunAllTests() {

            var hs = Enumerable.Range(0, 100).Select(i => _random.Next(0, i).ToString()).ToHashSet();
            var ls    = new List<string>(hs);

            var bigList = FillList(1_000_000, ls);

            //warmup, let all assemblies load
            RunTest(() => bigList.Take(100).Where(s => !hs.Contains(s)).ToList(), "", true);
            RunTest(() => bigList.Take(100).Where(s => !ls.Contains(s)).ToList(), "", true);
            //exec real tests
            RunTest(() => bigList.Where(s => !hs.Contains(s)).ToList(), "where hashset");
            RunTest(() => bigList.Where(s => !ls.Contains(s)).ToList(), "where list");
            RunTest(() => ForEach(hs).ToList(),                         "foreach hashset");
            RunTest(() => ForEach(ls).ToList(),                         "foreach list");

            IEnumerable<string> ForEach(ICollection<string> coll) {
                foreach (var s in bigList) {
                    if (!coll.Contains(s))
                        yield return s;
                }
            }
        }

        private List<string> FillList(int totalCount, IList<string> elementsToUse)
        {
            var input = new List<string>();
            
            for (var i = 0; i < totalCount; i++)
            {
                var n = _random.Next(0, elementsToUse.Count);
                input.Add(elementsToUse[n]);
            }

            return input;
        }

        private void RunTest(Func<IList<string>> test, string name, bool silent = false) {
            var watch = new Stopwatch();
            watch.Start();
            var list = test.Invoke();

            if (silent) {
                return;
            }
            var ms = watch.Elapsed.TotalMilliseconds;
            watch.Stop();
            Console.WriteLine("Test '{0:-20}' took {1:N5} msec", name, ms);
        }
    }
}

【讨论】:

    【解决方案2】:

    我认为您在这里比较完全错误的数字。

    1. 真的什么都不做。它创建一个IEnumerable 实例,该实例将在需要时调用过滤器函数,即在迭代集合时。因为您的小提琴不会迭代集合,所以过滤谓词永远不会真正执行。
    2. RemoveAll 和 foreach 实现也意味着比较苹果和橘子,因为您的 foreach 实现使用了 ToLower,这是一个非常昂贵的函数。去掉ToLower 方法调用,foreach 是最快的。特别是,将项目添加到列表大约是 O(1),而从列表中删除项目是 O(n),因此很明显RemoveAll 比 foreach 慢。
    3. 我相信列表中的 foreach 仍会创建枚举器的实例。你可以通过使用for-loop 来省略它。这将“更快”,但肯定不会解决任何问题。
    4. 你为什么还要问? 10k 件真的不算多。我宁愿专注于让过滤谓词正确,而不是考虑如何调用它。除非您确实有高性能需求,否则这些差异可以忽略不计。

    【讨论】:

    • 但这不是说创建IEnumerable实例应该是最快的吗?还要感谢您指出 .ToLower 函数。 “很明显 RemoveAll 比 foreach 慢。” 我删除它并尝试了“基准”15-20,RemoveAll 仍然是最快的方法。我也在问,因为我想学习,但我没有在网上找到解释。感谢您的贡献顺便说一句^^
    • 值得注意的是,HashSet 在这里也不是很有帮助。如果它很大,它会更有效,但是其中的项目很少,一个简单的列表或数组会更快。另请注意,HashSet&lt;T&gt; 需要 IEqualityComparer&lt;T&gt;,因此如果您想忽略大小写,可以传递 StringComparer.OrdinalIgnoreCase 以提高效率。
    • @TimSchmelter 好的,到目前为止,我几乎了解了所有内容。但是我仍然不明白为什么如果我使用 HashSet 我得到 .Where 比 .RemoveAll 慢,如果我使用 List 我得到 .Where 比 .RemoveAll 快
    • 您需要为每个测试重新填写列表。在您的第一次测试中,您有 10.000 个元素。然后在第一次调用 RemoveAll 之后,您删除其中的 5.500 个(所有不等于“好”的);
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-04-09
    • 1970-01-01
    • 2021-10-15
    • 1970-01-01
    • 2019-04-22
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多