V8 祭り

ウェブっ子の間では Google Chrome の JS 処理系である V8 祭りが絶賛開催中らしい. いつもは出遅れる私もたまにはやんやしたいと思っていろいろ読んでみたものの, VM に食傷気味な自分に気付いた. けれど, そうは言っても祭りは別腹. 一通り騒いでみます.

販促マンガ や 資料 によれば, V8 は以下のような特徴を備えている.

hidden class transition と fast property access

generational accurate GC accurate だから incremental GC もできる オブジェクトの rellocation はするけど handle は使わず参照元書き換え

中間表現のインタプリタなしの native code 生成.

instruction cache

コードをみたところ, incremental GC 以外は実装されているかんじ. incremental GC も, マンガを注意深く読むと "we can also implement incremental garbage collection" となっており, 実装したとは書いてない. まあ広告だしね...

広告に書いてないところで面白かった(?) のはこんなところ:

JIT は x86 と ARM. ARM は Android 用であろう. ゲーム屋待望の PowerPC 版はなし.

VM 実行状態のシリアライザつき. 用途不明.

VM のインスタンスは複数 つくれない. YARV 風なネイティブスレッド + 単一ロックのスレッドモデル embedding には向かないし, マルチプロセスになってない普通のブラウザに組込むのも辛い気がする



特にマルチ VM ができないのはひどい. ホストがマルチプロセスだからって今時こりゃねーよ. グローバル変数は悪ですよ. まあ Android 移植で困るだろうから, そのうち直すんだろうけどね...

ダメなところはさておき, 面白そうなのは

JIT と instruction cache

hidden class transition

precise GC

あたりだろうか. 速いコードといったら JIT だとおもうので, そのへんを中心に hidden class transition もちらっと見るかんじを目指して読んでいきます.

コード概観

V8 のコードはおよそ 13 万行ある. (テストのぞく.) これだけのコードが単一ディレクトリにまとめて入っており, モジュールの内訳がわかりにくい. とりあえず主観で分類してみよう. (...リストは長くなりすぎたので 別ページへ.) こんなかんじになった:

共通部分: 基本的なデータ構造, メモリ管理, 移植層,...

ホスト API: ライブラリとして呼び出すための API と実装

パーサ: JavaScript をパースして AST を作る

オブジェクトモデル: JavaScript のオブジェクトなど, GC で管理されるオブジェクトの実装

GC: ヒープやらマークスイープやら

JIT: 機械語生成

ランタイム: 生成された機械語から呼び出される API.

デバッグ支援そのほか

処理系はまずスクリプトの文字列を AST に変換する. JIT モジュールはこの AST をトラバースし, 機械語を出力する. 出力された機械語を呼び出せば 実行スタートといった塩梅になる. さすがにまとめてコンパイルは辛いので遅延の仕組みは入ってるっぽい.

JIT をみるためには, JIT 本体とオブジェクトモデルを眺めればいい.

メモリ管理

...といっても季節柄メモリ管理を無視はできない. なのでちょっとだけ.

GC がからむとメモリ管理には慎重でありたい. V8 のメモリ管理は大きくわけて三系統ある. V8 は, メモリ管理の方法にあわせ, ある種の注釈として基底クラスを用意している. malloc()/free() 相当の API を使う自前メモリ管理の対象は Malloced クラスを継承する. (allocation.h) Malloced なクラスの数は多くない. GC で確保するヒープは Malloced である. そりゃそうだ.

apache の memory pool のように文脈に紐づけて一括解放する場合は ZoneObject を継承する. (zone.h) AST のノードは ZoneObject である. (ちなみに一括解放のコンテクストも singleton だった. まじかよ.)

GC で管理するオブジェクトは HeapObject を継承する. (objects.h) HeapObject のサブクラスは JS のオブジェクトや, JIT されたコードは GC で管理される. objects.h 冒頭のコメントを見るとよい.

動的なメモリ管理の対象とならず, 他のオブジェクトに埋め込まれるクラスは Embedded を継承する. これらのクラスは operator new(), delete() が呼ばれるとエラーになる. インスタンスをつくらず, グローバル変数やそのアクセサをまとめる名前空間として使うクラスは AllStatic を継承する. AllStatic なクラスの多さは V8 コードベースの不吉な匂いを象徴している.

V8 のマネジドオブジェクト

GC の対象となる HeapObject は処理系のあちこちに顔をだす. まずはこの構造を確認したい.

HeapObject は Object のサブクラスである. Object のサブクラスにはこの他に Smi クラスがある. HeapObject を始めとする Object の一族は C++ に反旗をひるがえす挑発的な作りになっている.

Object の一族にはメンバ変数がひとつもない. 仮想関数もない. コンストラクタもデストラクタもない. この一族は C++ のオブジェクトモデルを完全に無視し, オブジェクトのメモリレイアウトを自分で取り仕切っている.

たとえば Array クラスには length というメンバ変数...があるかのようなアクセサが定義されている.

// objects.h class Array: public HeapObject { public: // [length]: length of the array. inline int length(); inline void set_length(int value); ... };

実装はこんなマクロ:

// objects-inl.h INT_ACCESSORS(Array, length, kLengthOffset) #define INT_ACCESSORS(holder, name, offset) \ int holder::name() { return READ_INT_FIELD(this, offset); } \ void holder::set_##name(int value) { WRITE_INT_FIELD(this, offset, value); } ... #define READ_INT_FIELD(p, offset) \ (*reinterpret_cast<int*>(FIELD_ADDR(p, offset))) #define WRITE_INT_FIELD(p, offset, value) \ (*reinterpret_cast<int*>(FIELD_ADDR(p, offset)) = value) ... #define FIELD_ADDR(p, offset) \ (reinterpret_cast<byte*>(p) + offset - kHeapObjectTag)

this のアドレスに適当なオフセットを足して該当の型にキャストしている. 要するにふつうコンパイラがやってくれることを自分でやっている.

初期化もコンストラクタなんて軟弱なものは使わない:

// heap.cc Object* Heap::AllocateFixedArray(int length, PretenureFlag pretenure) { ... int size = FixedArray::SizeFor(length); Object* result; ... result = AllocateRaw(size, space); ... // Initialize the object. reinterpret_cast<Array*>(result)->set_map(fixed_array_map()); FixedArray* array = FixedArray::cast(result); array->set_length(length); for (int index = 0; index < length; index++) array->set_undefined(index); return array; }

GC 用のヒープから必要なメモリを確保し, そのあと初期化している. バイト列を自分で直接操作するのは, メモリレイアウトを完全に把握して precise GC をするためだろう. ただ C++ への愛があれば pragma でパディングを制御しつつ operator new() で GC のヒープを確保することもできたはず. V8 の書き手は C++ があまり好きじゃないんだろうね.

V8 は Ruby と同様, 整数値はヒープにとらずポインタに埋め込む. だから 整数値をあらわす Smi (small integer) クラスのコードは少し面白いことになる.

// objects-inl.h int Smi::value() { return reinterpret_cast<int>(this) >> kSmiTagSize; }

ポインタからタグビットを削っている. this をシフトするコードってはじめてみたよ...

ポインタワードには整数(smi)か HeapObject かエラー値が含まれており, 下位のタグビットでそれを判別する. タグビットのレイアウトは以下のとおり:

// objects.h // Formats of Object*: // Smi: [31 bit signed int] 0 // HeapObject: [32 bit direct pointer] (4 byte aligned) | 01 // Failure: [30 bit signed int] 11

よくみると, 先に登場した FIELD_ADDR() の定義でタグビット(kHeapObjectTag) を除去しているのがわかる.

オブジェクトの型情報: Map オブジェクト

HeapObject の先頭ワードは常に Map オブジェクトを指している. Map オブジェクトは Class みたいなもので, 該当オブジェクトのレイアウト情報などを持っている. 宣伝文句で hidden class と呼んでいるものの実体はこの Map クラスである. (資料のリンクをたどると, Map という名前は Self 由来であることがわかる.)

// objects.h class Map: public HeapObject { public: // instance size. inline int instance_size(); inline void set_instance_size(int value); // instance type. inline InstanceType instance_type(); inline void set_instance_type(InstanceType value); ... // [prototype]: implicit prototype object. DECL_ACCESSORS(prototype, Object) // [constructor]: points back to the function responsible for this map. DECL_ACCESSORS(constructor, Object) // [instance descriptors]: describes the object. DECL_ACCESSORS(instance_descriptors, DescriptorArray) // [stub cache]: contains stubs compiled for this map. DECL_ACCESSORS(code_cache, FixedArray) ... };

いかにも型情報っぽい.

マークビット

V8 の元ねたになった Strongtalk VM では先頭ワードに GC のマークビットなどが入っており, Map を指すのは二番目のワードだったらしい. V8 では 先頭ワードの下位ビットをマークビットに使うことで 1 ワードの節約に成功している. (Strongtalk の ML でも小ささを自慢している.)

Map を挿す先頭ワードのためにわざわざラッパクラスまである.

//objects.h class MapWord BASE_EMBEDDED { public: // Normal state: the map word contains a map pointer. // Create a map word from a map pointer. .. static inline MapWord FromMap(Map* map); // View this map word as a map pointer. inline Map* ToMap(); ... // Return this map word but with its mark bit set. inline void SetMark(); // Return this map word but with its mark bit cleared. inline void ClearMark(); ... explicit MapWord(uintptr_t value) : value_(value) {} uintptr_t value_; ... };

// objects-inl.h void MapWord::SetMark() { value_ &= ~kMarkingMask; } ... void MapWord::ClearMark() { value_ |= kMarkingMask; }

全体の荒々しさと比べて抽象の粒度がちぐはぐな気はするけど, そこはこだわりの現れなのかも.

Map, オブジェクトレイアウト, Precise GC

2 ワード目以降のレイアウトはオブジェクトの型によってことなる. 先頭ワードの Map オブジェクトを参照することで型がわかり, 型からレイアウトを知ることができる. 要するにダウンキャストできる.

GC もこの型情報を利用してオブジェクトグラフをトラバースする. (mark-sweep の mark とかね.) オジェクトグラフのトラバースには Visitor パターン, ObjectVisitor が使われる. HeapObject が visit 用 API を用意している:

// object.cc void HeapObject::Iterate(ObjectVisitor* v) { // Handle header IteratePointer(v, kMapOffset); // Handle object body Map* m = map(); IterateBody(m->instance_type(), SizeFromMap(m), v); } void HeapObject::IterateBody(InstanceType type, int object_size, ObjectVisitor* v) { ... switch (type) { case FIXED_ARRAY_TYPE: reinterpret_cast<FixedArray*>(this)->FixedArrayIterateBody(v); break; case JS_OBJECT_TYPE: case JS_VALUE_TYPE: case JS_ARRAY_TYPE: case JS_FUNCTION_TYPE: case JS_GLOBAL_OBJECT_TYPE: reinterpret_cast<JSObject*>(this)->JSObjectIterateBody(object_size, v); break; case JS_BUILTINS_OBJECT_TYPE: reinterpret_cast<JSObject*>(this)->JSObjectIterateBody(object_size, v); break; case ODDBALL_TYPE: reinterpret_cast<Oddball*>(this)->OddballIterateBody(v); break; case PROXY_TYPE: reinterpret_cast<Proxy*>(this)->ProxyIterateBody(v); break; case MAP_TYPE: reinterpret_cast<Map*>(this)->MapIterateBody(v); break; case CODE_TYPE: reinterpret_cast<Code*>(this)->CodeIterateBody(v); break; .... default: PrintF("Unknown type: %d

", type); UNREACHABLE(); } } } ... void JSObject::JSObjectIterateBody(int object_size, ObjectVisitor* v) { // Iterate over all fields in the body. Assumes all are Object*. IteratePointers(v, kPropertiesOffset, object_size); } ... void HeapObject::IteratePointers(ObjectVisitor* v, int start, int end) { v->VisitPointers(reinterpret_cast<Object**>(FIELD_ADDR(this, start)), reinterpret_cast<Object**>(FIELD_ADDR(this, end))); }

オブジェクトが自己申告するならそりゃ precise だよね. GC の起点となる グローバル変数やスタックフレームにも, ObjectVisitor がトラバースするための API がある.

オブジェクトのプロパティ

V8 の目玉の一つにプロパティアクセスの高速化がある. 従来の JavaScript 処理系は, プロパティをインスタンス単位のハッシュテーブルに保存していた. V8 は "fast mode" と銘打って, ハッシュではなく配列にプロパティを保存することができる.

// objects-inl.h ACCESSORS(JSObject, properties, FixedArray, kPropertiesOffset)

ここでは properties フィールドの型は FixedArray (配列) と宣言されているが, モードによっては Dictionary(=ハッシュ) が含まれることもある. このように配列を使うかハッシュを使うかは排他である. どちらを使っているかは上の properties フィールドに代入されているオブジェクトの型情報(Map)でわかる.

bool JSObject::HasFastProperties() { return !properties()->IsDictionary(); } ... bool Object::IsDictionary() { return IsHashTable() && this != Heap::symbol_table(); } ... bool Object::IsHashTable() { return Object::IsHeapObject() && HeapObject::cast(this)->map() == Heap::hash_table_map(); } ... bool Object::IsHeapObject() { return HAS_HEAP_OBJECT_TAG(this); }

配列のインデクスとプロパティ名の関係は, インスタンスに対応する Map オブジェクトの instance_descriptors フィールドに記録されている. instance_descriptors はプロパティの情報を示す Descriptor オブジェクトの配列である.

// objects.h class Map: public HeapObject { public: // [instance descriptors]: describes the object. DECL_ACCESSORS(instance_descriptors, DescriptorArray) ... };

// objects.h class Descriptor BASE_EMBEDDED { public: ... private: String* key_; // プロパティ名 Object* value_; // 配列内のインデクス PropertyDetails details_; // ReadOnly, DontDelete, DontEnum などのフラグ詰め合せ };

Dictionary を使うのはプロパティ数が多すぎる時など限られた場面のようなので, 以下ではもっぱら配列のケースだけに注目して話を進める.

プロパティの get と set をそれぞれ見てみよう.

まず get から

// なぜか JSObject じゃなくて Object のメソッド. Object* Object::GetProperty(String* key) { PropertyAttributes attributes; return GetPropertyWithReceiver(this, key, &attributes); } ... Object* Object::GetPropertyWithReceiver(Object* receiver, String* name, PropertyAttributes* attributes) { LookupResult result; Lookup(name, &result); return GetProperty(receiver, &result, name, attributes); } ... Object* Object::GetProperty(Object* receiver, LookupResult* result, String* name, PropertyAttributes* attributes) { ... if (!result->IsProperty()) { // プロパティがなかった... *attributes = ABSENT; return Heap::undefined_value(); } ... Object* value; JSObject* holder = result->holder(); switch (result->type()) { case NORMAL: // ハッシュを引く場合 value = holder->property_dictionary()->ValueAt(result->GetDictionaryEntry()); ASSERT(!value->IsTheHole() || result->IsReadOnly()); return value->IsTheHole() ? Heap::undefined_value() : value; case FIELD: // 配列アクセスの場合 value = holder->properties()->get(result->GetFieldIndex()); // インデクスでとれる ASSERT(!value->IsTheHole() || result->IsReadOnly()); return value->IsTheHole() ? Heap::undefined_value() : value; ... default: UNREACHABLE(); return NULL; } }

set.

Object* JSObject::SetProperty(String* name, Object* value, PropertyAttributes attributes) { LookupResult result; LocalLookup(name, &result); return SetProperty(&result, name, value, attributes); }

Object* JSObject::SetProperty(LookupResult* result, String* name, Object* value, PropertyAttributes attributes) { ... switch (result->type()) { case NORMAL: // ハッシュの場合. property_dictionary()->ValueAtPut(result->GetDictionaryEntry(), value); return value; case FIELD: // 配列の場合 properties()->set(result->GetFieldIndex(), value); return value; ... default: UNREACHABLE(); } UNREACHABLE(); return value; }

雰囲気はわかった(よね?)けれど, 疑問もある. たとえば...:

Map が型情報だとして, どうやってオブジェクトの型を決めるの？ マンガにあった hidden class の話はどうなったの?

配列に保存するのはいいとして, Lookup() でプロパティ名の文字列から配列の添字(インデクス)を検索すると遅いんじゃない?

とりあえず一つ目から片付けていきたい.

隠れクラスの実現: Map Transition

オブジェクトの型情報である Map クラスのインスタンスはまず, 同じコンストラクタ関数を持つオブジェクト間で共有される. つまり同じコンストラクタをもてば同じ型だとみなされる. 関数オブジェクトは自身が construct するオブジェクトに登録する Map オブジェクトを, 自分の Map とは別に持っている.

実行時にプロパティが増えるとオブジェクト内のプロパティのレイアウト(=どんな名前のプロパティがいくつあるか)が変わる. Map にはプロパティのレイアウトが記述されているから, それが変わると同じ Map を使えなくなる. このときオブジェクトは新しい Map を作り, 自分に登録する. この仕組みを Map Transition と呼ぶようだ. 要するにプロパティが増減すると型が変わるわけ.

function Hello() { this.foo = 1; this.bar = 2; } var hello1 = new Hello(); var hello2 = new Hello(); // hello1 と hello2 は Map を共有: 生成元のコンストラクタが同じ. hello1.baz = 3; // hello1 の Map は baz を含む新しい Map に差しかわり, hello2 とは別マップを参照する.

コードはこんなの:

// objects.cc Object* JSObject::SetProperty(String* name, Object* value, PropertyAttributes attributes) { LookupResult result; LocalLookup(name, &result); return SetProperty(&result, name, value, attributes); // ここにすすむ }

Object* JSObject::SetProperty(LookupResult* result, String* name, Object* value, PropertyAttributes attributes) { .... if (result->IsNotFound()) { return AddProperty(name, value, attributes); // ここにすすむ } .... } Object* JSObject::AddProperty(String* name, Object* value, PropertyAttributes attributes) { if (HasFastProperties()) { // Ensure the descriptor array does not get too big. if (map()->instance_descriptors()->number_of_descriptors() < DescriptorArray::kMaxNumberOfDescriptors) { ... return AddFastProperty(name, value, attributes); // ここにすすむ } } else ...{ } } ... }

ようやく本題:

Object* JSObject::AddFastProperty(String* name, Object* value, PropertyAttributes attributes) { ... DescriptorArray* old_descriptors = map()->instance_descriptors(); ... // Compute the new index for new field. int index = map()->NextFreePropertyIndex(); ... // Allocate new instance descriptors with (name, index) added // プロパティの Descriptor を追加した新しい DescriptorArray をつくる FieldDescriptor new_field(name, index, attributes); Object* new_descriptors = old_descriptors->CopyInsert(&new_field, REMOVE_TRANSITIONS); ... if (map()->unused_property_fields() > 0) { ASSERT(index < properties()->length()); // Allocate a new map for the object. // Map も新しいのを作る Object* r = map()->Copy(); Map* new_map = Map::cast(r); ... // We have now allocated all the necessary objects. // All the changes can be applied at once, so they are atomic. ... // 新しい Map に新しい DescriptorArray を挿し... new_map->set_instance_descriptors(DescriptorArray::cast(new_descriptors)); new_map->set_unused_property_fields(map()->unused_property_fields() - 1); // それを自分にセットする set_map(new_map); // プロパティも忘れず追加. properties()->set(index, value); } else { ... } return value; }

さて, 一つ気になることがある. 同じコンストラクタを持てば Map が共有されるとして, コンストラクタの中でプロパティが追加されたら何がおこるのだろう. 追加のたびに Map ができてしまったら, 空のオブジェクト以外はみな違う Map を持つことになってしまう. (つまり大半のオブジェクトが違う型になってしまう.) それじゃ台無しだ. コンストラクタ内のプロパティアクセスは特別扱い...なんてのも苦しい. 継承なんかもあるし...

V8 の Map Transition は, この問題をうまく扱っている. transit 前の Map に, "この Map にはプロパティが追加されて新しい Map ができたよ" というマークを 残しておくのだ. 先の AddFastProperty() のコードで "..." と省略していた場所に, 実はその仕組みが潜んでいる. 省略箇所を変えて引用しなおし:

Object* JSObject::AddFastProperty(String* name, Object* value, PropertyAttributes attributes) { ... // Compute the new index for new field. int index = map()->NextFreePropertyIndex(); // Allocate new instance descriptors with (name, index) added FieldDescriptor new_field(name, index, attributes); Object* new_descriptors = old_descriptors->CopyInsert(&new_field, REMOVE_TRANSITIONS); ... if (map()->unused_property_fields() > 0) { ASSERT(index < properties()->length()); // Allocate a new map for the object. Object* r = map()->Copy(); if (r->IsFailure()) return r; Map* new_map = Map::cast(r); if (allow_map_transition) { // ある所定の条件を満たすなら... // Allocate new instance descriptors for the old map with map transition. // 古い(既存の) DescriptorArray に "MapTransitionDescriptor" を追加する MapTransitionDescriptor d(name, Map::cast(new_map), attributes); Object* r = old_descriptors->CopyInsert(&d, KEEP_TRANSITIONS); ... old_descriptors = DescriptorArray::cast(r); } // 既存の Map の DescriptorArray を MapTransitionDescriptor を追加したものに差し替える map()->set_instance_descriptors(old_descriptors); // あとはこれまでどおり new_map->set_instance_descriptors(DescriptorArray::cast(new_descriptors)); new_map->set_unused_property_fields(map()->unused_property_fields() - 1); set_map(new_map); properties()->set(index, value); } else { ... } return value; }

MapTransitionDescriptor が問題の "マーク". この MapTransitionDescriptor には, 追加されたプロパティ名と transit 先の Map, そして "transit した" という事実を表すフラグが入っている.

class MapTransitionDescriptor: public Descriptor { public: MapTransitionDescriptor(String* key, Map* map, PropertyAttributes attributes) : Descriptor(key, map, attributes, MAP_TRANSITION) { } };

JSObject::SetProperty() がこのマークをみつけたら, 新しい Map を作るかわりに transit 先の Map を使いつつプロパティを登録する.

Object* JSObject::SetProperty(LookupResult* result, String* name, Object* value, PropertyAttributes attributes) { ... switch (result->type()) { case NORMAL: ... case FIELD: ... case MAP_TRANSITION: // マーク発見! if (attributes == result->GetAttributes()) { // Only use map transition if the attributes match. // マークを使ってプロパティを追加! return AddFastPropertyUsingMap(result->GetTransitionMap(), name, value); } else { // Map を再利用できないこともある... return AddFastProperty(name, value, attributes); } ... default: UNREACHABLE(); } UNREACHABLE(); return value; } ... Object* JSObject::AddFastPropertyUsingMap(Map* new_map, String* name, Object* value) { int index = new_map->PropertyIndexFor(name); if (map()->unused_property_fields() > 0) { ASSERT(index < properties()->length()); properties()->set(index, value); // プロパティを追加しつつ... } else { ... } set_map(new_map); // 与えられた Map に transit! return value; }

こうしてめでたく Map が共有された. なんとなく transit 元から "継承" されたクラスができるかんじだね.

こうした追跡の仕組みがあるなら, コンストラクタ毎に最初の Map をわける必要はないようにも思える. ただ全てのオブジェクトで最初の Map を共有してしまうと, 最初 Map には大量の MapTransitionDescriptor が登録されてしまう. これは性能上都合が悪そうだ. だからコンストラクタ単位で分割しているのだろう.

型付け, JIT, instruction cache (はまた今度)

hidden class の謎が解けたところで, もう一つ疑問が残っていた: "DescriptorArray から配列インデクスを検索すると遅いじゃん?" これはまったくそのとおりで, 上の JSObject::GetProperty() や JSObject::SetProperty() は どう見ても速いはずがない. かわりに Map オブジェクトを活用した JIT の仕組みが快速アクセスを実現する.

...のだけれど今日はもう力尽きました. コードひどすぎ. 続きはそのうち.