V8 の JSON.stringify が二倍高速になった理由 (翻訳)

JSON.stringify はデータのシリアライズで中心的な役割を果たす JavaScript 関数であり、そのパフォーマンスはウェブにおける様々な基礎的操作に影響する。例えばネットワークリクエストを組み立てる処理や localStorage にデータを保存するときのデータの変換で JSON.stringify は利用される。この関数が高速になればページのインタラクションが高速になり、アプリケーションの待ち時間が短くなる。そのため、ここ最近のエンジニアリングの成果によって V8 の JSON.stringify二倍以上高速になったと伝えられることを我々は嬉しく思っている。この記事では、この改善を可能にした技術的な最適化を解説する。

副作用を持たないファストパス

この最適化の根本にあるのは、次の単純な観察を利用した新しいファストパスである: もしオブジェクトのシリアライズ処理が副作用を起こさないと保証できるなら、特殊化されたずっと高速な実装を使用できる。この文脈で「副作用」とは、オブジェクトに対するストリームライン化された単純な走査を不可能にする任意の条件を言う。この副作用にはユーザーが定義したシリアライズ処理の実行といった明らかなケースが含まれるのに加えて、ガベージコレクションのサイクルを起動する可能性のある細かな内部操作も含まれる。正確に何が副作用を持つか、そして副作用をどうれば避けられるかは制限の節で説明する。

V8 は現在のシリアライズ処理が副作用を持たないことを理解すると、高度に最適化されたパスを実行する。このとき、汎用のシリアライザで必要になる様々なチェックや防御的ロジックといった時間のかかる処理はスキップされる。そのため、プレーンデータを表現する最も一般的な型の JavaScript オブジェクトに対する大きな高速化が達成される。

さらに、この新しいファストパスは反復的であり、汎用シリアライザのように再帰的ではない。このアーキテクチャによってスタックオーバーフローのチェックが不必要になるのに加えて、文字列の表現を変更した後に素早く処理を再開できる。さらに、開発者がシリアライズできるオブジェクトグラフの深さの最大値が以前より格段に大きくなる。

異なる文字列表現の扱い

V8 において文字列は 1 バイト文字または 2 バイト文字を使って表現される。もし文字列が ASCII 文字だけを含むなら、V8 はそれを 1 文字が 1 バイトの文字列として保存する。一方で、文字列に ASCII の範囲外の文字が一つでも含まれるなら、1 文字が 2 バイトの表現が文字列全体で使用される。このときメモリ使用量は事実上 2 倍になる。

二種類の文字列を同じ関数で処理すると頻繁な型検査と分岐が避けられない。これを避けるため、文字列化関数は全体が文字の型でテンプレート化されるようになった。これは、V8 の中に二つの特殊化されたシリアライザが個別に含まれることを意味する: 一方は 1 バイト文字列に対してだけ最適化され、もう一方は 2 バイト文字列に対してだけ最適化される。こうしたときバイナリサイズは増加するものの、それに十分見合うパフォーマンスの向上が得られると私たちは考えている。

新しい実装は二つの表現が混ざる場合も効率的に処理できる。シリアライズ処理において、V8 はファストパスで処理できずスローパスへのフォールバックが必要になる表現 (例えば平坦化処理で GC を呼び出す可能性がある ConsString など) を検出するために各文字列のインスタンス型を調べているはずである。この必須の処理により、文字列を 1 文字を 1 バイトで表現するか 2 バイトで表現するかも判明する。

この仕組みのおかげで、楽観的な 1 バイト文字列化関数から厳密な 2 バイト文字列化関数への切り替えコストが事実上ゼロになる。元々存在したチェックにより 2 バイト文字列が生成されると分かった場合は、現在の状態を引き継いだ 2 バイト文字列化関数が呼び出される。このとき最終的な結果は最初の 1 バイト文字列化関数の結果と 2 バイト文字列化関数の結果を連結することで得られる。この戦略では頻出のケースで高度に最適化されたパスを使うことができ、それでいて 2 バイト文字列の扱いも軽量かつ効率的となる。

SIMD を使った文字列シリアライズの最適化

JavaScript の任意の文字列は、JSON にシリアライズするときエスケープが必要な文字 ("\) を含む可能性がある。エスケープが必要かどうかを 1 バイトずつチェックする古典的なループよりも高速な手法が存在する。

V8 では、文字列の長さに応じて二つの戦略が使い分けられる:

どちらの戦略を使ったとしても、処理は非常に効率的である: 文字列がチャンクごとに高速にスキャンされる。特殊文字を含むチャンクが存在しない (最もよくある) ケースでは、文字列全体をそのままコピーできる。

さらに高速なファストパス

メインのファストパスの中に、さらに別種の最適化が可能な「追い越し車線」が存在することに私たちは気が付いた。デフォルトのファストパスはオブジェクトのプロパティを走査し、一連のチェックを実行する: プロパティが列挙可能なこと、キーが Symbol でないこと、そしてキーがエスケープを必要とする ("\ などの) 文字を含まないことが確認される。最後のチェックには文字列のスキャンが必要になる。

このチェックを除去するため、オブジェクトの hidden class に新しいフラグが導入された。オブジェクトの全プロパティのシリアライズが完了したとき、全てのプロパティが 「列挙可能」「キーが Symbol でない」「キーがエスケープが必要な文字を含まない」を満たすなら、そのオブジェクトの hidden class には「fast-json-iterable」の印が付けられる。

シリアライズされるオブジェクトが fast-json-iterable の印が付いた hidden class を持つ (同じシェイプを持つオブジェクトの配列をシリアライズする場合など、非常によくあるケース) なら、チェックを飛ばして全てのキーを文字列バッファに直接コピーできる。

この最適化は JSON.parse にも実装してある。例えば配列に含まれるオブジェクトは同じ hidden class を持つ場合が多いので、そういった場合には配列のパースでキーの処理が高速になる。

double を文字列に変換する高速なアルゴリズム

数値を文字列に変換する処理は驚くほど複雑であり、パフォーマンスへの影響も大きい。JSON.stringify を改善していく中で、コアの DoubleToString で使われるアルゴリズムを更新すれば大幅な高速化が可能だと判明した。そこで、長い間使われてきた Grisu3 アルゴリズムを高速な Dragonbox に置き換えた。

この最適化の必要性は JSON.stringify のプロファイルから判明したものの、新しい Dragonbox を使う実装は V8 に含まれる全ての Number.prototype.toString の呼び出しに恩恵をもたらした。つまり、JSON のシリアライズだけではなく数値を文字列に変換するあらゆるコードの性能が (自動的に) 向上した。

内部で使われる一時的なバッファの最適化

文字列を構築する処理では間違いなくメモリの管理が大きなオーバーヘッドになる。以前の V8 の文字列化関数は C++ ヒープ上に確保される単一の連続バッファを利用していた。このアプローチは単純である一方で、大きな欠点が一つある: バッファの空間が足りなくなると、現在より大きいバッファをアロケートして既存のデータをそこに全てコピーしなければならない。巨大な JSON オブジェクトを扱うとき、この再アロケートと既存データのコピーが大きなオーバーヘッドとなっていた。

最適化を可能にしたのは「一時的なバッファにデータが連続して格納される事実は利用されない」という観察だった。最後に最終的な結果として単一の文字列として組み立てられるだけに過ぎない。

この観察を活用するために、新しい V8 ではセグメントバッファ (sagmented buffer) が使われるようになった。セグメントバッファでは、処理の進行と共に大きくなる一つの大きなメモリブロックではなく、それほど大きくないバッファ (セグメント) のリストが使われる。一つのセグメントが埋まると、新しいセグメントがアロケートされ、以降のデータはそこに書き込まれる。このため高価なコピー処理を完全に避けられる。なお、このセグメントバッファは V8 の Zone メモリにアロケートされる。

制限

JSON.stringify に新しく実装されたファストパスが高速なのは、実行される可能性の高い単純なケースに特殊化されているためである。シリアライズされるデータがそういったケースに該当しない場合、V8 は正しさを保証するために低速な汎用シリアライザに切り替える。改善されたパフォーマンスの恩恵を完全に受けるには、JSON.stringify の呼び出しが次の条件を満たす必要がある:

こういった条件は圧倒的に多くのユースケース (API レスポンスを表すデータのシリアライズや設定オブジェクトのキャッシュ) で自然に満たされるので、開発者はパフォーマンスの改善を自動的に受けられる。

結論

JSON.stringify からゼロから考え直し、高レベルのロジックからコアのメモリ管理や文字列処理にまで手を入れることで、JetStream2 json-stringify-inspector ベンチマークにおいて二倍以上の高速化が達成された。異なるプラットフォームにおける結果を次の図に示す。この最適化は V8 のバージョン 13.8 (Chrome 138) 以降で利用可能である。

JetStream2 の結果
JetStream2 の結果

Attribution

Portions of this page are reproduced from work created and shared by the V8 project and used according to terms described in the Creative Commons 3.0 Attribution License.

The original source page is How we made JSON.stringify more than twice as fast.

広告