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 据我所知,它也只迭代直到它到达一个有效的项目或直到它到达末尾。这在某种程度上与foreachbreak 相同。

但即使是迭代类的性能也比我简单的 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&lt;T&gt; 的强制转换操作。这种优化是有问题的,因为你总是想得到第一个项目(而不是在做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&lt;Item, DateTime, bool&gt;,因为FirstOrDefault 不会知道如何使用它。 @RobertKoritnik:更新了我的帖子。我没有看到您的时差(我在 4.0 框架上),也更新了移出投影。 @RobertKoritnik:只需从您的测试循环中调用 lambda i =&gt; 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”?

Kotlin 中 `forEach` 中的 `break` 和 `continue`

使用 Linq 替换 foreach 语句

来自多个 Foreach 的 Linq

forEach方法如何跳出循环

LINQ中ForEach方法的使用