Dart VM

本文是对Dart官方VM的介绍的总结摘要,推荐直接阅读官方原文

Dart VM is a collection of components for executing Dart code natively. Notably, it includes the following:

  • Runtime System
    • Object Model
    • Garbage Collection
    • Snapshots
  • Core libraries’ native methods
  • Development Experience components accessible via service protocol * Debugging * Profiling * Hot-reload
  • Just-in-Time (JIT) and Ahead-of-Time (AOT) compilation pipelines
  • Interpreter
  • ARM simulators

下图是runtime执行代码的示意图:

isolate

isolate中有两种Thread:

  • 一个mutator thread用来执行dart 代码
  • 多个helper thread 用来执行GC、JIT等

此外,一个isolate有一个heap,用来存储所有的dart object(GC发生在这里)。

一个OSThread一次只能进入一个isolate,当其进入之后,该isolate的mutator thread便和这个OSThread关联起来执行dart代码。当OSThread要进入一个isolate的时候,必须先退出当前关联的isolate。

isolate的mutator thread可能在不同时间关联不同的OSThread,但同一时刻最多只能有一个OSThread。

VM执行Dart代码有两种方式:JIT和AOT,不管哪一种都不会直接执行Dart源码,而是经过转化之后的Kernel Binary(also called dill files)which contain serialized Kernel ASTs

一般来说,从Dart source code到Dart VM执行分为下面几步:

dart-to-kernel

VM expects to be given Kernel binaries (also called dill files) which contain serialized Kernel ASTs. The task of translating Dart source into Kernel AST is handled by the common front-end (CFE) written in Dart and shared between different Dart tools (e.g. VM, dart2js, Dart Dev Compiler).

Dart VM has multiple ways to execute the code, for example:

  • from source or Kernel binary using JIT;
  • from snapshots:
    • from AOT snapshot;
    • from AppJIT snapshot.

1. Running from source via JIT.

从Dart Source加载到VM中

为了保证直接从源代码执行Dart的便利性,独立的dart可执行文件承载了一个称为内核服务(kernel service)的辅助isolate,它处理Dart源代码编译成内核的过程。然后,VM将运行产生的内核二进制文件(Kernel Binary)。

kernel-service

上图中,一个被称为kernel service的isolate使用CFE将Dart Source编译为为Kernel Binary然后交给main isolate执行。

这并不是安排CFE和VM执行Dart Source的唯一方式,比如Flutter就将CFE(封装之后的)和VM分别置于两个设备上:

flutter-cfe

当热更新触发时,Flutter使用封装过的CFE以及一个Flutter独有的Kernel-to-Kernel转换,将修改过的Dart Source编译为Kernel Binary,然后推送到设备上面(比如手机)执行。

在VM中执行

上面是Dart Source加载到VM的过程,下面是Dart代码在VM中执行的过程分析:

1)当Kernel Binary加载到VM中之后,只会解析加载的类和库的基本信息。

kernel-loaded-1

2)当runtime实际用到的时候才会去获取完整的信息用来创建对象分配内存等:

kernel-loaded-2

此时,从Kernel Binary中读取出了class members,此时已经有足够的信息让runtime用来调用方法(successfully resolve and invoke methods)了,比如调用main方法,但是具体的方法体此时依旧还没有被反序列(deserialized)。

3)在这个阶段,所有的function只是持有了一个真正要执行的方法体的placeholder指向LazyCompileStub,当runtime要执行的时候再创建并运行可执行代码。

raw-function-lazy-compile

这时候执行方法有两个阶段:

  1. unoptimized 默认执行时直接从Kernel Binary创建IL然后转化为machine code并运行
  2. optimized 在a阶段的热点代码会被从普通IL优化为SSA IL,然后转化为machine code运行,如果遇到优化失效的,再回退到a阶段执行代码(后面是否需要再走b阶段,需要重新判断)

unoptimized code

这个阶段,从Kernel Binary生成Machine Code主要分为2步:

(1)Kernel BinaryIL

在这个阶段,从Kernel Binary中的AST中解析产生对应的control flow graph(CFG)。

CFG由intermediate language(IL)组成,这个阶段使用的IL指令类似基于stack的虚拟机:他们从stack中读取操作数,执行操作,然后将结果push回这个stack中。

unoptimized-compilation

但并不是所有的方法都有对应的Dart/Kernel AST bodies(比如一些native方法或者artificial tear-off functions generated by Dart VM),这种情况下,他们凭空创建(in these cases IL is just created from the thin air)。

(2) ILMachine Code

由一条IL对应生成多行machine language instruction

在这个阶段不会进行优化,主要目的是快速创建出可执行代码(produce executable code quickly

内联缓存(inline caching)

在这个阶段,编译器(unoptimizing compiler)不会尝试静态解析任何没有在Kernel Binary中解析的调用(any calls that were not resolved in Kernel binary),因此调用(MethodInvocation or PropertyGet AST nodes)被认为是完全动态的, VM使用内联缓存(inline caching)来实现动态调用。

内联缓存的实现主要有:

  • 一个call site specific cache,将调用的类与方法映射在一起,如果receiver和已有的缓存类对应,那么就应该调用对应的方法,还有个计数器(invocation frequency counters)标记这个方法被调用多少次(对应下文的RawICData)
  • 一个共享的lookup stub,实现了方法调用的最快路径(method invocation fast path),在发生调用时通过lookup stub查询是否有entry与receiver的类匹配,有的话就用调用entry并增加frequency counter;否则就调用系统的runtime system helper兜底(如果成功运行了就更新上面的缓存,这样下次调用就不用再走runtime了)。

inline-cache-1

optimized code

虽然Unoptimizing compiler可以执行任意Dart代码,但是太慢了,所以在以上述方式执行代码的同时会记录以下信息:

  • Inline cache收集在调用点的receiver类型receiver types observed at callsites
  • 和方法对应的execution counters以及basic blocks within functions追踪代码的热点区域(hot regions of the code)

Optimized compilations 和Unoptimizing compiler开始的步骤类似:

(1) Kernel Binaryunoptimized IL

(2) unoptimized ILSSA based ILoptimized IL

当上述代码执行的时候,如果程序调用计数器(invocation frequency counters)到达某个阈值,这个方法就会被交给一个后台优化编译器(background optimizing compiler)来优化,将unoptimized IL转化为SSA(static single assignment)形式的IL。

最后将SSA IL优化为optimized IL。

(3) optimized ILmachine code

在优化完成后,编译器会要求mutator thread进入safepoint并将优化后的代码绑定到方法上(attaches optimized code to the function)。

safepoint的含义是,thread关联的state(比如heap,stack frame等)是一致的,并且可以在不中断线程的情况下访问或修改。通常意味着thread被暂停,或者在当前环境外(比如执行native代码)。

optimizing-compilation

上述这种基于乐观假设的优化,可能没法处理部分情况,从而回退到未优化的代码(deoptimization),然后再执行未优化过程(通常会丢弃优化后的代码,再判断是否有热点代码需要优化),主要有2种方式:

  1. eager deoptimization 在内联检查的时候,判断优化的条件是否满足,不满足的话就丢弃优化代码
  2. lazy deoptimization 全局分析指示在更改优化代码的内容时丢弃优化代码(之前优化的条件不满足了)。

2. Running from AOT snapshot

Snapshot’s format is low level and optimized for fast startup,包含了要创建的object以及如何关联这些对象的说明信息(instructions)。

VM可以将Heap/甚至是Heap中的object graph序列化成为snapshot,然后再从这个snapshot中重建对应的状态:

snapshot

最初的snapshot并不包含machine code,直到AOT compiler的出现。

AOT compiler和snapshot-with-code使得VM可以在那些JIT受限的设备上运行:

snapshot-with-code

snapshot-with-code和普通的snapshot基本一致,唯一不同的是多出的machine code不需要deserizlization,事实上machine code在被分配到内存后可以立即成为heap的一部分(directly become part of the heap after it was mapped into memory)。

3. Running from AppJIT snapshot

AppJIT snapshot主要用于减少大型Dart application的JIT热身时间。

AppJIT snapshots were introduced to reduce JIT warm up time for large Dart applications like dartanalyzeror dart2js. When these tools are used on small projects they spent as much time doing actual work as VM spends JIT compiling these apps.

他的主要实现是:先用模拟数据在VM上运行,然后将其生成的code以及VM内部的数据结构序列化为AppJIT snapshot加载到VM中运行,只在正式的数据和模拟训练的配置无法匹配的时候执行JIT(execution profile on the real data does not match execution profile observed during training)。

snapshot-appjit

4. Running from AppAOT snapshot

AOT与JIT各有优劣:

  • AOT启动时间更短
  • JIT峰值性能更优

无法进行JIT意味着

  1. AOT snapshot must contain executable code for each and every function that could be invoked during application execution;
  2. the executable code must not rely on any speculative assumptions that could be violated during execution;

为了满足上述要求, AOT汇编过程会进行全局静态分析以确定程序的哪些部分是可以从已知的entry point触达的,分配哪些类的实例,以及类型在程序中是如何应用的(which parts of the application are reachable from known set of entry points, instances of which classes are allocated and how types flow through the program)。

AOT上述这些分析是保守的,可能在准确性上犯错,与之相比,JIT则在性能方面不行,因为JIT需要deoptimize兜底实现正确的行为。

所以AOT将所有潜在的可触达的功能编译为native code,而无需投机性优化(All potentially reachable functions are then compiled to native code without any speculative optimizations)。

aot

从上图可以看出,AOT中,Kernel Binary先经过TFA收集变量、方法等信息,以此来移除不可达的方法,并devirtuablize method(确定虚拟方法的具体执行)。之后经过VM再移除一些不可达方法。

Resulting snapshot can then be run using precompiled runtime, a special variant of the Dart VM which excludes components like JIT and dynamic code loading facilities.

Switchable Calls

即使有全局和局部分析,AOT编译依然可能包含一些无法被非虚拟化(devirtualized)的call sites,为了解决这个问题,AOT编译出的代码和runtime会使用JIT中用到的内联缓存(Inline Caching)技术的拓展——switchable calls

参考资料

sdk/index.md at main · dart-lang/sdk

Dart VM 介绍