为什么 C# 编译器为每个传递的委托创建一个新的 Action 实例?

Why does the C# compiler create a new Action instance for every passed delegate?

考虑以下代码:

public static void M() {
    A(V);
    A(V);
    A(V);
}

public static void V() {

}

public static void A(Action x) {
    x();   
}

这在幕后被编译为:

public static void M() {
    A(new Action(V));
    A(new Action(V));
    A(new Action(V));
}

但是,我们可以编写自己的简单性能改进程序来减少不必要的垃圾:

private static readonly Action v = new Action(V);
A(v);
A(v);
A(v);

对于这个非常简单的案例,Roslyn 有什么理由不能进行类似的优化吗?

如果答案是否定的,那么当方法不是静态成员而是实例成员时呢?那么当捕获到封闭变量时呢?

你的问题有许多不同的组成部分和细微差别,所以我会一次又一次地尝试分解它们。

首先,编译器通过语法糖施展魔法。如您所述,

public static void M() { A(V); }

相当于

public static void M() { A(new Action(V)); }

但编译器为您省去了必须直接声明动作实例的麻烦。然而,无论哪种情况,生成的 IL 都需要执行一系列步骤:

IL_000C:  ldnull  
IL_000D:  ldftn       UserQuery.V
IL_0013:  newobj      System.Action..ctor
IL_0018:  call        UserQuery.A
IL_0014:  ldarg.0     
IL_0015:  ldarg.0     
IL_0016:  ldftn       UserQuery.V
IL_001C:  newobj      System.Action..ctor
IL_0021:  call        UserQuery.A
IL_0027:  ldarg.0     
IL_0028:  ldarg.0     
IL_0029:  ldftn       UserQuery.V
IL_002F:  newobj      System.Action..ctor
IL_0034:  call        UserQuery.A

在指令 IL_000D 处为我们的 V 方法生成了一个本机指针。前面的指令只是告诉我们该方法是静态的,否则我们会看到指令 IL_000C: ldarg.0 因为我们的实例方法参数需要被压入计算堆栈。然而,在任何一种情况下,新的动作实例仍然需要在指令 IL_0013: newobj 中生成,因为我们正在传递一个方法 pointer (在引擎盖下),而不是方法实例。最后,一旦我们有了指针和新实例,我们就可以调用我们的 A 方法。

但是,在您的第二个示例中,情况发生了变化:

IL_0001:  ldsfld      UserQuery.v
IL_0006:  call        UserQuery.A
IL_000B:  nop         
IL_000C:  ldsfld      UserQuery.v
IL_0011:  call        UserQuery.A
IL_0016:  nop         
IL_0017:  ldsfld      UserQuery.v
IL_001C:  call        UserQuery.A

我们不生成指针或创建新对象,而是简单地将静态字段 v 的值推入 ldsfld 指令中的计算堆栈。因为我们有这个值,所以除了调用我们的 A 方法之外,我们不需要执行任何额外的操作。

再一次,在我们的第二个示例中,为实例方法声明生成了一条附加指令,但它不会改变参数的生成和传递方式,这是 Roslyn 无法优化的根本原因。 .编译器有义务生成运行时理解和期望的 IL。尝试优化您的第一种情况以使其像第二种情况一样是一组根本不同的指令,因此无法对其进行优化。

we can write our own simple performance improvement that reduces unnecessary garbage

您重新发现了公共子表达式消除的特例——识别两个或多个表达式何时具有完全相同的值、计算一次该值并存储它的优化在要重新使用的变量中。

在继续之前,我提醒您,所有所谓的 "optimizations" 实际上都是以一物换另一物。您建议的优化以每次调用产生的少量收集压力为代价,而不是少量的内存泄漏。静态字段中的缓存值将成为第 2 代堆的永久成员。这值得吗?这是一个你想通过实际测量来回答的问题。

For this very simple case, is there any reason Roslyn couldn't make a similar optimisation?

没有原则上无法执行此优化的原因如果优化没有对程序的行为产生不可接受的变化.

特别是,优化会导致两个先前值相等但引用不相等的委托变为引用相等。这可能是可以接受的。

实际上,实施优化需要在设计、实施、测试和维护执行优化的代码方面付出大量努力。 C# 不实现公共子表达式消除优化。这种优化的性价比很差。很少有人编写会从优化中受益的代码,并且优化很小,正如您发现的那样,如果您关心的话,很容易进行优化 "by hand"。

我注意到 C# 确实对 lambda 进行了类似的缓存。它不会做公共子表达式消除,但它会只生成一次某些lambda并缓存结果:

void M() { Action x = () => {}; ... }

就像你写的那样生成:

static Action anon = null;
void M() 
{
  if (anon == null) anon = () => {};
  Action x = anon;
  ...

If the answer is no, what about when the methods are not static but instance members?

没有原则上无法执行此优化的原因如果优化没有对程序的行为产生不可接受的变化.

我注意到,在这种情况下,当然需要优化来推断实例何时相同。不这样做将无法保持程序行为不能改变的不变性。

同样,在实践中,C# 不执行公共子表达式消除。

And what about when there are closed-over variables captured?

被什么俘虏了?您刚才在谈论方法组转换为委托,显然现在我们正在谈论转换为委托的 lambda。

C# 规范明确指出,编译器可以选择对相同的 lambda 执行公共子表达式消除,也可以不执行,视情况而定。

没有原则上无法执行此优化的原因如果优化没有对程序的行为产生不可接受的变化。由于规范明确指出允许此优化,因此根据定义它是可以接受的。

同样,在实践中,C# 不执行公共子表达式消除。

也许您注意到了这里的趋势。 "is such and such an optimization permitted?" 问题的答案几乎总是 "yes, if it does not produce an unacceptable change in the behaviour of the program"。但是"does C# implement such and such an optimization in practice?"这个问题的答案通常是否定的

如果您想了解编译器执行的优化的一些背景知识,I described them in 2009

在大多数情况下,Roslyn 在这些优化方面做得更好。例如,Roslyn 在将临时值和局部变量具体化为临时变量而不是持久变量方面做得更好。我完全重写了可空算术优化器; my eight-part series of articles describing how is here。还有更多的改进。不过我们从来没有考虑过做 CSE。