拡大するGoエコシステムのためのgoplsのスケーリング
Scaling gopls for the growing Go ecosystem by Robert Findley and Alan Donovan
この夏の初め、GoチームはGo用の言語サーバーであるgoplsのv0.12をリリースしました。このバージョンでは、より大規模なコードベースにスケールできるようコアを書き直しています。これは1年に及ぶ取り組みの集大成であり、私たちはその成果を共有できることを嬉しく思うとともに、新しいアーキテクチャと、それがgoplsの将来にとって何を意味するのかについても少しお話ししたいと思います。
v0.12のリリース以降、私たちは新しい設計をさらに調整し、メモリ上に保持する状態をこれほど大きく減らしながらも、(自動補完や参照検索といった)インタラクティブなクエリの速度をv0.11と同等に保つことに注力してきました。もしまだ試していなければ、ぜひ試してみてください。
$ go install golang.org/x/tools/gopls@latest
ぜひこの簡単なアンケートを通じて、使ってみた感想をお聞かせください。
メモリ使用量と起動時間の削減
詳細に入る前に、まず結果を見てみましょう。以下のグラフは、GitHub上で特に人気の高いGoリポジトリ28個における、起動時間とメモリ使用量の変化を示しています。これらの計測は、ランダムに選んだGoファイルを開き、goplsが状態を完全に読み込み終えるのを待った上で行いました。また、最初のインデックス作成のコストは多くの編集セッションにわたって償却されると考えられるため、計測はそのファイルを2回目に開いたときのものを採用しています。
これらのリポジトリ全体では、削減量は平均でおよそ75%になりました。ただし、メモリ削減は線形ではありません。プロジェクトが大きくなるほど、メモリ使用量の相対的な減少幅も大きくなるのです。この点については以下で詳しく説明します。
goplsと進化するGoエコシステム
goplsは、特定の言語に依存しないエディタに対して、自動補完、フォーマット、相互参照、リファクタリングといったIDEライクな機能を提供します。2018年の誕生以来、goplsはguru、gorename、goimportsといった、それまでばらばらだった多くのコマンドラインツールを統合し、VS Code Go拡張機能のデフォルトバックエンドとなったほか、他の多くのエディタやLSPプラグインでも使われるようになりました。もしかすると、皆さんはそれと気づかないままエディタ経由でgoplsを使っているかもしれません。それこそが私たちの目標なのです!
5年前、goplsは状態を保持するセッションを維持するだけで性能を向上させていました。従来のコマンドラインツールは実行のたびに最初からやり直す必要がありましたが、goplsは中間結果を保存することでレイテンシを大幅に削減できました。しかし、その状態の保持にはコストが伴い、時間が経つにつれて、goplsのメモリ使用量の多さがほとんど我慢の限界だという声をユーザーから多く聞くようになりました。
一方でGoエコシステムは成長を続け、より大規模なリポジトリでより多くのコードが書かれるようになっていました。Goワークスペースによって開発者は複数のモジュールを同時に扱えるようになり、またコンテナ化された開発環境によって、言語サーバーはますますリソースが制約された環境に置かれるようになりました。コードベースは大きくなる一方で、開発環境は小さくなっていったのです。この状況に対応するには、goplsのスケールの仕方そのものを変える必要がありました。
goplsのコンパイラとしての原点に立ち返る
多くの点で、goplsはコンパイラに似ています。Goのソースファイルを読み込み、パースし、型チェックし、解析する必要があり、そのためにGo標準ライブラリやgolang.org/x/toolsモジュールが提供する、コンパイラの構成要素の多くを利用しています。これらの構成要素は「シンボリックプログラミング」という手法を用いています。動作中のコンパイラの中では、fmt.Println のような各関数を表す単一のオブジェクト、つまり「シンボル」が存在します。関数へのあらゆる参照は、そのシンボルへのポインタとして表現されます。2つの参照が同じシンボルを指しているかどうかを調べるのに、名前について考える必要はありません。ただポインタを比較すればよいのです。ポインタは文字列よりもはるかに小さく、ポインタの比較は非常に安価なので、シンボルはプログラムほど複雑な構造を表現するのに効率的な方法なのです。
リクエストに素早く応答するために、gopls v0.11はこれらすべてのシンボルをメモリ上に保持していました。まるでプログラム全体を一度にコンパイルしているかのような状態です。その結果、メモリのフットプリントは編集対象のソースコードに比例するだけでなく、それよりもはるかに大きくなっていました(たとえば、型付けされた構文木は通常、ソーステキストの30倍もの大きさになります!)。
分割コンパイル
1950年代の初期のコンパイラの設計者たちは、モノリシックなコンパイルの限界にすぐに気づきました。彼らが出した解決策は、プログラムをいくつかの単位に分割し、それぞれを個別にコンパイルするというものでした。分割コンパイルによって、小さな断片に分けて処理することで、メモリに収まりきらないプログラムをビルドできるようになります。Goにおいて、この単位はパッケージです。異なるパッケージのコンパイルを完全に切り離すことはできません。パッケージPをコンパイルする際、コンパイラはPがインポートするパッケージが提供するものについての情報を依然として必要とします。これを実現するために、GoのビルドシステムはPをコンパイルする前にPがインポートするすべてのパッケージをコンパイルし、Goコンパイラは各パッケージがエクスポートするAPIのコンパクトな要約を書き出します。Pがインポートするパッケージの要約は、P自身のコンパイルへの入力として提供されます。
gopls v0.12は、コンパイラが使っているのと同じパッケージ要約フォーマットを再利用することで、この分割コンパイルをgoplsにもたらします。考え方自体はシンプルですが、細部には巧妙な工夫があります。以前はプログラム全体を表すデータ構造を調べていた各アルゴリズムを、コンパイラがオブジェクトコードを出力するのと同じように、一度に1つのパッケージだけを対象として処理し、パッケージごとの結果をファイルに保存するように書き直しました。たとえば、以前は関数へのすべての参照を見つけるには、プログラムのデータ構造の中から特定のポインタ値が出現する箇所をすべて探すだけでよかったのですが、今では、goplsが各パッケージを処理する際に、ソースコード内の各識別子の位置とそれが指すシンボルの名前を対応付けるインデックスを構築して保存しなければなりません。クエリの実行時には、goplsはこれらのインデックスを読み込んで検索します。「実装を探す」といった他のグローバルなクエリも、同様の手法を使っています。
go build コマンドと同様に、goplsは各パッケージから計算された情報の要約を記録するために、ファイルベースのキャッシュストアを使うようになりました。ここには各宣言の型、相互参照のインデックス、各型のメソッドセットなどが含まれます。このキャッシュはプロセスをまたいで永続化されるため、ワークスペースで2回目にgoplsを起動すると、はるかに速く応答できる状態になっていることに気づくでしょう。また、2つのgoplsインスタンスを実行した場合も、それらは協調して相乗効果を発揮します。

この変更の結果、goplsのメモリ使用量は、開いているパッケージの数とその直接のインポートの数に比例するようになりました。これが、先ほどのグラフでスケーリングが線形以下(サブリニア)になっている理由です。リポジトリが大きくなるほど、開いている1つのパッケージから見えるプロジェクトの割合は小さくなっていくのです。
きめ細かな無効化
あるパッケージに変更を加えたとき、再コンパイルが必要なのは、そのパッケージを直接あるいは間接的にインポートしているパッケージだけです。この考え方は、1970年代のMake以来、あらゆるインクリメンタルビルドシステムの基礎であり、goplsも誕生当初からこの考え方を採用しています。実のところ、LSP対応エディタでのキー入力の一つひとつが、インクリメンタルビルドを起動しているのです! しかし、大規模なプロジェクトでは間接的な依存関係が積み重なり、こうしたインクリメンタルな再ビルドが遅くなりすぎてしまいます。実際には、この作業の多くは厳密には不要です。既存の関数内に文を1つ追加するといった大半の変更は、インポートの要約に影響を与えないからです。
あるファイルに小さな変更を加えた場合、そのパッケージは再コンパイルしなければなりませんが、その変更がインポートの要約に影響を与えないのであれば、他のどのパッケージもコンパイルする必要はありません。変更の影響は「刈り取られる(pruned)」わけです。インポートの要約に影響を与える変更の場合は、そのパッケージを直接インポートしているパッケージの再コンパイルが必要になりますが、そうした変更のほとんどは、それらのパッケージのインポートの要約には影響を与えません。その場合も影響は刈り取られ、間接的にインポートしているパッケージの再コンパイルは回避されます。この刈り取りのおかげで、低レベルのパッケージへの変更が、そのパッケージに間接的に依存するすべてのパッケージの再コンパイルを必要とすることは稀です。刈り取られたインクリメンタルな再ビルドによって、作業量は各変更の影響範囲に比例するようになります。これは新しい考え方ではありません。Vestaによって導入され、go buildでも使われています。
v0.12リリースでは、同様の刈り取り手法をgoplsに導入し、さらに一歩進んで、構文解析に基づくより高速な刈り取りのヒューリスティックを実装しました。シンボル参照を単純化したグラフをメモリ上に保持することで、goplsはパッケージ c への変更が、参照の連鎖を通じてパッケージ a に影響を与えうるかどうかを素早く判定できます。

上の例では、a から c への参照の連鎖が存在しないため、a は c に間接的に依存しているにもかかわらず、c への変更の影響を受けません。
新たに広がった可能性
これまでに達成した性能改善には満足していますが、それだけでなく、goplsがもはやメモリの制約を受けなくなったことで実現可能になったいくつかの機能についても、私たちは期待に胸を膨らませています。
1つ目は、堅牢な静的解析です。以前は、静的解析ドライバーはgoplsのパッケージのメモリ上の表現に対してしか動作できなかったため、依存関係を解析できませんでした。そうすると、あまりにも多くの追加コードを取り込んでしまうことになるからです。この制約がなくなったことで、gopls v0.12にはすべての依存関係を解析する新しい解析ドライバーを組み込むことができ、より高い精度を実現しました。たとえば、goplsは今や、fmt.Printf をラップした独自のユーザー定義関数においても、Printf の書式指定ミスに関する診断を報告できるようになりました。特筆すべきは、go vet は長年この精度を提供してきましたが、goplsはこれまで各編集のたびにリアルタイムでそれを行えませんでした。それが今ではできるようになったのです。
2つ目は、よりシンプルなワークスペース設定とビルドタグの扱いの改善です。この2つの機能はいずれも、マシン上のどのGoファイルを開いても、goplsが「正しいことをしてくれる」ようにするものですが、どちらも今回の最適化がなければ実現できませんでした。たとえば、ビルド構成が1つ増えるたびに、メモリのフットプリントも掛け算式に膨れ上がってしまうからです!
試してみよう
スケーラビリティとパフォーマンスの改善に加えて、私たちは数多くの報告されたバグや、移行の過程でテストカバレッジを改善している間に発見した未報告のバグの多くも修正しました。
最新のgoplsをインストールするには、次のコマンドを実行します。
$ go install golang.org/x/tools/gopls@latest
ぜひ試してみて、アンケートにもご協力ください。もしバグに遭遇したら、報告していただければ、私たちが修正します。
By Robert Findley and Alan Donovan