作者:字节移动技术 —— 杨浩
简介
Dart VM(Dart Virtual Machine)是一系列帮助dart代码本地化运行的组件集合,其中的核心组件如下:
-
运行时系统(Runtime System)
-
核心库(Core Libraries)
-
开发体验组件(Development Experience Components)
-
JIT(just in time)和AOT(ahead of time)的编译流水线
-
解释器(Interpreter)
-
ARM模拟器(ARM Simulator)
这篇文章主要关注于dart代码在Dart VM上的几种常见编译模式:
-
from source or kernel binary using JIT;
-
from snapshots:
-
from AppJIT snapshot;
-
from AppAOT snapshot;
-
Dart VM Isolate
在Dart VM中任何dart代码都是运行在某个isolate中的,每个isolate都是独立的,它们都有自己的存储空间、主线程和各个辅助线程,isolate之间互不影响。在Dart VM中可能同时运行着多个isolate,但它们不能直接共享数据,可以通过端口(ports:Dart VM中的一个概念,和网络中的端口不一样)相互交流信息。
从上面这张图片中不难看出在一个isolate中主要包括以下几个部分:
-
Heap:存储dart代码运行过程中创建的所有object,并由GC(垃圾回收)线程管理。
-
Mutator Thread:主线程,负责执行dart代码。
-
Helper Thread:辅助线程,完成Dart VM中对isolate的一些管理、优化等任务。
同时我们可以看到VM中有一个特殊的vm-isolate,其中保存了一些全局共享的常量数据。虽然isolate之间不能相互引用,但是每个isolate都能引用vm-isolate中保存的数据。
在这里我们再深入探讨一下isolate和OS thread的关系,实际上这是十分复杂和不确定的,因为这取决于平台特性和VM被打包进应用的方式,但是有以下三点是确定的:
-
一个OS thread一次只能进入一个isolate,若想进入其他的isolate必须先从当前isolate退出。
-
一个isolate一次只能关联一个mutator thread,mutator thread用于执行dart代码和调用VM中public的C API。
-
一个isolate可以同时关联多个helper thread,比如JIT编译线程、GC线程等。
实际上Dart VM在内部维护了一个全局的线程池ThreadPool来管理OS thread,所有创建线程的请求都被描述成发向线程池的ThreadPool::Task,比如GC回收内存时发送请求SweeperTask,线程池会首先检查池中是否有可用的OS thread,有的话则直接复用,没有的话则会创建一个新的线程。
Run From Source Via JIT
在dart中我们经常使用dart <filename.dart>命令来执行dart源码文件,一个简单的示例如下:
// hello.dart
main() => print('Hello, World!');
// execute it in command line
$ dart hello.dart
Hello, World!
那么在这种模式下Dart VM是如何工作的呢?实际上从dart2开始Dart VM已经不再直接面向源码工作了,而是面向中间文件kernel binary file(这是一个.dill文件,其中包含序列化的kernel AST)。而将源码翻译成kernel binary的工作是由dart sdk中的一个工具common front-end (CFE)完成的,它也被其他一些工具所共享(包括VM,dart2js,Dart Dev Compiler等)。
上面这张图简单地展示了dart代码在VM之前的编译流程。但实际上为了保留用户能够直接从源码开始执行的便利性,运行dart文件时会先在VM中开启一个名叫kernel service的helper isolate,它通过CFE将源码编译成kernel binary再交由VM执行。
这并不是唯一的方式去搭配CFE和VM,比如在flutter中就将它们分开来:在开发机器上完成CFE编译,再将kernel文件交由运行在目标设备上的VM执行。
下图给出了一个debug模式下dart代码的执行流程,如果我们仔细观察这个过程可以发现:当启动flutter_tool运行dart代码时并不是由flutter_tool本身完成source-to-kernel的编译,而是开启了名叫frontend_server的持久性进程来完成这一工作。实际上它只是CFE的一层薄封装,里面还添加了flutter特有的kernel-to-kernel转换,最终生成的kernel binary会经由flutter_tool发送到设备上的flutter engine执行。
frontend_server的持久性会在热重载中发挥重要作用,因为它保存了上一次的CFE状态,当用户执行热重载时可以依据之前的记录只重新编译修改的部分而不必编译全部。
VM会加载kernel binary并解析成对应的对象模型,但这个过程是lazy的(如下图):一开始只有关于library和class的基本信息会被加载成heap中的entity,每个entity都含有一个指向生成它们的binary的指针,当以后被需要的时候可以生成更多信息。
比如当runtime需要实例化一个类或查找类成员时,就会依据这个指针找到对应的binary并生成该类的所有信息。在这个阶段类的字段(field)会全部被加载,但类的方法只加载了签名,对应的函数体依旧采用lazy的模式,只有被用到才会完全加载,但此时已经有了足够的信息给runtime去解析和引用类方法。
初始状态下每个函数中并没有真正的可执行代码,而是包含一个指向LazyCompileStub(全部函数共享)的占位符,LazyCompileStub会请求runtime生成当前函数的可执行代码并建立映射关系,以后每次调用都会返回对应代码段的执行结果。
函数第一次编译使用的是没有任何优化的unoptimizing pipeline,目的是快速生成可执行代码,其中包含两个阶段:
-
将kernel binary中的函数体(序列化的AST)转成control flow graph(CFG) ,CFG由basic block构成,而每个basic block由intermediate language(IL)指令组成。IL指令是基于虚拟机的栈指令,基本模式就是先从栈中获取操作数,然后执行操作并把结果压入栈中。
-
IL指令不经过任何优化直接转为机器码。
值得一提的是unoptimizing pipeline不会静态解析任何没能在kernel binary中解析的调用,会把所有未解析的调用视为完全动态的并通过inline caching完成动态绑定。
Inline catching的实现主要包括以下两个内容:
-
Call site specific cache:对每个未解析的调用点创建一个相关联的cache(RawICData object),其中保存了不同类和其应该调用的方法,除此之外还包括一些辅助信息,例如调用次数等。
-
Inline cache stub:这份stub被所有cache所共享,因为执行逻辑都是一样的:首先对调用点cache进行线性查找,若存在匹配的类则直接绑定对应方法;若没找到则调用runtime完成调用解析,并更新cache,下次遇到相同的类就不用再调用runtime了。
下图给出了一个简单的示例,可以看到在animal.toface()这个动态调用点上关联了一个cache和引用了InlineCacheStub代码段。
由于unoptimizing pipeline中没有进行任何优化,虽然编译速度快,但所生成的代码执行起来效率低下。为此VM提供了一条optimizing pipeline,可以根据程序运行所产生的profile进行自适应优化。
首先需要知道的是未优化程序会在运行时会收集如下信息:
-
每个动态调用点的inline cache中所包含的类信息
-
每个函数的调用计数器(用来追踪函数的调用频率)
当一个函数的调用次数达到一定的阈值后就会将它发送给一个辅助线程background optimizing compiler进行优化。
函数的优化过程如下:
-
和unoptimizing pipeline相似,同样需要先把kernel binary中序列化的AST转为由IL指令构成的CFG,但不同的是函数在未优化期间已经运行过多次并构建了较为完善的inline cache,所以可以直接引用而不需要重新构建。
-
将IL指令转为static single assignment(SSA)形式(每个变量只能被赋值一次,每个变量在使用之前必须被定义),这种IL形式有利于后续的优化分析。
-
优化SSA形式的IL指令,这之中包括基于profile信息的适应性优化,也包括dart特有的一些优化过程。(e.g. inlining, range analysis, type propagation, representation selection, store-to-load and load-to-load forwarding, global value numbering, allocation sinking, etc.)
-
将优化后的IL指令转为机器码(这里用到了线性扫描的寄存器分配(linear scan register allocation),优点是生成代码的速度快,常被用在JIT编译模式中)
当优化完成后,background optimizing compiler就会先请求主线程进入一个安全点(为了让background optimizing compiler线程可以放心操作),然后将优化后的代码和对应函数联系起来,下次调用执行的就是优化后的代码了。
这里涉及到一个技术on stack replacement(OSR),简单来说就是用一个新的栈帧替换掉原来久的栈帧。我们可以通过OSR来让优化后的函数代码迅速投入使用,只需要替换掉原来的函数栈帧即可,这个过程甚至可以发生在函数运行的时候。
从上图中我们可以看出optimizing pipeline对每个优化的地方都做了标记(deoptimization id),这个是用来干什么的呢?我们先来看一个示例:
void printAnimal(obj) {
print('${obj.toString()}');
}
// 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());
在这个示例的循环中printAnimal()函数一直接收的都是Cat对象,那么优化器就会基于这个经验作出推断性假设:obj永远是一个Cat对象。再基于这个假设优化obj.toString()这个动态调用点,从原来的inline cache变为简单地验证一下obj是否为Cat对象,是的话直接调用Cat.toString()方法。
但这个假设在17行被打破,printAnimal()函数接收了一个Dog对象,不再符合“obj永远是一个Cat对象”的假设,这个时候之前的优化代码就不管用了。因此我们需要解优化,即回到优化之前的版本,这时我们的deoptimization id就可以发挥作用了,VM中就是通过它来找到对应未优化版本代码的正确位置并继续执行。(这个过程需要非常小心,因为在函数运行过程中跳转到其他位置执行容易产生side-effect)
在解优化后我们通常会丢弃已经过期的优化代码,并在之后根据新的信息进行重新优化。
从这个例子中可以看出当优化是基于某个可能被违反的假设时,必须要保证以下两点:
-
能够检测出所有可能的违反情况;一种常见的做法是在每次要使用优化产物之前检测一下当前优化所基于的假设是否成立,比如在上面这个例子中虽然优化了obj的方法调用,但依然在调用之前检查了一下obj是否为Cat对象。
-
能够在违反的情况下恢复;当检测到假设不成立时,所有基于此的优化都是无效的,所以runtime需要找到所有已过时的优化代码并恢复它们。若执行栈中也包含过时代码,则采用lazy deoptimize的方法,先将其标记,当执行到此处时再进行解优化。
Running from Snapshots
除了常见的从dart源码开始启动的方式以外,Dart VM也可以从一个snapshot文件开始启动,并且这种启动方式要比前者快得多。
这得益于VM可以将heap中的数据或者更准确来说是对象图(object graph)序列化为一个二进制snapshot,并且可以根据这个snapshot文件迅速还原出原来的数据结构,所以当一个isolate再次启动时可以不必再从源码开始解析构建,这大大节省了编译时间。
最初snapshot是不包含机器码的(如上图所示),但在后来的AOT模式中加入了这一特性,所以现在的snapshot在解析后不仅可以快速构建数据结构,还可以获得可执行代码,结构大致如下,其中machine code本身就是二进制的,所以并不需要特意序列化和反序列化。
在了解了什么是snapshot之后,接下来会介绍几种使用snapshot的常见场景
Running from AppJIT snapshots
AppJIT snapshot是snapshot中的一种,主要应用场景在于减少dart常用工具的启动时间。比如像dartanalyzer 和 dart2js这两个工具,本身具有一定的体量,当运行一些小型项目时往往编译工具所需要的时间会长于真正用来编译项目的时间,这是十分不理想的。
而AppJIT snapshot就可以很好地解决这个问题:先将某个耗时工具在VM中用模拟数据(mock training data)跑起来,再将生成的机器码和内部构建的数据结构序列化为AppJIT snapshot,以后每次使用该工具时都直接从snapshot开始启动而不再从源码启动,并且依旧按照JIT模式优化更新工具的表现(所以不用担心用模拟数据训练的工具不适配真实数据)。
从以下示例我们可以很容易地看出AppJIT snapshot对性能表现的显著提升。
# 从工具的源码执行
$ dart pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.07 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
# 先生成工具的AppJIT snapshot再执行
$ dart --snapshot-kind=app-jit --snapshot=dart2js.snapshot \
pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.05 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
# 从工具的AppJIT snapshot执行
$ dart dart2js.snapshot -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 0.73 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
在flutter中flutter_tools工具就是以snapshot的形式存在一个cache目录下的,每次调用flutter命令都会直接从snapshot中解析数据并快速装载进vm中,这大大提高了工具的启动速度。
Running from AppAOT snapshots
AppAOT snapshot也是snapshot的一种,但和AppJIT snapshot有很大不同,因为这是在AOT模式下的产物,也就是说将不再支持JIT特性,这意味着以下两点:
-
AppAOT snapshot需要包含所有在程序运行中可能被调用的函数的可执行代码,因为无法像JIT一样在运行过程中编译和补充。
-
所有可执行代码都不能依赖可能在运行过程中违反的假设。
为了满足上述要求,AOT compilation pipeline中引入了type flow analysis(TFA)对代码进行全局静态分析,其中包括找出所有可达代码、确认哪些类有被实例化过、确认变量类型的流动过程等等。所有的分析都是保守的,也就是说更注重正确性,而非像JIT一样更注重性能表现(因为JIT可以随时解优化回到未优化版本以保证正确性)。
VM会基于TFA信息对代码进行静态优化,比如丢弃不可达函数和根据类型信息静态解析对象的方法调用等等。然后所有的可达函数都会被编译成可执行代码,虽然编译使用的工具链和JIT模式相同,但过程中不会做任何推测性优化。
当所有函数编译完成后就可以对heap生成一个AppAOT snapshot了,VM中提供了一种特殊的precompiled runtime来运行这种snapshot,相对于JIT来说简化了很多不必要的组件。
下面给出一个使用dart AOT模式的示例,实际上flutter中的release模式就是用Dart VM中的AOT模式处理dart代码。
# Need to build normal dart executable and runtime for running AOT code.
$ tool/build.py -m release -a x64 runtime dart_precompiled_runtime
# Now compile an application using AOT compiler
$ pkg/vm/tool/precompiler2 hello.dart hello.aot
# Execute AOT snapshot using runtime for AOT code
$ out/ReleaseX64/dart_precompiled_runtime hello.aot
Hello, World!
现在我们讨论另一个问题,前面我们提到过可以通过TFA信息静态解析方法调用,但仍然存在一些动态调用点受限于有限的信息无法被静态解析,对于这种情况precompiled runtime中采用了一种名叫switchable calls的方法来解决,实际上这是我们之前介绍过的inline cache的一种扩展。
回忆一下之前的内容,inline cache最重要的两个部分就是:和调用点相关联的cache以及一个用于执行逻辑的代码段。在JIT模式中只需要更新cache即可,代码段是不变且共享的;然而在AOT模式下,代码段不再是共享的了,而是和cache一样与调用点相关联,并且两者都是可以被替换的。
初始情况下调用点进入unlinked state,此时cache中只有方法名,当调用发生时stub会请求runtime去寻找cache中对应的方法。
若寻找成功则直接调用,然后调用点会进入monomorphic state,此时cache中保存了该方法对应的类名class C,下次调用时stub中会判断当前调用对象是否为class C的实例,是的话则直接调用该方法,否则进入下一个状态,这里分两种情况。
第一种情况是调用对象是class C某个子类的实例,并且该子类没有重写C.method这个方法,那么此时C.method仍然是有效的。在这种情况下调用点会进入single target state,cache中保存cid的下界和上界,stub中会利用对象的cid进行一次巧妙的判断(cid是在AOT编译过程中用深度优先的方式为每个类分配的一个整数id)。假设class C有子类D0、D1···、Dn,并且这些子类都没有重写C.method,那么若有 C.cid <= classId(obj) <= max(D0.cid, ..., Dn.cid)成立,则对象调用C.method就是合理的。
第二种情况是对象调用的方法不再是C.method或者在single target state下发生了miss,这时就不得不去寻找新的调用方法了,于是调用点进入IC State,这和我们之前介绍的incline cache模式十分相像——cache中保存不同类对应的调用方法,在stub中进行线性查找。
cache中数组的长度会不断增长,当达到一定阈值后就会进入megamorphic state,将数组变成一个类似字典的结构。
至此Dart VM中常见的几种编译模式就已经介绍完了,实际上Dart VM中还有更多、更丰富的技术手段值得探索,作为flutter的核心组件,研究Dart VM绝对是一件有价值的事情,希望未来可以看到更多有关Dart VM的技术文章!
参考
关于字节移动平台团队
字节跳动移动平台团队(Client Infrastructure)是大前端基础技术行业领军者,负责整个字节跳动的中国区大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率,支持的产品包括但不限于抖音、今日头条、西瓜视频、火山小视频等,在移动端、Web、Desktop等各终端都有深入研究。
就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣可以联系邮箱 chenxuwei.cxw@bytedance.com,邮件主题 简历-姓名-求职意向-期望城市-电话。