🎯

Dartソースコードが実行されるまでの構造を理解する

2022/11/18に公開約15,300字

背景

たまたま React/React Native について少し調べる機会があり、その派生でReact NativeとFlutterの違いを調べていくうちに「普段書いている Flutter/Dart のソースコードが iOS/Android などネイティブプラットフォーム上で実行されるまで何が行われているのか」が気になり、周辺情報をインプットしていました。これまでも何となく全体像はイメージしていたものの曖昧で、改めて調べた内容を自分なりにまとめてみた備忘録に近い内容となっております。

動機は Flutter ですが、調べていくうちに Dart や Dart VM を掘っていった形になっているため、あまり Flutter の話(Engine 等)は出てきません。ただ、Flutter と Dart で多少異なる部分はあると思いつつ、実行に Dart VM を使っている以上根本は変わらないと思いますので多少参考になるかもしれません。
※本記事はアプリケーションの開発スキル向上に直接寄与するような内容ではありません(= 知らなくとも普通にアプリは作れる)点をご容赦下さい。

概要

本記事での要点は下記です。Flutter → Dart → Dart VM の順にコンパイルを中心に実行までの流れを見ていきます。

  • Dart VM には JIT&AOT のパイプラインがある
  • Dart コード(アプリケーションコードおよび Flutter Framework も含む)は、JIT/AOT いずれも Kernel AST と呼ばれる中間表現を介して機械語(ネイティブコード)に変換されている
    • 例)ARM, x86_64
  • Dart コードを実行するにはソースコードからコンパイルする方法とスナップショットから作成する方法の 2 種類がある
  • Flutter のリリースモード(AOT コンパイル)においても Dart VM はランタイムに絞った軽量な形で利用されている
    • デバッグモードのみ Dart VM が利用されているわけではない

Flutter 観点での実行

Flutter の 3 層レイヤーのおさらい

前提知識として、Flutter Engine について軽くおさらいします。
Flutter には Flutter FrameworkFlutter Engine が存在しているのは有名な話ですね(ちなみに、モノレポではなくレポジトリごと分割している理由は Why we have a separate engine repo · flutter/flutter Wiki に記載してあります)。Framework は普段私たちが Widget などを使って開発を行っている部分で、Engine は開発した Flutter アプリケーションを実行するための環境です。

この Flutter Engine は一言でいうと「Flutter アプリケーションを実行するためのポータブルなランタイム」です。アニメーションやグラフィック、Dart ランタイムなど様々な Flutter コアライブラリが実装されています。有名どころだと、2D グラフィックライブラリの Skia( 近い将来、新しいレンダリングレイヤの Impeller に置き換わるはず )や iOS や Android などのプラットフォームデバイスの中で Engine をホストできる Dart VM(後述)などがあります。本記事では Dart VM を中心にその構造を記載しています。
一応、公式ドキュメント記載の見慣れた図を再掲しておきます。

ネイティブアプリとして実行されるまで

iOS/Android でどのようにコードが実行されるのか、については公式ドキュメントの FAQ にある How does Flutter run my code on XXX ? に記載されています。例として iOS のリンクを貼っておきます。誰かに質問されたときは、この内容を伝えられればまず問題ないでしょう。
https://docs.flutter.dev/resources/faq#run-ios

内容を以下に記載しますが、全体的にライトな書きぶりとなっています(FAQ なので)。

リリースモード

1 パラグラフ目はリリースモードの話となっています。
まず、Dart コード(アプリケーションコードも SDK も両方)は機械語(ARM)に AOT コンパイルされます。コンパイルされたバイナリは iOS プロジェクトの「Runner」に含まれ、.ipaファイルとしてビルドされます。ちなみにアプリケーションコードはFrameworks/App.frameworkとしてコンパイルされています。そしてアプリが起動すると同時に、Flutter ライブラリを読み込みます。レンダリングやユーザーによるインプット操作やイベントハンドリングなどは全て、コンパイルされた Flutter アプリに含まれています。Unity などのゲームエンジンと同じような挙動となります。
上記をまとめると以下の図のようなイメージだと思います。

デバッグモード

FAQ の 2 パラグラフ目はデバッグモードの話となっています。
Dart VM 上で実行されており、お馴染みの Hot Reload(再コンパイル無しで変更を反映する特徴)も紹介されています。残りはデバッグモードではパフォーマンスが劣ることを意識してください、程度の書きぶりとなっています。

上記が一応 Flutter 目線で記載されているネイティブアプリ実行までのプロセスとなります。全体像が把握できたところで本記事での後半では、AOT コンパイルや Dart VM について言及しながらコンパイルの仕組みを詳細に見ていこうと思います。

(余談)React Native のコンパイル方法との違い

話はやや脱線しますが、良く比較される同じクロスプラットフォームである React Native とはいくつか差異がありますが、AOTコンパイルの可否も大きな違いです。React Native では Flutter とは異なり、ネイティブの UI コンポネントを利用するアプローチであるため、ユーザー操作やイベントハンドリングはBridge を使ってネイティブと JavaScript を行き来する必要がありました。その点が、ユーザーインタフェースも全て機械語に事前コンパイルされている Flutter との大きな違いで、実行速度の点でも様々な議論がありましたね。
https://medium.com/inloopx/native-apps-with-flutter-and-react-native-8d300805b3c6

最近では、JSI(Javascript Interface)による Bridge の解消や、軽量かつ高速な JavaScript エンジンである Hermes の登場によりこの辺りの課題を解消しつつあるようです。
https://reactnative.dev/docs/hermes

Dart 観点での実行

今度は Dart の公式ドキュメントからコンパイルの詳細を見ていきます。Flutter 観点での情報よりも詳細に記載されています。
ドキュメントに記載もありますが、そもそも Dart という言語のゴールは「アプリ用の柔軟なランタイムを活用し、マルチプラットフォーム開発の生産的なプログラミング言語を提供すること」となっています。「どのプラットフォーム上でもアプリケーションを実行できる」という点を強みとした位置づけとなっていますね。

Dart is a client-optimized language for developing fast apps on any platform. Its goal is to offer the most productive programming language for multi-platform development, paired with a flexible execution runtime platform for app frameworks.

その特徴は以下の図からも分かる通り、プラットフォームに合わせて実行可能な機械語に柔軟に変換されます。

  1. 全てのプラットフォームで実行できる
    • プラットフォームに合わせて機械語 or JavaScript に変換する
  2. 開発用とプロダクション用のツールチェーンが分かれている
    • Hot Reload やデバッグツールの提供により生産性 UP

https://dart.dev/overview#platform

JIT と AOT

Dart Native はモバイル、デスクトップ、サーバなどを指し、Dart Web はブラウザを指します。前者が機械語に変換するのに対し、後者は JavaScript へのトランスパイルとなるため異なるアプローチでコンパイルされます。まとめると以下のマトリックスとなります。

コンパイラ表 開発モード プロダクションモード
Dart Native JIT(just-in-time) AOT(ahead-of-time)
Dart Web dartdevc dart2js

ご存知の通り、Flutter でのビルドには Debug / Release / Profile の 3 つのモードがありますが、それらに対応する形で Dart のモードも切り替わって実行されており、Debug/Profile ビルドでは JIT コンパイルが、Relase ビルドでは AOT コンパイルがそれぞれ行われています。

JIT と AOT の特徴を簡単にテーブルにしました。

項目 JIT AOT
実行速度 ウォームアップが必要のため起動が遅いが、時間経過とともに最大パフォーマンスに達する 起動後すぐにピークパフォーマンスへ達する(最大パフォーマンスは JIT の方が高い場合もある)
コンパイルのタイミング 実行時 実行前
コンパイル時間 短い 長い
目的 Hot Reload などの素早く安定な開発フローを提供 利用者(ユーザー)体験を重視

Dart はこの両方のコンパイル形式をサポートしているというわけですね。詳細はこちらの記事が分かりやすいです。
https://qiita.com/kurun_pan/items/15a64ed67f3e68613cf2

dart compile

Dart のコンパイルも当然手元で実行することができ、その手順は公式ドキュメントに記載されています。かなりシンプルですが、適当に手元で動かしてみるとイメージがしやすいと思います。
https://dart.dev/tools/dart-compile

面白くはないですが、以下のプリントするだけの Dart ファイルでコンパイルしてみました。

hello.dart
void main() {
  print('Hello World!');
}

実行結果です。出力された成果物や特徴についてはコメントで記載しています。

❯ dart compile exe hello.dart
Info: Compiling with sound null safety
# output: 4.9MB
# スタンドアローンな実行ファイル
# ランタイムも含まれるので最もサイズが大きい
-> hello.exe

❯ dart compile aot-snapshot hello.dart
Info: Compiling with sound null safety
# output: 954KB
# ランタイムは含まれず機械語のみのためもっとも軽量
-> hello.aot

❯ dart compile jit-snapshot hello.dart
Compiling hello.dart to jit-snapshot file hello.jit.
Info: Compiling with sound null safety
Hello World! # JITコンパイラで同時に実行もされる
# output: 4.8MB
# 全ソースコードの中間表現 + JITコンパイルの最適化
-> hello.jit

今回実行したコマンドについて公式ドキュメントのテーブルに分かりやすく記載されています。主にソースコードからそのまま実行するのがexeコマンドで、スナップショットから生成して実行するのがaot-snapshot, jit-snapshotであり、Dart コードを実行するアプローチが大きく 2 種類存在することがわかります。それぞれどういった違いがあるのか、スナップショットがいつ使われるのか等については、後述の Introduction to Dart VM セクションにて記載します。

生成物の特徴は公式ドキュメントのテーブルにも記載されています(一部抜粋)。

Note: You don’t need to compile Dart programs before running them. Instead, you can use the dart run command, which uses the Dart VM’s JIT (just-in-time) compiler—a feature that’s especially useful during development. For more information on AOT and JIT compilation, see the platforms discussion.

また、普段私たちはコンパイルをあまり意識することはなく dart run コマンドを使用している思いますが、これはコマンド実行時に内部的には Dart VM の JIT コンパイラを使っていると明記されていますね。

ドキュメントにも度々でていますが、これら JIT & AOT コンパイラは Dart VM にて実行されています。次のセクションではこの Dart VM について見ていきます。

Introduction to Dart VM

Vyacheslav Egorov(@mraleph)さんという Google の中の人を中心に Dart VM チームが書いている Dart VM の詳細が記載されているドキュメントです。現在 WIP という位置づけであるもののかなり詳しく記載されています。本記事では要所のみを抜粋して記載しておりますので、詳細や全容は記事を見ていただければと思います。

https://mrale.ph/dartvm/

VM という混乱を招く名称

The name "Dart VM" is historical.

この記載の通り、その名称は歴史的なもので「VM」という名称ではありますが、実行環境だけではなく AOT パイプラインが存在するなど、その意味は広義であることを知っておくとその後の理解が進みます。

私も最初勘違いしていたのですが、「Dart VM」と記載されると一般に JVM のような 実行環境としてのみの役割をイメージしてしまいますが、Dart VM の場合は少し異なります。Dart VM はバーチャルマシンでもあるのですがそれは単に一つの要素であり、常に JIT コンパイルがされるわけでは有りません。
リリースモード実行時には AOT コンパイルが行われますが、これも Dart VM の AOT パイプライン の中で行われます。コンパイルされた機械語は、precompiled runtime と呼ばれる軽量な(JIT パイプラインのサブセット) Dart VM 上のランタイム上で実行されます。つまり、VM に留まらない多様な機能を Dart VM は提供していることになります。

Dart VM の特徴

  • Dart ランタイムの提供
    • ガベージコレクションやメモリ管理、Isolate 管理などを行う
  • Dart コアライブラリのネイティブメソッドを提供
  • 優れた開発者体験の提供
    • デバッグツール
    • ホットリロード(デバッグ/プロファイル時のみ)
  • JIT & AOT コンパイラの両方のパイプラインを提供

ちなみに、Dart VM のディレクトリはこちらです。
https://github.com/dart-lang/sdk/tree/f83c6d5e999eed7318ab4e39c6e58b6062ba7ddd/runtime/vm

Dart VM がコードを実行する仕組み

前セクションの「dart compile」でも記載しましたが、Dart コードの実行には下記の 2 パターンがあります。これらの大きな違いは ソースコードをいつどのように実行可能なコードに変換するか であり、ランタイム環境は同じです。

  1. ソースコードから JIT で実行する方法
  2. スナップショット(AOT/JIT スナップショット)から実行する方法

前述した dart compile のコマンドと対応させると下記となります。

1.ソースコードから実行
  - JIT: $ dart run
2.スナップショットから実行
  - JIT: $ dart compile jit-snapshot
  - AOT: $ dart compile aot-snapshot & dartaotruntime

1. ソースコードから JIT で実行するステップ

以下のようなコマンドラインから Dart コードを実行する際に、何が起こっているのかについて記載します。

hello.dart
$ dart hello.dart
-> Hello World!

Dart VM は Dart コードをそのまま実行しているわけではない

当初、JIT コンパイラでは Dart のソースコードをそのまま実行しているのかと思っていたのですが、Dart VM でそれはできません。代わりに、Kernel ASTs.dill)と呼ばれる中間表現(抽象構文木のバイナリ)に一度変換し、それを VM 上で実行します。平たく言うと Kernel AST は、「Dart VM 上で実行するためのバイナリ」ということになります。
※AST: Abstruct Syntax Tree

図中にもありますが、Dart ソースコードの Kernel AST への変換は common front-end (CFE) と呼ばれる Dart で書かれた低レベル API で行われています。Common という名称は、Dart Native(VM)と Dart Web(dart2js, dartdevc)で共通で利用されているためだと思われます。

ちなみに、下記のように手元で Dart コードを Kernel AST に変換することもできます。

❯ dart compile kernel hello.dart
Compiling hello.dart to kernel file hello.dill.
Info: Compiling with sound null safety
# output
-> hello.dill

実行結果はこのようになります。.dillファイルは抽象構文木のバイナリなので見ても中身は良くわかりませんが、これが Kernel AST そのものです。

Flutter での Kernel AST の処理

具体的にイメージできるように、Flutter のケースで Kernel AST の動きを見てみます。Flutter では Dart 実行と異なり、下記の通りカーネルへのコンパイルと実行はそれぞれ別の場所で行われます。HOST と書かれている部分が、ビルドする私たちのマシンや CI/CD 環境を指し、DEVIVE は iOS/Android などの実行するプラットフォームを指しています。この間の Kernel AST の移動はflutter_toolによって行われています。

flutter_toolは Dart コードをパースすることはできませんが、Kernel AST への変換をfrontend_server(CFE)のプロセスを永続化することができ、これによって Flutter のカーネル変換ができるようになっています。
frontend_server のプロセスは永続しているためステートフルであり、前回のコンパイル結果を持っています。これによって開発者が出した Hot Reload のリクエストを検知して、前回のコンパイル結果を再利用し差分のみの再コンパイルが行われています。
まとめると、私たちが変更した Dart コードは frontend_server(CFE)によって動的に検知され差分バイナリを生成後、VM 上に変更分が行き渡ることで、変更結果が即時に画面上に反映されている体験ができているわけですね。

2. スナップショットから実行するステップ

また、VM にはバイナリのスナップショットを作成する機能があります。スナップショットを作成することで、別の Isolate で VM を起動する時にスナップショットを元に高速にかつ同じ状態で起動できるようになります。

このスナップショットの機能を使うことで、本来ウォームアップが必要な JIT コンパイラでも起動時間を短縮することができます(AppJIT snapshots)。

少しスタミナが切れてしまったのと、この辺りは解釈に自信が無いので気になる方は元記事をご参照下さい 🙏(改めて追記するかもしれません)

結局 Dart VM はどこで使われているのか?

AOT モードでも実行時に Dart VM が利用されているのか、が個人的に躓いたポイントでした。JIT モードではコンパイルも実行も同じタイミングで分かりやすいですが、AOT モードにおいても事前コンパイルした機械語を実行するために、 Dart VM はランタイムに絞った軽量な形で利用されているそうです。

この点については、前述の Introduction to Dart VM で紹介した著者の Vyacheslav Egorov さんによる下記 StackOverflow コメントが非常にわかりやすかったです。
https://stackoverflow.com/questions/46961097/is-the-dart-vm-still-used/46988481#46988481

In the JIT mode Dart VM is capable of dynamically loading Dart source, parsing it and compiling it to native machine code on the fly to execute it. This mode is used when you develop your app and provides features such as debugging, hot reload, etc.

JIT モードではフル装備の Dart VM が利用されており、プログラム実行中に動的に Dart コードのパースやコンパイル(JIT コンパイル)ができる形になっています。これによりホットリロードの恩恵を受けるなど、スムーズに開発ができる環境が提供されています。

JIT に関しては分かりやすいですね。以下が AOT に関する記述です。

In the AOT mode Dart VM does not support dynamic loading/parsing/compilation of Dart source code. It only supports loading and executing precompiled machine code. However even precompiled machine code still needs VM to execute, because VM provides runtime system which contains garbage collector, various native methods needed for dart:* libraries to function, runtime type information, dynamic method lookup, etc. This mode is used in your deployed app.

事前コンパイルされた機械語が動くための環境としてAOTモードでもVMのランタイムは必要です。理由は、ガベージコレクタや Dart ライブラリのネイティブメソッドの提供などで、これらはデプロイされたアプリケーション上にも存在します。

Where does precompiled machine code for the AOT mode comes from? This code is generated by (a special mode of the) VM from your Flutter application when you build your app in the release mode.

機械語の事前コンパイルも Dart VM(スペシャルモードと表現している箇所)の中で行われています。VM はランタイムだけではなく、プログラム実行前でも単純なコンパイラとして機能しているということですね。

このようにリリースモードでは、デバッグモードの JIT で使うフル装備 Dart VM の中から実行に必要な最低限のランタイム部分のみを使用し、機械語を実行しています。JIT の VM には Hot Reload などの開発に必要な部品が含まれていますが、AOT の VM にはそれら開発に必要な部分を削ったサブセットのような VM となります。

図にすると下記のようなイメージでしょうか?
JIT モードから AOT モードへ向かう矢印は実際にそのプロセスがあるというわけではなく、AOT モードの VM は JIT モードのサブセットであることを意図しています。JIT モードでは存在していた、開発に必要な部品が AOT モードだと欠落(良くドキュメントではstrippedと表現されています)しているのが要点です。

まとめ

Dart ソースコードが実行されるまでステップをまとめます。

  1. Dart ソースコードは CFE と呼ばれる front_end ツールによって Kernel AST と呼ばれる中間表現(抽象構文木)に変換される
  2. Kernel AST(.dill)は抽象構文木のバイナリ表現
  3. AOTモードでは TFA により Kernel AST が最適化される
  4. Dart VM にて Kernel AST を解釈する
    • JITの場合:
      • Hot Reload や Debug を行うための開発部品をすべて含んだフルの VM がデバイスに渡る
      • JIT compiler/interpreter により実行時にプログラムが逐一処理される
    • AOTの場合:
      • VM の中の AOT パイプラインで機械語に pre-compile される
      • 機械語を実行するための軽量なランタイムのみを含んだ VM 上でプログラムが実行される
  5. スナップショットにより起動時間の短縮

実際アプリケーション開発で意識することは少ないですが、Flutter、Dart、Dart VM の関係性が見え、実行プロセスの解像度がまあまあ上がったと思います。個人的に低レイヤーの話はあまり明るくなく、解釈誤っている箇所などありましたらフィードバックいただけますと幸いです 🙏

参考

Discussion

ログインするとコメントできます