C# Lambda表达式的性能问题/可能性/指南

28
我正在使用不同的Lambda表达式语法来测试性能差异。如果我有一个简单的方法:

I'm testing performance differences using various lambda expression syntaxes. If I have a simple method:


public IEnumerable<Item> GetItems(int point)
{
    return this.items.Where(i => i.IsApplicableFor(point));
}

在这里,涉及到与point参数相关的一些变量提升,因为从lambda的角度来看,它是一个自由变量。如果我将调用此方法一百万次,将其保持不变还是改变以提高性能?

我有哪些选项,哪些是可行的?据我所知,我必须摆脱自由变量,这样编译器就不必在每次调用此方法时创建闭合类并对其进行实例化。与非闭合版本相比,这种实例化通常需要相当长的时间。

问题是我想要设计一些 lambda写作指南 ,它们通常可以使用,因为似乎每次我写一个经常使用的lambda表达式时都会浪费一些时间。我必须手动测试它以确保其有效,因为我不知道要遵循哪些规则。

替代方法

& 示例控制台应用程序代码

我还编写了同一方法的另一个版本,它不需要任何变量提升(至少我认为不需要,但你们理解这个的人让我知道是否正确):

public IEnumerable<Item> GetItems(int point)
{
    Func<int, Func<Item, bool>> buildPredicate = p => i => i.IsApplicableFor(p);
    return this.items.Where(buildPredicate(point));
}

请查看此处的Gist。只需创建一个控制台应用程序,并将整个代码复制到namespace块内的Program.cs文件中即可。您会发现,尽管第二个示例不使用free variables,但它要慢得多。

一个反例

我想构建一些lambda最佳使用指南的原因是,我之前遇到过这个问题,令我惊讶的是,当使用predicate builder lambda表达式时,其中一个方法竟然运行得更快。

现在请解释一下,我完全迷失了,因为当我知道我的代码中有一些重度使用方法时,可能根本不会使用lambda。但我想避免这种情况,并深入了解其本质。

编辑

您的建议似乎无效

我尝试实现了一个自定义查找类,其内部类似于编译器对于free variable lambda所做的操作。但是,我实现了模拟类似情况的实例成员。以下是代码:

private int Point { get; set; }
private bool IsItemValid(Item item)
{
    return item.IsApplicableFor(this.Point);
}

public IEnumerable<TItem> GetItems(int point)
{
    this.Point = point;
    return this.items.Where(this.IsItemValid);
}

有趣的是,这个版本与慢速版本一样慢。我不知道为什么,但它似乎除了快速版本以外什么也没做。它重复使用了相同的功能,因为这些附加成员是同一个对象实例的一部分。无论如何,我现在非常困惑!

我已经更新了Gist源代码,所以你可以自行测试。


7
你是否剖析了你的代码,并确定这是瓶颈所在的地方?无论如何,这是一个有趣的问题,我给你点赞。 - Cameron
这个问题似乎有点相关:https://dev59.com/BXI-5IYBdhLWcg3wSGUB。我不确定如何在代码中实现你的例子,而不是与 C# 编译器做的事情基本相同。 - millimoose
1
这里的开销更多地与枚举和调用委托有关,而不是捕获本地值以供委托实现引用。如果这真的是一个瓶颈,最好的微优化方法实际上涉及使用数组和基于整数的索引。就个人而言,我更喜欢扩展方法和lambda表达式的可读性,因为性能差异只有在嵌套很深的内部循环中才会显现出来,此时您可能需要寻找更好的算法。 - Dan Bryant
@RobertHarvey:可能高达4-5倍...请检查Gist并自行执行。如果时间太短,请相应地增加“IterationCount”常量,以便迭代方法每个周期执行约1秒。 - Robert Koritnik
1
@RobertK 关于你最后的评论 =) - Josh Darnell
显示剩余11条评论
4个回答

3
你为什么认为第二个版本不需要变量提升?你使用Lambda表达式定义了Func,这将需要与第一个版本相同的编译器技巧。
此外,你创建了返回FuncFunc,这会让我有些费解,并且几乎肯定每次调用都需要重新评估。
我建议你在发布模式下编译它,然后使用ILDASM检查生成的IL。这应该能让你了解生成的代码。
另一个可以让你更深入了解的测试是,使谓词调用一个使用类作用域变量的单独函数。例如:
private DateTime dayToCompare;
private bool LocalIsDayWithinRange(TItem i)
{
    return i.IsDayWithinRange(dayToCompare);
}

public override IEnumerable<TItem> GetDayData(DateTime day)
{
    dayToCompare = day;
    return this.items.Where(i => LocalIsDayWithinRange(i));
}

那会告诉你,如果提升 day 变量是否实际上会花费任何东西。
是的,这需要更多的代码,我不建议您使用它。正如您对先前回答建议类似的内容所指出的那样,这将创建使用本地变量的闭包。重点是,为了使事情正常工作,您或编译器必须执行此类操作。除了编写纯迭代解决方案之外,您无法执行任何魔术来防止编译器不得不执行此操作。
我的观点是,在我的情况下,“创建闭包”只是一个简单的变量赋值。如果这比您使用 Lambda 表达式的版本快得多,则说明编译器为闭包创建的代码存在某些效率低下的问题。
我不确定您从哪里获取有关必须消除自由变量和闭包成本的信息。您能给我一些参考资料吗?

我很好奇,所以我查看了简单闭包情况下的IL。它发出一个简单的类,其中有一个公共字段'int point'。调用方法实例化'magic class',设置点值一次并创建Func委托。然后它调用Where。委托主体实际上只是lambda表达式的内容,只使用类上的point字段。在这里没有任何手动更快的操作(使用外部类),事实上,自动生成的机制可能从使用公共字段中获得微小的性能提升。 - Dan Bryant
“我不确定你从哪里得到的……”:我已经检查了编译后创建无自由变量lambda表达式的源代码,编译器几乎只会创建一个公共静态方法而不是闭包类并实例化它,这种情况仅在使用自由变量时才会发生。你可以自己试一下看看。这就是为什么我想将带有自由变量的lambda转换为仅具有绑定变量的lambda的主要原因 - Robert Koritnik
我实际上已经实现了完全相同的功能,但同时也消除了您使用的lambda的需要。方法引用就足够了。但是你知道吗?速度和慢版本一样慢。请在Gist上检查最新版本。 - Robert Koritnik

1

你的第二个方法运行速度比第一个慢8倍。正如@DanBryant在评论中所说,这与在方法内构造和调用委托有关,而不是变量提升。

我觉得你的问题很混乱,因为它让我感觉你希望第二个示例比第一个示例更快。我也认为,由于“变量提升”,第一个示例会变得无法接受的缓慢。第二个示例仍然有一个自由变量(point),但它增加了额外的开销-我不明白你为什么认为它会去除自由变量。

根据你发布的代码,上面的第一个示例(使用简单的内联谓词)的性能只比简单的for循环慢10%:

foreach (TItem item in this.items)
{
    if (item.IsDayWithinRange(day))
    {
        yield return item;
    }
}

因此,总结如下:

  • for 循环是最简单的方法,也是“最佳情况”。
  • 内联谓词稍微慢一些,因为有一些额外的开销。
  • 在每次迭代中构建并调用返回 FuncFunc 比两者都要慢得多。

我认为这些都不足为奇。‘指南’是使用内联谓词 - 如果性能差,可以通过移动到普通循环来简化。


我不明白为什么第二个例子中的 point 是一个自由变量,因为它作为参数传递到 lambda 中,而不是在其中使用。在我看来,这是一个绑定变量。但我很清楚我可能错了。当我还是学生的时候,他们没有教我们 lambda。这就是我想更好地理解它们的主要原因。 - Robert Koritnik
我本来以为第二个例子会更快一些。请查看我的编辑答案或直接访问这个问题,结果发现一个谓词构建器lambda表达式使代码更加流畅。我原本也期望在这里能够实现同样的效果,但却大失所望。 - Robert Koritnik

0
当使用延迟执行的LINQ表达式在包含其引用的自由变量的同一范围内执行时,编译器应该检测到这一点,并且不会创建对lambda的闭包,因为它是不必要的。
验证的方法是通过类似以下内容的测试进行测试:
public class Test
{
   public static void ExecuteLambdaInScope()
   {
      // here, the lambda executes only within the scope
      // of the referenced variable 'add'

      var items = Enumerable.Range(0, 100000).ToArray();

      int add = 10;  // free variable referenced from lambda

      Func<int,int> f = x => x + add;

      // measure how long this takes:
      var array = items.Select( f ).ToArray();  
   }

   static Func<int,int> GetExpression()
   {
      int add = 10;
      return x => x + add;  // this needs a closure
   }

   static void ExecuteLambdaOutOfScope()
   {
      // here, the lambda executes outside the scope
      // of the referenced variable 'add'

      Func<int,int> f = GetExpression();

      var items = Enumerable.Range(0, 100000).ToArray();

      // measure how long this takes:
      var array = items.Select( f ).ToArray();  
   }

}

编译器如何知道 lambda 是在 add 的作用域内执行的? - Oskar Berggren

0

我为您对基准测试进行了分析,并得出了许多结论:

首先,它在调用ToList时,在行return this.GetDayData(day).ToList();上花费了一半的时间。如果您删除它并手动迭代结果,则可以测量方法之间的相对差异。

其次,由于IterationCount = 1000000RangeCount = 1,您正在计时不同方法的初始化而不是执行它们所需的时间。这意味着您的执行配置文件受到创建迭代器、逃逸变量记录和委托以及随之产生的数百个gen0垃圾收集的影响。

第三,"慢"方法在x86上真的很慢,但在x64上与"快"方法的速度大致相同。我认为这是由于不同的JITters如何创建委托。如果您从结果中排除委托创建,则"快"和"慢"方法的速度相同。

第四,如果您实际上需要大量调用迭代器(在我的电脑上,目标是x64,使用RangeCount = 8),则“slow”实际上比“foreach”更快,“fast”比它们都要快。

总之,“lifting”方面可以忽略不计。在我的笔记本电脑上测试表明,像您这样捕获变量每次创建lambda时需要额外的10ns(而不是每次调用),这还包括额外的GC开销。此外,虽然在“foreach”方法中创建迭代器要比创建lambda略快,但实际调用该迭代器要比调用lambda慢。

如果创建委托所需的几个额外纳秒对于您的应用程序来说太多,请考虑将它们缓存起来。如果您需要这些委托的参数(即闭包),请考虑创建自己的闭包类,以便您可以创建它们一次,然后在需要重用其委托时仅更改属性。以下是一个示例:

public class SuperFastLinqRangeLookup<TItem> : RangeLookupBase<TItem>
    where TItem : RangeItem
{

    public SuperFastLinqRangeLookup(DateTime start, DateTime end, IEnumerable<TItem> items)
        : base(start, end, items)
    {
        // create delegate only once
        predicate = i => i.IsDayWithinRange(day);
    }

    DateTime day;
    Func<TItem, bool> predicate;

    public override IEnumerable<TItem> GetDayData(DateTime day)
    {
        this.day = day; // set captured day to correct value
        return this.items.Where(predicate);
    }
}

当你增加RangeCount时,lambda变得非常快,因为你消除了多次创建lambda的需要。它之后所做的就是迭代项目。 - Robert Koritnik
你怎么期望我缓存一个lambda表达式?我已经将谓词构建器(如第二个示例中)放在方法范围之外并放入类本身,但没有任何区别(静态或实例变量)。我也很困惑,因为谓词构建器使得我的其他非常相似的代码运行速度更快。我现在非常困惑,因为我以为我知道的现在我完全不知道了。而且仍然不知道。 - Robert Koritnik
在这两种情况下都调用了 ToList(),因此在那里花费了多少时间并不重要。在这两种情况下,所花费的时间是相同的。唯一的区别是 foreach 循环和带有自由变量的 lambda 表达式。因为这个自由变量阻止了 lambda 缓存。编译器会创建一个闭包类,并在每次调用 GetDayData 方法时实例化一个新实例。这就是与代码差异相关的减速。平均部分是无关紧要的。 - Robert Koritnik
@RobertKoritnik:我在帖子末尾添加了一个示例,演示如何缓存lambda。当“RangeCount = 1”时,它与“foreach”一样快,并且当“RangeCount”更高时,它与所有其他lambda一样快。 - Gabe
这与我之前添加到(Gist)[https://gist.github.com/1403144]的最后一个类有些相似,唯一的区别是我根本没有使用lambda表达式,而是使用了私有实现的类方法。我想请您查看我的`NoLinqRangeLookup`类(我知道它应该被称为`NoLambda...`,但不要紧)。我的类不使用任何lambda比你的慢。这让我感到很困惑。我将不得不检查其编译源代码,但您可以为此提供一些线索。希望如此。 - Robert Koritnik
你的类和我的区别在于,我的类在构造函数中创建一个委托并将其保存在类的字段中,而你的类每次都会创建一个新的委托。请记住,lambda表达式只是一个委托,因此它们之间没有速度差异;差异只在语法上。 - Gabe

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