C#编译器或JIT可以优化lambda表达式中的方法调用吗?

我在另一个StackOverflow问题开始( 在评论中 )的讨论后开始这个问题,我很想知道答案。 考虑以下表达式:

var objects = RequestObjects.Where(r => r.RequestDate > ListOfDates.Max()); 

在这种情况下,将ListOfDates.Max()的评估移出Where子句会有任何(性能)​​优势,还是1.编译器或2. JIT优化它?

我相信C#只会在编译时进行常量折叠,并且可以认为ListOfDates.Max()在编译时是不可知的,除非ListOfDates本身在某种程度上是常量。

也许还有另一个编译器(或JIT)优化,确保只评估一次?

嗯,这是一个复杂的答案。

这里涉及两件事。 (1)编译器和(2)JIT。

编译器

简而言之,编译器只是将您的C#代码转换为IL代码。 对于大多数情况来说,这是一个非常简单的翻译,.NET的核心思想之一是每个函数都被编译为IL代码的自治块。

所以,不要期望C# – > IL编译器过多。

JIT

那……有点复杂。

JIT编译器基本上将您的IL代码转换为汇编程序。 JIT编译器还包含基于SSA的优化器。 但是,有一个时间限制,因为我们不希望在代码开始运行之前等待太久。 基本上这意味着JIT编译器不会做所有超级酷的东西,这将使你的代码变得非常快,只是因为这会花费太多时间。

我们当然可以对它进行测试:)确保VS在运行时进行优化(选项 – >调试器 – >取消选中抑制[…]和我的代码),在x64发布模式下编译,设置断点并查看切换到汇编程序视图时会发生什么。

但是,嘿,只有理论才有趣; 让我们来测试吧。 🙂

 static bool Foo(Func foo, int a, int b) { return foo(a, b) > 0; // put breakpoint on this line. } public static void Test() { int n = 2; int m = 2; if (Foo((a, b) => a + b, n, m)) { Console.WriteLine("yeah"); } } 

你应该注意的第一件事是断点被击中。 这已经告诉该方法没有内联; 如果是的话,你根本不会遇到断点。

接下来,如果您观看汇编程序输出,您会注意到使用地址的“调用”指令。 这是你的function。 仔细观察,你会发现它正在调用代表。

现在,基本上这意味着调用没有内联,因此没有进行优化以匹配本地(方法)上下文。 换句话说,不使用委托并在您的方法中放置东西可能比使用委托更快。

另一方面,通话非常有效。 基本上,函数指针只是传递和调用。 没有vtable查找,只是一个简单的调用。 这意味着它可能胜过呼叫成员(例如IL callvirt )。 静态调用(IL call )应该更快,因为这些是可预测的编译时间。 再来一次,让我们来测试,好吗?

 public static void Test() { ISummer summer = new Summer(); Stopwatch sw = Stopwatch.StartNew(); int n = 0; for (int i = 0; i < 1000000000; ++i) { n = summer.Sum(n, i); } Console.WriteLine("Vtable call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n); Summer summer2 = new Summer(); sw = Stopwatch.StartNew(); n = 0; for (int i = 0; i < 1000000000; ++i) { n = summer.Sum(n, i); } Console.WriteLine("Non-vtable call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n); Func sumdel = (a, b) => a + b; sw = Stopwatch.StartNew(); n = 0; for (int i = 0; i < 1000000000; ++i) { n = sumdel(n, i); } Console.WriteLine("Delegate call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n); sw = Stopwatch.StartNew(); n = 0; for (int i = 0; i < 1000000000; ++i) { n = Sum(n, i); } Console.WriteLine("Static call took {0} ms, result = {1}", sw.ElapsedMilliseconds, n); } 

结果:

 Vtable call took 2714 ms, result = -1243309312 Non-vtable call took 2558 ms, result = -1243309312 Delegate call took 1904 ms, result = -1243309312 Static call took 324 ms, result = -1243309312 

这里有趣的事实上是最新的测试结果。 请记住,静态调用(IL call )是完全确定的。 这意味着优化编译器是一件相对简单的事情。 如果检查汇编器输出,您会发现对Sum的调用实际上是内联的。 这是有道理的。 实际上,如果你要测试它,只需将代码放在方法中就像静态调用一样快。

关于Equals的一个小评论

如果你测量哈希表的性能,我的解释似乎有点可疑。 看起来如果IEquatable让事情变得更快。

嗯,这确实是真的。 :-)哈希容器使用IEquatable来调用Equals 。 现在,众所周知,对象都实现了Equals(object o) 。 因此,容器可以调用Equals(object)Equals(T) 。 呼叫本身的性能是一样的。

但是,如果您还实现IEquatable ,则实现通常如下所示:

 bool Equals(object o) { var obj = o as MyType; return obj != null && this.Equals(obj); } 

此外,如果MyType是结构,则运行时还需要应用装箱和拆箱。 如果只调用IEquatable ,则不需要这些步骤。 因此,即使它看起来较慢,这与呼叫本身无关。

你的问题

在这种情况下,将ListOfDates.Max()的评估移出Where子句会有任何(性能)​​优势,还是1.编译器或2. JIT优化它?

是的,会有一个优势。 编译器/ JIT不会优化它。

我相信C#只会在编译时进行常量折叠,并且可以认为ListOfDates.Max()在编译时是不可知的,除非ListOfDates本身在某种程度上是常量。

实际上,如果将静态调用更改为n = 2 + Sum(n, 2)您会注意到汇编器输出将包含4 。 这certificate了JIT优化器确实可以进行常量折叠。 (实际上,如果您了解SSA优化器的工作原理,那么这一点非常明显...... const折叠和简化被称为几次)。

函数指针本身未优化。 但它可能在未来。

也许还有另一个编译器(或JIT)优化,确保只评估一次?

至于“另一个编译器”,如果你愿意添加“另一种语言”,你可以使用C ++。 在C ++中,这些类型的调用有时会被优化掉。

更有趣的是,Clang基于LLVM,并且还有一些用于LLVM的C#编译器。 我相信Mono可以选择优化LLVM,CoreCLR正在研究LLILC。 虽然我没有对此进行测试,但LLVM肯定可以进行这些优化。