16

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

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

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

我相信 C# 只会在编译时进行常量折叠,并且可以说 ListOfDates.Max() 在编译时无法知道,除非 ListOfDates 本身以某种方式保持不变。

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

4

1 回答 1

17

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

这里涉及两件事。(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");
    }
}

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

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

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

另一方面,通话非常有效。基本上,函数指针只是简单地传递和调用。没有 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 的调用实际上是内联的。这是有道理的。实际上,如果您要对其进行测试,只需将代码放入方法中就与静态调用一样快。

关于 Equals 的一点说明

如果您测量哈希表的性能,我的解释似乎有些可疑。它似乎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是一个结构,运行时也需要应用装箱和拆箱。如果它只是调用IEquatable<T>,则不需要这些步骤。因此,即使它看起来较慢,但这与调用本身无关。

你的问题

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

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

我相信 C# 只会在编译时进行常量折叠,并且可以说 ListOfDates.Max() 在编译时无法知道,除非 ListOfDates 本身以某种方式保持不变。

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

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

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

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

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

于 2016-04-09T20:37:45.190 回答