原文地址: https://mrale.ph/dartvm/
发现一篇2019年的译文: https://annatarhe.github.io/2019/01/31/introduction-to-dart-vm.html, 部分中文专有名词摘自该译文
备注: 原文仍在处在修改状态, 翻译此文时,原文最后修改时间为2020年1月29日
本文的目标:本文的目标是作为DartVM 开发的参考手册, 供新团队成员、潜在的外部贡献者、或者其他任何对VM内部原理感兴趣的人。 本文从介绍DartVM 简介开始,然后详细介绍了VM中的各个组件
DartVM 由组件构成,他们支撑了原生执行Dart 代码, 主要包括如下部分
Dart 虚拟机是历史继承的。 Dart虚拟机原意是它会提供一个高级编程语言执行环境,但是它不代表Dart 代码在DartVM 上执行时总是解释的或者JIT编译的。 例如,Dart代码可以被DartVM AOT流水线编译成机器码然后在一个叫作预编译的运行时(precompiled runtime)的被裁剪的Dart VM版本上执行,这个预编译的运行时 不包含任何编译器组件并且无法动态加载Dart 源代码。
Dart 虚拟机有多种方式来执行代码,例如:
这些不同的方式之间的主要区别是虚拟机何时以及如何把Dart 源代码转换成可执行代码。 支撑运行的运行时环境都是一样的。
独立分区-isolate
任何虚拟机中的Dart代码都是在独立分区(isolate) 中运行,独立分区可以描述为一个独立的Dart宇宙,拥有自己的内存堆并且通常有自己的主控线程(mutator thread)。 很多独立分区的Dart代码可以并发执行,但是这些分区无法直接分享状态,只能通过端口(ports) 不是网络端口,来传递消息来共享。
操作系统线程和独立分区的关系有一些模糊并高度依赖虚拟机是如何继承到应用中的。 只有如下可以得到保障:
但是同一个操作系统线程可以先进入一个独立分区,执行Dart 代码,然后离开这个独立分区并进入另一个独立分区。另外很多不同的操作系统线程可以进入一个独立分区并在其中执行Dart 代码,不是并发的。
一个独立分区可以关联一个主控线程的同时可以关联多个辅助线程,例如
虚拟机内部使用一个线程池(dart::ThreadPool)来管理操作系统线程,代码是基于线程池task 概念构建的而不是操作系统的线程构建的。例如,在触发垃圾回收时,VM 不是新建一个专门的线程来执行后台扫描,而是发送一个dart::ConcurrentSweeperTask 给全局的虚拟机线程池,而线程池的具体实现可以选择一个空闲的线程来执行或者当没有空闲的线程时新建一个线程来执行。类似的独立分区消息处理的事件循环线程也不是一个专门的消息循环线程,而是当新消息来的时候发送一个dart::MessageHandlerTask 给线程池来执行。
本节解释当你从命令行执行Dart 代码时发生了什么。
// hello.dart
main() => print('Hello, World!');
$ dart hello.dart
Hello, World!
由于Dart 2虚拟机已经把直接执行原始代码的能力移除了,而虚拟机预期是接收内核二进制(kernel binaries)也叫作dill 文件, 这些文件包含序列化过的内核抽象语法树Kernel ASTs. 把Dart 代码翻译成内核抽象语法树是通过Dart 语言编写的通用前端编译器( common front-end (CFE) ) 并且在不同的Dart编译工具共享。例如(VM, dart2js, Dart Dev Compiler)
独立部署的dart可执行程序为了能够保留独立执行Dart 代码的便利性,包括了一个辅助独立分区叫作内核服务(kernel service), 它来处理把Dart 源代码编译成内核。然后虚拟机就可以运行生成的内核二进制。
这种设置方式并不是唯一的方式来安排CFE 和VM 来运行Dart 代码。 例如, Flutter 就通过把编译到内核和运行内核放到不同的机器上来完全分开这两个步骤。编译到内核是在开发者的机器上而运行内核是在目标终端机器上,通过flutter 工具把内核二进制发给终端上的flutter 引擎。
注意,flutter 工具不负责解析Dart, 而是创建一个独立的进程-编译前端服务frontend_server, 这个服务是对CFE 和一些flutter 特定的内核到内核转换的很薄的封装。 编译前端服务把Dart 代码编译成内核文件,flutter 工具然后把这些内核文件发送给终端设备。在使用热更新时会使用编译前端服务的持久化能力,在热更新时,前端编译服务会复用前一次编译的CFE 状态,只会重新编译哪些真正改变的库。
当内核二进制加载进虚拟机时,就被解析并创建成表示不同程序的实体。这些过程是懒加载的:刚开始加载一些关于库和类的基本信息。 每个从内核二进制生成的实体都持有一个指向内核二进制的指针,用这个指针来完成后续额外需要信息的加载。
关于类的信息,只有在后续运行时需要的时候,才会被完全反序列化(例如,查找类成员,创建对象等)。在这个阶段类对象从内核二进制中读取进来。然而全部函数实体不是在这个阶段反序列化,只有函数签名。
到此时,从内核二进制已加载了足够的信息让运行时环境完成解析和调用函数。 例如,可以完成解析一个库的main 函数并调用它。
初始化时,所有的函数实体都只是一个占位符, 而不是它的可执行代码, 他们指向LazyCompileStub, 它会请求运行时系统给当前函数来生成可执行代码,并接着调用新生成的代码。
当函数第一次被编译时,是由无优化编译器完成的。
无优化编译器分2个步骤来生成机器代码:
在这个阶段也没有任何优化。没有优化的主要目标是为了快速的生成可执行代码。
这同时意味着无优化的编译器不会试图静态解析在内核二进制中没有解析的任何调用,因此函数调用编译时都被当做是动态的。 虚拟机目前没有使用任何形式的基于虚拟表或者接口表的分发,而是使用内联缓存inline caching.来实现动态调用
内联缓存背后的核心思想是把解析函数的结果缓存成一个特殊的调用块call site缓存。虚拟机使用的内联缓存机制包括
下图展示了一个animal.toFace()
调用块的内联缓存的结构和状态, 它被Dog 这个实例执行了2次, 被Catzhege 实例执行了1次。
无优化的编译器自己就足够执行任何Dart 代码。 但是它生成的代码效率低,速度慢,因此虚拟机实现了自适应的优化编译流水线。自适应优化背后的思想是用程序中的执行信息来决定优化的方向。
无优化的编译器运行时会收集下面的信息:
当执行计数器关联的函数到达一定的阈值时,这个函数就被提交给后台优化编译器来做优化。
优化编译的刚开始的步骤和无优化的编译一样:遍历序列化的内核抽象语法树来构建函数代码对应的未优化的中间语言指令。不是直接通过中间语言创建低级机器代码,优化的编译器通过把未优化的中间语言翻译成静态单引用static single assignment (SSA)来生成基础的优化的中间语言。 基于SSA 的IL 然后被传递给一系列经典的和Dart 特定的优化:例如内联,预期分析,类型提升,表达选择,保存到加载和加载到加载转发,全局数字缓存,分配下沉等。 最后,优化的中间语言使用线性扫码寄存器分配器和一对多低级中间语言指令生成机器代码。
当编译完成时,后台编译器请求控制线程进入安全点然后把优化完成的代码链接到函数上。
下一次函数调用时,就会使用优化过的代码。有些函数包含非常长的循环,对这些循环在执行时就替换成优化过的代码是很有必要的。 这个过程叫作栈上替换OSR (on stack replacement) ,这个名字是因为一个版本的包含该函数的栈帧,被透明的替换成另一个版本的包含相同函数的栈帧。
需要强调的是,优化编译器生成的代码是依赖由程序执行profile 生成的特定假设。 例如一个动态调用块,只是被一个单独类C调用会被转换成一个直接调用,调用前有一个检查,检查接收者是否是类C。 但是这些假设可能在后续的代码执行过程中被违反。
void printAnimal(obj) {
print('Animal {');
print(' ${obj.toString()}');
print('}');
}
// Call printAnimal(...) a lot of times with an intance of Cat.
// As a result printAnimal(...) will be optimized under the
// assumption that obj is always a Cat.
for (var i = 0; i < 50000; i++)
printAnimal(Cat());
// Now call printAnimal(...) with a Dog - optimized version
// can not handle such an object, because it was
// compiled under assumption that obj is always a Cat.
// This leads to deoptimization.
printAnimal(Dog());
任何优化代码时做的执行时假设,有可能会被后续其他代码执行破坏时, 就需要对这些破坏增加保护机制,当破坏时来恢复。
这个恢复的过程叫作去优化deoptimization, 当优化的代码遇到一个无法处理的情况时, 它会简单的把执行转为未优化的代码并继续用未优化的执行。 未优化的函数版本没有任何依赖并且可以处理所有可能的输入。
虚拟机通常在去优化后放弃优化过的函数版本,然后使用更新过的执行信息在执行时重新优化。
编译器有2种虚拟机守护特定假设。
虚拟机具备把独立分区的堆、更准确的说是堆中的对象图序列化成二进制快照的能力。 然后,快照就可以用来在启动虚拟机独立分区时重建当时的状态。
快照文件的格式是低层并未了快速启动优化过的, 它本质上是一个要创建的对象的列表和如何把这些对象链接起来的指令。 这就是快照原本的设计思想:不是解析Dart源代码然后逐渐创建虚拟机内部数据结构,虚拟机可以通过所有必须的数据结构直接从快照中快速恢复一个独立分区。
期初快照不包含机器代码,但是这个能力后来在AOT 编译器开发的时候被添加进来。开发AOT 编译器和带代码的快照是为了让虚拟机可以在一些平台上使用, 这些平台JIT 编译器由于平台限制无法实现。
带代码的快照的工作方式和普通快照有一些小区别:他们包括一个代码区这和快照中的其他部分不一样,代码区不需要反序列化。 这个代码区布局的方式让它可以在映射到内存之后,直接成为堆的一部分。
AppJIT 快照的引入是为了减少JIT 对于大型Dart应用的预热时间,比如dartanalyzer 或dart2js。 当这些工具用在小项目时他们会消耗很多时间做真正的工作当虚拟机使用JIT 方式编译这些应用。
AppJIT 快照解决了这个问题:一个应用在虚拟机上运行可以使用一些预设的训练数据,然后所有生成的代码和虚拟机内部数据结构都被序列化成一个AppJIT快照。 这个快照就可以用来分发而不是分发整个应用的源代码。 从这个快照启动的虚拟机仍然可以使用JIT, 当它发现使用真实数据生成的执行配置和训练时生成的配置不同时。
AOT 快照引入时原本是为了那些不支持JIT 编译的平台,但是他们可以用在这种情况,快速启动和一致的体验值得承受潜在的峰值性能惩罚。
关于JIT 和AOT 性能的比较通常有一些混淆。 JIT 可以直接访问准确的本地类型信息和当前正在运行的程序的执行配置,但是这是用预热的时间消耗换来的。AOT 可以推断和证明不同全局配置,这是用编译时间换来的,但是它没有关于程序真正是如何执行的信息,换句话说AOT 编译的代码几乎没有预热就可以达到最高的性能。 就目前而言,Dart虚拟机JIT 有最好的峰值性能,AOT 有最优的启动性能。
不支持JIT 意味着
为了满足这些需求,AOT 编译进程会做全局静态分析(类型流分析tTFA- ype flow analysis)来判断程序中哪些部分对于一直的入口点是可达的,哪些类的实例会被创建以及这些类型是如何在程序中流转的。 所有这些分析都是保守的:因为这他们可能会在正确性上出错,和JIT对比会不一样,JIT 会在性能方面出错,因为JIT 会通过去优化成未优化的代码来保证正确的行为。
所有潜在可达的函数都会被编译成原生代码并且没有任何特意的优化。然而类型流信息仍然用来特殊化这些代码。
当所有函数都完成编译,一个堆的快照就完成了。
生成的快照可以运行在预编译运行时系统中,一个特殊的Dart虚拟机类型,排除了想JIT 和加载动态代码等组件。
尽管全局和本地分析AOT 编译的代码可能仍然会包含无法被去虚拟化devirtualized 的调用块,(他们无法被静态解析)。为了弥补这样的缺陷,AOT 编译的代码和运行时使用一种在JIT 中的内联缓存的扩展技术。这个扩展的版本叫作可切换调用 switchable calls.
JIT 部分已经描述过每一个内联缓存都关联一个调用块,包含2部分: 一个缓存对象和一块要调用的原生代码块。 在JIT 模式运行时只会更新缓存本身, 而在AOT 模式运行时可以根据内联缓存的状态选择同时替换缓存和要调用的原生代码。
起始时所有的动态调用都处在未连接unlinked状态。当这样的调用块第一次触发时, SwitchableCallMissStub 被调用,这简单调用运行时辅助类DRT_SwitchableCallMiss 来链接这个调用块。
如果可能的DRT_SwitchableCallMiss 试图把调用块转换成单一状态。在这个状态调用块会转换成直接调用,会通过一个特殊的入口点进入该方法,在入口点会校验接收者是不是预期的类。
在上述例子中,我们假设当obj.methed() 第一次执行时,obj 是一个类C的实例,obj.method 方法解析成调用C.method。
下次执行同样的调用块时,会直接调用C.method 方法, 跳过方法查找流程。 然而如果调用C.method方法块时通过一个特殊入口点是, 会检查obj 是否是一个类C 的对象。 如果不是, DRT_SwitchableCallMiss 会被调用,然后尝试选择下一个调用块状态。
C.method 可能仍然是一个可用的调用目标。 例如 obj 是一个类D 的实例, 类D继承自类C 但是没有重写C.mehod 方法。 在这种情况,我们检查调用块是否能转换成单一目标状态,SingleTargetCallStub 。
这个桩是基于AOT编译这样的事实,大多数类都是分配一个整数id, 使用深度优先遍历继承关系的方式来确定。 如果C 是一个父类, 有子类D0, ..., Dn 并且他们都没有复写C.mehod 方法, 那么C.:cid <= classId(obj) <= max(D0.:cid, ..., Dn.:cid)
表明obj.method 会解析成C.mehod。 在这种情况下不去和一个单独类比较,我们可以使用类id 范围检查, 这会对类C所有的子类都有效。
否则调用块会被置换来使用线性搜索内联缓存,和JIT 模式中使用的类似。(查看 ICCallThroughCodeStub, dart::UntaggedICData and dart::PatchableCallHandler::DoMegamorphicMiss)
最后,如果再线性数组中检查的数字 超过阈值,调用块就回被置换成使用一个类似字典的数据结构。 (see MegamorphicCallStub, dart::UntaggedMegamorphicCache and dart::PatchableCallHandler::DoMegamorphicMiss)
原文未完成
对象模型, 原文未完成