【问题标题】:Why does the C# compiler create a new Action instance for every passed delegate?为什么 C# 编译器会为每个传递的委托创建一个新的 Action 实例?
【发布时间】:2017-03-31 21:59:15
【问题描述】:

考虑以下代码:

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

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

【问题讨论】:

  • 这是字面上静态成员和实例成员之间的根本区别。你到底在问什么?为什么 Roslyn 从一开始就没有完全抛弃该语言的行为?
  • @DavidL - 你能进一步解释一下吗?
  • @Enigmativity 很高兴尝试一下;请看下文。
  • 我相信这很可能会出现在 C# 8 中:github.com/dotnet/roslyn/pull/6642

标签: c# optimization roslyn


【解决方案1】:

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

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

在继续之前,我提醒您,所有所谓的“优化”实际上都是在以一件事换另一件事。您提出的优化以每次调用产生的少量收集压力换取少量内存泄漏。静态字段中的缓存值将成为第 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# 规范明确指出,编译器可以选择对相同的 lambda 进行公共子表达式消除,也可以不进行,只要它认为合适。

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

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

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

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

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

【讨论】:

    【解决方案2】:

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

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

    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
    

    我们无需生成指针或创建新对象,只需在ldsfld 指令中将静态字段 v 的值压入评估堆栈即可。由于我们有值,我们不需要执行任何额外的操作,只需调用我们的A 方法。

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

    【讨论】:

      猜你喜欢
      • 2021-09-13
      • 1970-01-01
      • 1970-01-01
      • 2016-12-24
      • 1970-01-01
      • 2020-11-24
      • 1970-01-01
      • 2011-11-21
      • 1970-01-01
      相关资源
      最近更新 更多