技术开发 频道

.NET Core中“分层JIT编译”内部结构解读

  【IT168 技术】.NET运行时(CLR)主要使用JIT编译器将可执行文件转换为机器代码(暂时搁置AOT编译的场景),正如微软公司的官方文档所描述:在执行时,JIT编译器将MSIL (微软中间语言)转换为本地代码。在编译期间,代码必须通过验证过程,检查MSIL和元数据,以确定代码是否可以被确定为类型安全代码。但是这个过程是如何运作的呢?

  在JIT编译时,要考虑到在执行过程中可能永远不会调用某些代码的可能性。而不是使用时间和内存来转换所有的MSIL PE文件中的本地代码,而是在执行过程中根据需要转换MSIL,并将生成的本地代码存储在内存中,以便在该进程的场景中进行后续调用。加载程序在类型被加载和初始化时创建,并附加一个存根到类型中的每个方法。当第一次调用某个方法时,存根将控制传递给JIT编译器,JIT编译器将该方法的MSIL转换为本地代码,并修改存根直接指向生成的本机代码。因此,对JIT编译方法的后续调用将直接转到本机代码。

  这真的很简单。但是,如果想知道更多的内容,这篇文章的其余部分将详细探讨这个过程。

  另外,人们将看到一个新特性,它正在进入核心CLR(公共语言运行库),称之为“分层编译”。这对于CLR来说是一个很大的改变,直到现在,.NET方法在第一次使用时才被编译一次。分层编译正在改变这种情况,允许将方法重新编译为更加优化的版本,就像Java Hotspot编译器一样。

  它是如何工作的

  但在考虑未来的计划之前,当前的CLR如何让JIT编译器将一种方法从IL(中间语言)转换为本地代码?那么,其实可以用视图来表示,因为“一图胜千言”。

  该方法被JIT编译之前:

.NET Core中分层JIT编译内部结构解读

  该方法被JIT编译之后:

全面扫盲:独家解读.NET Core中“分层JIT编译”的内部结构

  需要注意的事情是:

  ?CLR放入了一个“预编码”和“存根”,以便将初始方法调用转移到PreStubWorker()方法(最终调用JIT)。这些是程序人员编写的汇编代码片段,只包含几条指令。

  一旦这个方法被JIT编译成“本地代码”,就会创建一个稳定的入口点。在CLR的剩余生命周期中保证不会改变,所以余下的运行时间可以保持稳定。

  ?“临时入口点”不会消失,但仍然可用,因为可能有其他方法可以调用它。然而,相关的“预编码修正”已被重写或“修补”,以指向新创建的“本地代码”而不是PreStubWorker()。

  ?CLR不改变JIT编译的调用指令的地址,它只改变‘precode’中的地址。但是因为CLR中的所有方法调用都是通过预编码进行的,所以第二次调用新的JIT编译后的方法时,调用将以“本地代码”结束。

  作为参考,“稳定的入口点”与调用RuntimeMethodHandle.GetFunctionPointer()方法时返回的IntPtr的内存位置相同。

  如果人们想亲自看到这个过程,可以重新编译CoreCLR源代码,添加相关的调试信息,或者只是使用WinDbg,并按照这篇博客文章中的步骤来做。

  最后,下面列出了所涉及的核心CLR源代码的不同部分:

  JIT Helpers for ‘PrecodeFixupThunk’

  PrecodeFixupThunk (i386 assembly)

  ThePreStub (i386 assembly)

  PreStubWorker(..)

  MethodDesc::DoPrestub(..)

  MethodDesc::DoBackpatch(..)

  MethodDesc::SetStableEntryPointInterlocked(..)

  注意:这篇文章并不是要研究JIT本身是如何工作的,如果感兴趣的话,可以参阅资深开发人员编写的概述。

  JIT编译和执行引擎(EE)交互

  使所有这些工作JIT和EE必须一起工作,以了解涉及的内容,查看这个评论,描述确定JIT编译可以使用哪种类型的预编码的规则。所有这些信息都存储在EE中,因为它是唯一一个完全了解某种方法的地方,所以JIT必须询问哪种模式可以工作。

  另外,JIT必须向EE询问函数入口点的地址是什么,这是通过以下方法完成的:

  · CEEInfo::getFunctionEntryPoint(..)

  o Then calls MethodDesc::TryGetMultiCallableAddrOfCode(..)

  · CEEInfo::getFunctionFixedEntryPoint(..)

  o Then calls MethodDesc::GetMultiCallableAddrOfCode(..)

  预编码和存根

  有不同的类型或'precode'可用,'FIXUP','REMOTING'或'STUB',可以看到MethodDesc :: GetPrecodeType()中使用的规则。另外,由于它们是低级别的机制,所以它们在CPU体系结构中的实现与代码中的注释不同:

  临时入口点有两个实现选项:

  (1)紧凑的入口点。它们提供尽可能密集的入口点,但不能修补指向最终的代码。未经调试的方法的调用是通过插槽进行间接调用。

  (2)预编码。预编码将被修补以指向最终的代码,从而可以将临时入口点嵌入到代码中。未被调用的方法的调用是直接调用直接跳转。

  (1)用于x86,(2)用于64位以在每个平台上获得非常好的性能。对于ARM(1)被使用。

  BOTR还提供了更多关于“预编码”的信息。

  最后,事实证明,如果没有遇到“stub”(或“trampolines”,“thunk”等),就不能进入CLR的内部,例如,

  虚拟方法(接口)调度

  跳转存根

  泛型共享

  Dll导入回调

  分层编译

  在进一步讨论之前,要指出的是分层编译工作正在进行中。作为一个指示,为了让它工作,现在必须设置一个名为COMPLUS_EXPERIMENTAL_TieredCompilation的环境变量。看来,目前的工作重点放在基础设施上(即CLR的变化),那么在默认启用之前,必须进行大量的测试和性能分析。

  如果想了解该功能的目标以及它如何适应更广泛的“代码版本化”流程,那么建议阅读一些优秀的设计文档,包括未来的路线图可能性。

  为了说明迄今为止所涉及的情况,目前正在进行的工作有:

  调试器(例如,如果在调试器连接之前,采用分层JIT编译重新编译该方法,并且在分层JIT编译替代代码时源线路断点停止工作,则断点不会被命中)

  分析API - 例如分层JIT编译:实施额外的Profiler API

  诊断 - 全部通过分层JIT编译进行跟踪:设计/实施适当的诊断,将IL固定到ETW(Event Tracing for Windows)的本地映射

  Interpreter(解释器) - CLR有一个内置的解释器

  ReJIT的历史

  这是能够让CLR为用户重新调试的一个方法,但是它只能和Profiling API一起工作,这意味着用户必须编写一些C/ C ++ COM代码才能实现。另外ReJIT只允许在同一级别重新编译该方法,所以不会产生更多的优化代码。这主要是为了帮助监视或分析工具。

  它是如何工作的?

  最后是如何工作,这需要查看一些图表。首先回顾一下,一旦某个方法被JIT编译,关闭分层编译(与上面的图相同),其结果将是什么:

全面扫盲:独家解读.NET Core中“分层JIT编译”的内部结构

  现在,作为比较,以下是启用分层编译的同一个阶段:

.NET Core中分层JIT编译内部结构解读

  主要区别在于分层编译迫使方法调用通过另一个间接层次“预存”。这是为了能够计算方法被调用的次数,然后一旦达到阈值(当前为30),“预存根”被重写为指向“优化本地代码”:

  请注意,原始的“本地代码”仍然可用,所以如果需要,可以恢复更改,方法调用可以返回到未优化的版本。

  使用计数器

  可以在prestub.cpp的这个评论中看到更多关于计数器的细节:

全面扫盲:独家解读.NET Core中“分层JIT编译”的内部结构

  实质上,“存根”回调到“分层编译管理器”,直到“分层编译”被触发,一旦发生“存根”被“回补”,停止被调用。

  为什么没有“解释”模式?

  如果人们想知道为什么分层编译没有解释模式,那么其答案是已经采用一个解释器,但是这不适合生产代码吗?这是一个很好的问题,人们猜对了,因为解释器还不够完善,不能运行生产代码。如果人们希望调试和分析工具正常工作,只要有足够的时间和精力,这一切都是可以解决的,但这并不是最容易开始的地方。

  非优化和优化的JITting之间的开销有多大的不同?

  在机器上,大约65%的时间使用了非优化的jitting,优化的jitting与IL输入大小类似,但是人们期望的结果会因工作负载和硬件而有所不同。进入第一步检查应该会更容易收集更好的测量结果。

  但是从几个月前开始,也许Mono的新.NET解释器会改变一些事情,谁知道呢?

  为什么不采用LLVM?

  最后,为什么不使用LLVM来编译代码,可以从Introduce a tiered JIT (comment)进行了解。

  在GC(垃圾回收器)和EH(异常处理模型)中,CLR所需的LLVM支持与Java所需的LLVM支持(可能仍然存在)存在显著差异,而且必须在优化器中加以限制。仅举一个例子:CLR GC当前不能容忍指向对象末尾的托管指针。Java通过基类/派类生的成对报告机制处理这个问题。人们可能需要为这种成对的CLR报告提供支持,或者限制LLVM的优化器通过从不创建这些类型的指针。最重要的是,LLILC的JIT编译速度很慢,很难确定它最终会产生什么样的代码质量。

  因此,弄清楚LLILC如何适应尚未存在的多层方法似乎为时尚早。现在的想法是将框架分层,并使用RyuJit作为二级JIT。随着人们越来越多的了解,可能会发现确实存在更高级别的工作空间,或者至少应该更好地理解需要做些什么才能使这些事情变得有意义。

  总结

  还有一个很好的副产品是微软的.NET Open Source 和开放的work-in-progress功能。对此感兴趣的人可以下载最新的代码尝试一下,看看它们是如何工作的。

0
相关文章