メインコンテンツまでスキップ

V8におけるポインタ圧縮

· 約27分
Igor SheludkoとSantiago Aboy Solanes、*ポインタ圧縮の達人*

メモリとパフォーマンスの間には常に戦いがあります。ユーザーとしては、速度が速いことを望むと同時に、できるだけ少ないメモリ消費で済ませたいと思います。しかし、通常、パフォーマンスを向上させるにはメモリ消費の代償が伴い(その逆も然り)、ジレンマを抱えることになります。

2014年、Chromeは32ビットプロセスから64ビットプロセスへ移行しました。これにより、Chromeはセキュリティ、安定性、パフォーマンス面で改善が図られましたが、ポインタが4バイトではなく8バイトを占有するようになったため、メモリコストが増加しました。これに対応する形でV8におけるオーバーヘッド削減に取り組み、失われた4バイトをできるだけ多く取り戻そうとしました。

実装に入る前に、現状を正しく評価するための基準を知っておく必要があります。メモリとパフォーマンスを測定するために、人気のある実際のウェブサイトを反映したウェブページセットを使用します。このデータによると、デスクトップ版Chromeのレンダラープロセスのメモリ消費の平均で40%、最大で60%がV8に起因していることが示されています。

Chromeレンダラーのメモリに占めるV8のメモリ消費割合

ポインタ圧縮は、V8で進行中のメモリ消費削減の取り組みの一つです。アイデアは非常に簡単で、64ビットのポインタを格納する代わりに、ある「基準」アドレスからの32ビットのオフセットを格納することができます。この単純なアイデアによって、V8でどれほどの効果が得られるでしょうか?

V8のヒープには、浮動小数点値、文字列の文字、インタプリタのバイトコード、タグ付き値(詳細は次のセクション参照)など、多くの項目が含まれています。ヒープを調査したところ、実際のウェブサイトではこれらのタグ付き値がV8のヒープの約70%を占めていることがわかりました。

タグ付き値についてさらに詳しく見てみましょう。

V8における値のタグ付け

V8では、JavaScriptの値はオブジェクトとして表現され、オブジェクト、配列、数値、文字列に関係なく、V8ヒープに割り当てられます。これにより、任意の値をオブジェクトへのポインタとして表現することが可能になります。

多くのJavaScriptプログラムでは、ループ内でインデックスをインクリメントするなど、整数値を使った演算を実行します。整数がインクリメントされるたびに新しい数値オブジェクトを割り当てる必要を避けるために、V8はオブジェクトポインタ内に追加または代替データを格納するためのよく知られたポインタタグ付け技法を使用します。

タグビットは二重の目的を果たしており、V8ヒープ内にあるオブジェクトへの強いポインタまたは弱いポインタ、または小さな整数を示します。このため、整数の値をタグ付き値内に直接格納することができ、追加のストレージを割り当てる必要がありません。

V8は常にワード境界に揃ったアドレスでヒープ内にオブジェクトを割り当てるため、最下位の2ビット(またはマシンワードサイズに応じて3ビット)をタグ付けに使用することができます。32ビットアーキテクチャでは、V8は最下位ビットを使用してSmisとヒープオブジェクトポインタを区別します。また、ヒープポインタの場合、次のビットを使用して強い参照と弱い参照を区別します:

|----- 32 bits -----| Pointer: |addressw1| Smi: |int31_value_0|

ここで、wは強いポインタと弱いポインタを区別するために使用されるビットです。

なお、Smi値は符号ビットを含めて31ビットのペイロードしか持つことができません。ポインタの場合、ヒープオブジェクトアドレスのペイロードとして使用できるのは30ビットです。ワードアライメントによって、割り当ての粒度は4バイトとなり、アドレス指定可能な空間は4 GBとなります。

64ビットアーキテクチャの場合、V8の値は以下のようになります:

|----- 32 bits -----|----- 32 bits -----| Pointer: |__addressw1| Smi: |int32_value|0000000000000000000|

32ビットアーキテクチャとは異なり、64ビットアーキテクチャではV8がSmi値ペイロードに32ビットを使用できることに気付くでしょう。ポインタ圧縮による32ビットSmiの影響については、次のセクションで説明します。

圧縮されたタグ付き値と新しいヒープレイアウト

ポインタ圧縮では、64ビットアーキテクチャ上でタグ付き値の両方の種類を32ビットに収めることを目指します。ポインタを32ビットに収めるには以下の方法を用います:

  • V8オブジェクトをすべて4GBのメモリ範囲内に割り当てること
  • ポインタをこの範囲内のオフセットとして表現すること

このような厳密な制限を設けるのは残念ですが、64ビットアーキテクチャでも、ChromeのV8はすでにV8ヒープのサイズに関して2GBまたは4GBの制限があります(基盤となるデバイスの性能に応じて)。Node.jsのような他のV8埋め込み環境では、より大きなヒープが必要な場合があります。もし4GBの最大値を設定した場合、それはこれらの埋め込み環境がPointer Compressionを使用できないことを意味します。

次に問われるのは、32ビットポインタがV8オブジェクトを一意に識別できるようにヒープレイアウトを更新する方法です。

簡易ヒープレイアウト

簡易な圧縮方式は、アドレス空間の最初の4GBにオブジェクトを割り当てることです。

簡易ヒープレイアウト

残念ながら、この方法はV8には適していません。例えばWeb/サービスワーカーのために、Chromeのレンダープロセス内で複数のV8インスタンスを作成する必要がある場合があります。この方式では、すべてのV8インスタンスが同じ4GBのアドレス空間を競争し、結果的にすべてのV8インスタンスに対して合計で4GBのメモリ制限が課されることになります。

ヒープレイアウト v1

もしV8のヒープを他の場所にあるアドレス空間の連続した4GBの領域に配置する場合、ベースからの符号なし32ビットオフセットはポインタを一意に識別できます。

ヒープレイアウト、ベースは開始に整列

ベースが4GBに整列されていることを確保すれば、すべてのポインタで上位32ビットは同じになります。

            |----- 32 bits -----|----- 32 bits -----|
Pointer: |________base_______|______offset_____w1|

また、Smiのペイロードを31ビットに制限し、下位32ビットに配置することでSmiを圧縮可能にすることもできます。基本的に、32ビットアーキテクチャ上のSmiに近づける形となります。

         |----- 32 bits -----|----- 32 bits -----|
Smi: |sssssssssssssssssss|____int31_value___0|

s はSmiペイロードの符号値を表します。符号拡張表現を使用する場合、64ビットワードに対して1ビットの算術シフトだけでSmiを圧縮・解凍できます。

こうしてポインタやSmiの上位半ワードがどちらも下位半ワードによって完全に定義されることがわかります。その結果、メモリ内に保存する際には下位半ワードだけ保存することが可能になり、タグ付き値の保存に必要なメモリを半分に削減できます。

                    |----- 32 bits -----|----- 32 bits -----|
Compressed pointer: |______offset_____w1|
Compressed Smi: |____int31_value___0|

ベースが4GBに整列されていることを考慮すれば、圧縮は単なる切り捨てになります。

uint64_t uncompressed_tagged;
uint32_t compressed_tagged = uint32_t(uncompressed_tagged);

ただし、解凍コードは少し複雑になります。Smiを符号拡張するかポインタをゼロ拡張するか、およびベースを加えるかどうかを区別する必要があります。

uint32_t compressed_tagged;

uint64_t uncompressed_tagged;
if (compressed_tagged & 1) {
// ポインタの場合
uncompressed_tagged = base + uint64_t(compressed_tagged);
} else {
// Smiの場合
uncompressed_tagged = int64_t(compressed_tagged);
}

次に、解凍コードを簡素化するために圧縮方式を変更してみましょう。

ヒープレイアウト v2

もし4GBの開始部分ではなく、ベースを_中央_に配置する場合、圧縮された値をベースからの符号付き32ビットオフセットとして扱うことができます。この場合、全体の予約は4GBに整列されなくなりますが、ベースは整列されています。

ヒープレイアウト、ベースが中央に整列

新しいレイアウトでは、圧縮コードはそのまま変わりません。

しかしながら、解凍コードはより簡素化されます。符号拡張がSmiとポインタの場合で共通化され、分岐はベースをポインタの場合に加えるかどうかだけになります。

int32_t compressed_tagged;

// ポインタとSmiの共通コード
int64_t uncompressed_tagged = int64_t(compressed_tagged);
if (uncompressed_tagged & 1) {
// ポインタの場合
uncompressed_tagged += base;
}

コード内の分岐の性能はCPUの分岐予測ユニットに依存しています。もし分岐なしで解凍を実現できれば、性能が向上する可能性があると考えました。少しのビット処理を用いることで、上記のコードを分岐なしで書き換えることができます。

int32_t compressed_tagged;

// ポインタとSmiの同じコード
int64_t sign_extended_tagged = int64_t(compressed_tagged);
int64_t selector_mask = -(sign_extended_tagged & 1);
// マスクはSmiの場合は0、ポインタの場合はすべて1になります
int64_t uncompressed_tagged =
sign_extended_tagged + (base & selector_mask);

そのため、分岐なしの実装から開始することを決定しました。

パフォーマンスの進化

初期パフォーマンス

過去に使用していたピークパフォーマンスベンチマークOctaneでパフォーマンスを測定しました。現在では日々の作業でピークパフォーマンスの向上に注力しているわけではありませんが、特に_すべてのポインタ_のような性能に敏感なものについてはピークパフォーマンスを低下させたくありません。このタスクにはOctaneが引き続き優れたベンチマークであると考えています。

このグラフは、x64アーキテクチャでポインター圧縮の実装を最適化および調整している間のOctaneのスコアを示しています。グラフでは、数値が高いほど優れています。赤い線は既存のフルサイズポインターx64ビルドを示し、緑の線はポインター圧縮バージョンを示しています。

Octaneの初期改善ラウンド

最初の動作する実装では、約35%のリグレッションギャップがありました。

バンプ (1), +7%

最初に「ブランチレスが速い」という仮説を検証し、ブランチレスのデコンプレッションとブランチ付きのデコンプレッションを比較しました。その結果、仮説が間違っていることが判明し、x64ではブランチ付きバージョンの方が7%速かったのです。それは非常に大きな違いでした!

x64アセンブリを見てみましょう。

デコンプレッションブランチレスブランチ付き
コード```asm```asm \
movsxlq r11,[…]movsxlq r11,[…] \
movl r10,r11testb r11,0x1 \
andl r10,0x1jz done \
negq r10addq r11,r13 \
andq r10,r13done: \
addq r11,r10
``````
サマリー20バイト13バイト
^^実行された命令数6つ実行された命令数3または4つ
^^分岐なし1つの分岐
^^追加のレジスタ1つ

ここでのr13は基準値のために使用される専用レジスタです。ブランチレスコードが大きくなるだけでなく、より多くのレジスタが必要である点に注目してください。

Arm64でも同じ現象を観察しました。ブランチ付きバージョンは強力なCPUで明らかに速かったです(コードサイズは両ケースで同じでした)。

デコンプレッションブランチレスブランチ付き
コード```asm```asm \
ldur w6, […]ldur w6, […] \
sbfx x16, x6, #0, #1sxtw x6, w6 \
and x16, x16, x26tbz w6, #0, #done \
add x6, x16, w6, sxtwadd x6, x26, x6 \
done: \
``````
サマリー16バイト16バイト
^^実行された命令数4つ実行された命令数3または4つ
^^分岐なし1つの分岐
^^追加のレジスタ1つ

低価格帯のArm64デバイスでは、どちらの方向でもほとんど性能差がありませんでした。

私たちの考えは次のとおりです: 現代のプロセッサでは分岐予測器が非常に優れており、コードサイズ(特に実行パスの長さ)が性能により影響を及ぼしました。

バンプ (2), +2%

TurboFanはV8の最適化コンパイラで、「Sea of Nodes」というコンセプトに基づいて構築されています。簡単に言うと、各操作はグラフのノードとして表されます(詳細版はこのブログ投稿をご覧ください)。これらのノードには、データフローや制御フローなどのさまざまな依存関係があります。

ポインター圧縮にとって重要な操作はロードとストアであり、これらはV8ヒープとパイプラインの残り部分を接続します。ヒープから圧縮値をロードするときに毎回デコンプレッションを行い、値をストアする前に圧縮する場合、パイプラインはフルポインターモードで行っていた通りに動作し続けることができます。そのため、ノードグラフに新たにデコンプレッションと圧縮の明示的な値操作を追加しました。

デコンプレッションが必ずしも必要でない場合があります。たとえば、圧縮値がどこかからロードされただけで、次に新しい場所にストアされる場合です。

不要な操作を最適化するため、TurboFanに新たな「デコンプレッション排除」フェーズを実装しました。このフェーズは、圧縮が直接後に続くデコンプレッションを排除する役割を果たします。これらのノードが必ずしも互いに隣り合っていないため、グラフを通じてデコンプレッションを伝播させ、後々圧縮に遭遇して両方を排除することを試みます。この改善により、Octaneのスコアが2%向上しました。

バンプ (3), +2%

生成されたコードを調査していたとき、ロード直後にデコンプレッションされる値が少し冗長なコードを生成していることを確認しました:

movl rax, <mem>   // ロード
movlsxlq rax, rax // 符号拡張

メモリからロードした値を直接符号拡張するように修正した結果:

movlsxlq rax, <mem>

さらに2%の改善が得られました。

改善 (4), +11%

TurboFan最適化フェーズは、グラフに対してパターンマッチングを行うことで動作します。サブグラフが特定のパターンに一致すると、意味的に同等(ただしより良い)サブグラフや命令に置き換えられます。

一致を見つけられないことは必ずしも明示的な失敗ではありません。ただし、グラフ内に明示的なDecompress/Compress操作が存在すると、以前は成功していたパターンマッチングが成功しなくなり、その結果として最適化が静かに失敗することがあります。

「壊れた」最適化の例の一つがallocation preternuringでした。この新しい圧縮/解圧縮ノードを認識するようにパターンマッチングを更新した結果、さらに11%の改善が得られました。

さらなる改善

Octaneの2回目の改善ラウンド

改善 (5), +0.5%

TurboFanでのDecompression Eliminationを実装する過程で多くを学びました。明示的なDecompression/Compressionノードアプローチには次の特徴がありました:

利点:

  • この操作の明示性により、サブグラフの正規パターンマッチングを行うことで不要な解圧を最適化できました。

ただし、実装を進める中で以下の欠点が明らかになりました:

  • 新しい内部値表現が増えたことで、可能な変換操作が組み合わせ爆発を引き起こし、扱いきれなくなりました。既存の表現セット(tagged Smi、tagged pointer、tagged any、word8、word16、word32、word64、float32、float64、simd128)に加えて、圧縮されたポインタ、圧縮されたSmi、または圧縮された任意(ポインタまたはSmiのいずれかである可能性がある圧縮値)が生じました。
  • グラフのパターンマッチングに基づく既存の最適化の一部が静かに作動しなくなり、その結果一部で回帰が発生しました。いくつか修正しましたが、TurboFanの複雑性は引き続き増加しました。
  • レジスターアロケータがグラフ内のノードの量にますます不満を抱き、しばしば悪いコードを生成しました。
  • ノードグラフが巨大化したことで、TurboFanの最適化フェーズが遅くなり、コンパイル中のメモリ消費が増加しました。

そこで私たちは一歩後退し、TurboFanにおけるポインタ圧縮をサポートするための簡素化された方法を考えることにしました。新しいアプローチとして、圧縮ポインタ/Smi/任意表現を廃止し、ストアとロード内での明示的な圧縮/解圧ノードを暗黙的に行い、常にロード前に解圧し、ストア前に圧縮することを前提としました。

また、TurboFanに新たなフェーズを追加し、「Decompression Elimination」を置き換えることにしました。この新しいフェーズでは、実際には圧縮や解圧が必要ない場合を認識し、それに応じてストアとロードを更新します。このアプローチにより、TurboFanでのポインタ圧縮サポートの複雑性が大幅に削減され、生成されるコードの品質が向上しました。

新しい実装は初期バージョンと同じくらい効果的で、さらに0.5%の改善をもたらしました。

改善 (6), +2.5%

性能の均衡に近づいてきましたが、まだ差が存在していました。新たなアイデアを出す必要がありました。その1つが、Smi値を取り扱うコードが上位32ビットを「見る」ことを避けるようにすることでした。

解圧の実装を思い出してみましょう:

// 古い解圧の実装
int64_t uncompressed_tagged = int64_t(compressed_tagged);
if (uncompressed_tagged & 1) {
// ポインタの場合
uncompressed_tagged += base;
}

Smiの上位32ビットを無視する場合、それを未定義と仮定できます。この場合、ポインタとSmiの場合の特殊ケースを避け、Smiでも解圧時に基準値を無条件で加算できます。このアプローチを「Smi破損」と呼びます。

// 新しい解圧の実装
int64_t uncompressed_tagged = base + int64_t(compressed_tagged);

また、もはやSmiの符号拡張を気にする必要がないため、この変更によってヒープレイアウトv1に戻ることができます。これは4GB予約の開始部分を指す基準値があるものです。

ヒープレイアウト、基準値が開始位置に整列

解圧コードに関して、符号拡張操作をゼロ拡張に変更することになりますが、これも同程度のコストです。ただし、ランタイム(C++)側での処理が簡素化されます。例えば、アドレス空間領域予約コード(詳細はいくつかの実装詳細セクションを参照)。

比較のためのアセンブリコードはこちら:

解凍分岐ありSmiが破損する
コード```asm```asm \
movsxlq r11,[…]movl r11,[rax+0x13] \
testb r11,0x1addq r11,r13 \
jz done
addq r11,r13
done:
``````
要約13バイト7バイト
^^3または4命令が実行される2命令が実行される
^^1つの分岐分岐なし

それで、V8内のSmiを使用するすべてのコード部分を新しい圧縮スキームに適応させました。これにより、さらに2.5%の改善が得られました。

残されたギャップ

残されたパフォーマンスのギャップは、ポインタ圧縮と根本的に非互換であるため無効化された、64ビットビルド用の2つの最適化によって説明されます。

Octaneの最終改善段階

32ビットSmi最適化 (7)、-1%

64ビットアーキテクチャ上でのフルポインタモードのSmiの形状を思い出しましょう。

        |----- 32ビット -----|----- 32ビット -----|
Smi: |____int32_value____|0000000000000000000|

32ビットSmiは次の利点を持っています:

  • 数値オブジェクトにボックス化する必要なくより広い範囲の整数を表現できる;
  • 読み書き時に32ビット値への直接アクセスが可能。

この最適化はポインタ圧縮では実行できません。なぜなら、ポインタとSmiを区別するビットのために32ビットの圧縮ポインタにはスペースがないためです。64ビットフルポインタバージョンで32ビットSmiを無効化すると、Octaneスコアが1%低下します。

ダブルフィールドのアンボクシング (8)、-3%

この最適化では、特定の仮定の下で浮動小数点値を直接オブジェクトのフィールドに格納しようとします。これにより、Smiだけでなくさらに多くの数値オブジェクトの割り当てを減らすことが目的です。

以下のJavaScriptコードを想像してください:

function Point(x, y) {
this.x = x;
this.y = y;
}
const p = new Point(3.1, 5.3);

一般的に言えば、メモリ内のオブジェクトpがどのように見えるかを見ると、次のようになります:

メモリ内のオブジェクトp

この記事で隠されたクラス、プロパティ、エレメントバッキングストアについてさらに読むことができます。

64ビットアーキテクチャでは、ダブル値はポインタと同じサイズです。そのため、Pointのフィールドが常に数値値を含むと仮定すると、フィールド内に直接それらを格納することが可能です。

この仮定がフィールドのために破られる場合、例えば以下の行を実行した後:

const q = new Point(2, 'ab');

yプロパティの数値値は代わりにボックス化されて格納される必要があります。さらに、この仮定に依存する推測的に最適化されたコードがどこかにある場合、もはや使用されるべきではなく、捨てられる必要があります(非最適化)。このような「フィールドタイプ」の一般化の理由は、同じコンストラクタ関数から作成されたオブジェクトの形状の数を最小化することであり、これはより安定したパフォーマンスを得るために必要です。

メモリ内のオブジェクトpとq

これを適用すると、ダブルフィールドアンボクシングは次の利点を提供します:

  • オブジェクトポインタを通じて浮動小数点データに直接アクセスできるため、数値オブジェクトを介した余分な参照を回避できる;
  • 多くのダブルフィールドアクセスを行うタイトループ(特に数値計算アプリケーション)に対して、より小さく高速な最適化コードを生成可能。

ポインタ圧縮が有効な場合、ダブル値は圧縮フィールドに収まらなくなります。しかし、将来的にはこの最適化をポインタ圧縮に適応できる可能性があります。

なお、高いスループットを必要とする数値計算コードは、Float64 TypedArraysにデータを格納したり、Wasmを使用したりすることで、このダブルフィールドアンボクシング最適化なしでも最適化可能な方法で書き直すことができます(ポインタ圧縮と互換性のある方法で)。

その他の改善 (9)、1%

最後に、TurboFanでの解凍除去最適化を微調整することで、さらに1%の性能向上を実現しました。

実装の詳細

ポインター圧縮を既存のコードに統合することを簡素化するために、すべての読み込みで値を解凍し、すべての書き込みで値を圧縮することを決定しました。これにより、タグ付き値のストレージフォーマットだけを変更し、実行フォーマットは変更せずに保ちました。

ネイティブコード側

解凍が必要な場合に効率的なコードを生成できるようにするためには、基準値が常に利用可能である必要があります。幸いなことに、V8にはすでに専用のレジスタがあり、「ルーツテーブル」を指しているため、JavaScriptおよびV8内部オブジェクトへの参照が常に利用可能です(たとえば、undefined、null、true、falseなど多くの値)。このレジスタは「ルートレジスタ」と呼ばれ、より小さく共有可能なビルトインコードを生成するために使用されます。

そこで、ルーツテーブルをV8ヒープ予約領域に配置し、ルートレジスタは両方の目的(ルートポインターおよび解凍の基準値として)で使用可能となりました。

C++側

V8のランタイムはV8ヒープ内のオブジェクトにアクセスする際に、ヒープに保存されたデータを便利に見るためのC++クラスを使用します。注意すべきなのは、V8オブジェクトはむしろC PODに近い構造であり、C++のオブジェクトとは異なることです。このヘルパー「ビュー」クラスにはuintptr_tフィールドが1つだけ含まれており、それに関連するタグ付き値が格納されています。これらのビュークラスは単語サイズであるため、ゼロオーバーヘッドで値で渡すことができます(現代のC++コンパイラに感謝します)。

以下はヘルパークラスの擬似例です:

// 隠されたクラス
class Map {
public:

inline DescriptorArray instance_descriptors() const;

// Mapビューオブジェクトで保存された実際のタグ付きポインタ値。
const uintptr_t ptr_;
};

DescriptorArray Map::instance_descriptors() const {
uintptr_t field_address =
FieldAddress(ptr_, kInstanceDescriptorsOffset);

uintptr_t da = *reinterpret_cast<uintptr_t*>(field_address);
return DescriptorArray(da);
}

ポインター圧縮版の初回実行に必要な変更を最小限に抑えるために、解凍に必要な基準値の計算をgetterに統合しました。

inline uintptr_t GetBaseForPointerCompression(uintptr_t address) {
// アドレスを4GBに丸める
const uintptr_t kBaseAlignment = 1 << 32;
return address & -kBaseAlignment;
}

DescriptorArray Map::instance_descriptors() const {
uintptr_t field_address =
FieldAddress(ptr_, kInstanceDescriptorsOffset);

uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address);

uintptr_t base = GetBaseForPointerCompression(ptr_);
uintptr_t da = base + compressed_da;
return DescriptorArray(da);
}

性能測定により、読み込みごとに基準値を計算することが性能に悪影響を与えることが確認されました。その理由は、C++コンパイラがGetBaseForPointerCompression()呼び出しの結果がV8ヒープ内の任意のアドレスで同じであることを知らず、そのためコンパイラが基準値計算を統合できないためです。このコードは複数の命令と64ビットの定数から構成されており、これによってコード膨張が生じます。

この問題に対処するために、V8インスタンスポインタを解凍の基準値として再利用しました(ヒープのレイアウトでのV8インスタンスデータを思い出してください)。このポインタは通常ランタイム関数で利用可能であるため、getterコードを簡素化し、V8インスタンスポインタを要求することで回帰を改善しました:

DescriptorArray Map::instance_descriptors(const Isolate* isolate) const {
uintptr_t field_address =
FieldAddress(ptr_, kInstanceDescriptorsOffset);

uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address);

// 丸める必要はありません。Isolateポインタ自体が基準値だからです。
uintptr_t base = reinterpret_cast<uintptr_t>(isolate);
uintptr_t da = DecompressTagged(base, compressed_value);
return DescriptorArray(da);
}

結果

ポインター圧縮の最終的な数値を見てみましょう!これらの結果を得るために、この記事の冒頭で紹介したのと同じブラウジングテストを使用しました。これは、実世界のウェブサイトの使用を代表するユーザーストーリーです。

これらのテストでは、ポインター圧縮がV8ヒープサイズを最大43%削減することを確認しました!さらに、デスクトップでChromeのレンダラープロセスメモリを最大20%削減します。

Windows 10でブラウジングした際のメモリ節約

もう1つ重要なのは、すべてのウェブサイトが同じ量で改善されるわけではないという点です。たとえば、過去には、V8ヒープメモリがNew York TimesよりもFacebookで多く使用されていましたが、ポインター圧縮により実際には逆になりました。この違いは、ウェブサイトによってタグ付き値の数が異なることに起因します。

これらのメモリ改善に加え、実世界の性能改善も見られました。実際のウェブサイトではCPUとガベージコレクタ時間の利用が減少しました!

CPUおよびガベージコレクション時間の改善

結論

ここにたどり着くまでの旅は簡単ではありませんでしたが、その価値はありました。300+コミットの後、ポインター圧縮を使用したV8は、32ビットアプリケーションを実行しているかのようなメモリ使用量でありながら、64ビットのパフォーマンスを実現しています。

私たちは常に改善を目指しており、以下の関連タスクを計画しています:

  • 生成されるアセンブリコードの品質を向上させる。いくつかの場合では、より少ないコードを生成することでパフォーマンスの向上が可能だと分かっています。
  • 関連するパフォーマンスの後退に対応する。これには、ポインター圧縮に適した方法で再びダブルフィールドのアンボクシングを可能にするメカニズムが含まれます。
  • 8GBから16GBの範囲で、より大きなヒープをサポートするアイデアを探求する。