本文へスキップ

新しいRelayコンパイラの紹介

·12分間の読書

本日、新しい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でスキーマごとに1つ以上のスレッドを使用して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に感謝します。これは現在デフォルトで有効になっています。貢献してくれた多くの人々がいます—これは真のコミュニティの努力でした!