すべてを最適化する:一週間、68リリース、そしてJSONの547倍高速化
前回のブログ記事はPerry v0.5.12とともに公開されました。今日はv0.5.80です。これは7日間で68回のパッチリリースであり、そのほぼすべてが1つのこと――残っていたすべての遅いパスを速いパスに変えること――に集中していました。
v0.5.0でのLLVMへの切り替えは、v0.5.12までにCraneliftとの同等性を回復しました。それは1つの物語の終わりであり、別の物語の始まりでした。LLVMは今やすべてを見ています。問いは「なぜこれが遅いのか?」ではなく、「なぜこれはまだ速くないのか?」に変わりました――こちらの方がはるかに扱いやすい問いです。
この記事は今週のツアーです。JSONが547倍の高速化を達成しました。mimallocがグローバルアロケータになりました。プロパティアクセスにモノモルフィックインラインキャッシュが加わりました。Bufferにはnoaliasメタデータ付きの型付きポインタスロットが加わりました。FastifyとWebSocketサーバーが1分後にクラッシュしなくなりました。そしてベンチマークの数値が再び動きました。
1. JSON:547倍の差を埋める
v0.5.29時点で、Perryの20レコード配列に対するJSON.parseはNodeより547倍遅い状態でした。v0.5.46までに1.3倍になりました。この数値は今週最大の差分であり、この記事の他のすべての最適化は同じテーマのバリエーションなので、辿っておく価値があります。そのテーマとは――やらなくていい仕事はやらない、ということです。
元のパーサーは、プロパティごとに1つのVec、オブジェクトごとに1つのキー用Vec、そしてキーキャッシュ用にRefCellで保護されたスレッドローカルを1つ割り当てていました。すべての文字列をコピーしていました。すべてのフィールド名を再ハッシュしていました。20レコードすべてが全く同じフィールドを全く同じ順序で持っていても、レコードごとに真新しいオブジェクトシェイプを構築していました。Nodeのパーサーはパターンを検出し、すべてのレコードで単一のシェイプを共有することでこれを処理します。Perryのパーサーはそうしていませんでした。
修正は4つのステップで入りました:
- スレッドローカルな
PARSE_KEY_CACHEによるキーのインターン化(v0.5.45)。最初のレコードはN個のキー文字列を割り当て、2番目から20番目のレコードはゼロ個を割り当てます。繰り返されるキーは同じポインタに解決されるため、strcmpなしでシェイプキャッシュのルックアップキーとして使用できます。 - 遷移キャッシュ経由のシェイプ共有(v0.5.45)。
js_object_set_field_by_nameで構築されるオブジェクトは同じ遷移グラフを辿ります。スキーマが繰り返されると、keys_arrayポインタが共有され、これがポリモルフィックインラインキャッシュがヒットするために必要なものです。 - ゼロコピー文字列パース + インクリメンタルオブジェクト構築(v0.5.46)。
parse_string_bytesはバックスラッシュエスケープがない場合(これはすべてのキーとほとんどの値に当てはまる一般的なケース)にParsedStr::Borrowed(&[u8])を返すようになりました。parse_objectは、まずVecに集めるのではなく、直接フィールドを書き込みます。 - パース中のGC抑制(v0.5.60、#59クローズ)。大きな配列のパースは、タイトなループで何千もの小さなオブジェクトを割り当てます。それぞれがGCの閾値チェックを誘発していました。「パース進行中」フラグを設定して、パースが返るまで収集を延期します――ヒープサイズは実効的には同じですが、記録用のブランチは大幅に減ります。
次にstringifyです。同一形状のオブジェクトを何百万回も含む同種の配列に対するJSON.stringifyは、オブジェクトごとに完全なプロパティ反復を行っていましたが、シェイプが安定した配列にとっては純粋な無駄です。5ステップの修正でそのギャップのほとんども埋まりました:
- v0.5.62:数値用のitoa / ryuファストパス、HashSetの代わりに深さベースの循環参照チェック。
- v0.5.63:
toJSONガード + 永続的なキーキャッシュ + インラインディスパッチ(積み重なっていた3つの呼び出しごとのコスト)。 - v0.5.65:同種シェイプ用stringifyテンプレート + ASCIIエスケープファストパス。すべての要素が同じシェイプを持つ場合、キー/コロン/カンマの骨組みは一度だけ事前計算されます。
- v0.5.70、v0.5.72、v0.5.75:呼び出しごとのシェイプテンプレートキャッシュ、パース残りのGCギャップを閉じる、残っていた固定の呼び出しごとのオーバーヘッドを排除。
- v0.5.79:小さな値用のパス。数値、ブーリアン、短い文字列は、オブジェクト機構を一切セットアップしない直接パスを通ります。
累積結果:週の初めにNodeから547倍離れていたJSONパイプラインが、現実的なワークロードでパースで約1.3倍、stringifyでは競争力のある状態になりました。
2. アロケータの話
Perryは多くを割り当てます。すべてのオブジェクトリテラル、配列リテラル、文字列連結、クロージャで。アロケータはホットで、v0.5の大半ではRustのデフォルトのシステムアロケータに加えて、短命な値用のスレッドローカルアリーナという構成でした。
v0.5.67でグローバルアロケータをmimallocに置き換えました。これはCargo.tomlの1行変更で、小さな割り当てを多数行うワークロード――つまりすべてのTypeScriptプログラム――に即座に効果を発揮します。v0.5.66はこれに先行して、すべてのgc_mallocのスレッドローカル状態を呼び出しごとに単一のTLSアクセスにまとめ、mimallocへのパスを可能な限り安価にしました。
v0.5.68はこれをさらに推し進めて、アリーナ割り当ての文字列を導入しました。短命な文字列(中間の連結結果、split()のピース、パーサーのスクラッチ)はグローバルアロケータを完全にスキップし、自然な境界でリセットされるスレッドごとのバンプアリーナに着地します。JSONパースについてはこれ単独で2桁パーセントの勝利でした。
そして全く割り当てない2つの最適化もあります:
- エスケープしないオブジェクトのスカラー置換(v0.5.17、その後v0.5.76でオブジェクトリテラル)。オブジェクトがそれを囲む関数から決して出ないなら、存在する必要はありません。そのフィールドは単なるローカルになります。不透明なアロケータ呼び出しの背後にオブジェクトを隠すのをやめれば、LLVMはこれを標準で処理できます。
- エスケープしない配列のスカラー置換(v0.5.73)。同じ考え方――配列がエスケープしなければ、その要素はSSA値になり、割り当て全体が消えます。
配列リテラルのパスについて言えば、v0.5.69は正確なサイズのファストパスを追加し(サイズがコンパイル時に既知であれば容量拡張の機構をスキップ)、v0.5.74は小さな配列リテラル用のバンプアロケータIRをインライン化して、LLVMが割り当てを見て、畳み込み、ホイスト、または除去できるようにしました。配列重視のベンチマークはもう一歩前進しました。
締めくくりとして、v0.5.25はもっと静かなバグを修正しました:gc_mallocが自身のパスで収集を発動させておらず、malloc重視のワークロードは何かがチェックするまでヒープが無制限に成長する可能性がありました。v0.5.61は閾値に適応的なステップサイジングを追加しました。これは実際に望ましいものです――ヒープが小さいときは安価にチェックし、大きいときは頻度を下げる。
3. プロパティアクセスが本物のインラインキャッシュを獲得
あらゆる現代のJavaScriptエンジンは、プロパティアクセスにポリモルフィックインラインキャッシュ(PIC)を持っています。Perryのv0.5系列の大半では、PropertyGetはスレッドローカルハッシュでのシェイプテーブル検索を経由していました。コールドコードにはそれで問題ありません。しかし、ある呼び出しサイトでのプロパティ読み取りの95%が同じシェイプを見ている場合――これはほとんど常にそうですが――には問題です。
v0.5.44でPropertyGet用のモノモルフィックインラインキャッシュが入りました。各PropertyGetサイトは呼び出しサイトごとのキャッシュエントリを持ちます:期待されるシェイプポインタとフィールドオフセットです。ヒットパスは単一の比較とインデックス付きロードです。ミスパスはキャッシュを更新する遅いヘルパーにフォールスルーします。
; Monomorphic IC fast path for obj.foo
%shape_ptr = load ptr, ptr %obj_shape_slot
%expected = load ptr, ptr @ic_expected_12
%hit = icmp eq ptr %shape_ptr, %expected
br i1 %hit, label %ic_hit, label %ic_miss
ic_hit:
%off = load i32, ptr @ic_offset_12
%addr = getelementptr i8, ptr %obj, i32 %off
%val = load i64, ptr %addr
; ... use val
br label %contv0.5.51は動的なプロパティ書き込み用のコンテンツハッシュによるシェイプ遷移キャッシュを追加しました。同じフィールドを同じ順序で成長させる2つのオブジェクトは同じ遷移にハッシュされるため、最終的に同じシェイプを共有することになります――つまり、PICの読み取り側が実際にヒットするようになります。
v0.5.55は遷移キャッシュから最後のTLSアクセスを取り除きました。v0.5.46は、フィールドが8個を超えるオブジェクトがインラインスロットの範囲外の初期化されていないメモリを読み取っていたPICミスハンドラのバグを修正しました(#55クローズ)。v0.5.78は、生の数値のような非ポインタレシーバーへのインデックスからPropertyGetのPICを保護するガードを追加しました――これは過度に楽観的な型絞り込みで発生する可能性があり、ICにおける最後の安定性問題の1つでした。
ネットの効果:プロパティ重視のコード――実際にはほとんどのTypeScript――はICだけで1週間前より約2~3倍高速になりました。
4. 整数、ビット演算、そして| 0パターン
NaN-boxingはすべての数値をf64にします。TypeScriptプログラマは整数セマンティクスを強制するためにx | 0と書きます。V8はそれを安価にするために15年を費やしてきました。Perryは今週それに追いつきました。
変更のスタック、順に:
- v0.5.48:
(int / const) | 0にsdiv。LLVMはsmulh + asrに畳み込み、fdivの約10サイクルに対して約2サイクルです。 - v0.5.48:Uint8ArrayGet境界への
@llvm.assume。境界チェックのbranch+phiダイアモンドを、ベクトライザが推論可能な単一の基本ブロックに置き換えます。 - v0.5.49:NaN/Infinityを含むビット演算がToInt32仕様に従って0を生成するように修正。まず正しさから。
- v0.5.50:値が有限と判明している場合に5命令のNaN/Infガードをスキップする
toint32_fast。加えて小さなヘルパーへのalwaysinlineとclamp検出。 - v0.5.52:clamp関数を
smin/smaxイントリンシックで直接ターゲット。Clampはインクリメントに次いで最も一般的な整数パターンです。 - v0.5.53:有限と判明している値に対する
x | 0とx >>> 0はnoopになります――ただのfptosi + sitofpで、ガードは一切ありません。 - v0.5.56:i32ネイティブのビット演算。Uint8ArrayGet/Setでのi32インデックスと値。
- v0.5.58、v0.5.60:
Math.imulはpolyfillパスの代わりにネイティブi32乗算にロワリングされます。Polyfill検出はユーザーが書いたMath.imulシムを認識して置き換えます。 - v0.5.59:純粋関数のinitインライン化 + 整数ローカルシード。関数ローカルな整数解析が、呼び出し先が小さく純粋な場合に呼び出し境界を越えて見ることができるようになります。
- v0.5.37~v0.5.40:アキュムレータパターンの整数算術ファストパス。古典的な
for (...) acc += f(i)ループは、型が許せば最初から最後までi32に留まります。
v0.5.41は微妙なものです。codegenがモジュールレベルのconst K: number[][] = [[...], ...]を見ると、全体を.rodataにあるフラットな[N x i32]定数にロワリングします。K[y][x]は単一のgetelementptr + load i32になります。v0.5.43の整数解析ブリッジと組み合わさり、これがimage_conv(4K RGBフレームに対する5×5ガウシアンブラー)に単一リリースで3倍の高速化をもたらしたものです。
5. BufferとUint8Array
バイナリワークロード――暗号、画像処理、パース、ネットワーク――はBufferとUint8Arrayに住んでいます。v0.5.64でこれらに型付きポインタスロットとnoaliasメタデータを与えました。Bufferは以前はalloca double内のNaN-boxed doubleでしたが、今はalloca i64内の生のi64ポインタで、LLVMアノテーションが「このポインタはスコープ内の他のポインタとエイリアスしない」とオプティマイザに伝えます。これにより、オプティマイザが普段は行わないロード/ストアの並び替え、ベクトル化、レジスタアロケーションが解放されます。
v0.5.80でここでの最後の正しさの問題を解決しました:モジュール全体のバッファalias-scopeカウンタが関数ごとにリセットされていたため、まれにLLVMが同じスコープIDを共有すべきではないスコープをまたいで推論する可能性がありました。今やカウンタはモジュール全体で、noaliasの話は盤石です。
v0.5.53はUint8ArraySetをブランチレスにしました――境界外で0を書き込むif/elseの代わりにマスク付きストアです。v0.5.54は長いパターン用のTwo-Way indexOfとアリーナ割り当てのsplitを追加し、これらが合わさって文字列重視のBufferパースのギャップの大半を埋めました。
6. 文字列:ASCIIがファストパス
JavaScriptの文字列はUTF-16ですが、現実世界の文字列(キー、識別子、HTTPヘッダー、JSONの骨組み)のほとんどはASCIIです。v0.5.71はASCII文字列に対するO(1)のcharCodeAtとcodePointAtを追加しました――UTF-16スキャンなし、ただのバイトロードです。v0.5.20では既にindexOf、slice、charAtがASCIIではUTF-16スキャンをバイパスするようになっていました。
同じリリースに含まれる正しさに関する注記:String.lengthはバイト数の代わりにUTF-16コード単位(ECMAScript仕様)を返すようになりました。これは"café".lengthが4の代わりに5を返していた潜在的なバグでした。
7. サーバーが実際に落ちなくなった
今週の最も地味な作業は、同時に最もユーザーから見える作業でもありました:長時間実行のNodeスタイルサーバー――Fastify、ws、http、net――が数分後にクラッシュしないようにすることです。
クラッシュはすべて同じ根本原因を共有していました:GCがリスナーのクロージャを知らなかったのです。wss.on('message', handler)と書くと、クロージャは変数をキャプチャし、それらはGC割り当てのセル内のフィールドとして存在します。GCルートスキャナがそれらのセルを訪問することを知らなければ、キャプチャされた変数は回収され、次のメッセージイベントは解放されたメモリを逆参照します。
- v0.5.26:
net.Socketイベントリスナーのクロージャをルートスキャン(#35クローズ)。 - v0.5.27:
ws、http、events、fastifyに拡張。 - v0.5.28:モジュールレベルのグローバルをGCルートとして登録(#36クローズ)。1層上のライフタイムバグ。
- v0.5.21:Fastify/WebSocketリクエストハンドラ内での
gc()の安全性――明示的なGC呼び出しが、リクエストハンドラがアリーナへのポインタを保持している間に走っていました(#31クローズ)。
GC作業と並行して、v0.5.20はメインイベントループ――プレースホルダではなく本物――を出荷しました。これは最後の同期呼び出しが返った後に終了する代わりに、WebSocketとタイマーベースのサーバーを生き続けさせます(refs #28)。Perryを本番HTTPサーバーとして実行しようとしている人にとって、これは単独で最も影響の大きい修正でした。Fastifyが落ちなくなりました。WebSocketサーバーが落ちなくなりました。
v0.5.19はJSValue FFI引数/戻り値のSysV AMD64 ABI不一致を修正しました――Linux上でネイティブFFI呼び出しが引数を密かに破損する可能性がある問題です。v0.5.18はaxios(get/post/put/delete/patch)のネイティブディスパッチを追加しました。response.statusとresponse.dataを含みます。v0.5.30はfastify request.header()とrequest.headers[]のディスパッチを修正しました。これは大文字小文字を区別しないルックアップでundefinedを返していました。
8. @perry/postgres:これらすべてを必要にしたドライバ
今週の作業の多くは、1つのワークロードによって牽引されました:Perryネイティブで動作する完全なNode互換のPostgresドライバを動かすことです。このドライバはTLS対応で、クロスモジュールのコーデックレジストリを持ち、cancel/close/notifyをサポートし、今やpg、postgres.js、tokio-postgresに対してベンチマークを取っています。
ドライバ側のパフォーマンス作業はコンパイラ側と並行しました:
- 列ごとのコーデックをホイストして、セルごとのBufferコピーを削除。中間割り当てを避けるためint8にはBigInt(string)。
- オブジェクト形式の行用の動的なシェイプごとのRowコンストラクタ。クエリが常に同じ列を返すなら、ドライバは初回にシェイプ特化した行コンストラクタを構築して再利用します――これがコンパイラのPICと組み合わさると、行のフィールドアクセスが他のどんなオブジェクトのフィールドアクセスと同じくらい高速になります。
- int8/numeric/dateに生の文字列を望む呼び出し元のための
parseTypes: 'minimal'オプトアウト。
これはコンパイラが常に可能にすることを意図していた正のフィードバックループです。本物のドライバは本物のボトルネックを浮上させます。ボトルネックは1行のリプロデューサとしてGitHub issueにファイルされます。1週間のコンパイラ修正後、ドライバが速くなり、コンパイラは他のみんなにとっても速くなります。それが計画全体で、7日間に圧縮されています。
9. 名前を挙げるに値する正しさの修正
パフォーマンス作業は、川を浚渫するとスーパーのカートが浮かび上がるように、正しさの問題を浮上させます。部分的なリスト:
- Promise.raceは拒否時に
.reasonの代わりに.valueを読んでいたため、拒否が静かに飲み込まれていました(v0.5.13~v0.5.14)。 - Promise.anyは、すべての入力プロミスが拒否された場合に適切な
AggregateErrorをスローするようになりました。Promise.withResolversを追加し、queueMicrotaskの順序を修正しました。 [..."hello"]は壊れたオブジェクトの代わりに文字の配列を生成するようになりました(#16クローズ)。- BigInt算術と
BigInt()強制変換(#33クローズ)。i64 bigintファストパス(v0.5.29)が一般的なケースを安価にします。 - Buffer.indexOf / Buffer.includesに数値のバイト引数を渡すと、バイト値の代わりにバッファポインタと比較していました(#56クローズ)。
- NaN/Infinityを含むビット演算がToInt32仕様に従って0を生成するようになりました(#57クローズ)。
- Windows x86_64:5つのプラットフォーム固有の修正――
localtime、clangの検出、いくつかのcodegen調整――でWindows x86_64をグリーンに戻しました(v0.5.72)。
10. 数値
前回の記事の目玉ベンチマークはfactorialで、Nodeより24.6倍高速でした。その数値は変わっていません。今週動いたのはその周辺すべてです:
| Workload | v0.5.12 | v0.5.80 | Delta |
|---|---|---|---|
| JSON.parse (20-record schema) | 547x Nodeより遅い | 1.3x Nodeより遅い | ~420x |
| image_conv (4K 5×5 blur) | 1,980ms | 457ms | 4.3x |
| Property-heavy code (PIC hit) | baseline | 2–3x | 2–3x |
| Fibonacci(40) | 401ms | 309ms | 1.3x |
| Fastify uptime under load | クラッシュまで約60秒 | 無期限 | ∞ |
Node.jsに対する15個のフルベンチマークスイートは依然として14勝1引き分け――前回の記事と同じ表で、全体的にわずかに良い数値です。今週の本当の動きは、そのスイートに入っていなかったワークロード:JSON、画像処理、長時間実行サーバーにあります。ギャップが住んでいた場所で、それらこそ埋まったものです。
11. 次は何か
まだ追いかけている1つのベンチマークはimage_conv対Zigです。Perryは457ms、Zigは246msです。そのギャップはアーキテクチャ的なもので、最適化パスレベルではなく、3つの場所に住んでいます:
- 型付きバッファローカル。Bufferの作業の大半は今週着地しましたが、buffer型の関数パラメータとローカルは依然としてアクセスごとにアンボックスします。ループカウンタに使っている
i64スロット方式をバッファに拡張する必要があります。 - 内部/境界ループの分割。ブラーループはすべてのピクセル――クランプが不要な99.9%のピクセルも含めて――をクランプします。境界領域(クランプあり)と内部(クランプなし)に分割すると、LLVMがNEONの
ld3/st3で内部をベクトル化できます。 - ダブルABIのFNV-1aハッシュ。ハッシュヘルパーはNaN-box ABIを通して呼ばれます。ホットパス用に生のi64入出力に特化させるのは数時間の作業で、ハッシュ重視のすべてのワークロードで元が取れます。
これらはPERF_ROADMAP.mdで追跡しています。次のサイクルで見られることを期待してください。
まとめ
今週のパターン――68回のパッチリリース、ほぼすべてパフォーマンス、1つのJSONギャップが547倍から1.3倍へ――は、LLVMへの切り替えの丘の向こう側、良い側に渡ったときに起こることです。オプティマイザは今や壁ではなく味方であり、残っているものの大半は小さく、具体的で、測定可能な作業です:遅いパスを見つけ、なぜオプティマイザがそれを見通せないのか突き止め、構造を露出させ、再度測定する。これらのコミットのどれも特殊なものではありません。必要な場所で適用されているだけです。
これらのいずれかを試してみたいなら:
brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-appソース:github.com/PerryTS/perry ― ドキュメント:docs.perryts.com ― チェンジログ:CHANGELOG.md
issue、リプロデューサ、そして十分に速くないベンチマーク:どんどん送ってください。このペースが機能するのは、バグ報告が1行のリプロデューサに変えられるほど具体的だからです。この記事のすべてのコミットには理由があって#Nが付いています。
― Ralph