Goブログ

拡大するGoエコシステムに対応するためのgoplsのスケーリング

Robert FindleyとAlan Donovan
2023年9月8日

今年の初め、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回目に開いたときに取得しています。

Relative savings
in memory and startup time

これらのリポジトリ全体で、平均して約75%の削減が見られますが、メモリの削減は非線形です。プロジェクトが大きくなるほど、メモリ使用量の相対的な減少も大きくなります。これについては、以下で詳しく説明します。

goplsと進化するGoエコシステム

goplsは、自動補完、フォーマット、相互参照、リファクタリングなどのIDEのような機能を備えた、言語に依存しないエディターを提供します。2018年の開始以来、goplsはgurugorenamegoimportsなどの多くの異なるコマンドラインツールを統合し、VS Code Go拡張機能および他の多くのエディターやLSPプラグインのデフォルトのバックエンドになりました。おそらく、あなたは知らず知らずのうちに、エディターを通してgoplsを使用していたかもしれません。それが目標です!

5年前、goplsはステートフルセッションを維持するだけでパフォーマンスが向上しました。古いコマンドラインツールは実行するたびに最初からやり直す必要がありましたが、goplsは中間結果を保存してレイテンシを大幅に短縮することができました。しかし、そのすべての状態にはコストがかかり、時間の経過とともに、goplsのメモリ使用量が多いことはほとんど許容できないというユーザーからの声がますます多くなりました。

一方、Goエコシステムは成長しており、より大きなリポジトリでより多くのコードが記述されるようになりました。Goワークスペースにより、開発者は複数のモジュールを同時に操作できるようになり、コンテナ化された開発では、言語サーバーがますますリソース制約のある環境に置かれるようになりました。コードベースはますます大きくなり、開発環境はますます小さくなりました。goplsが対応し続けるためには、goplsのスケーリング方法を変更する必要がありました。

goplsのコンパイラのルーツの再考

多くの点で、goplsはコンパイラに似ています。Goソースファイルを読み取り、解析し、型チェックし、分析する必要があります。これには、Go標準ライブラリおよびgolang.org/x/toolsモジュールによって提供されるコンパイラの構成要素の多くを使用します。これらの構成要素は、「シンボリックプログラミング」の技法を使用します。実行中のコンパイラでは、fmt.Printlnなどの各関数を表す単一のオブジェクトまたは「シンボル」があります。関数へのすべての参照は、そのシンボルへのポインターとして表されます。2つの参照が同じシンボルについて話しているかどうかをテストするために、名前について考える必要はありません。ポインターを比較するだけです。ポインターは文字列よりもはるかに小さく、ポインターの比較は非常に安価であるため、シンボルはプログラムのように複雑な構造を表すための効率的な方法です。

要求に迅速に応答するために、gopls v0.11は、goplsがプログラム全体を一度にコンパイルしているかのように、これらのすべてのシンボルをメモリに保持しました。その結果、メモリフットプリントは、編集中のソースコードに比例し、それよりもはるかに大きくなりました(たとえば、型付き構文木は通常、ソーステキストの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インスタンスを実行すると、それらは相乗的に連携します。

separate compilation

この変更の結果、goplsのメモリ使用量は、開いているパッケージとその直接インポートの数に比例します。これが、上のグラフで準線形スケーリングが観察される理由です。リポジトリが大きくなるにつれて、1つの開いているパッケージによって観測されるプロジェクトの割合が小さくなります。

きめ細かい無効化

1つのパッケージを変更すると、そのパッケージを直接的または間接的にインポートするパッケージを再コンパイルするだけで済みます。このアイデアは、1970年代のMake以来、すべてのインクリメンタルビルドシステムの基礎であり、goplsはその開始以来、それを使用しています。実際、LSP対応のエディターでのすべてのキーストロークがインクリメンタルビルドを開始します!ただし、大規模なプロジェクトでは、間接的な依存関係が加算され、これらのインクリメンタルな再構築が遅すぎます。既存の関数内でのステートメントの追加など、ほとんどの変更はインポートの概要に影響を与えないため、この作業の多くは厳密には必要ではないことがわかりました。

1つのファイルで小さな変更を加えると、そのパッケージを再コンパイルする必要がありますが、変更がインポートの概要に影響を与えない場合は、他のパッケージをコンパイルする必要はありません。変更の影響は「プルーニング」されます。インポートの概要に影響を与える変更には、そのパッケージを直接インポートするパッケージの再コンパイルが必要ですが、そのような変更のほとんどはそれらのパッケージのインポートの概要には影響を与えないため、その場合でも影響はプルーニングされ、間接的なインポーターの再コンパイルが回避されます。このプルーニングのおかげで、低レベルのパッケージの変更によって、そのパッケージに間接的に依存するすべてのパッケージの再コンパイルが必要になることはめったにありません。プルーニングされたインクリメンタルな再構築により、作業量が各変更のスコープに比例するようになります。これは新しいアイデアではありません。Vestaによって導入され、go buildでも使用されました。

v0.12リリースでは、goplsに同様のプルーニング手法が導入され、さらに一歩進んで、構文解析に基づくより高速なプルーニングヒューリスティックが実装されています。goplsは、メモリ内のシンボル参照の簡略化されたグラフを保持することで、パッケージcの変更が参照の連鎖を介してパッケージaに影響を与える可能性を迅速に判断できます。

fine-grained invalidation

上記の例では、aからcへの参照の連鎖はないため、間接的に依存しているにもかかわらず、aはcの変更の影響を受けません。

新たな可能性

私たちは達成したパフォーマンスの向上に満足していますが、goplsがメモリによって制約されなくなった今、実現可能なgoplsのいくつかの機能についても期待しています。

1つ目は、堅牢な静的解析です。以前は、静的解析ドライバはgoplsのパッケージのインメモリ表現に対して動作する必要があり、依存関係を解析できませんでした。そうすると、あまりにも多くの追加コードを取り込んでしまうからです。その要件がなくなったことで、gopls v0.12に新しい解析ドライバを含めることができ、すべての依存関係を解析できるようになり、精度が向上しました。たとえば、goplsは、fmt.Printfをラップしたユーザー定義のラッパー内であっても、Printfのフォーマットミスに関する診断を報告するようになりました。特に、go vetは何年もこのレベルの精度を提供していましたが、goplsは編集のたびにリアルタイムでこれを行うことができませんでした。それが今では可能になりました。

2つ目は、よりシンプルなワークスペース構成ビルドタグの処理改善です。これらの2つの機能はどちらも、マシン上の任意のGoファイルを開いたときにgoplsが「正しいことを行う」ようにすることに相当しますが、(たとえば)各ビルド構成がメモリフットプリントを増大させるため、最適化作業なしではどちらも実現不可能でした。

ぜひお試しください!

スケーラビリティとパフォーマンスの向上に加えて、移行中にテストカバレッジを改善している間に発見した多数報告されたバグや、報告されていないバグも多く修正しました。

最新のgoplsをインストールするには

$ go install golang.org/x/tools/gopls@latest

ぜひお試しいただき、アンケートにご協力ください。また、バグが発生した場合は、報告してください。修正いたします。

次の記事: GoでのWASIサポート
前の記事: Go 1.21でのプロファイルガイド最適化
ブログインデックス