Dart VM
概述
Dart VM是历史名称。从某种意义上说,Dart VM是一种虚拟机,它为高级编程语言提供了执行环境,但是,这并不意味着在Dart VM上执行时,Dart总是被解释或JIT编译。例如,可以使用Dart VM AOT编译管道将Dart代码编译为机器代码,然后在称为预编译运行时的Dart VM剥离版本中执行,该版本不包含任何编译器组件,并且无法动态加载Dart源代码。
Dart VM是用于本地执行Dart代码的组件的集合。它包括以下内容:
-
runtime
运行时系统- 对象模型Object Model
- 垃圾收集GC
- 快照Snapshots
- 核心库本机方法
- 通过服务协议可访问的Development Experience组件
- 调试Debugging
- Profiling
- Hot - reload
-
JIT
和AOT
编译管道 - 解析程序Interpreter
- ARM模拟器
Dart VM如何运行用户程序代码?
Dart VM具有多种执行代码的方式
- 使用JIT从源代码或内核二进制文件中获取;
- 从快照获取
- 从AOT快照获取
- 来自AppJIT快照
两者之间的主要区别在于VM将Dart源代码转换为可执行代码的时间以及方式。有利于执行的运行时环境保持不变。
VM中的所有的Dart代码都在某种isolate环境中运行,这可以描述为一个隔离的Dart宇宙,它具有自己的内存(堆),并且通常具有自己的控制线程(mutator线程)。可能有许多隔离程序同时执行Dart代码,但它们不能直接共享任何状态,只能通过端口传递消息进行通信(不要与网络端口混淆!)。
OS线程和isolate之间的关系有点模糊,并且高度依赖于VM如何嵌入到应用程序中。仅保证以下几点:
- 一个OS线程一次只能输入一个isolate。如果要输入另一个isolate,则必须先离开当前isolate。
- 一次只能有一个与isolate子关联的mutator线程。Mutator线程是执行Dart代码并使用VM的公共C API的线程。
- 但是,同一OS线程可以先输入一个isolate,执行Dart代码,然后离开该isolate并输入另一个isolate。
- 不同的OS线程可以输入同一isolate,并在其中执行Dart代码,但不能在同一个isolate中同时执行。
- 除了单个mutator线程之外,隔离还可以与多个helper线程关联,例如:
- 后台JIT编译器线程;
- GC清扫器线程;
- 并发GC标记线程。
在内部,VM使用线程池(ThreadPool
)管理OS线程,并且代码围绕ThreadPool::Task
概念,而不是OS线程的概念构建。例如,在GC VM将a发布SweeperTask
到全局VM线程池之后,而不是生成专用线程来执行后台扫描,并且线程池实现要么选择一个空闲线程,要么在没有可用线程的情况下生成一个新线程。同样,用于isolate消息处理的事件循环的默认实现实际上并没有产生专用的事件循环线程,而是MessageHandlerTask
每当新消息到达时,它都会将a 张贴到线程池中。
Tips
类
Isolate
表示隔离,类Heap
-隔离的堆。类Thread
描述与连接到isolate对象的线程关联的状态。请注意,该名称Thread
有些令人困惑,因为所有连接到与isolate相同的isolate的OS线程都将重用同一Thread
实例。请参见Dart_RunLoop
和MessageHandler
以了解isolate消息处理的默认实现。*
通过JIT从源代码运行
本节试图介绍当您尝试从命令行执行Dart时发生的情况:
// hello.dart
main () => print ('Hello,World!' );
$ dart hello.dart
Hello,World!
由于Dart 2 VM不再具有直接从原始源执行Dart的功能,因此VM希望获得包含串行化Kernel AST的内核二进制文件。将Dart源代码转换为Kernel AST的任务由Dart 编写并在不同Dart工具(例如VM,dart2js,Dart Dev Compiler)之间共享的通用前端(CFE)处理。
为了保持直接从源代码独立dart可执行文件托管Dart的便利性,需要一个名为内核服务的helper isolate程序,该服务程序将Dart源代码编译为Kernel binary内核二进制文件。然后,VM将运行生成的内核二进制文件。
image.png
但是,此设置不是安排CFE和VM运行Dart代码的唯一方法。
例如,Flutter 通过将编译(生成内核二进制文件)和执行放到不同的设备上来,将编译和内核完全分开:编译发生在开发人员机器(主机)上,执行在目标移动设备上进行,目标移动设备接收flutter工具发送给它的内核二进制文件。
image.png
注意,该flutter工具不会处理Dart本身的解析,而是会产生另一个持久性进程frontend_server,该进程本质上是围绕CFE的精简包装,并且是一些Flutter特定的内核到内核的转换。frontend_server将Dart源代码编译为内核文件,flutter然后将该工具发送到设备。frontend_server当开发人员请求Hot reload时,过程的持久性开始发挥作用:在这种情况下,frontend_server可以重用先前编译中的CFE状态,并且仅重新编译实际更改的部分。
将内核二进制文件加载到VM后,将对其进行解析以创建表示各种程序实体的对象。但是,这是懒加载的:首先只加载有关库和类的基本信息。每个来自内核二进制文件的实体都会保留指向二进制文件的指针,以便以后可以根据需要加载更多信息。
image.png
仅当在以后需要运行时(例如,查找类成员,分配实例等)时,才完全反序列化有关类的信息。在这一阶段,从内核二进制文件中读取类成员。但是,功能完整的主体在此阶段不会反序列化,只有它们的签名反序列化。
image.png
此时,从内核二进制文件加载了足够的信息以供runtime成功解析和调用方法。例如,它可以解析和调用main库中的函数。
Tips
package:kernel/ast.dart
定义描述内核AST的类。package:front_end
处理解析Dart源并从中构建Kernel AST。kernel::KernelLoader::LoadEntireProgram
是将内核AST反序列化为相应VM对象的入口点。pkg/vm/bin/kernel_service.dart
实现内核服务isolate,runtime/vm/kernel_isolate.cc
将Dart实现粘合到VM的其余部分。package:vm
托管大多数基于内核的VM特定功能,例如各种内核到内核的转换。但是,package:kernel
由于历史原因,某些特定于VM的转换仍然存在。一个复杂的变换的一种很好的例子是package:kernel/transformations/continuation.dart
,它desugarsasync
,async*
和sync*
功能。
最初,所有函数的主体都具有占位符,而不是实际的可执行代码:它们指向LazyCompileStub,它只是简单地要求runtime系统为当前函数生成可执行代码,然后尾部调用此新生成的代码。
image.png
首次编译函数时,是通过未优化编译器来完成的。
image.png
未优化的编译器分两步生成机器代码:
1、 对函数主体的序列化AST进行遍历以生成函数主体的控制流程图(CFG)。CFG由填充有中间语言(IL)指令的base blocks组成。此阶段使用的IL指令类似于基于堆栈的虚拟机的指令:它们从堆栈中获取操作数,执行操作,然后将结果压入同一堆栈。实际上并非所有的功能有实际dart/内核AST机构,如当地人 C ++定义的或人工的撕开由dart VM生成的函数-在这些情况下IL被刚刚从稀薄的空气产生的而不是从内核AST生成它,。
2、使用一对多的IL低级指令,可将生成的CFG直接编译为机器代码:每个IL指令可扩展为多个机器语言指令。
在此阶段没有执行优化。未优化编译器的主要目标是快速生成可执行代码。
这也意味着未优化的编译器不会尝试静态解析任何未在内核二进制文件中解析的调用,因此将调用(MethodInvocation
或PropertyGet
AST节点)视为完全动态的进行编译。VM当前不使用任何形式的基于虚拟表或接口表的调度,而是使用嵌入式缓存实现动态调用。
内联缓存的核心思想是在调用位置特定的缓存中缓存方法解析的结果。VM使用的内联缓存机制包括以下内容:
内联缓存的原始实现实际上是在修补函数的本机代码,因此命名为内联 缓存。内联缓存的想法可以追溯到Smalltalk-80,请参阅Smalltalk-80系统的有效实现:
-
RawICData
将接收方的类映射到方法的调用位置特定的缓存(对象),如果接收方具有匹配的类,则应调用该方法。高速缓存都存储了一些辅助信息,例如调用频率计数器,这些信息跟踪给定类在此调用位置上出现的频率。 - 共享查找存根,它实现方法调用快速路径。该存根在给定的高速缓存中进行搜索以查看其是否包含与接收者的类别匹配的条目。如果找到该条目,则存根将增加频率计数器和尾调用缓存的方法。否则,存根将调用实现方法解析逻辑的runtime system助手。如果方法解析成功,则将更新缓存,并且随后的调用无需进入runtime system。
下图说明了与animal.toFace() call site关联的内联缓存的结构和状态,该内联缓存使用的实例执行两次,分别执行Dog实例两次和Cat实例一次。
image.png
VM的自适应优化编译
本身不优化编译器就足以执行任何可能的Dart代码。但是,它生成的代码相当慢,因此VM还实现了自适应优化编译管道。自适应优化的思想是使用正在运行的程序的执行配置文件来驱动优化决策。
当未优化的代码运行时,它将收集以下信息:
- 与每个动态调用点关联的内联高速缓存收集有关观察到的接收器类型的信息;
- 与功能和功能内基本块关联的执行计数器跟踪代码的热门区域。
当与某个功能关联的执行计数器达到某个阈值时,该功能将提交给后台优化编译器进行优化。
优化编译的方式与未优化编译的方式相同:通过序列化内核AST为要优化的功能构建未优化的IL。但是,优化编译器并没有直接将IL降低为机器代码,而是将未优化的IL转换为静态单个分配。(SSA)形式的优化IL,基于收集到的类型反馈,对基于SSA的IL进行专业化推测,并通过一系列传统的和Dart特定的优化:例如:内联,范围分析,类型传播,表示选择,store-to-load和load-to-load发展,使用线性扫描寄存器、分配器和简单的一对多低级IL指令等将优化的IL转换为机器代码。
编译完成后,后台编译器请求mutator线程输入safepoint,并将优化的代码附加到该函数。下次调用该函数时,它将使用优化的代码。
image.png有些函数包含很长的运行循环,因此在函数仍在运行时将执行从未优化的代码切换到优化的代码是有意义的。此过程之所以称为堆栈替换(OSR),是因为该功能的一个版本的堆栈框架被同一功能的另一个版本的堆栈框架透明替换的事实
Tips:
编译器源位于
runtime/vm/compiler
目录中。编译管道的入口点是CompileParsedFunctionHelper::Compile
。IL在runtime/vm/compiler/backend/il.h
中定义。内核到IL的转换始于kernel::StreamingFlowGraphBuilder::BuildGraph
,此功能还处理各种人工功能的IL构造。StubCode::GenerateNArgsCheckInlineCacheStub
生成内联缓存存根的机器代码,同时InlineCacheMissHandler
处理IC丢失。runtime/vm/compiler/compiler_pass.cc
定义优化编译器传递及其顺序。JitCallSpecializer
进行大多数基于类型反馈的专业化。
重要的是要强调,由优化编译器生成的代码在基于应用程序执行配置文件的推测性假设下是专用的。例如,仅将单个类别的实例C作为接收者进行观察的动态调用位置将转换为直接调用,之前将进行检查以确认接收者具有预期的类别C。但是,这些假设可能在以后的程序执行过程中被违反:
去优化
每当优化的代码做出一些无法从静态不可变信息中得出的假设时,它都需要防止违反这些假设,并且能够在发生这种违反时恢复。
这种恢复过程称为去优化:只要优化版本遇到无法解决的情况,它就会将执行转移到未优化功能的匹配点,然后继续执行。未优化的功能版本不做任何假设,可以处理所有可能的输入。在正确的位置输入未优化的函数绝对至关重要,因为代码会产生副作用(例如,在上面的函数中,未优化发生在我们已经执行完第一个函数之后print)。使用deopt id完成对未优化到VM中未优化代码中位置的匹配指令
VM通常会在取消优化后放弃功能的优化版本,然后在以后使用更新的类型反馈再次对其进行优化。
VM保护编译器进行推测性假设的方式有两种:
- 内嵌检查(例如CheckSmi,CheckClassIL指令)验证,如果假设成立,在使用的网站,编译器做出这种假设。例如,将动态调用转换为直接调用时,编译器会在直接调用之前添加这些检查。在此类检查中发生的取消优化称为“ 急切优化”,因为它在达到检查时就立刻发生。
- 全局警卫指示runtime在更改优化代码所依赖的内容时丢弃优化代码。例如,优化编译器可能会发现某些类C从不扩展,并且在类型传播过程中使用了此信息。但是,随后的动态代码加载或类最终确定可能会引入-的子类C,这会使假设无效。此时,runtime需要查找并丢弃所有在C没有子类的假设下编译的优化代码。runtime可能会在执行堆栈上找到一些现在无效的优化代码-在这种情况下,受影响的帧将被标记为不优化,并且当执行返回到它们时将进行不优化。这种取消优化称为惰性取消优化:因为延迟到控制返回到优化代码为止。
反优化器设备位于
runtime/vm/deopt_instructions.cc
。它本质上是反优化指令的微型解释器,用于描述如何从优化代码的状态重建未优化代码的所需状态。CompilerDeoptInfo::CreateDeoptInfo
在编译过程中,优化代码中的每个潜在的反优化位置都会生成反优化指令。
从快照Snapshot运行
VM能够将isolate 堆或更精确地讲,将驻留在堆中的对象图序列化为二进制快照。然后,在启动VM isolate时,可以使用快照来重新创建相同的状态。
image.png
Snapshot的格式是低级的,并且针对快速启动进行了优化-本质上是要创建的对象列表以及如何将它们连接在一起的说明。那是快照背后的原始想法:代替解析Dart源并逐步创建内部VM数据结构,VM只需快速启动一个具有所有必要数据结构的isolate。
最初,快照不包括机器代码,但是后来在开发AOT编译器时添加了此功能。开发AOT编译器和带代码快照的动机是为了允许VM在由于平台级别限制而无法进行JITing的平台上使用。
带代码的快照的工作方式几乎与普通快照相同,只是有一点点不同:它们包括一个代码部分,该部分与快照的其余部分不同,不需要反序列化。该代码节的放置方式使其在映射到内存后可以直接成为堆的一部分。
image.png
runtime/vm/clustered_snapshot.cc
处理快照的序列化和反序列化。一整套API函数Dart_CreateXyzSnapshot[AsAssembly]
负责写出堆的快照(例如Dart_CreateAppJITSnapshotAsBlobs
和Dart_CreateAppAOTSnapshotAsAssembly
)。另一方面,Dart_CreateIsolate
可以选择从快照数据开始一个isolate。
从AppJIT快照运行
引入AppJIT快照可以减少大型Dart应用程序(例如dartanalyzer或dart2js)的JIT预热时间。当这些工具用于小型项目时,他们花费的实际时间与VM花费JIT编译这些应用程序所花费的时间相同。
AppJIT快照可以解决此问题:可以使用一些模拟训练数据在VM上运行应用程序,然后将所有生成的代码和VM内部数据结构序列化为AppJIT快照。然后可以分发此快照,而不是以源(或内核二进制)形式分发应用程序。
如果在实际数据上执行配置文件与训练期间观察到执行配置文件不匹配的话,从该快照开始的VM仍然可以改为使用JIT
image.png
从AppAOT快照运行
AOT快照最初是为无法进行JIT编译的平台引入的,但它们也可以用于快速启动和一致性能值得潜在性能损失的情况
关于如何比较JIT和AOT的性能特征,通常会有很多困惑。JIT可以访问正在运行的应用程序的精确本地类型信息和执行配置文件,但是它必须付出预热的代价。AOT可以在全局范围内推断和证明各种属性(必须为此付出编译时间),但是没有有关程序实际执行方式的信息,AOT编译后的代码几乎在没有预热的情况下几乎立即达到其峰值性能。当前,Dart VM JIT具有最佳的峰值性能,而Dart VM AOT具有最佳的启动时间。
无法进行JIT意味着:
- AOT快照必须包含在应用程序执行期间可以调用的每个功能的可执行代码;
- 可执行代码不得依赖于执行期间可能违反的任何推测性假设;
为了满足这些要求,AOT编译过程会进行全局静态分析(类型流分析或TFA),以从已知的入口点集中确定应用程序的哪些部分可访问,分配了哪些类的实例以及类型如何在程序中流动。所有这些分析都是保守的:这意味着它们会在正确性方面出错-与可以在性能方面出错的JIT形成鲜明对比,因为JIT始终可以取消优化为未优化的代码以实现正确的行为。
然后,所有可能达到的功能都将编译为本机代码,而无需进行任何推测性优化。但是,类型流信息仍然用于使代码专用化(例如,取消虚拟化调用)。
编译完所有函数后,即可获取堆的快照。
然后,可以使用预编译的运行时来运行生成的快照,该运行时是Dart VM的特殊变体,其中不包括JIT和动态代码加载工具之类的组件。
image.png
package:vm/transformations/type_flow/transformer.dart
是基于TFA结果进行类型流分析和转换的切入点。Precompiler::DoCompileAll
是VM中AOT编译循环的入口点。
可切换调用
即使进行了全局和局部分析,AOT编译代码仍可能包含无法静态虚拟化的调用点。为了补偿此AOT编译代码和运行时,请使用JIT中扩展的内联缓存技术。此扩展版本称为可切换调用。
JIT部分已经描述过,与调用点关联的每个内联缓存均由两部分组成:一个缓存对象(由的一个实例表示RawICData
)和要调用的一大堆本机代码(例如InlineCacheStub
)。在JIT模式下,runtime只会更新缓存本身。但是,在AOT中,runtime可以选择替换缓存和要调用的本机代码,具体取决于内联缓存的状态。
最初,所有动态调用均以未链接状态开始。首次
UnlinkedCallStub
调用DRT_UnlinkedCall
此调用点时,它仅调用runtime帮助程序来链接此调用位置。如果可能,
DRT_UnlinkedCall
尝试将调用点转换为单态。在这种状态下,调用点变成直接调用,该调用通过特殊的入口点进入方法,该入口点验证接收方是否具有预期的类。image.png
此存根基于以下事实:对于AOT编译,大多数类使用继承层次结构的深度优先遍历分配给整数ID。如果
C
是具有子类的基类,D0, ..., Dn
并且没有子类覆盖,C.method
则C.:cid <= classId(obj) <= max(D0.:cid, ..., Dn.:cid)
表示obj.method
解析为C.method
。在这种情况下,我们可以使用类ID范围检查(单个目标状态),而不是与单个类(单态状态)进行比较,它适用于的所有子类。C
否则,调用位置将切换到使用线性搜索内嵌的缓存,类似于JIT模式时使用的(见ICCallThroughCodeStub
,RawICData
和DRT_MegamorphicCacheMissHandler
)。
最后,如果所述线性阵列中的检查的数量增加过去阈调用点被切换到使用状结构字典(见
MegamorphicCallStub
,RawMegamorphicCache
和DRT_MegamorphicCacheMissHandler
)。image.png