本日、新しいRustベースのRelayコンパイラのプレビューをオープンソースとしてリリースできることを非常に嬉しく思います ( v13.0.0-rc.1
)! この新しいコンパイラはより高速で、新しいランタイム機能をサポートし、将来のさらなる成長のための強力な基盤を提供します。
このリリースに至るまで、Metaのコードベースは止まることなく成長を続けてきました。当社の規模では、コードベース内のすべてのクエリをコンパイルするのにかかる時間が、開発者の生産性を直接犠牲にして増加していました。JavaScriptベースのコンパイラを最適化するためのさまざまな戦略を試しましたが (後述)、パフォーマンスの向上を少しずつ積み重ねる能力は、コードベース内のクエリ数の増加に追いつきませんでした。
そこで、コンパイラをRustで書き直すことにしました。Rustを選んだのは、高速でメモリセーフであり、スレッド間で大きなデータ構造を安全に共有するのが容易だからです。開発は2020年初頭に始まり、コンパイラはその年の終わりに社内で出荷されました。ロールアウトはスムーズで、アプリケーション開発に中断はありませんでした。最初の社内ベンチマークでは、コンパイラは平均でほぼ5倍、P95ではほぼ7倍のパフォーマンスが向上していることが示されました。その後、コンパイラのパフォーマンスはさらに向上しています。
この記事では、Relayにコンパイラがある理由、新しいコンパイラで何を開放したいのか、その新機能、そしてRust言語を選択した理由について探ります。新しいコンパイラをすぐに使い始めたい場合は、コンパイラパッケージのREADMEまたはリリースノートを確認してください!
Relayにコンパイラがある理由
Relayには、安定性の保証を提供し、優れたランタイムパフォーマンスを実現するためにコンパイラがあります。
理由を理解するために、フレームワークを使用するワークフローを考えてみましょう。Relayでは、開発者はGraphQLと呼ばれる宣言型言語を使用して、各コンポーネントが必要とするデータを指定しますが、その取得方法は指定しません。次に、コンパイラはこれらのコンポーネントのデータ依存関係を、特定のページに必要なすべてのデータをフェッチするクエリにまとめ、Relayアプリケーションに高いレベルのパフォーマンスと安定性をもたらすアーティファクトを事前に計算します。
このワークフローでは、コンパイラは
- コンポーネントを個別に推論できるようにし、大規模なバグの発生を不可能にし、
- できる限り多くの作業をビルド時にシフトし、Relayを使用するアプリケーションのランタイムパフォーマンスを大幅に向上させます。
これらについて順番に詳しく調べてみましょう。
ローカル推論のサポート
Relayでは、コンポーネントはGraphQLフラグメントを使用して、独自のデータ要件のみを指定します。次に、コンパイラはこれらのコンポーネントのデータ依存関係を、特定のページに必要なすべてのデータをフェッチするクエリにまとめます。開発者は、そのデータ依存関係がより大きなクエリにどのように適合するかを気にすることなく、コンポーネントの記述に集中できます。
ただし、Relayはこのローカル推論をさらに一歩進めます。コンパイラは、Relayランタイムが特定のコンポーネントのフラグメントによって選択されたデータのみを読み取るために使用するファイルも生成します (これをデータマスキングと呼びます)。そのため、コンポーネントは、明示的に要求しなかったデータに (実際には、型レベルだけでなく!) アクセスすることはありません。
したがって、1つのコンポーネントのデータ依存関係を変更しても、別のコンポーネントが表示するデータに影響を与えることはありません。つまり、開発者はコンポーネントを個別に推論できます。これにより、Relayアプリは比類のないレベルの安定性を実現し、大規模なバグの発生を不可能にします。これは、Relayが同じコードベースに触れる多くの開発者に拡張できる理由の重要な部分です。
ランタイムパフォーマンスの向上
Relayは、コンパイラを利用してできる限り多くの作業をビルド時にシフトし、Relayアプリのパフォーマンスを向上させています。
Relayコンパイラはすべてのコンポーネントのデータ依存関係に関するグローバルな知識を持っているため、手作業で記述した場合と同じくらい優れており、一般的にはさらに優れたクエリを作成できます。これは、ランタイムでは非現実的なほど遅くなるような方法でクエリを最適化することで実現できます。たとえば、生成されたクエリからアクセスできないブランチをプルーニングし、クエリの同一セクションを平坦化します。
また、これらのクエリはビルド時に生成されるため、RelayアプリケーションはGraphQLフラグメントから抽象構文木 (AST) を生成したり、それらのASTを操作したり、ランタイム時にクエリテキストを生成したりすることはありません。代わりに、RelayコンパイラはアプリケーションのGraphQLフラグメントを、ネットワークデータをストアに書き込み、それを読み出す方法を記述した、事前に計算され最適化された命令 (プレーンなJavaScriptデータ構造として) に置き換えます。
この配置の追加の利点は、Relayアプリケーションバンドルにスキーマも、永続化されたクエリを使用する場合はGraphQLフラグメントの文字列表現も含まれていないことです。これにより、アプリケーションのサイズが削減され、ユーザーの帯域幅が節約され、アプリケーションのパフォーマンスが向上します。
実際、新しいコンパイラはさらに進んで、別の方法でユーザーの帯域幅を節約します。Relayは、ビルド時に各クエリテキストをアプリケーションのサーバーに通知し、一意のクエリIDを生成できます。これは、アプリケーションがユーザーの低速ネットワークを介して非常に長い可能性のあるクエリ文字列を送信する必要がないことを意味します。このような永続化されたクエリを使用する場合、ネットワークリクエストを行うためにネットワークを介して送信する必要があるのは、クエリIDとクエリ変数だけです!
新しいコンパイラで何ができるか
コンパイルされた言語は、動的言語と比較した場合、摩擦を生み出し、開発者の速度を低下させると認識されることがあります。ただし、Relayはコンパイラを利用して摩擦を減らし、一般的な開発者タスクを容易にします。たとえば、Relayは、ページネーションや新しい変数を使用したクエリの再フェッチなど、微妙に間違えやすい一般的なインタラクションのための高レベルのプリミティブを公開しています。
これらのインタラクションに共通しているのは、古いクエリから新しいクエリを生成する必要があるため、ボイラープレートと重複が発生することです。これは自動化の理想的なターゲットです。Relayはコンパイラのグローバルな知識を利用して、開発者が1つのディレクティブを追加し、1つの関数呼び出しを変更することでページネーションと再フェッチを有効にできるようにします。それだけです。
しかし、開発者がページネーションを簡単に追加できるようにすることは、氷山の一角に過ぎません。 コンパイラに対する私たちのビジョンは、機能の出荷とボイラープレートの回避のためのより高レベルのツールを提供し、開発者にリアルタイムのアシスタンスと洞察を提供し、GraphQLを操作するための他のツールで使用できる部品で構成されていることです。
このプロジェクトの主な目標は、書き直されたコンパイラのアーキテクチャが、今後数年間でこのビジョンを達成できるようにすることでした。
そして、まだそこまで到達していませんが、各基準で大きな成果を上げています。
たとえば、新しいコンパイラには、新しい@required
ディレクティブのサポートが付属しています。これは、指定されたサブフィールドが読み出されたときにnullの場合、親のリンクされたフィールドをnullにしたり、エラーをスローしたりします。これは些細な品質改善のように聞こえるかもしれませんが、コンポーネントのコードの半分がnullチェックである場合、@required
は非常に良く見え始めます!
@required
なしのコンポーネント
@required
付きのコンポーネント
次に、コンパイラは、入力時にフィールド名を自動補完し、カーソルを合わせたときに型情報を表示する、社内専用のVSCode拡張機能を強化します。まだ公開していませんが、いつか公開したいと思っています! 私たちの経験では、このVSCode拡張機能を使用すると、GraphQLデータの操作が非常に簡単かつ直感的になります。
最後に、新しいコンパイラは、他のGraphQLツールで再利用できる一連の独立したモジュールとして記述されました。これをRelayコンパイラプラットフォームと呼んでいます。社内では、これらのモジュールは、他のコード生成ツールや、さまざまなプラットフォーム向けの他のGraphQLクライアントで再利用されています。
コンパイラのパフォーマンス
これまで、なぜ Relay にコンパイラが必要なのか、そしてリライトによって何を実現したいのかについて議論してきました。しかし、2020 年にコンパイラの書き換えを決定した理由、つまりパフォーマンスについてはまだ触れていません。
コンパイラの書き換えを決定する以前は、コードベース内のすべてのクエリをコンパイルするのにかかる時間が、コードベースの成長とともに徐々に、しかし容赦なく遅くなっていました。パフォーマンスの向上を少しずつ積み重ねるだけでは、コードベース内のクエリ数の増加に対応できず、この苦境から抜け出す漸進的な方法はありませんでした。
JavaScript の限界に達する
以前のコンパイラは JavaScript で書かれていました。これは、いくつかの理由から自然な言語選択でした。チームが最も経験豊富な言語であり、Relay ランタイムが書かれていた言語(コンパイラとランタイム間でコードを共有できる)、そして GraphQL のリファレンス実装やモバイル GraphQL ツールが書かれていた言語でもありました。
コンパイラのパフォーマンスは、しばらくの間は妥当な範囲でした。Node/V8 には高度に最適化された JIT コンパイラとガベージコレクタが付属しており、注意深く使用すれば非常に高速になります(私たちはそうしていました)。しかし、コンパイル時間は増大していました。
私たちは、対応するためにいくつかの戦略を試しました。
- コンパイラをインクリメンタルにしました。変更に応じて、その変更の影響を受けた依存関係のみを再コンパイルするようにしました。
- どの変換が遅いか(特に flatten)を特定し、可能なアルゴリズムの改善(メモ化の追加など)を行いました。
- 公式の
graphql
npm パッケージの GraphQL スキーマ表現が、スキーマを表すために数ギガバイトのメモリを消費していたため、カスタムフォークに置き換えました。 - 最も頻繁に実行されるコードパスで、プロファイラ主導のマイクロ最適化を行いました。たとえば、オブジェクトを複製して変更するために
...
演算子を使用するのをやめ、代わりにオブジェクトのプロパティをコピーする際に明示的にリストアップするようにしました。これにより、オブジェクトの隠しクラスが保持され、コードをより JIT 最適化できるようになりました。 - コンパイラを、各ワーカーが単一のスキーマを処理する複数のワーカーに分散するように再構成しました。複数のスキーマを持つプロジェクトは Meta の外では一般的ではないため、これを行ったとしても、ほとんどのユーザーはシングルスレッドのコンパイラを使用することになります。
これらの最適化は、Relay の急速な社内採用に対応するには不十分でした。
最大の問題は、NodeJS が共有メモリを持つマルチスレッドプログラムをサポートしていないことでした。最善の方法は、メッセージを渡して通信する複数のワーカーを起動することです。
これは一部のシナリオではうまく機能します。たとえば、Jest はこのパターンを採用し、ファイルの変換テストを実行する際にすべてのコアを利用します。Jest はプロセス間で多くのデータやメモリを共有する必要がないため、これはうまく機能します。
一方、私たちのスキーマはメモリに複数のインスタンスを持つには大きすぎるため、JavaScript でスキーマごとに複数のスレッドを使用して Relay コンパイラを効率的に並列化する適切な方法は単純にありませんでした。
Rust を選択する
コンパイラの書き換えを決定した後、プロジェクトのニーズを満たす言語はどれかを確認するために、多くの言語を評価しました。高速でメモリ安全であり、同時実行をサポートする言語が必要でした。できれば、同時実行のバグは実行時ではなくビルド時に検出されるものが望ましいと考えていました。同時に、社内で十分にサポートされている言語が望ましいとも考えていました。これにより、選択肢はいくつかになりました。
- C++ はほとんどの基準を満たしていましたが、習得が難しいと感じました。また、コンパイラは私たちが期待するほど安全性に貢献してくれませんでした。
- Java もおそらく適切な選択肢でした。高速でマルチコアに対応していますが、低レベルの制御はあまりできません。
- OCaml はコンパイラの分野で実績のある選択肢ですが、マルチスレッド化が困難です。
- Rust は高速でメモリ安全であり、同時実行をサポートしています。スレッド間で大きなデータ構造を安全に共有するのが簡単です。Rust に対する一般的な期待、チーム内での過去の経験、Facebook の他のチームでの使用状況などから、これは私たちの明確な第一候補でした。
社内での展開
Rust は素晴らしい適合性であることがわかりました!主に JavaScript 開発者で構成されたチームは、Rust を簡単に採用できました。また、Rust の高度な型システムは、ビルド時に多くのエラーを検出するため、高い開発速度を維持するのに役立ちました。
私たちは 2020 年初頭に開発を開始し、その年末にコンパイラを社内展開しました。最初の社内ベンチマークでは、コンパイラのパフォーマンスが平均でほぼ 5 倍、P95 ではほぼ 7 倍向上していることが示されました。その後、コンパイラのパフォーマンスをさらに向上させています。
OSS でのリリース
本日、Relay v13 の一部として、新しいバージョンのコンパイラを公開できることを嬉しく思います。新しいコンパイラ機能には、以下が含まれます。
@required
ディレクティブ。- 共通フラグメントのインライン化を防ぎ、生成されるファイルのサイズを小さくするために使用できる
@no_inline
ディレクティブ。 - 競合する GraphQL フィールド、引数、およびディレクティブの検証
- TypeScript 型生成のサポート
- リモートクエリの永続化のサポート。
コンパイラの詳細については、README および リリースノート をご覧ください。
私たちは、開発者がグラフ上の派生値にアクセスできるようにする機能、ローカルデータを更新するためのより人間工学的な構文のサポートを追加する機能、VSCode 拡張機能を完全に充実させるなど、コンパイラ内で機能を開発し続けており、それらをすべてオープンソースでリリースしたいと考えています。このリリースを誇りに思っていますが、まだ多くのことが控えています!
謝辞
このブログ記事について素晴らしいフィードバックを提供してくれた Joe Savona、Lauren Tan、Jason Bonta、Jordan Eldredge に感謝します。コンパイラのバグに関連する問題を提出してくれた ch1ffa、robrichard、orta、sync に感謝します。TypeScript サポートを追加してくれた MaartenStaa に感謝します。@required
ディレクティブを有効にすることがいかに難しいかを指摘してくれた @andrewingram に感謝します。このディレクティブは現在、デフォルトで有効になっています。他にも多くの貢献者がいます。これはまさにコミュニティの努力の結晶でした!