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

时间:2017-03-31 03:57:13

标签: c# optimization roslyn

请考虑以下代码:

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是否有任何理由无法进行类似的优化?

如果答案是否定的,那么方法何时不是静态但实例成员呢?什么时候有关闭的变量被捕获?

2 个答案:

答案 0 :(得分:17)

  

我们可以编写自己的简单性能改进,以减少不必要的垃圾

您重新发现了公共子表达式消除的特殊情况 - 优化识别两个或多个表达式具有完全相同的值,计算一次值,并将其存储在变量中重新使用。

在继续之前,我告诫你,所有所谓的“优化”实际上都是另一回事。您建议的优化会在每次调用时产生少量的收集压力,而不是内存泄漏。静态字段中的缓存值将成为gen 2堆的永久成员。那值得吗?这是一个你想通过实际测量来回答的问题。

  

对于这个非常简单的案例,Roslyn是否有任何理由无法进行类似的优化?

如果优化没有对程序的行为产生不可接受的改变,那么原则没有理由无法执行此优化

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

实际上,实现优化需要花费大量精力来设计,实现,测试和维护执行优化的代码。 C#没有实现常见的子表达式消除优化。这种优化具有很差的优势。很少有人编写可以从优化中受益的代码,并且优化很小,如您所知,如果您愿意,很容易“手动”进行优化。

我注意到C#在lambda上做了类似的缓存。它不会执行常见的子表达式消除,但它只会生成一定的lambdas并缓存结果:

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

就好像你写了:

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

如果答案是否定的,那么方法何时不是静态但实例成员呢?

如果优化没有对程序的行为产生不可接受的改变,那么原则没有理由无法执行此优化

我注意到在这种情况下,需要进行优化以推断出实例当然是相同的。如果不这样做,将无法保持程序行为不得改变的不变性。

同样,在实践中,C#不会消除常见的子表达式。

  

当捕获封闭变量时会怎样?

被截获的是什么?你刚才谈到方法组转换给代表,显然现在我们正在谈论转换为代表的lambda。

C#规范明确指出编译器可以选择在相同的lambdas上进行公共子表达式消除,或者不相同。

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

同样,在实践中,C#不会消除常见的子表达式。

也许你在这里注意到一种趋势。问题的答案“是这样的,允许这样的优化吗?”几乎总是“是的,如果它不会对程序的行为产生不可接受的变化”。但问题的答案是“C#在实践中实现了这样的优化吗?”通常没有。

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

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

答案 1 :(得分:1)

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

首先,编译器通过语法糖执行魔术。如你所说,

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

在指令V处为我们的IL_000D方法生成本机指针。前面的指令只是告诉我们该方法是静态的,否则我们会看到指令IL_000C: ldarg.0,因为我们的实例方法参数需要被推送到评估堆栈。但是,在任何一种情况下,新的动作实例仍然需要在指令IL_0013: newobj生成,因为我们传递方法 指针 (在引擎盖下),而不是方法 实例 。最后,一旦我们有了指针和新实例,我们就可以调用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。尝试优化您的第一种情况,就像第二种情况一样,是一组根本不同的指令,因此无法对其进行优化。