foreach + break vs linq FirstOrDefault 性能差异
Posted
技术标签:
【中文标题】foreach + break vs linq FirstOrDefault 性能差异【英文标题】:foreach + break vs linq FirstOrDefault performance difference 【发布时间】:2012-01-03 02:01:28 【问题描述】:我有两个类执行特定日期的日期日期范围数据获取。
public class IterationLookup<TItem>
private IList<Item> items = null;
public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
this.items = items.OrderByDescending(keySelector).ToList();
public TItem GetItem(DateTime day)
foreach(TItem i in this.items)
if (i.IsWithinRange(day))
return i;
return null;
public class LinqLookup<TItem>
private IList<Item> items = null;
public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
this.items = items.OrderByDescending(keySelector).ToList();
public TItem GetItem(DateTime day)
return this.items.FirstOrDefault(i => i.IsWithinRange(day));
然后我进行速度测试,结果表明 Linq 版本慢了大约 5 倍。当我在本地存储项目而不使用ToList
枚举它们时,这将是有意义的。这会使它变得更慢,因为每次调用FirstOrDefault
,linq 也会执行OrderByDescending
。但事实并非如此,所以我真的不知道发生了什么。 Linq 的执行应该与迭代非常相似。
这是衡量我的时间的代码摘录
IList<RangeItem> ranges = GenerateRanges(); // returns List<T>
var iterLookup = new IterationLookup<RangeItems>(ranges, r => r.Id);
var linqLookup = new LinqLookup<RangeItems>(ranges, r => r.Id);
Stopwatch timer = new Stopwatch();
timer.Start();
for(int i = 0; i < 1000000; i++)
iterLookup.GetItem(GetRandomDay());
timer.Stop();
// display elapsed time
timer.Restart();
for(int i = 0; i < 1000000; i++)
linqLookup.GetItem(GetRandomDay());
timer.Stop();
// display elapsed time
为什么我知道它应该表现更好?因为当我在不使用这些查找类的情况下编写非常相似的代码时,Linq 的执行与foreach
迭代非常相似...
// continue from previous code block
// items used by both order as they do in classes as well
IList<RangeItem> items = ranges.OrderByDescending(r => r.Id).ToList();
timer.Restart();
for(int i = 0; i < 1000000; i++)
DateTime day = GetRandomDay();
foreach(RangeItem r in items)
if (r.IsWithinRange(day))
// RangeItem result = r;
break;
timer.Stop();
// display elapsed time
timer.Restart();
for(int i = 0; i < 1000000; i++)
DateTime day = GetRandomDay();
items.FirstOrDefault(i => i.IsWithinRange(day));
timer.Stop();
// display elapsed time
我认为这是非常相似的代码。 FirstOrDefault
据我所知,它也只迭代直到它到达一个有效的项目或直到它到达末尾。这在某种程度上与foreach
和break
相同。
但即使是迭代类的性能也比我简单的 foreach
迭代循环更差,这也是一个谜,因为与直接访问相比,它的所有开销都是对类中方法的调用。
问题
我在我的 LINQ 类中做错了什么,导致它执行得非常慢?
我在迭代类中做错了什么,所以它的执行速度是直接foreach
循环的两倍?
正在测量哪些时间?
我执行以下步骤:
-
生成范围(如下结果所示)
为 IterationLookup、LinqLookup(以及我优化的日期范围类 BitCountLookup,这里不讨论)创建对象实例
使用先前实例化的 IterationLookup 类启动计时器并在最大日期范围内(如结果中所示)内的随机天执行 100 万次查找。
使用先前实例化的 LinqLookup 类启动计时器并在最大日期范围内(如结果中所示)内的随机天执行 100 万次查找。
使用手动 foreach+break 循环和 Linq 调用启动计时器并执行 100 万次查找(6 次)。
如您所见,没有测量对象实例化。
附录 I:超过百万次查找的结果
这些结果中显示的范围不重叠,这应该使两种方法更加相似,以防 LINQ 版本在成功匹配时不会中断循环(它很可能会这样做)。
生成范围: ID 范围 000000000111111111122222222223300000000011111111112222222222 123456789012345678901234567890112345678901234567890123456789 09 22.01.-30.01。 |-------| 08 14.01.-16.01。 |-| 07 16.02.-19.02。 |--| 06 15.01.-17.01。 |-| 05 19.02.-23.02。 |---| 04 01.01.-07.01.|-----| 03 02.01.-10.01。 |-------| 02 11.01.-13.01。 |-| 01 16.01.-20.01。 |---| 00 29.01.-06.02。 |-------| 查找类... - 迭代:1028ms - Linq: 4517ms !!!这就是问题!!! - 位计数器:401 毫秒 手动循环... - 迭代:786ms - Linq:981ms - 迭代:787ms - Linq:996ms - 迭代:787ms - Linq:977ms - 迭代:783ms - Linq:979ms附录 II:GitHub:自己测试的 Gist 代码
我已经提出了一个要点,因此您可以自己获取完整的代码并查看发生了什么。创建一个 Console 应用程序并将 Program.cs 复制到其中并添加属于此要点的其他文件。
抓住它here。
附录 III:最终想法和测量测试
最有问题的当然是 LINQ implementationatino,它非常慢。事实证明,这与委托编译器优化有关。 LukeH provided the best and most usable solution 这实际上让我尝试了不同的方法。我在 GetItem
方法(或在 Gist 中称为 GetPointData
)尝试了各种不同的方法:
大多数开发人员会做的通常方式(并且也在 Gist 中实现,并且在结果显示这不是最好的方式后没有更新):
return this.items.FirstOrDefault(item => item.IsWithinRange(day));
通过定义一个局部谓词变量:
Func<TItem, bool> predicate = item => item.IsWithinRange(day);
return this.items.FirstOrDefault(predicate);
本地谓词构建器:
Func<DateTime, Func<TItem, bool>> builder = d => item => item.IsWithinRange(d);
return this.items.FirstOrDefault(builder(day));
本地谓词构建器和本地谓词变量:
Func<DateTime, Func<TItem, bool>> builder = d => item => item.IsWithinRange(d);
Func<TItem, bool> predicate = builder(day);
return this.items.FirstOrDefault(predicate);
类级别(静态或实例)谓词构建器:
return this.items.FirstOrDefault(classLevelBuilder(day));
外部定义的谓词并作为方法参数提供
public TItem GetItem(Func<TItem, bool> predicate)
return this.items.FirstOrDefault(predicate);
在执行这个方法时,我也采取了两种方法:
直接在for
循环内的方法调用处提供谓词:
for (int i = 0; i < 1000000; i++)
linqLookup.GetItem(item => item.IsWithinRange(GetRandomDay()));
在for
循环之外定义的谓词生成器:
Func<DateTime, Func<Ranger, bool>> builder = d => r => r.IsWithinRange(d);
for (int i = 0; i < 1000000; i++)
linqLookup.GetItem(builder(GetRandomDay()));
结果 - 什么表现最好
为了在使用迭代类时进行比较,它大约需要。 770ms 对随机生成的范围执行 100 万次查找。
3 本地谓词生成器被证明是经过最佳编译器优化的,因此它的执行速度几乎与通常的迭代一样快。 800 毫秒。
6.2 谓词构建器在for
循环之外定义:885ms
for
循环中定义的 6.1 谓词:1525ms
所有其他时间都在 4200ms - 4360ms 之间,因此被视为不可用。
因此,每当您在外部频繁调用的方法中使用谓词时,请定义一个构建器并执行它。这将产生最好的结果。
对此我最大的惊喜是委托(或谓词)可能会耗费这么多时间。
【问题讨论】:
您是在 IDE 之外的 Release 构建中计时吗? 我认为性能差异来自 BCL 确定给定集合是否实现IList<T>
的强制转换操作。这种优化是有问题的,因为你总是想得到第一个项目(而不是在做LastOrDefault
时)。
@JamesMichaelHare:这些都处于没有附加调试器的调试模式。让我也检查一下发布模式……好吧。结果如下:Release 执行速度更快,但在 5 倍因子方面仍然存在异常情况。
@tahir:FirstOrDefault() 如果未找到则不会抛出。仅抛出空参数。
@RobertKoritnik:如果你可以发布整个代码 sn-p 包括数据生成。我们没有看到 5 倍的差异,因此生成数据或运行测试的方式可能存在一些问题。
【参考方案1】:
有时 LINQ 看起来更慢,因为在循环中生成委托(尤其是在方法调用上的不明显循环)会增加时间。相反,您可能需要考虑将您的查找器移出类以使其更通用(就像您的键选择器正在构建中):
public class LinqLookup<TItem, TKey>
private IList<Item> items = null;
public IterationLookup(IEnumerable<TItem> items, Func<TItem, TKey> keySelector)
this.items = items.OrderByDescending(keySelector).ToList();
public TItem GetItem(Func<TItem, TKey> selector)
return this.items.FirstOrDefault(selector);
由于您没有在迭代代码中使用 lambda,这可能会有所不同,因为它必须在每次通过循环时创建委托。通常,这个时间对于日常编码来说是微不足道的,调用委托的时间并不比其他方法调用贵,只是在一个紧密的循环中创建委托会增加一点额外的时间。
在这种情况下,由于类的委托永远不会更改,因此您可以在循环的代码之外创建它,这样效率会更高。
更新:
实际上,即使没有任何优化,在我的机器上以发布模式编译我也看不到 5 倍的差异。我刚刚对只有一个 DateTime
字段的 Item
执行了 1,000,000 次查找,列表中有 5,000 个项目。当然,我的数据等是不同的,但是当你抽象出委托时,你可以看到时间真的很接近:
迭代:14279 毫秒,0.014279 毫秒/调用
linq w opt:17400 毫秒,0.0174 毫秒/调用
这些时间差异非常很小,值得使用 LINQ 来提高可读性和可维护性。不过,我没有看到 5 倍的差异,这让我相信我们在您的测试工具中没有看到一些东西。
【讨论】:
为什么使用 LinqLookup 和不重用谓词的手动 LINQ 调用之间有这么大的区别?至少它们应该产生相似的结果(就像 foreach 迭代一样)...... 在您的手动调用中,编译器可能意识到它是循环内的同一个 lambda 并进行优化,而在您的类中,它不知道该方法将被调用 1百万次,因此无法优化。 另一个问题:当它还需要一个名为day
的附加参数时,我应该如何在本地保存谓词?我无法创建Func<Item, DateTime, bool>
,因为FirstOrDefault
不会知道如何使用它。
@RobertKoritnik:更新了我的帖子。我没有看到您的时差(我在 4.0 框架上),也更新了移出投影。
@RobertKoritnik:只需从您的测试循环中调用 lambda i => i.IsWithinRange(date)
(而不是直接在您的课程中),但我不认为这是您的 5 倍问题,这是一个小的优化,但一个LINQ 稍慢的原因。慢 5 倍完全是另一回事,似乎与 LINQ 无关。【参考方案2】:
除了Gabe's answer,我可以确认差异似乎是由每次调用GetPointData
重新构建委托的成本造成的。
如果我在IterationRangeLookupSingle
类中的GetPointData
方法中添加一行,那么它会减慢到与LinqRangeLookupSingle
相同的爬行速度。试试看:
// in IterationRangeLookupSingle<TItem, TKey>
public TItem GetPointData(DateTime point)
// just a single line, this delegate is never used
Func<TItem, bool> dummy = i => i.IsWithinRange(point);
// the rest of the method remains exactly the same as before
// ...
(我不确定为什么编译器和/或抖动不能忽略我在上面添加的多余委托。显然,委托 在您的 LinqRangeLookupSingle
类中是必需的。)
一种可能的解决方法是在LinqRangeLookupSingle
中组合谓词,以便将point
作为参数传递给它。这意味着只需要构造委托一次,而不是每次调用GetPointData
方法时。例如,以下更改将加速 LINQ 版本,使其与foreach
版本相当:
// in LinqRangeLookupSingle<TItem, TKey>
public TItem GetPointData(DateTime point)
Func<DateTime, Func<TItem, bool>> builder = x => y => y.IsWithinRange(x);
Func<TItem, bool> predicate = builder(point);
return this.items.FirstOrDefault(predicate);
【讨论】:
这个解决方案实际上大大加快了速度(几乎与迭代方法一样快)。但不用定义两个谓词,只需定义一个构建器并在FirstOrDefault
) 中调用它;
@Robert:是的,这就是我最初的想法,但我把它分成两部分只是为了说明发生了什么。
检查我在上面原始问题中提出的测量结果。这可能会让您感到惊讶,但您的回答使我找到了最佳解决方案。
@Robert:结果有点出人意料。我不禁觉得编译器应该能够优化你原来的 LINQ 类(毕竟它可以优化手动 LINQ 循环,它可以优化“builder”版本)。这感觉对我来说就像一个编译器错误,尽管我也觉得我忽略了一些重要的细节,只是偶然发现了一个幸运的解决方法。我希望 C# 规范中的某处对此有适当的解释和理由。
@JonSkeet:嘿,卢克,除非 Jon Skeet 为这个问题添加他自己的 2 美分,否则我们将不得不认为编译器优化并没有像我们认为的那样优化。 :) Jon 几乎肯定会知道这段代码背后的原因。【参考方案3】:
假设你有一个这样的循环:
for (int counter = 0; counter < 1000000; counter++)
// execute this 1M times and time it
DateTime day = GetRandomDay();
items.FirstOrDefault(i => i.IsWithinRange(day));
此循环将创建 1,000,000 个 lambda 对象,以便 i.IsWithinRange
调用访问 day
。每次创建 lambda 后,调用 i.IsWithinRange
的委托平均被调用 1,000,000 * items.Length
/ 2 次。 foreach
循环中不存在这两个因素,这就是显式循环更快的原因。
【讨论】:
好的,你如何解释类和手动 LINQ 使用之间的 巨大 时间差异(不管foreach
循环)?
@Robert:我不明白“类和手动 LINQ 用法”是什么意思。你能告诉我这意味着什么以及时间差异是什么吗?以上是关于foreach + break vs linq FirstOrDefault 性能差异的主要内容,如果未能解决你的问题,请参考以下文章
Scala:“map”vs“foreach” - 有没有理由在实践中使用“foreach”?