2019 Javascriptエンジン俯瞰
こんにちは 2019 Javascript Advent Calendarの11日目です
2019はJSエンジンが新たに2つもリリースされた
まずFacebook産のhermes
もう一つがFFMPEG作者のbellardが実装したquickjs
この2つを見ていこうと思う
ちなみにhermesは以前にも書いたので正直あまり書くことは無い
http://abcdef.gets.b6n.ch/entry/2019/07/22/142510
特徴
hermes
- C++
- FacebookがReact Nativeの高速化用に実装したエンジン
- レジスタマシンのバイトコードインタプリタを搭載
- flowを解釈できる
- commonjsを解釈して実行できる
- バイトコードのexportとimportも可能でスタートアップタイムを高速化することが可能
- JITはx86_64の実装はあるが使うパスが無いためOFFになっている
- let/constやclass、ES Moduleといった機能はサポートされていない
- Reflectionやwith、Symbol.speciesといったものは今後もサポートしない
- 一部Ecma262と仕様が異なる
quickjs
- すべてCで書かれている
- とにかく小さい(x86で190 KiB)
- スタックマシンのバイトコードインタプリタを搭載
- Ecmascript2019はほぼ完全にサポートEcmascript2020(予定)もサポート
- BigInt/BigFloatもサポート
- jsをExecutableにコンパイルできる
- バイトコードインタプリタ
実装
パーサ
hermes
hermesはパースしながらASTを生成する
hermesのASTはESTreeの仕様に準拠しているのでわかりやすい
quickjs
quickjsは非常にドラスティックな実装となっており、パースしながらなんとBytecodeを直接出力する
ファイルサイズ削減のためにASTをスキップしていて、完全にコンパクト方面に振り切っている感じ
ここらへんはエンジンの色がでていて面白い
Bytecode
hermes
hermesのバイトコードはBytecodeList.defに全てまとまっており、全てマクロ呼び出しで後々再利用できるようになっている
hermesのバイトコードは可変長で、オペランド数はMaxが6、オペランドのサイズも可変長
NewObject等の割と大きめな命令から、BitXorの様なプリミティブ命令まで実装されている
quickjs
対するquickjsのバイトコードはquickjs-opcode.hにすべてまとまっており、こちらも同じくマクロ呼び出しとなっている
まあ言語エンジンを実装するときは大体こうなるよね
quickjsのバイトコードだが、まあ大体hermesと同じような粒度かな
一部push_i32のようなスタックマシンぽいコードもあったり
Bytecodeは両者ともわりかし似ている感じ
Object Model
hermes
以前も書いたが、hermesはNaN-Boxingで実装されている(NaN-BoxingについてはFacebookのHermes Javascript Engineについてにも書いた)
hermesのヒープオブジェクトはGCCellクラスを頂点としたモデルになっている
GCCell
|
+--------+------+-----------------------+-----------+----------------+------------+--------------+--------------+-----+-----+---------+
| | | | | | | | | | | |
JSObject Domain VariableSizeRuntimeCell HiddenClass PropertyAccessor HashMapEntry OrderedHashMap OrderedHashMap Type1 Type2 EmptyCell FinalizerCell
|
|--------+--------------+------------------------------------+---------+---------------+-------------+----------+------+-------+-----------+---------+-----------------+--------+----------------+-----------------+------------+----------------+
| | | | | | | | | | | | | | | | | |
Callable RequireContext HostObject ArrayImpl JSArrayIterator JSArrayBuffer JSDataView JSDate JSError JSGenerator JSMapImpl JSMapIteratorImpl JSRegExp JSTypedArrayBase JSWeakMapImplBase PrimitiveBox JSStringIterator SingleObject
| | | | |
+-------------+-----------------------------------+ +---------+ | | +--------+--------+---------+
| | | | | | | | | | |
BoundFunction NativeFunction JSFunction Arguments JSArray JSTypedArray JSWeakMapImpl JSString JSNumber JSBoolean JSSymbol
| |
NativeConstructor NativeConstructor JSGeneratorFunction GeneratorInnerFunction
JSObject階層以外は省略したが、hermesは上記の様なオブジェクト階層を持っておりまあ割と複雑
GCCellクラスはVTableというオブジェクトに関する情報を保持しているクラスをラップしており、VTableが実際にGC用の型情報やマーク情報を持っている
ただ、VTableクラス自体はGCによってアドレスが変わる可能性があるため、GCCellクラスがハンドルの役目を果たしている
そのため、GCCellはメンバを持たない
ランタイムでのオブジェクトの型情報はその名の通りHiddenClassが保持しており、V8っぽくHiddenClassの中にプロパティのキャッシュを持っていたりする
当然プロパティ追加時のTransitionも実装されていて、ちゃんとhidden classとして機能している
{symbol_id} = internal variable name id
{property_flag} = enumerable|writable|configurable|...
+------------------------+
| HiddenClass(root) |
+------------------------+
|
| (Transition HiddenClass(propertyA))
|
+------------------------+
| HiddenClass(propertyA) |
+------------------------+
| (TransitionMap {symbol_id}_{property_flag}: HiddenClass(propertyB), {symbol_id}_{property_flag}: HiddenClass(propertyC)})
+-------------------------+
| |
+------------------------+ +------------------------+
| HiddenClass(propertyB) | | HiddenClass(propertyC) |
+------------------------+ +------------------------+
こんな風に1つの派生だけの場合はTransitionオブジェクトを直接参照して、複数のTransitionがある場合はsymbol_idとproperty_flagのキーとHiddenClassを直接ハッシュマップとして持つことでTransitionを実現している
quickjs
quickjsはCなので明示的なオブジェクト階層はないもののJSObjectが多くのJS型のベースとなっておりメンバをunionを選択する形となる
またJSValueという空のstructをGenericな値として利用しており、内部に持つ値はvoid*ではなくJSValue*型で保持している
JSObject | +-JSBoundFunction +-JSCFunctionDataRecord +-JSForInIterator +-JSArrayBuffer +-JSTypedArray +-JSFloatEnv +-JSMapState +-JSMapIteratorData +-JSArrayIteratorData +-JSRegExpStringIteratorData +-JSGeneratorData +-JSProxyData +-JSPromiseData +-JSPromiseFunctionData +-JSAsyncFunctionData +-JSAsyncFromSyncIteratorData +-JSAsyncGeneratorData +-func +-cfunc +-JSTypedArray +-array +-JSRegExp
こんな感じでJSObjectにunionを持っている
クラシックなunionベースのモデルなので読みやすくていい
JSObjectはルートの構造体となるので、型情報や、GCMark等の情報を保持している
型情報は全てJSObject内のJSShapeというstructに持っている
JSShapeはプロパティ自体も保持しているが、現在のプロパティのバージョンをハッシュとしても持っており、プロパティが変化することでJSShape内のハッシュ値も変化する
さらに次のハッシュ値をプロパティのメモリアドレスから計算して、前のJSShapeへ持たせることで、次のJSShapeを検索することが可能になり、Transitionを実現している
+-----------+
| JSShape A | |A(next_hash = null)| ...
+-----------+
|
| Add property
|
+-----------+
| JSShape B | |A(next_hash = 3) | |B(next_hash = null)|
+-----------+
だいぶ簡略化して描いているが、next_hashに次のインデックスを格納するイメージ(本当は直接インデックスは格納しない)
VM
hermes
再掲となるが
VMで利用されるレジスタは一応無限の仮想レジスタとなっているが、単純なリニア生存区間解析をCFG上で行っている
一応無制限と書いたのは、Bytecodeのレジスタインデックスが1byteしか受けつけないので、実質256までしかレジスタ割付ができないからである
https://github.com/facebook/hermes/blob/master/doc/Design.mdに書いてあるが、Facebook調べでは256以上のレジスタを使う関数は 見当たらなかったらしい(ので大丈夫ということか)
quickjs
スタックマシンなので単純にstack pointerを伸縮している
両者ともラベルアドレスへのダイレクトジャンプを実装している(もちろんラベルのアドレスが取得できないコンパイラ向けのswitch-caseもある)
ちなみにどんな機能かというと
Label_A: { ... } Label_B: . . . const void* dispatch_tables = {&&Label_A, ...} goto *dispatch_tables[1]
とすることでなんとlabelへとgotoできるという素敵な仕様
これを使うことでジャンプの度にcmpする必要がなくなる(indexの計算のみ)さらにCPUの分岐予測もいらないので、VMのループが高速化される
GC
hermes
GenerationalなMark-Sweep GCでしたそれ以外特に言うこともないかな
オブジェクトグラフをひたすらVisitorがたどっていくprecise GCの実装となっていた
quickjs
こっちはちょっと特殊で参照カウント + mark-sweepとなっている
最初にオブジェクトグラフを巡回して参照カウントをdecrefしたのち、参照カウントが0のオブジェクトをdelete_listに詰め込んで回り、全て回収完了後にJSObjectを解放する
Pythonの方式と同じ方法でオブジェクトを削除している
すべての割り当てられたオブジェクトはobj_list内にあるので、それをtmp_obj_listにコピーして、
巡回中の参照カウントが1以上のオブジェクトはobj_listに戻す
+----------------+
| global_env |
+----------------+
|
| +----------------+
| | tmp_obj_list |
| +----------------+
| |
| | +------------+ +------------+
| |-----| objectA 1 |------->| objectB 1 |
| | +------------+ | +------------+
| | |
| | | +------------+
| | +-->| objectC 2 |
| | +------------+
| REF | +------------+ ↑
|==============> |-----| objectD 2 |--------------+
| +------------+
|
| +------------+ REF
| ---->| objectF 2 |<=====+
| +------------+ | +------------+ |
|-----| objectE 1 |---+ |
+------------+ | +------------+ REF |
---->| objectG 2 |<=====+
+------------+
これが巡回前 参照されているのはすでにObjectDのみの状態で、objectFとobjectGは循環参照している
+----------------+
| global_env |
+----------------+
|
| +----------------+
| | tmp_obj_list |
| +----------------+
| |
| | +------------+ +------------+
| |-----| objectA 0 |------->| objectB 0 |
| | +------------+ | +------------+
| | |
| | | +------------+
| | +-->| objectC 1 |
| | +------------+
| REF | +------------+ ↑
|==============> |-----| objectD 1 |--------------+
| +------------+
|
| +------------+ REF
| ---->| objectF 1 |<=====+
| +------------+ | +------------+ |
|-----| objectE 0 |---+ |
+------------+ | +------------+ REF |
---->| objectG 1 |<=====+
+------------+
これが巡回後
親が外部から参照されているオブジェクト以外は参照カウントが0になる、そして親が0の場合は回収されるので、循環していても関係なく削除できるようになる
ちなみにquickjsはメモリ割り当てに通常のmallocを使用していて、mallocの管理ヘッダ分のサイズも計算して割当中のオブジェクトサイズを保持している
メモリを回収する場合も単純にfreeを呼び出しており、メモリ管理は基本的にすべてglibcにまかせている
まとめ
簡単に2つのエンジンを俯瞰したが、quickjsは軽い実装ながら面白い箇所が多く、またコードもきれいで読みやすいのでおすすめ
quickjs.cにほぼ全てのコードが書かれているので読むのも簡単だと思う
hermesは普通に読むの面倒だし、そんなに面白いことも無いかもしれない
疲れました
では