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

11

好的,这只是出于好奇心,没有实际帮助。

我知道使用表达式树可以像常规C#编译器一样即时生成MSIL代码。由于编译器可以决定优化,我想问的是在Expression.Compile()期间生成的IL是否与调试模式和发布模式下的IL有所不同?主要有两个问题:

  1. 由于在编译时,编译器可以在调试模式和发布模式下生成不同(可能稍有不同)的IL,当在调试模式和发布模式下构建表达式时,生成的IL是否有所不同?

  2. 同时,JIT在运行时将IL转换为本机代码,而在调试模式和发布模式下应该会有很大的区别。编译表达式也是这种情况吗?或者说表达式树中的IL根本没有被转化成本地代码吗?

我的理解可能有误,如有错误请纠正。

注意:我考虑的是除去调试器的情况。我询问的是Visual Studio中默认配置设置中随附的“调试”和“发布”情况。


你需要考虑的第一件事是你所说的“调试模式”和“发布模式”的含义。构建配置会影响各种编译时设置,但运行时是否附加调试器也会产生差异,这会影响JIT优化(至少如此)。 - Jon Skeet
@JonSkeet 我在谈论调试器分离的情况(我将编辑到答案中),但我不知道其他编译时设置。你是指平台配置,如x86、x64等吗? - nawfal
我的意思是基本的“Debug”或“Release”配置,它会影响编译时优化设置和预处理器符号,如DEBUG。 - Jon Skeet
@JonSkeet 确实是关于这些配置的问题。说“发布模式”和“发布配置”是不同的吗? - nawfal
1
配置基本上捆绑了许多开关,包括优化和预处理器符号 - 值得指定您感兴趣的确切内容。 (例如,有人可以调整特定配置,在“发布”中包含更多调试信息,但仍然进行优化。) - Jon Skeet
3个回答

12
由于编译器在编译调试模式和发布模式时可以生成不同(可能略有不同)的IL,编译表达式时在调试模式和发布模式下生成的IL是否会有差异?
答案很简单:不会。给定两个相同的LINQ/DLR表达式树,如果一个应用程序在发布模式下运行,另一个在调试模式下运行,则生成的IL不会有任何差异。我也不确定如何实现它;我不知道System.Core中的代码以可靠的方式了解你的项目正在运行的是调试版还是发布版。
但这个答案可能会误导人。表达式编译器发出的IL可能在调试和发布构建之间没有区别,但在C#编译器生成表达式树的情况下,表达式树本身的结构可能会因调试和发布模式而异。我对LINQ/DLR内部非常熟悉,但对C#编译器则了解不太多,所以我只能说在这些情况下可能会有差异(也可能没有)。
同时,将IL转换为本机代码的JIT在调试模式和发布模式下生成的机器码也可能存在差异。但是,由于只有一些额外的临时值,结果可能完全相同。我怀疑在更大更复杂的方法中,两者的差异可能更大,因为通常存在JIT花费时间/努力来优化给定方法的上限。但是,您似乎更感兴趣的是编译的LINQ/DLR表达式树的质量如何与编译在调试或发布模式下的C#代码相比。我可以告诉你,LINQ / DLR LambdaCompiler 进行的优化非常少——肯定比 C# 编译器的 Release 模式少。Debug 模式可能更接近,但我更倾向于 C# 编译器稍微更积极一些。LambdaCompiler 通常不会尝试减少临时本地变量的使用,而条件、比较和类型转换等操作通常会使用比您预期更多的中间本地变量。实际上,我只能想到三种它执行的优化:
  1. 在可能的情况下,嵌套的 lambda 表达式将被内联("可能的情况"往往是"大多数情况")。这实际上可以帮助很多。请注意,仅当您 Invoke 一个 LambdaExpression 时才起作用;如果在表达式内调用编译的委托,则不适用。
  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

整数求平方。

我不确定这是否能够充分展示问题,但是我想到了以下的示例:

// 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。

在Debug模式下,由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()总是带着“优化”进行?

1
通过比较由csc.exe生成的IL和由LINQ/DLR表达式树生成的IL,您无法得出任何有意义的结论:这里有两个完全独立的编译器在工作,并且它们执行的优化类型之间没有关系。C# Release模式下方法大小与LambdaCompiler生成的方法大小相匹配的事实是偶然的。所有这些都表明,对于一个微不足道的方法,当以Debug模式运行时,LambdaCompiler生成了更有效的IL,而C#编译器则没有。 - Mike Strobel
3
根据阅读方式的不同,你的结论或多或少是正确的:LambdaCompiler执行的优化在项目构建配置方面是无关的,因此实际上是“始终启用”的。然而,与csc.exe执行的优化相比,LambdaCompiler执行的优化在任何情况下都是完全不同的。 - Mike Strobel
1
@MikeStrobel 很好的信息。我确实怀疑是这样的,所以我试图小心得出我的结论。 - Jeppe Stig Nielsen
1
确实,C#编译器大多数时候都是一个黑盒子,这并没有帮助我们。我们所知道的关于它执行的优化的大部分信息都是从微软内部人员的评论或输出比较中获得的。这意味着我真的应该声明这两组优化是相互独立的,而不是“非常不同”;我的评论中也包含了一些假设。 - Mike Strobel

1

关于IL

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

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

最后,正如其他人提到的那样,DEBUG != UNOPTIMISED——“可调试”汇编的概念更多地涉及惯例而非其他内容(反映在.Net项目的默认编译设置中),这些惯例控制PDB中的详细信息(顺便说一句,并不是PDB的存在与否),以及代码是否被优化。因此,可以有一个经过优化的调试汇编,也可以有一个未经优化的发布汇编,甚至可以有一个带有完整PDB信息的经过优化的发布汇编,它可以像标准的“调试”汇编一样进行调试。
此外,表达式树编译器将lambda表达式中的表达式直接翻译为IL(除了一些细节,例如从派生引用类型到基本引用类型的冗余下转换),因此生成的IL与您编写的表达式树一样被优化。因此,Debug/Release构建之间的IL不太可能不同,因为实际上不存在Debug/Release进程,只有一个汇编,正如上面所提到的,没有可靠的方法来检测它。
但是JIT呢?
当涉及JIT将IL翻译成汇编语言时,值得注意的是JIT(虽然不确定.NET Core)在启动进程时附带调试器和未附带调试器时的行为是不同的。尝试使用VS中的F5从发布版本开始比较调试行为与之后附加到它时的行为。

现在,这些差异可能不主要是由于优化(其中很大一部分差异可能是确保PDB信息在生成的机器代码中维护),但是当附加到发布进程时,您将看到更多的“方法已经优化”消息在堆栈跟踪中,而如果完全没有看到,则会在启动时通过调试器附加运行它。

我的重点是,如果存在调试器可能会影响静态构建的IL的JIT行为,那么它可能会影响其JIT动态构建的IL的行为,例如绑定委托或在此情况下的表达式树。不过,我们无法确定它们之间有多大的不同。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接