表达树生成的IL是否经过优化?

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

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

  1. 因为在编译时编译器可以在调试模式和释放模式下产生不同的(可能是略微的)IL,在调试模式和发布模式下构建时通过编译表达式生成的IL是否存在差异?

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

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

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

因为在编译时编译器可以在调试模式和释放模式下产生不同的(可能是略微的)IL,在调试模式和发布模式下构建时通过编译表达式生成的IL是否存在差异?

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

然而,这个答案实际上可能会产生误导。 表达式编译器发出的IL在调试和发布版本之间可能没有区别,但是在C#编译器发出表达式树的情况下,表达式树本身的结构可能在调试和释放模式之间不同。 我对LINQ / DLR内部结构非常了解,但与C#编译器的关系并不多,所以我只能说这些情况可能存在差异(可能没有)。

此外,在运行时将IL转换为本机代码的JIT在调试模式和发布模式下都应该有很大的不同。 编译表达式也是如此吗? 或者来自表达树的IL根本没有被咬过?

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

我可以告诉你,LINQ / DLR LambdaCompiler执行的优化很少 – 肯定比Release模式下的C#编译器少; 调试模式可能更接近,但我会把我的钱放在C#编译器上稍稍激进。 LambdaCompiler通常不会尝试减少临时本地的使用,而条件,比较和类型转换等操作通常会使用比您预期的更多的中间本地。 我实际上只能想到它执行的三个优化:

  1. 嵌套的lambda将尽可能内联(并且“在可能的情况下”倾向于“大部分时间”)。 实际上,这可以帮助很多。 注意,这仅在您Invoke LambdaExpressionLambdaExpression ; 如果在表达式中调用已编译的委托,则它不适用。

  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树时,必须完成编译器通常在幕后执行的工作,例如自动插入隐式转换的代码。

平方int

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

 // make delegate and find length of IL: Func f = x => x * x; Console.WriteLine(f.Method.GetMethodBody().GetILAsByteArray().Length); // make expression tree Expression> 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 '
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 '
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()总是可以通过 “优化”进行?

关于IL

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

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

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

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

但JIT怎么样?

然而,当谈到JIT将IL转换为汇编程序时,我认为值得注意的是,如果一个进程是在连接调试器的情况下启动的,那么JIT(虽然不确定.Net核心)的行为会有所不同。 尝试使用VS中的F5启动发布版本,并比较调试行为与已经运行后附加到它的调试行为。

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

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