はじめに
こんにちは。kubell の tomoikey と申します。そろそろ新卒入社から3年が経ちます。
今回 Go の GraphQL ライブラリとして広く使われている gqlgen (GitHub Star 10.6k) にパフォーマンス改善の PR を送り、無事マージされました🎉
https://github.com/99designs/gqlgen/pull/3874
この記事ではどのような問題を発見してどうやって解決したのかを解説します。
発見した問題
gqlgen には GraphQL クエリで要求されたフィールドを収集する CollectFields という関数があります。
これは「どのフィールドが要求されているか」を計算するために使われる関数です。
問題は配列型のフィールドを JSON にマーシャリングする際に配列の各要素に対して CollectFields が毎回呼び出されていたことです。
query { users { # 10,000件のユーザー field1 field2 # ... 20フィールド } }
このようなクエリの場合、同じ SelectionSet (field1, field2, ...) に対して CollectFields が 10,000回 呼び出されていました。でも結果は毎回同じです。これは明らかに無駄な計算ですよね。
最初のアプローチ
最初に送った PR (#3868) ではテンプレートファイルを修正してループの外側で CollectFields を1回だけ呼び出すアプローチを取りました。
しかしメンテナーから以下のフィードバックをいただきました。
Any code we have (or add) to the template code is 10x as difficult to maintain as regular library code. (テンプレートコードへの追加は通常のライブラリコードの10倍メンテナンスが大変です)
gqlgen はコード生成ツールなのでテンプレートファイルから Go コードを生成します。テンプレートへの変更は生成されるコードのあらゆるパターンに影響を与えるためバグが入り込みやすく、テストも難しくなります。
そこでテンプレートファイルを一切変更せずにライブラリ側でキャッシュを導入するという新しいアプローチで PR を作り直しました。
解決策
新しいアプローチでは CollectFields 関数の内部にキャッシュ機構を組み込みました。これなら呼び出し側のテンプレートを一切変更することなく同じ入力に対しては計算結果をキャッシュして再利用するようになります。
キャッシュキーの設計
CollectFields の入力は以下の2つです。
SelectionSet- クエリで要求されたフィールドの集合
satisfies- 型の制約 (インターフェースや Union 型の解決に使用)
これらを組み合わせてキャッシュキーを作る必要があります。ここで工夫したのがスライスのポインタをキーの一部として使うという点です。
type collectFieldsCacheKey struct { selectionPtr uintptr // SelectionSet のポインタ selectionLen int // SelectionSet の長さ satisfiesHash uint64 // satisfies 配列のハッシュ値 }
SelectionSet は抽象構文木 (AST) の一部でありリクエスト中は不変です。なのでスライスのポインタを使ってキーを生成することで高速にキャッシュの検索ができます。
unsafe から reflect への変更
当初は unsafe.Pointer を使ってスライスのポインタを取得していましたが、コードレビューで「将来的な問題を避けるため unsafe を使わない方が安全」というフィードバックをいただき reflect.ValueOf().Pointer() に変更しました。
パフォーマンスへの影響を計測したところほぼ差がなかったため、より安全な実装を採用しました。
ベンチマーク結果
実際のクエリ処理を含むベンチマーク
クエリのパースからレスポンス生成までを含む実際の利用シーンに近いベンチマーク結果です。
| 配列サイズ | 実行時間 | メモリ使用量 |
|---|---|---|
| 1件 | +0.9% | +1.7% |
| 10件 | -1.3% | -7.9% |
| 100件 | -8.1% | -11.5% |
| 1,000件 | -7.0% | -12.2% |
| 10,000件 | -7.2% | -11.5% |
配列サイズが大きくなるほど効果が出て、100件以上では 7〜8% の実行時間削減 と 11〜12% のメモリ削減 を達成しました。
「たった 7%?」と思うかもしれませんがこれはクエリ全体の処理時間に対する改善です。GraphQL サーバーの処理にはパース、バリデーション、リゾルバ実行、シリアライズなど多くのステップがあります。その中の一部である CollectFields だけでこれだけ改善できたのは大きな成果だと考えています。
CollectFields 単体のベンチマーク
改善対象である CollectFields だけを計測すると効果はより顕著です。
| 配列サイズ | 実行時間 | メモリ使用量 |
|---|---|---|
| 1件 | +20.7% | +95.5% |
| 10件 | -82.7% | -78.0% |
| 100件 | -93.5% | -95.4% |
| 1,000件 | -94.6% | -97.1% |
| 10,000件 | -94.8% | -97.3% |
1件の場合はキャッシュのオーバーヘッドで若干遅くなりますが、ごく微量な差なので無視できます。 そして 10件以上になると劇的な改善が見られます。
技術的なポイント
なぜこの問題が見過ごされていたか
gqlgen は非常に成熟したライブラリですがこの問題が見過ごされていた理由として考えられるのは以下です。
小規模なデータでは問題が顕在化しない
数十~百件のレコード数だとおそらく人間がその遅さを感知できないでしょう。ですが数千件レベルになってくると次第にその差が開いてきます。
Chatwork のような大量のチャットルームや大量のユーザーを扱う GraphQL サーバーを考える場合、このような実装は顕著な問題になります。
実運用の中でも特異なケースで発生するパフォーマンスイシューはライブラリ開発時に見抜くのは難しいだろうなという気持ちになりました。 普段の開発から特異ケースを意識せねばならないな...と自分への戒めにもなりました。
テンプレートで生成されるコードの問題
テンプレートコードをテストするのは非常に難易度が高いでしょう。
テンプレートファイルには制御構文が存在するとはいえ、やっていることはリッチな文字列操作にすぎないため、何らかのテストで品質を維持する難易度は高いでしょう。
また動的に生成される都合上、出力パターンが無数に存在します。これもまた今回の問題を引き起こした原因の一つと言っても良いと考えています。
スライスポインタをキーに使う是非
今回の実装ではスライスのポインタをキャッシュキーの一部として使っています。これは以下の前提に基づいています。
- AST はリクエスト処理中は不変である
- キャッシュはリクエストスコープである
- リクエスト終了時に破棄される
この前提が崩れると問題が起きる可能性がありますが、gqlgen の現在のアーキテクチャではこれらの前提は成り立っています。
OSS コントリビューションの学び
今回の PR ではメンテナーや他のコントリビューターから多くのフィードバックをいただきました。
- 変更の影響範囲を最小限にする
- 同じ結果を得られるなら影響範囲の小さい方法を選ぶ
- 改めてプログラミングの基礎の大切さを学びました...
- パフォーマンスより保守性が優先されることがある
- 「速いコード」より「安心して使えるコード」が重視される
最初の PR がそのままマージされなかったときは少し残念でしたが、フィードバックを受けて設計を見直した結果よりシンプルで保守しやすい実装になりました。OSS では「動くコード」だけでなく「長期的にメンテナンスしやすいコード」が求められるということを改めて実感しました。
まとめ
- gqlgen の
CollectFields関数にキャッシュを導入して配列フィールドの処理を高速化した - 実際のクエリ処理全体で 実行時間 7〜8% 削減、メモリ 11〜12% 削減 を達成した
- 大規模な GraphQL アプリケーションでは特に効果を発揮した
gqlgen を使っている方はぜひ最新バージョンにアップデートしてみてください!
kubell ではエンジニアを募集しています!
少しでも興味を持っていただけた方は、ぜひ覗いてみてください!
www.kubell.com