The Go Blog

成長する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はコンパイラに似ています。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は現在、各宣言の型、相互参照のインデックス、各型のメソッドセットなど、各パッケージから計算された情報の要約を記録するために、ファイルベースのキャッシュストアを使用します。キャッシュはプロセス間で永続化されるため、ワークスペースでgoplsを2回目に起動すると、はるかに迅速にサービス提供の準備が整い、2つのgoplsインスタンスを実行すると、それらが相乗的に連携して動作することに気づくでしょう。

separate compilation

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

きめ細かい無効化

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

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

v0.12リリースでは、同様の剪定技術がgoplsに導入され、構文解析に基づいたより高速な剪定ヒューリスティックを実装するためにさらに一歩進んでいます。シンボル参照の単純化されたグラフをメモリに保持することで、goplsはパッケージ`c`での変更が参照の連鎖を通じてパッケージ`a`に影響を与える可能性があるかどうかを迅速に判断できます。

fine-grained invalidation

上記の例では、`a`から`c`への参照の連鎖がないため、`a`は`c`に間接的に依存しているにもかかわらず、`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でのプロファイルガイド最適化
ブログインデックス