💋

Goのビルド時間の内訳について

2021/04/29に公開

Go1.14の古いlinkerについての記事です

$ go version
go version go1.14 darwin/amd64

リンカによるリンクはGoバイナリをビルドする際に最後に行われる工程です。ビルド時間の大半はリンクに費やされることが多く、ビルド時間の律速となりうる可能性が高いです。

実際にデバッグツールでビルドにかかる時間を分析してみましょう。

例として、Genjiを使ってデータベースを作成するというビルドに大幅な時間がかかるプログラムを用意しました。

genji

後で詳細を述べますが、このパッケージはC言語のコードをGoでラップしたzstdというパッケージに依存しています。

ビルドしてみると筆者の環境で大体3,4秒かかります。

"Hello world"のプログラムが1秒未満でできることを考えるとだいぶ時間がかかっていると言えるでしょう。このビルドで何が起きているかみていきましょう。

ビルド

Goプログラムのビルドはいくつかのコマンドをつなぎ合わせたものであり、-xフラグをつけることで一連のコマンドログを出力させることができます。

下のコマンドログは、上のプログラムをビルドしてみた際のコマンドログを一部抜粋したものです。

build

ログは4段階に分かれています

  • 最終生成物のバイナリを作るのに必要な中間ファイルを入れておく一時フォルダを作っています
  • コンパイラは2つのファイルからアーカイブを作っています。 -packはアーカイブで出力させるためのフラグです
  • リンカが実行ファイルを作成しています。ここで使われているのはclangでデフォルトの外部リンカです。リンクの際に使うリンカはGoで実装された内部リンカとclanggccのような外部リンカのどちらかから選択できます。
  • 生成されたバイナリはカレンとディレクトリに配置され一時フォルダは削除されます

ログだけではどこがビルド時間のボトルネックになっているかはわかりにくいので時間の内訳を可視化してみました。

build timeline

コンパイルはキャッシュが効いて並列に実行されるので非常に高速な一方でリンクは非常に遅くビルド時間の大半を占めていることがわかります。

リンカについて

リンカの目標は実行ファイルを作成することです。

実行ファイルは、Goの標準ライブラリを含むパッケージに依存関係を持つコードを組み合わせたものです。

リンカの行う仕事は次のようになっています。

  • コンパイルされたパッケージや依存ライブラリを読み込んで関数や変数のシンボルを集めます。
  • 使われていない関数などのデットコードを取り除きます。これはコンパイル時にも行われますがコンパイルはパッケージ単位でコンパイルするので取り除かれないものもあります。リンカはパッケージ全体の情報を持っているので他のパッケージから使われていないデットコードを取り除くことができます。
  • DWARFの生成。リンカは実行ファイルに含めるデバッグ情報を作成する役割を担います。
  • PC-value tableや内部の関数テーブル、シンボルテーブルなどのメタデータの整理
  • 再配置。パッケージ単位でコンパイルを行うのでコンパイラは他のパッケージの関数がどこにあるのかわかりません。リンカは全てのパッケージの情報を集めて各関数呼び出しに適切にアドレスを割り当てていく役割を担います。

次はリンクにかかる時間の内訳をみてみましょう。

external link timeline

ロード時間はパッケージの数によって変動します。どのパッケージがロードされたかは、-xフラグを付けた時に出力されるldobjをたどっていけばわかります。次に例を示します。

ldobj

最後に行われるのが実行ファイルの生成です。これは今回Goでかかれた内部リンカでなく外部リンカを使用しているときのみ、実行されます。

このステップではビルドに必要なパッケージだけをリンクします。

external linker

内部リンカ

内部リンカは外部リンカより高速ですが、リンクに使用できない時もあります。

外部リンカを使う必要があるのは次のような場合です。

  • net, os/user, runtime/cgoまたはcgoを使っている時
  • プログラムがアーカイブやプラグイン、pieのようにexecutableモードでビルドされなかった時
  • ビルドターゲットがandroid, darwin/arm, darwin/arm64のとき

上の例では、依存パッケージがzstdからC言語のコードをロードしているため外部リンカを使う必要があります。

幸運なことに、今回の場合は、環境変数CGO_ENABLEDを設定することで内部リンカを使ってビルドができます。

$ CGO_ENABLED=0 go build 
1.48s user 0.64s system 154% cpu 1.374 total

ビルド時間が3倍ほど早くなりました。

今回のリンク時間の内訳です。

internal linker0

外部リンカを使っていた時はなかったものがあります。

DWARFセクションの圧縮です。DWARFセクションは外部リンカのときもありますが、圧縮は内部リンカを使う時のみ起こります。

実はDWARFセクションの圧縮の際に、ある程度再配置が行われているため、その後の再配置にかかる時間が外部リンカのときより短くなっています。

cgoを無効にしたのでC言語で書かれたzstdパッケージはもはや実行中に利用できません。しかしzstdパッケージを使わないようにすればビルドは成功します。

zstdパッケージを使っているGoプログラムをcgoを無効にした状態でビルドしようとすると次のようなエラーが発生します。

$ go build 
github.com/DataDog/zstd: build constraints exclude all Go files in /go/src/github.com/DataDog/zstd

また、-wフラグをつければ、DWARFセクションを削除することができ、リンク時間をさらに短縮することができます。

デバッグ情報がいらない場合に使える最適化手法で、この場合のリンク時間の内訳は次のようになります。

remove dwarf

最後に

リンカはGoツールチェーンで非常に重要な役割を担いますが、あまり積極的に改善がされてきませんでした。

Building a better Go linkerという文書でリンカに対する大幅な改善提案がなされ、実際に改良されたリンカが次のバージョン(1.15)で導入されることになっています。

References

Discussion