【问题标题】:Is IL generated by expression trees optimized?表达式树生成的 IL 是否经过优化?
【发布时间】:2013-10-21 17:27:03
【问题描述】:

好吧,这只是好奇,对现实世界没有帮助。

我知道使用表达式树,您可以像常规 C# 编译器一样动态生成 MSIL。由于编译器可以决定优化,我很想问在Expression.Compile() 期间生成的 IL 是什么情况。基本上两个问题:

  1. 由于在编译时编译器可以在调试模式和发布模式下产生不同的(可能是轻微的)IL,在构建时编译表达式生成的 IL 是否存在差异处于调试模式和发布模式?

  2. 此外,在运行时将 IL 转换为本机代码的 JIT 在调试模式和发布模式下应该有很大的不同。这也是编译表达式的情况吗?还是来自表达式树的 IL 根本没有被 jitted?

我的理解可能有缺陷,以防万一。

注意:我正在考虑调试器分离的情况。我问的是Visual Studio中“调试”和“发布”附带的默认配置设置。

【问题讨论】:

  • 您首先需要考虑的是您所说的“调试模式”和“发布模式”是什么意思。构建配置会影响各种编译时设置,但运行时是否附加调试器也存在差异,这会影响 JIT 优化(至少)。
  • @JonSkeet 我说的是调试器分离的情况(我将编辑到答案中),但我不知道其他编译时间设置。你的意思是x86、x64等平台配置?
  • 我的意思是基本的“调试”或“发布”配置,它会影响编译时优化设置和 DEBUG 等预处理器符号。
  • @JonSkeet 确实是关于我正在谈论的这些配置。是说“发布模式”与“发布配置”不同吗?
  • 该配置基本上捆绑了许多开关,包括优化和预处理器符号 - 值得准确指定您感兴趣的其中哪些。(例如,有人可以调整特定配置以包括“Release”中有更多调试信息,但仍在优化。)

标签: c# expression-trees compiler-optimization jit il


【解决方案1】:

由于在编译时编译器可以在调试模式和发布模式下产生不同的(可能略有)IL,在调试模式和发布模式下编译表达式生成的 IL 是否存在差异?

这个其实有一个非常简单的答案:不。给定两个相同的 LINQ/DLR 表达式树,如果一个由运行在发布模式下的应用程序编译,另一个在调试模式下编译,则生成的 IL 将没有区别。我不确定这将如何实施。我不知道System.Core 中的代码有任何可靠的方法可以知道您的项目正在运行调试版本或发布版本。

然而,这个答案实际上可能具有误导性。表达式编译器发出的 IL 在调试和发布版本之间可能没有区别,但在 C# 编译器发出表达式树的情况下,表达式树本身的结构可能在调试和发布模式之间有所不同。我相当熟悉 LINQ/DLR 内部结构,但对 C# 编译器了解不多,所以我只能说可能在这些情况下会有所不同(也可能不会)。

此外,在运行时将 IL 转换为本机代码的 JIT 在调试模式和发布模式下应该有很大的不同。这也是编译表达式的情况吗?还是表达式树中的 IL 根本没有被 jitted?

JIT 编译器输出的机器代码对于预优化的 IL 和未优化的 IL 来说不一定有很大不同。结果很可能是相同的,特别是如果唯一的区别是一些额外的临时值。我怀疑两者在更大和更复杂的方法中会出现更多分歧,因为 JIT 优化给定方法所花费的时间/精力通常有上限。但听起来您更感兴趣的是编译后的 LINQ/DLR 表达式树的质量与在调试或发布模式下编译的 C# 代码相比如何。

我可以告诉你,LINQ/DLR LambdaCompiler 执行的优化很少——肯定少于发布模式下的 C# 编译器;调试模式可能更接近,但我会把钱花在 C# 编译器上,稍微激进一点。 LambdaCompiler 通常不会尝试减少临时局部变量的使用,并且条件、比较和类型转换等操作通常会使用比您预期更多的中间局部变量。我实际上只能想到它确实执行的三个优化:

  1. 嵌套的 lambda 将尽可能内联(并且“尽可能”往往是“大部分时间”)。实际上,这可以提供很大帮助。请注意,这仅在您 InvokeLambdaExpression 时有效;如果您在表达式中调用已编译的委托,则它不适用。

  2. 省略了不必要/冗余的类型转换,至少在某些情况下是这样。

  3. 如果TypeBinaryExpression(即[value] is [Type])的值在编译时已知,则该值可以作为常量内联。

除了#3,表达式编译器没有“基于表达式”的优化;也就是说,它不会分析表达式树来寻找优化机会。列表中的其他优化很少或根本没有关于树中其他表达式的上下文。

通常,您应该假设编译的 LINQ/DLR 表达式生成的 IL 的优化程度远低于 C# 编译器生成的 IL。但是,生成的 IL 代码符合 JIT 优化条件,因此很难评估真实世界的性能影响,除非您实际尝试使用等效代码对其进行衡量。

使用表达式树编写代码时要记住的一件事是,实际上,是编译器1。 LINQ/DLR 树设计为由其他一些编译器基础结构发出,例如各种 DLR 语言实现。因此,由 来处理表达式级别的优化。如果您是一个草率的编译器并发出一堆不必要或冗余的代码,则生成的 IL 将更大,并且不太可能被 JIT 编译器积极优化。因此,请注意您构建的表达式,但不要太担心。如果您需要高度优化的 IL,您可能应该自己发出它。但在大多数情况下,LINQ/DLR 树的性能都很好。


1 如果您想知道为什么 LINQ/DLR 表达式对要求精确类型匹配如此迂腐,那是因为它们旨在用作多种语言的编译器目标,每种语言在方法绑定、隐式和显式类型转换等方面可能有不同的规则。因此,在手动构建 LINQ/DLR 树时,您必须完成编译器通常会在幕后完成的工作,例如自动插入代码以进行隐式转换。

【讨论】:

    【解决方案2】:

    int 求平方。

    我不确定这是否显示了很多,但我想出了以下示例:

    // make delegate and find length of IL:
    Func<int, int> f = x => x * x;
    Console.WriteLine(f.Method.GetMethodBody().GetILAsByteArray().Length);
    
    // make expression tree
    Expression<Func<int, int>> e = x => x * x;
    
    // one approach to finding IL length
    var methInf = e.Compile().Method;
    var owner = (System.Reflection.Emit.DynamicMethod)methInf.GetType().GetField("m_owner", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(methInf);
    Console.WriteLine(owner.GetILGenerator().ILOffset);
    
    // another approach to finding IL length
    var an = new System.Reflection.AssemblyName("myTest");
    var assem = AppDomain.CurrentDomain.DefineDynamicAssembly(an, System.Reflection.Emit.AssemblyBuilderAccess.RunAndSave);
    var module = assem.DefineDynamicModule("myTest");
    var type = module.DefineType("myClass");
    var methBuilder = type.DefineMethod("myMeth", System.Reflection.MethodAttributes.Static);
    e.CompileToMethod(methBuilder);
    Console.WriteLine(methBuilder.GetILGenerator().ILOffset);
    

    结果:

    在 Debug 配置中,编译时方法的长度为 8,而发出方法的长度为 4。

    在 Release 配置中,编译时方法的长度是 4,而发出方法的长度也是 4。

    IL DASM 在调试模式下看到的编译时方法:

    .method private hidebysig static int32  '<Main>b__0'(int32 x) cil managed
    {
      .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) 
      // Code size       8 (0x8)
      .maxstack  2
      .locals init ([0] int32 CS$1$0000)
      IL_0000:  ldarg.0
      IL_0001:  ldarg.0
      IL_0002:  mul
      IL_0003:  stloc.0
      IL_0004:  br.s       IL_0006
      IL_0006:  ldloc.0
      IL_0007:  ret
    }
    

    和发布:

    .method private hidebysig static int32  '<Main>b__0'(int32 x) cil managed
    {
      .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) 
      // Code size       4 (0x4)
      .maxstack  8
      IL_0000:  ldarg.0
      IL_0001:  ldarg.0
      IL_0002:  mul
      IL_0003:  ret
    }
    

    免责声明:我不确定是否可以得出任何结论(这是一个很长的“评论”),但也许Compile() 总是发生“优化”?

    【讨论】:

    • 通过比较csc.exe 生成的 IL 和 LINQ/DLR 表达式树生成的 IL 无法得出任何有意义的结论:有两个完全独立的编译器在工作,它们之间没有任何关系他们执行的优化类型。 C# Release 模式下的方法大小与LambdaCompiler 生成的方法大小相匹配的事实是偶然的。所有这些都表明,LambdaCompiler 为一个简单的方法生成了比 C# 编译器在调试模式下运行时更高效的 IL。
    • 然而,根据人们的阅读方式,您的结论或多或少是正确的:LambdaCompiler 执行的优化实际上是“始终启用”的,因为它们独立于项目的构建配置.但是,无论如何LambdaCompiler 执行的优化与csc.exe 执行的优化完全不同。
    • @MikeStrobel 好信息。我确实怀疑它是这样的,所以我尽量小心我的结论。
    • 确实,C# 编译器主要是一个黑匣子也无济于事。我们所知道的关于它执行的优化的大部分信息都是由微软内部人员从 cmets 或通过输出比较收集的。这意味着我真的应该说这两组优化彼此独立,而不是“完全不同”;我的 cmets 中也有一些假设。
    【解决方案3】:

    关于 IL

    正如其他答案所指出的那样,在运行时检测调试/发布并不是真正的“事情”,因为它是由项目配置控制的编译时决策,而不是在构建的程序集中真正可检测到的东西。运行时可以反映程序集上的AssemblyConfiguration 属性,检查其Configuration 属性——但这对于.Net 如此基础的东西来说是一个不精确的解决方案——因为该字符串实际上可以是任何东西

    此外,不能保证该属性存在于程序集中,并且由于我们可以在同一进程中混合和匹配发布/调试程序集,因此实际上不可能说“这是一个调试/发布过程”。

    最后,正如其他人所提到的,DEBUG != UNOPTIMISED -“可调试”程序集的概念更多的是关于约定而不是其他任何东西(反映在 .Net 项目的默认编译设置中)——控制细节的约定PDB(顺便说一句,不存在),以及代码是否经过优化。因此,可以有一个优化的调试程序集,以及一个未优化的发布程序集,甚至是一个具有完整 PDB 信息的优化发布程序集,可以像标准“调试”程序集一样进行调试。

    此外,表达式树编译器几乎直接将 lambda 中的表达式转换为 IL(除了一些细微差别,例如从派生引用类型到基引用类型的冗余向下转换),因此生成的 IL 与您编写的表达式树一样优化。因此,调试/发布版本之间的 IL 不太可能有所不同,因为实际上没有调试/发布进程之类的东西,只有一个程序集,而且如上所述,没有可靠的方法来检测。

    但是 JIT 呢?

    然而,当谈到将 IL 转换为汇编程序的 JIT 时,我认为值得注意的是 JIT(虽然不确定 .Net 核心)确实表现得很好如果一个进程启动时附加了调试器与没有启动时不同。尝试在 VS 中使用 F5 开始发布构建,并比较调试行为与在它已经运行后附加到它。

    现在,这些差异可能主要不是由于优化(很大一部分差异可能是确保 PDB 信息在生成的机器代码中得到维护),但您会看到更多“方法已优化”消息附加到发布进程时,堆栈跟踪中的堆栈跟踪比从一开始就附加调试器运行它时,如果有的话。

    我的观点的重点是,如果调试器的存在会影响 静态 构建的 IL 的 JITing 行为,那么它可能会影响其在 JITing 动态 时的行为构建 IL,例如绑定委托,或者在本例中为表达式树。不过,我不确定我们能说的有多么不同。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2019-01-09
      • 2011-06-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-07-12
      • 1970-01-01
      相关资源
      最近更新 更多