位元組小哥帶你揭祕Dart VM魔術盒

位元組移動技術發表於2021-05-11

作者:位元組移動技術 —— 楊浩

簡介

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,目的是快速生成可執行程式碼,其中包含兩個階段:

  1. 將kernel binary中的函式體(序列化的AST)轉成control flow graph(CFG) ,CFG由basic block構成,而每個basic block由intermediate language(IL)指令組成。IL指令是基於虛擬機器的棧指令,基本模式就是先從棧中獲取運算元,然後執行操作並把結果壓入棧中。

  2. 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進行優化。

函式的優化過程如下:

  1. 和unoptimizing pipeline相似,同樣需要先把kernel binary中序列化的AST轉為由IL指令構成的CFG,但不同的是函式在未優化期間已經執行過多次並構建了較為完善的inline cache,所以可以直接引用而不需要重新構建。

  2. 將IL指令轉為static single assignment(SSA)形式(每個變數只能被賦值一次,每個變數在使用之前必須被定義),這種IL形式有利於後續的優化分析。

  3. 優化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.)

  4. 將優化後的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常用工具的啟動時間。比如像dartanalyzerdart2js這兩個工具,本身具有一定的體量,當執行一些小型專案時往往編譯工具所需要的時間會長於真正用來編譯專案的時間,這是十分不理想的。

而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的技術文章!

參考

Introduction to Dart VM

關於位元組移動平臺團隊

位元組跳動移動平臺團隊(Client Infrastructure)是大前端基礎技術行業領軍者,負責整個位元組跳動的中國區大前端基礎設施建設,提升公司全產品線的效能、穩定性和工程效率,支援的產品包括但不限於抖音、今日頭條、西瓜視訊、火山小視訊等,在移動端、Web、Desktop等各終端都有深入研究。

就是現在!客戶端/前端/服務端/端智慧演算法/測試開發 面向全球範圍招聘!一起來用技術改變世界,感興趣可以聯絡郵箱 chenxuwei.cxw@bytedance.com,郵件主題 簡歷-姓名-求職意向-期望城市-電話

相關文章