C# 编译器或 JIT 能否优化掉 lambda 表达式中的方法调用?

Can the C# compiler or JIT optimize away a method call in a lambda expression?

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

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<int, int, int> 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");
    }
}

您应该注意到的第一件事是命中断点。这已经表明该方法不是内联的;如果是,你根本就不会命中断点。

接下来,如果您观察汇编器输出,您会注意到 'call' 指令使用地址。这是你的功能。仔细观察,您会发现它正在调用委托。

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

另一方面,调用 非常有效。基本上函数指针被简单地传递和调用。没有 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<int, int, int> 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 的调用实际上是内联的。这是有道理的。实际上,如果你会测试它,只需将代码放在方法中就和静态调用一样快。

关于等于的小评论

如果您衡量哈希表的性能,我的解释似乎有些可疑。看起来好像 IEquatable<T> 让事情变得更快。

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

但是,如果您还实施 IEquatable<T>,实施通常如下所示:

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

另外,如果MyType是一个struct,运行时候还需要进行装箱和拆箱。如果它只是调用 IEquatable<T>,则需要 none 这些步骤。所以,尽管它看起来更慢,但这与调用本身无关。

您的问题

Will there be any (performance) advantage of moving the evaluation of ListOfDates.Max() out of the Where clause in this case, or will 1. the compiler or 2. JIT optimize this away?

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

I believe C# will only do constant folding at compile time, and it could be argued that ListOfDates.Max() can not be known at compile time unless ListOfDates itself is somehow constant.

实际上,如果将静态调用更改为 n = 2 + Sum(n, 2),您会注意到汇编器输出将包含 4。这证明 JIT 优化器确实进行了常量折叠。 (如果你知道 SSA 优化器是如何工作的,这实际上是很明显的......调用了几次 const 折叠和简化)。

函数指针本身没有优化。不过可能会在未来。

Perhaps there is another compiler (or JIT) optimization that makes sure that this is only evaluated once?

至于'another compiler',如果你愿意加上'another language',你可以用C++。在 C++ 中,这些类型的调用有时会被优化掉。

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