低レベルメモリ操作によってJavaScriptCoreのArray.prototype.fillを最適化した
この記事はJavaScriptCore(以降JSCと呼びます)においてArray.prototype.fill
を最適化したパッチについて説明するものです。該当のパッチは2024年12月にWebKitのmainブランチにマージされました。
背景
JavaScriptにはArray.prototype.fill
というビルトイン関数があります。この関数は配列中の指定された範囲のすべての要素を指定された値に変更し、変更された配列を返します。たとえば、array.fill(/* value */ 5, /* start */ 2, /* end */ 6);
というJavaScriptのプログラムは、array[2]
からarray[6-1]
までの範囲の要素をすべて5
に変更します。
目的
このパッチの目的は、Array.prototype.fill
の一般的な用途での性能を改善することです。JavaScriptにはarray-likeオブジェクトという概念があります。Array.prototype.fill
を含む多くのビルトイン関数は、配列だけでなくarray-likeオブジェクトに対して使うことができます。しかし、それは一般的な用途ではありません。そのため、array-likeオブジェクトを対象とした性能の改善はこのパッチの目的ではありません。また、あまりにも多くの要素を持つ配列も一般的な用途ではないため、そのような配列を対象とした性能の改善も、このパッチの目的ではありません。
アプローチ
このパッチでは、Array.prototype.fill
をC++で実装することによって性能を改善するアプローチを選びました。このパッチより以前は、JSCArray.prototype.fill
はJavaScriptで実装されていました。JSCにおいて、ビルトイン関数をJavaScriptで実装することには、メリットとデメリットがあります。メリットは、JavaScriptで書かれたビルトイン関数はJITコンパイルの対象となるため、関数に特有の最適化を実装しなくてもある程度の性能が出ることです。一方でデメリットは2つあります。デメリットの1つめは、低レベルな操作を直接行うことができないため、関数に特有の最適化を実装しにくいことです。デメリットの2つめは、関数の実行回数がJITコンパイルの閾値に達していないときや、そもそもJITコンパイルが無効になっている環境において性能が低くなることです。Array.prototype.fill
の性能を改善するにあたって、JSCにおける配列の構造を利用した低レベルなメモリ操作が有効であると考えられたため、このパッチではC++で実装し直すアプローチを選びました。
実装方針
このパッチより以前の、JavaScriptで実装されたArray.prototype.fill
には性能上の問題がありました。この実装は、配列中の対象の範囲をfor文でループし、要素一つ一つに対して新しい値を代入することによってArray.prototype.fill
のセマンティクスを実現していました[1]。これはECMA-262におけるArray.prototype.fill
の仕様に忠実に再現した実装である一方で、性能の観点からは改善の余地があります。
そこでこのパッチでは、JavaScriptの代わりにC++でArray.prototype.fill
を実装することで性能を改善しました。C++を使うことによって、JSCにおける配列のメモリ表現を利用した低レベルな最適化が可能になります。JSCにおける一般的なケースでは、配列の要素は連続したメモリ領域に並びます。このパッチでは、その連続したメモリ領域に対して、C++のstd::fill
やMacOSのmemset_pattern_8
を使って新しい値を一度に直接書き込むことによって性能を改善しました。
実装上の注意点
実装するにあたって、注意するべきことが2つありました。
1つめは、Array.prototype.fill
の実行の前後で、配列の型を適切に変更する必要があることです。JSCの配列は、内部的に型のようなものを持っています。たとえば、まだ要素の型が決まっていない配列、32ビット整数のみを持つ配列、32ビット整数もしくは倍精度浮動小数点数のみを持つ配列、それ以外のオブジェクトなどの値を含みうる配列、というようにいくつかの型があります[2]。Array.prototype.fill
が埋める新しい値が、その時点での配列の型に合致しないことがあります。そのため、新しい値の種類に応じて、配列の型を更新しなければいけません。たとえば、32ビット整数のみを持つ配列の一部を倍精度浮動小数点数で埋める場合、配列の型を倍精度浮動小数点数のみを持つ配列に更新する必要があります[3]。
2つめは、配列の型によっては std::fill
や memset_pattern_8
などによって新しい値を一度に直接メモリに書き込むことができないということです。前述したとおり、JSCの配列は内部的に型のようなものを持っています。そして、配列の型からオブジェクトを含みうることがわかるとき、std:fill
や memset_pattern_8
などによって新しい値を一度に直接メモリに書き込むことはできません。これは、JSCには並行GCが存在しているためです。メモリの書き込み中、オブジェクトの状態が中途半端な状態で並行GCがオブジェクトをスキャンすると、その時点では不正なポインタを見に行くことになり、クラッシュする可能性がありますそのため、配列がオブジェクトを含みうるときは、対象のメモリ領域を単純なfor文でループし、1つ1つ値をセットする必要があります[4]。
評価
マイクロベンチマークによってこのパッチの効果を評価したところ、約3.3倍から4.9倍ほど実行速度が改善されたことがわかりました。このパッチで作成したマイクロベンチマークは、要素数が1024の配列を作成し、その配列に対してArray.prototype.fill
を10,000回実行します。また、実装上の注意点のセクションで説明したようにJSCの配列には型のようなものがあり、型によって性能特性が変わる可能性があるため、それぞれの型向けにベンチマークを作成しました。WebKitリポジトリ内にあるJSCのマイクロベンチマークを実行するスクリプトによって実行し、以下の結果を得ました。
配列の種類 | パッチをあてる前の実行時間(ms) | パッチをあてたあとの結果(ms) | 何倍速くなったか |
---|---|---|---|
まだ要素の型が決まっていない配列 | 5.3186+-0.0609 | 1.1502+-0.0220 | 約4.6倍 |
32ビット整数のみを含む配列 | 4.0577+-0.4950 | 1.1149+-0.0158 | 約3.6倍 |
32ビット整数もしくは倍精度浮動小数点数のみを含む配列 | 3.6932+-0.1337 | 1.1045+-0.0155 | 約3.3倍 |
それ以外のオブジェクトなどを含みうる配列 | 5.6943+-0.3723 | 1.1534+-0.0211 | 約4.9倍 |
実装上の注意点のセクションで説明したように、オブジェクトを含みうる配列に対しては新しい値を一度に直接書き込みことはできていないのですが、それでも十分に性能が改善していることがわかります。
まとめ
この記事では、C++で直接メモリを操作することによってJSCのArray.prototype.fill
の性能を改善した事例について紹介しました。実装上の都合によって制約を受けることはありましたが、性能を大きく改善することができました。
詳しくは当時のソースコードを参照してください。JavaScriptで記述されているため、JSCやWebKitに関する特別な知識がない場合でも読みやすいはずです。 ↩︎
JavaScriptの仕様において、数値を表す型
Number
とBigInt
のみであり、Number
はいわゆる倍精度浮動小数点数のことです。しかし、JSCでは数値の範囲が32ビット整数に収まるようであれば、32ビット整数として扱うことが多いです。おそらく、他のJavaScript処理系もそのようになっているのではないでしょうか。 ↩︎本記事で紹介しているメインとなるパッチの中では、配列の型を更新する処理を
Array.prototype.fill
のC++実装の中に直接書いています。しかし、後にこの処理を汎用的な関数として切り出しました。実装の詳細に興味がある場合は後続のパッチも参照してください。 ↩︎JSCのソースコード中には、このような並行GCの振る舞いに対応するために
gcSafeMemcpy
やgcSafeMemmove
といった関数が用意されています。このパッチでも、理想的にはgcSafeMemset
のようなものを新しく実装し使うべきだったのですが、それを実装するのは私には難しかったので諦めました。実装詳細が気になる場合はWebKitリポジトリのGCMemoryOperations.h
を参照してください。 ↩︎