blog

ソフトウェア開発|Goのインライン最適化

この記事では、Go コンパイラーがどのようにインライン化を実装しているか、そしてこの最適化が Go コードにどのような影響を与えるかについて説明します。...

Oct 13, 2025 · 7 min. read
シェア

この記事では、Go コンパイラーがインライン化をどのように実装しているか、またこの最適化が Go コードにどのような影響を与えるかについて説明します。

この記事では、 の事実上の標準 Go コンパイラに焦点を当てます。説明する概念は gccgo や llgo などの他の Go コンパイラにも広く適用できますが、実装や効果は異なるかもしれません。

インライン接続とは?

これは、短い関数が呼び出される場所での展開です。コンピュータの歴史の初期には、この最適化はプログラマーが手作業で実装していました。現在では、インライン化はコンパイル時に自動的に実装される基本的な最適化プロセスのステップの1つになっています。

なぜインライン接続が重要なのですか?

これには2つの理由があります。ひとつは、関数呼び出し自体のオーバーヘッドがなくなること。もうひとつは、コンパイラが他の最適化戦略をより効率的に実行できるようにするためです。

関数呼び出しのオーバーヘッド

どのような言語でも、関数を呼び出すには消費します。引数をレジスタやスタックに整列させたり、結果を返すときの逆プロセスもすべてオーバーヘッドになります。関数コールを導入すると、プログラムカウンタが命令ストリームのあるポイントから別のポイントにジャンプすることになり、パイプラインの遅延につながります。通常、関数内部では、関数実行のために新しいスタック・フレームを準備する必要があり、プリアンブルと同様に、呼び出し元に戻る際にスタック・フレーム・スペースを解放する必要があります。

Goの関数呼び出しでは、動的なスタック増加をサポートするために余分なリソースを消費します。関数に入るとき、goroutine が利用可能なスタック・スペースの量と、関数が必要とするスペースの量が比較されます。利用可能な領域が異なる場合、前処理は、データを新しいより大きな領域にコピーしてスタック領域を拡大するロジックにジャンプします。このコピーが完了すると、ランタイムは元の関数エントリにジャンプして戻り、スタック空間のチェックを実行します。このようにして、ゴルーチンは少量のスタック・スペースから開始し、必要なときに より大きなスペースを要求することができます。

ゴルーチンのスタックは幾何級数的に増大するので、このチェックが失敗することはめったにありません。このように、最近のプロセッサの分岐予測ユニットは、チェックが必ず成功すると仮定することで、スタック空間チェックの消費を隠すことができます。プロセッサがスタック空間チェックを誤って予測し、投機的実行で行っていた演算を放棄しなければならなくなった場合、パイプラインの遅れは、ゴルーチンのスタック空間が実行されているときに必要な演算数を増やすために消費されるリソースよりも少ないコストです。

最近のプロセッサは、予測実行技術により、各関数呼び出しにおける一般的な要素と囲碁固有の要素のオーバーヘッドを最適化できますが、これらのオーバーヘッドを完全に除去することはできないため、必要な作業を実行する各関数呼び出し中にパフォーマンスが低下します。関数呼び出し自体のオーバーヘッドは固定で、小さな関数の呼び出しは大きな関数よりもコストがかかります。

したがって、これらのオーバーヘッドをなくすには、関数呼び出しそのものをなくす必要があります。Goのコンパイラは、特定の条件下で、関数呼び出しを関数の内容に置き換えることで、これを行います。この処理は、関数呼び出しで関数本体を展開するため、こう呼ばれます。

最適化の機会の向上

クリフ・クリック博士は、インライニングは最新のコンパイラが行う最適化であり、定数伝搬やデッドコード除去のように、コンパイラの基本的な最適化であると説明しています。実際、インライン化によってコンパイラーはより深い部分まで見ることができ、呼び出される特定の関数のコンテキストを見て、さらに簡略化できるロジックを見つけたり、完全に削除したりすることができます。インライニングは再帰的に実行できるので、このような最適化の判断は、個々の関数のコンテキストだけでなく、関数呼び出しの連鎖全体にわたって行うことができます。

インラインの実際

次の例は、インライン化の影響を示しています:

  1. package main
  2. import "testing"
  3. //go:noinline
  4. func max(a, b int) int {
  5. if a > b {
  6. return a
  7. return b
  8. var Result int
  9. func BenchmarkMax(b *testing.B) {
  10. var r int
  11. for i := 0; i < b.N; i++ {
  12. r = max(-1, i)
  13. Result = r

このベンチマークを実行すると、次のような結果が得られます:

  1. % go test -bench=.
  2. BenchmarkMax-4 2.24 ns/op
  1. % go test -bench=.
  2. BenchmarkMax-4 0.514 ns/op

2.24ナノ秒から0.51ナノ秒へ、つまり78%の改善です benchstat

  1. % benchstat {old,new}.txt
  2. name old time/op new time/op delta
  3. Max-4 2.21ns ± 1% 0.49ns ± 6% -77.96% (p=0.000 n=18+19)

このブーストはどこから来たのですか?

まず、関数呼び出しとそれに関連する前処理を削除することが大きな要因です。呼び出し時にmax関数の本体を展開することで、プロセッサが実行する命令数を減らし、いくつかの分岐をなくします。

コンパイラはBenchmarkMaxを最適化したため、max関数の中身を見ることができ、より多くのブーストを行うことができます。max がインライン化されると、BenchmarkMax はコンパイラに次のように表示されます:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. if -1 > i {
  5. r = -1
  6. } else {
  7. Result = r

ベンチマークを再度実行して、手動でインライン化したバージョンとコンパイラでインライン化したバージョンの動作を確認します:

  1. % benchstat {old,new}.txt
  2. name old time/op new time/op delta
  3. Max-4 2.21ns ± 1% 0.48ns ± 3% -78.14% (p=0.000 n=18+18)

コンパイラはBenchmarkMaxのインライン化maxの結果を見ることができ、以前は実行できなかった最適化を実行できるようになりました。例えば、コンパイラーはiの初期値が 、であることに気づき、自己インクリメント演算しか行わないので、iに対するすべての比較はiが負でないと仮定することができます。したがって、条件式 -1 > i が真になることはありません。

-1 > i 決して真でないことが証明されたので、コンパイラーはコードを次のように単純化できます:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. if false {
  5. r = -1
  6. } else {
  7. Result = r

また、分岐は定数なので、コンパイラーは次のようにすることで、どこにも行かない分岐を削除することができます:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. Result = r

このように、インライン化とインライン化によるアンロックという最適化プロセスによって、コンパイラーは式 % go test -bench=. r = iに単純化します。

インライン化の限界

この記事では、私がインライン化と呼んでいる、関数を呼び出す関数の呼び出しスタックの一番下に関数を展開する行為について説明します。インライン化は再帰的な処理で、関数を呼び出す関数Aにインライン化した後、コンパイラはその結果のコードをAの呼び出し元にインライン化します。例えば、次のようなコードです:

  1. func BenchmarkMaxMaxMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. r = max(max(-1, i), max(0, i))
  5. Result = r

この例のコードも、コンパイラが上記のコードを繰り返しインライン化し、r = i式に減らすことができるため、同様に高速に実行されます。

  1. Goでは、メソッドはあらかじめ定義された正式なパラメータとアクセプタを持つ関数です。メソッドがインターフェイスを介して呼び出されないと仮定すると、消費されない関数を呼び出すと、メソッドを導入するのと同じ量が消費されます。

  2. Go 1.14以前では、スタック・チェックの前処理もSTWのゴミ・コレクターによって使用され、スタック・スペースを0に設定することで、すべてのアクティブなゴルーチンを次の関数呼び出し時に強制的にランタイム状態に切り替えていました。このメカニズムは最近、ランタイムがゴルーチンが関数呼び出しを行うのを待たずにサスペンドできる新しいメカニズムに置き換えられました。このメカニズムは、ランタイムがゴルーチンが関数呼び出しを行うのを待たずにサスペンドできる新しいメカニズムに置き換えられました。

  3. このことは、 % go test -bench=. 有無による結果の違いを比較することで、ご自身で確認することができます。

  4. BenchmarkMax-4 2.24 ns/op 確認できます。

を経由して

Read next