为什么这个函数更快,为什么多次枚举它比第一次更快?

4
我需要一个类似于TakeLast<T>(int n)的LINQ函数,涉及编程内容。我找到了这篇StackOverflow文章:https://dev59.com/G3A75IYBdhLWcg3wJFcL#3453282。我喜欢这个答案,因为它实现简单。后来,我的另一个同事指出Reverse()Skip(length - n)更耗费资源。这促使我写了一个测试。
以下是竞争函数。
public static IEnumerable<T> TakeLast<T>( this IEnumerable<T> c, int n ) {
    return c.Reverse().Take( n ).Reverse();
}


public static IEnumerable<T> TakeLast2<T>( this IEnumerable<T> c, int n ) {
    var count = c.Count();
    return c.Skip( count - n );
}

我测量了获取枚举类型Enumerable.Range(0, 100000)中最后10个元素的执行时间。发现:

  1. TakeLast() 更快,速度大约是原来的5倍。
  2. 使用 TakeLast() 枚举时,第二次及以后枚举明显更快。

以下是我的代码的.NET Fiddle(本地运行过,这里也有演示):http://dotnetfiddle.net/ru7PZE

问题

  1. 为什么 TakeLast() 更快?
  2. 为什么使用 TakeLast() 枚举时,第二次及以后枚举比第一次枚举快得多,但所有对 TakeLast2() 的枚举都差不多?

因为它已被枚举并且在调用 Reverse 方法时似乎得到了优化。 - csharpwinphonexaml
这可能与 Enumerable.Range 的实现密切相关。如果您真的关心 List<T> 的性能,应该使用它进行分析。如果您真的关心 Range 的性能,可以使用适当的起始/计数值创建一个新的 Range - Tim S.
4
你的基准测试有缺陷... - sloth
1
@LB2 是的,但基准测试有缺陷。第二个方法会急切地执行第一次迭代,而第一个方法则完全是惰性的操作。由于他在打印结果之前记录时间,所以所有被延迟的操作都不计入基准测试。 - Servy
1
@verdesrobert 你的基准测试实际上并没有执行查询,只是构建了查询。 输入实现IList的事实仅意味着Count不需要迭代序列,它可以免费获取计数,从而消除了生成查询时的时间差异。 这也使Skip直接跳到正确的位置。 实现IList的源序列是第二种方法的最佳情况; 第一种方法受益,但没有那么多 - Servy
显示剩余8条评论
1个回答

10

在打印出秒表经过的时间之后,您才会实现查询结果。LINQ查询使用延迟执行以避免实际执行查询,直到它们被枚举。就您的第二种方法而言,您在构建查询之前调用了Count。为了计算其值,Count需要枚举整个结果集。这意味着您的第二种方法需要每次迭代序列,而第一种查询能够成功地推迟工作,直到您显示时间之后。我希望它有更多的工作要做,在许多情况下,它只是等待你计时完成。

至于为什么重复调用第一种方法更快,这主要归结于执行任何代码时发生的JIT预热。第二种方法确实获得了加速,但由于它不能省略每次迭代查询(这是其成本的很大一部分),因此速度提升的百分比要小得多。

请注意,您的第二个查询会两次迭代源序列(除非可枚举对象恰好实现了ICollection)。这意味着,如果该对象可以有效地多次迭代,则不会有问题。如果它实现了IList,那么它实际上会快得多,但如果它是像IQueryable这样的东西,需要每次迭代执行一次昂贵的查询,则需要执行两次而不是一次。如果查询在多次迭代时甚至没有相同的内容,则可能会引起各种问题。


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