Dart VM 的相關簡介與執行模式解析

戀貓de小郭發表於2021-06-02

原文連結:mrale.ph/dartvm/

PS:內容比較繁雜,請酌情觀看

Dart VM 是用於本地執行 Dart 程式碼的元件集合,它主要包括以下內容:

  • 執行時系統
    • 物件模型
    • 垃圾收集
    • 快照
  • 核心庫的 native 方法
  • 可以通過 service protocol 訪問的元件: 除錯 * 分析 * 熱過載
  • 即時 (JIT) 和提前 (AOT) 編譯管道
  • Interpreter
  • ARM模擬器

Dart VM 從某種意義上說是一個虛擬機器,它為高階程式語言提供了一個執行環境,但這並不意味著 Dart 在 Dart VM 上執行時總是需要被解釋或 JIT 編譯的

例如可以使用 Dart VM AOT 將 Dart 程式碼編譯成機器程式碼,然後在 Dart VM 的裁剪版本中執行,這被稱為預編譯執行時,它不包含任何編譯器元件,無法動態載入 Dart 原始碼。

Dart VM 如何執行你的程式碼?

Dart VM 有多種執行程式碼的方式,例如:

  • 使用原始碼或核心二進位制檔案的 JIT 模式;
  • 使用快照:
    • 來自 AOT 快照;
    • 來自 AppJIT 快照;

然而它們之間的主要區別在於: VM “何時”以及“如何”將 Dart 原始碼轉換為可執行程式碼,然後保證執行的執行時環境保持不變。

VM 中的任何 Dart 程式碼都在某個 isolate 中執行,可以將其描述為:具有自己的記憶體(堆)並且通常具有自己的控制執行緒(mutator 執行緒)的 Dart 隔離宇宙

VM 可以有許多 isolate 同時執行 Dart 程式碼,但它們不能直接共享任何狀態,只能通過埠傳遞訊息進行通訊(不要與網路埠混淆!)。

這裡的 OS 執行緒和 isolate 之間的關係有點模糊,並且高度依賴於虛擬機器嵌入到應用程式的方式,但是主要需要保證以下內容:

  • 一個 OS 執行緒一次只能進入一個 isolate ,如果它想進入另一個 isolate,它必須離開當前 isolate
  • 一次只能有一個與 isolate 相關聯的 Mutator 執行緒,Mutator 執行緒是執行 Dart 程式碼並使用 VM 的公共 C API 的執行緒。

然而同一個 OS 執行緒可以先進入一個 isolate 執行 Dart 程式碼,然後離開這個 isolate 並進入另一個 isolate 繼續執行;或者有許多不同的 OS 執行緒進入一個 isolate 並在其中執行 Dart 程式碼,只是不會同時發生。

當然,除了單個 Mutator 執行緒之外,isolate 還可以關聯多個輔助執行緒,例如:

  • 一個後臺 JIT 編譯器執行緒;
  • GC sweeper 現場;
  • 併發 GC marker 執行緒;

VM 在內部使用執行緒池 (dart::ThreadPool) 來管理 OS 執行緒,並且程式碼是圍繞 dart::ThreadPool::Task 概念而不是圍繞 OS 執行緒的概念構建的。

例如在 GC VM 中將 dart::ConcurrentSweeperTask 釋出到全域性 VM 的執行緒池,而不是生成專用執行緒來執行後臺清除,並且執行緒池實現要麼選擇空閒執行緒,要麼在沒有可用執行緒時生成新執行緒;類似地,用於 isolate 來訊息處理事件迴圈的預設實現實際上,並沒有產生專用的事件迴圈執行緒,而是在新訊息到達時將dart::MessageHandlerTask 釋出到執行緒池

dart::Isolate 類相當於一個 isolatedart::Heap 類相當於 isolate 的堆,dart::Thread 類描述了執行緒連線到 isolate 相關的狀態。

請注意,該名稱 Thread 可能會讓人有些困惑,因為所有 OS 執行緒都附加到與 Mutator 相同的 isolate,將重用相同的 Thread 例項。有關 isolate 訊息處理的預設實現,請參閱 Dart_RunLoopdart::MessageHandler

通過 JIT 執行原始碼

本節將介紹當從命令列執行 Dart 時會發生什麼:

// hello.dart
main() => print('Hello, World!');

$ dart hello.dart
Hello, World!
複製程式碼

Dart 2 VM 開始不再具有從原始程式碼直接執行 Dart 的能力,相反 VM 希望獲得包含序列化核心 AST 的核心二進位制檔案(也稱為 dill 檔案)。將 Dart 原始碼翻譯成 Kernel AST 的任務是由通用前端 (CFE)處理的,CFE 是用 Dart 編寫並在不同 Dart 工具上共享(例如 VM、dart2js、Dart Dev Compiler)。

為了保持直接從原始碼執行 Dart ,這裡託管一個名為 kernel service 的輔助 isolate,它處理將 Dart 原始碼編譯到核心中,然後 VM 執行生成的核心二進位制檔案。

然而這種設定並不是 CFE 和 VM 執行 Dart 程式碼的唯一方法,例如 Flutter 是將編譯到 Kernel 的過程和從 Kernel 執行的過程完全分離,並將它們放在不同的裝置上實現:編譯發生在開發者機器(主機)上,執行在目標移動裝置上處理,目標移動裝置接收由 flutter 工具傳送給它的核心二進位制檔案。

這裡需要注意,該 Flutter 工具不處理 Dart 本身的解析, 相反它會生成另一個持久程式 frontend_server,它本質上是圍繞 CFE 和一些 Flutter 特定的 Kernel-to-Kernel 轉換的封裝。

frontend_server 將 Dart 原始碼編譯為核心檔案, 然後 flutter 將其傳送到裝置, 當開發人員請求熱過載時 frontend_server 開始發揮作用:在這種情況下 frontend_server 可以重用先前編譯中的 CFE 狀態,並重新編譯實際更改的庫。

一旦核心二進位制檔案載入到 VM 中,它就會被解析以建立代表各種程式實體的物件,然而這個過程是惰性完成的:首先只載入關於庫和類的基本資訊,源自核心二進位制檔案的每個實體都保留一個指向二進位制檔案的指標,以便以後可以根據需要載入更多資訊。

每當我們引用 VM 內部分配的物件時,我們都會使用 Untagged 字首,因為這遵循了 VM 自己的命名約定:內部 VM 物件的佈局由 C++ 類定義,名稱以 Untagged標頭檔案 runtime/vm/raw_object.h 開頭。例如 dart::UntaggedClass 是描述一個 Dart 類 VM 物件, dart::UntaggedField 是一個 VM 物件

只有在執行時需要它時(例如查詢類成員、分配例項等),有關類的資訊才會完全反序列化,在這個階段,類成員會從核心二進位制檔案中讀取,然而在此階段不會反序列化完整的函式體,只會反序列化它們的簽名。

此時 methods 在執行時可以被成功解析和呼叫,因為已經從核心二進位制檔案載入了足夠的資訊,例如它可以解析和呼叫 main 庫中的函式。

package:kernel/ast.dart 定義了描述核心 AST 的類; package:front_end 處理解析 Dart 原始碼並從中構建核心 AST。dart::kernel::KernelLoader::LoadEntireProgram是 將核心 AST 反序列化為相應 VM 物件的入口點;pkg/vm/bin/kernel_service.dart 實現了核心服務隔離,runtime/vm/kernel_isolate.cc 將 Dart 實現粘合到 VM 的其餘部分; package:vm 承載大多數基於核心的 VM 特定功能,例如各種核心到核心的轉換;由於歷史原因一些特定於 VM 的轉換仍然存在於 package:kernel 中。

最初所有的函式都會有一個佔位符,而不是它們的主體的實際可執行程式碼:它們指向 LazyCompileStub,它只是要求執行時系統為當前函式生成可執行程式碼,然後 tail-calls 這個新生成的程式碼。

第一次編譯函式時,是通過未優化編譯器完成的。

未優化編譯器分兩遍生成機器程式碼:

  • 1、遍歷函式體的序列化 AST 以生成函式體的控制流圖( CFG ),CFG 由填充有中間語言( IL ) 指令的基本塊組成。在此階段使用的 IL 指令類似於基於堆疊的虛擬機器的指令:它們從堆疊中獲取運算元,執行操作,然後將結果推送到同一堆疊。

實際上並非所有函式都具有實際的 Dart / Kernel AST 主體,例如在 C++ 中定義的本地函式或由 Dart VM 生成的人工 tear-off 函式,在這些情況下,IL 只是憑空建立,而不是從核心 AST 生成。

  • 2、生成的 CFG 使用一對多的底層 IL 指令直接編譯為機器程式碼:每個 IL 指令擴充套件為多個機器語言指令。

在此階段沒有執行任何優化,未優化編譯器的主要目標是快速生成可執行程式碼。

這也意味著:未優化的編譯器不會嘗試靜態解析核心二進位制檔案中未解析的任何呼叫,VM 當前不使用基於虛擬表或介面表的排程,而是使用內聯快取實現動態呼叫。

內聯快取的原始實現,實際上是修補函式的 native 程式碼,因此得名內聯快取,內聯快取的想法可以追溯到 Smalltalk-80,請參閱 Smalltalk-80 系統的高效實現。

內聯快取背後的核心思想,是在特定的呼叫點中快取方法解析的結果,VM 使用的內聯快取機制包括:

  • 一個呼叫特定的快取( dart::UntaggedICData),它將接收者的類對映到一個方法,如果接收者是匹配的類,則應該呼叫該方法,快取還儲存一些輔助資訊,例如呼叫頻率計數器,用於跟蹤給定類在此呼叫點上出現的頻率;

  • 一個共享查詢 stub ,它實現了方法呼叫的快速路徑。這個 stub 搜尋給定的快取,以檢視它是否包含與接收者的類匹配的條目。如果找到該條目,則 stub 將增加頻率計數器和 tail-calls 用快取方法。否則 stub 將呼叫一個執行時系統助手來實現方法解析邏輯。如果方法解析成功,則快取將被更新,後續呼叫將不需要進入執行時系統。

如下圖所示,展示了與 animal.toFace() 呼叫關聯的內聯快取的結構和狀態,該快取使用 Dog 的例項執行了兩次,使用 Cat 的例項執行了一次C。

未優化的編譯器本身足以執行任何 Dart 程式碼,然而它產生的程式碼相當慢,這就是為什麼 VM 還實現了自適應優化編譯管道的原因,自適應優化背後的想法是:使用執行程式的執行配置檔案來驅動優化決策

當未優化的程式碼執行時,它會收集以下資訊:

  • 如上所述,內聯快取收集有關在呼叫點觀察到的接收器型別的資訊;
  • 函式和函式內的基本塊相關聯的執行計數器跟蹤程式碼的熱點區域;

當與函式關聯的執行計數器達到一定閾值時,該函式被提交給後臺優化編譯器進行優化。

優化編譯的啟動方式與非優化編譯的啟動方式相同:通過遍歷序列化核心 AST ,為正在優化的函式構建未優化的 IL

然而不是直接將 IL 處理為機器程式碼,而是基於表單的優化 IL, 優化編譯器繼續將未優化的 IL 轉換為靜態單賦值(SSA) ,然後基於 SSA 的 IL 根據收集的型別反饋進行專業化的推測,並通過一系列Dart 的特定優化,例如:

  • 內聯(inlining);
  • 範圍分析(range analysis);
  • 型別傳播( type propagation);
  • 代理選擇(representation selection);
  • 儲存載入和載入轉發(store-to-load and load-to-load forwarding);
  • 全域性值編號(global value numbering);
  • 分配下沉(,allocation sinking)等,;

最後使用線性掃描暫存器和簡單的一對多降低 IL 指令,將優化的 IL 轉化為機器程式碼。

編譯完成後,後臺編譯器會請求 mutator 執行緒進入安全點並將優化的程式碼附加到函式中。

廣義上講,當與執行緒相關聯的狀態(例如堆疊幀、堆等)一致,並且可以在不受執行緒本身中斷的情況下訪問或修改時,託管環境(虛擬機器)中的執行緒被認為處於安全點。通常這意味著執行緒要麼暫停,要麼正在執行託管環境之外一些程式碼,例如執行非託管 native 程式碼。

下次呼叫此函式時, 它將使用優化的程式碼。 某些函式包含非常長的執行迴圈,對於那些函式,在函式仍在執行時,將執行從未優化程式碼切換到優化程式碼是有意義的。

這個過程被稱為堆疊替換( OSR ),它的名字是因為:一個函式版本的堆疊幀被透明地替換為同一函式的另一個版本的堆疊幀。

編譯器原始碼位於 runtime/vm/compiler 目錄中;編譯管道入口點是 dart::CompileParsedFunctionHelper::Compile;IL 在 runtime/vm/compiler/backend/il.h 中定義;核心到 IL 的轉換從 dart::kernel::StreamingFlowGraphBuilder::BuildGraph 開始,該函式還處理各種人工函式的 IL 構建;當 InlineCacheMissHandler 處理 IC 的未命中,dart::compiler::StubCodeCompiler::GenerateNArgsCheckInlineCacheStub 為內聯快取存根生成機器程式碼; runtime/vm/compiler/compiler_pass.cc 定義了優化編譯器傳遞及其順序; dart::JitCallSpecializer 大多數基於型別反饋的專業化。

需要強調的是,優化編譯器生成的程式碼,是在基於應用程式執行配置檔案的專業推測下假設的。

例如,一個動態呼叫點只觀察到一個 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());
複製程式碼

每當程式碼正在做一些假設性優化時,它可能會在執行過程中被違反,所以它需要保證當出現違反假設的情況下,可以恢復原本的執行。

這個恢復過程又被稱為去優化:當優化版本遇到它無法處理的情況時,它只是將執行轉移到未優化函式的匹配點,並在那裡繼續執行,函式的未優化版本不做任何假設,可以處理所有可能的輸入。

VM 通常在去優化後丟棄函式的優化版本,而之後再次重新優化它時,會 使用更新的型別反饋。

VM 有兩種方式保護編譯器做出的推測性假設:

  • 內聯檢查(例如CheckSmi,CheckClassIL 指令)驗證假設在編譯器做出此假設的使用站點是否成立。例如將動態呼叫轉換為直接呼叫時,編譯器會在直接呼叫之前新增這些檢查。
  • Global guards 會執行時丟棄優化程式碼,當依賴的內容變化時。例如優化編譯器可能會觀察到某個 C 類從未被擴充套件,並在型別傳播過程中使用此資訊。然而隨後的動態程式碼載入或類終結可能會引入一個子類 C。此時執行時需要查詢並丟棄在 C 沒有子類的假設下編譯的所有優化程式碼。執行時可能會在執行堆疊上找到一些現在無效的優化程式碼,在這種情況下受影響的幀將被標記為“去優化”,並在執行返回時取消優化。這種去優化被稱為惰性去優化: 因為它被延遲執行,直到控制返回到優化的程式碼

去優化器機制在 runtime/vm/deopt_instructions.cc 中,它本質上是一個解優化指令的微型直譯器,它描述瞭如何從優化程式碼的狀態,重建未優化程式碼的所需狀態。去優化指令由 dart::CompilerDeoptInfo::CreateDeoptInfo 在編譯期間針對優化程式碼中的每個潛在"去優化"位置生成。

從快照執行

VM 能夠將 isolate 的堆,或位於堆中的更精確地序列化物件的圖稱為二進位制快照,然後可以使用快照在啟動 VM isolates 時重新建立相同的狀態。

快照的格式是底層的,並且針對快速啟動進行了優化:它本質上是一個要建立的物件列表以及有關如何將它們連線在一起的說明

快照背後的最初想法:VM 無需解析 Dart 源和逐步建立內部 VM 資料結構,而是可以將所有必要的資料結構從快照中快速解包出來,然後進行 isolate up。

快照的想法源於 Smalltalk 影像,而後者又受到 Alan Kay 的碩士論文的啟發。Dart VM 使用叢集序列化格式,這類似於 《Parcels: a Fast and Feature-Rich Binary Deployment Technology》和《Clustered serialization with Fuel》論文中描述的技術。

最初快照不包括機器程式碼,但是後來在開發 AOT 編譯器時新增了此功能。開發 AOT 編譯器和帶有程式碼的快照的動機:是為了允許在由於平臺級別限制而無法進行 JIT 的平臺上使用 VM

帶有程式碼的快照的工作方式幾乎與普通快照相同,但有細微差別:它們包含一個程式碼部分,這部分與快照的其餘部分不同,它不需要反序列化,此程式碼部分的放置方式允許它在對映到記憶體後直接成為堆的一部分。

runtime/vm/clustered_snapshot.cc 處理快照的序列化和反序列化; API 函式 Dart_CreateXyzSnapshot[AsAssembly] 負責寫出堆的快照(例如Dart_CreateAppJITSnapshotAsBlobsDart_CreateAppAOTSnapshotAssembly ); Dart_CreateIsolateGroup 可選擇獲取快照資料以啟動 isolate

從 AppJIT 快照執行

引入 AppJIT 快照是為了減少大型 Dart 應用程式的 JIT 預熱時間,例如 dartanalyzerdart2js。當這些工具用於小型專案時,它們花在實際工作上的時間與 VM 花在 JIT 編譯這些應用程式上的時間一樣多。

AppJIT 快照可以解決這個問題:可以使用一些模擬訓練資料在 VM 上執行應用程式,然後將所有生成的程式碼和 VM 內部資料結構序列化為 AppJIT 快照,然後分發此快照,而不是以源(或核心二進位制)形式分發應用程式。

從這個快照開始的 VM 仍然可以 JIT。

從 AppAOT 快照執行

AOT 快照最初是為無法進行 JIT 編譯的平臺引入的,但它們也可用於快速啟動和更低效能損失的情況。

關於 JIT 和 AOT 的效能特徵比較通常存在很多混淆的概念:

  • JIT 可以訪問正在執行的應用程式的本地型別資訊和執行配置檔案,但是它必須為預熱付出代價;
  • AOT 可以在全域性範圍內推斷和證明各種屬性(為此它必須支付編譯時間),沒有關於程式實際執行方式的資訊, 但 AOT 編譯程式碼幾乎立即達到其峰值效能,幾乎沒有任何預熱.

目前 Dart VM JIT 的峰值效能最好,而 Dart VM AOT 的啟動時間最好。

無法進行 JIT 意味著:

  • 1、AOT 快照必須包含可以在應用程式執行期間呼叫的每個函式的可執行程式碼;
  • 2、可執行程式碼不得依賴任何可能在執行過程中會被違反的推測性假設;

為了滿足這些要求,AOT 編譯過程會進行全域性靜態分析(型別流分析或TFA),以確定應用程式的哪些部分可以從已知的入口點集合、分配哪些類的例項,以及型別如何在程式運轉。

所有這些分析都是保守的:意味著它們在沒辦法和 JIT 一樣執行更多的優化執行,因為它總是可以反優化為未優化的程式碼以實現正確的行為。

所有可能用到的函式都會被編譯為本機程式碼,無需任何推測優化,而型別流資訊仍然用專門程式碼處理(例如去虛擬化呼叫)。

編譯完所有函式後,就可以拍攝堆的快照,然後就可以使用預編譯執行時執行生成的快照,這是 Dart VM 的一種特殊變體,它不包括 JIT 和動態程式碼載入工具等元件。

package:vm/transformations/type_flow/transformer.dart 是基於 TFA 結果的型別流分析和轉換的入口點;dart::Precompiler::DoCompileAll 是 VM 中 AOT 編譯迴圈的入口點。

可切換呼叫

即使進行了全域性和區域性分析,AOT 編譯程式碼仍可能包含無法去虛擬化的呼叫(意味著它們無法靜態解析)。為了補償這種 AOT 編譯程式碼,執行時使用 JIT 中的內聯快取技術擴充套件,此擴充套件版本稱為 switchable calls

JIT 部分已經描述了與呼叫點關聯的每個內聯快取由兩部分組成:

  • 快取物件(由 dart::UntaggedICData 例項表示);
  • 要呼叫的原生程式碼塊(例如 InlineCacheStub);

在 JIT 模式下,執行時只會更新快取本身,但是在 AOT 執行時可以根據內聯快取的狀態選擇替換快取和要呼叫的本機程式碼。

最初所有動態呼叫都以未連結狀態開始,當達到第一次呼叫點 SwitchableCallMissStub 被呼叫時,它只是呼叫到執行幫手 DRT_SwitchableCallMiss 連結該呼叫位置。

之後 DRT_SwitchableCallMiss 會嘗試將呼叫點轉換為單態狀態,在這種狀態下呼叫點變成了直接呼叫,它通過一個特殊的入口點進入方法,該入口點驗證接收者是否具有預期的類。

在上面的示例中,我們假設 obj.method() 第一次執行的例項是 C, 並 obj.method 解析為 C.method

下次我們執行相同的呼叫點時,它將 C.method 直接呼叫,繞過任何型別的方法查詢過程。

但是它會將 C.method 通過一個特殊的入口點進入,這將驗證它 obj 仍然是 C, 如果不是這種情況,將呼叫 DRT_SwitchableCallMiss 並嘗試選擇下一個呼叫點狀態。

C.method 可能仍然是呼叫的有效目標,例如 obj 是 D extends C , 但不覆蓋的類的例項 C.method,在這種情況下,我們會檢查呼叫點是否可以轉換為單個目標狀態,由 SingleTargetCallStub 實現(另見 dart::UntaggedSingleTargetCache)。

相關文章