2019 Javascript engine 俯瞰
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
は普通に読むの面倒だし、そんなに面白いことも無いかもしれない
疲れました
では