2.Goパフォーマンスの最適化——コンパイル最適化
10602 ワード
テキストリンク:https://github.com/sxs2473/go...
この文書では、Creative Commons Attribution-ShareAlike 4.0 Internationalプロトコルを使用してライセンスを取得します.
コンパイル最適化
脱出分析 インライン デッドコード消去
Goコンパイラは2007年頃からPlan 9コンパイラツールチェーンの1つとして機能している.当時のコンパイラはAhoとUllmanのDragon Bookによく似ていた.
2015年、当時のGo 1.5コンパイラはCから機械的にGoに翻訳された.
1年後、Go 1.7はSSA技術に基づく新しいコンパイラバックエンドを導入し、以前のPlan 9スタイルのコードに取って代わった.この新しいバックエンドは、汎用およびアーキテクチャ固有の最適化に多くの可能性を提供します.
我々が議論する最初の最適化は脱出分析である.
脱出分析を説明するために、まずGo specでスタックやスタックに言及していないことを思い出してみましょう.Go言語にはゴミ回収があるだけで、どのように実現されているのかは説明されていません.
Go specに従うGoインプリメンテーションは、各割り当て操作をスタック上で実行することができる.これはごみ回収器に大きな圧力をもたらすが、これは絶対的な間違いである--長年、gccgoの脱出分析に対する支持は非常に限られているため、このようなことが有効とされている.
しかしながら、goroutineのスタックは、局所変数を格納する安価な場所として存在する.スタック上でゴミ回収を実行する必要はありません.したがって、スタックにメモリを割り当てるのもより安全で効率的です.
いくつかの言語では、
Goでは、関数呼び出しのライフサイクルを超える値がある場合、コンパイラは自動的にスタックに移動します.私たちはこの現象を「この値が山に逃げた」と呼んでいます.
この例では、
これは初期のGoからありました.最適化というより、自動正確性特性です.Goでスタックに割り当てられた変数のアドレスを返すことができません.
同時にコンパイラも逆のことをすることができます.割り当てられるものを見つけてスタックに移動します.
次の例を見てみましょう.
証明しろ!
脱出分析に関するコンパイラの決定を印刷するには、
8行目は、コンパイラが
22行目は、
識別
とにかく、22行目を心配しないでください.これは私たちの議論にとって重要ではありません.
この例は私たちがシミュレーションしたものです.本物のコードではなく、ただの例です.
Goでは、関数呼び出しに一定のオーバーヘッドがある.スタックとプリエンプトチェック.
ハードウェアブランチ予測器は、いくつかの機能を改善したが、機能サイズとクロックサイクルについては、依然としてコストである.
インラインは、これらのコストを回避するための古典的な最適化方法です.
インラインはリーフ関数にのみ有効であり、リーフ関数は他の関数を呼び出さない.その理由は次のとおりです.関数が多くの作業をしている場合は、前のシーケンスのオーバーヘッドは無視できます. 他方、小関数は、比較的少ない有用な作業に固定的なオーバーヘッドを支払う.これらは、最も利益があるため、インラインターゲットの機能です.
もう1つの原因は、深刻なインラインがスタック情報を追跡しにくくすることです.
コンパイラは2行の情報を印刷しました.最初の3行目、 をインラインできることを示しています.は次に、
注意:
何もしない、通常のインライン
なぜ
何が起こったのかを理解するために、コンパイラが
Before:
After:
分岐の結果が分かった以上、結果の内容も分かります.これを分岐消去と言います.
分岐が除去され,結果は常に
さらにブランチ除去を再利用し、
最後は
ブランチ除去はデッドコード除去と呼ばれる最適化である.実際には、静的証明を使用して、コードのセグメントが永遠に達成できないことを示します.通常、デッドコードと呼ばれます.したがって、最終的なバイナリファイルでコンパイル、最適化、または発行する必要はありません.
デッドコード除去は,ループと分岐によって生成されるコードの数を減らすためにインラインと共に動作することを見出し,これらのループと分岐は到達不可能であることを実証した.
これを利用して、高価なデバッグを実現し、隠すことができます.
コンストラクションタグと組み合わせると、これは非常に役に立つかもしれません.
Using//+build to switch between debug and release builds How to use conditional compilation with the go build tool
コンパイラIDは次のとおりです.
次のコンパイラ機能の操作を検討します. を印刷する.
注意:If you find that subsequent runs of
この文書では、Creative Commons Attribution-ShareAlike 4.0 Internationalプロトコルを使用してライセンスを取得します.
コンパイル最適化
このセクションでは、Goコンパイラが実行する3つの重要な最適化について説明します.
Goコンパイラの歴史
Goコンパイラは2007年頃からPlan 9コンパイラツールチェーンの1つとして機能している.当時のコンパイラはAhoとUllmanのDragon Bookによく似ていた.
2015年、当時のGo 1.5コンパイラはCから機械的にGoに翻訳された.
1年後、Go 1.7はSSA技術に基づく新しいコンパイラバックエンドを導入し、以前のPlan 9スタイルのコードに取って代わった.この新しいバックエンドは、汎用およびアーキテクチャ固有の最適化に多くの可能性を提供します.
エスケープ分析
我々が議論する最初の最適化は脱出分析である.
脱出分析を説明するために、まずGo specでスタックやスタックに言及していないことを思い出してみましょう.Go言語にはゴミ回収があるだけで、どのように実現されているのかは説明されていません.
Go specに従うGoインプリメンテーションは、各割り当て操作をスタック上で実行することができる.これはごみ回収器に大きな圧力をもたらすが、これは絶対的な間違いである--長年、gccgoの脱出分析に対する支持は非常に限られているため、このようなことが有効とされている.
しかしながら、goroutineのスタックは、局所変数を格納する安価な場所として存在する.スタック上でゴミ回収を実行する必要はありません.したがって、スタックにメモリを割り当てるのもより安全で効率的です.
いくつかの言語では、
C
およびC++
のように、スタックにメモリを割り当てるかスタックにメモリを割り当てるかはプログラマによって手動で決定される.スタック割り当てはmalloc
およびfree
を使用し、スタック割り当てはalloca
を通過する.このメカニズムを誤って使用すると、メモリエラーの原因になります.Goでは、関数呼び出しのライフサイクルを超える値がある場合、コンパイラは自動的にスタックに移動します.私たちはこの現象を「この値が山に逃げた」と呼んでいます.
type Foo struct {
a, b, c, d int
}
func NewFoo() *Foo {
return &Foo{a: 3, b: 1, c: 4, d: 7}
}
この例では、
NewFoo
関数で割り当てられたFoo
がスタックに移動されるので、NewFoo
が戻った後もFoo
が有効である.これは初期のGoからありました.最適化というより、自動正確性特性です.Goでスタックに割り当てられた変数のアドレスを返すことができません.
同時にコンパイラも逆のことをすることができます.割り当てられるものを見つけてスタックに移動します.
脱出分析-例1
次の例を見てみましょう.
// Sum 0-100
func Sum() int {
const count = 100
numbers := make([]int, count)
for i := range numbers {
numbers[i] = i + 1
}
var sum int
for _, i := range numbers {
sum += i
}
return sum
}
Sum
は、0~100のints
の数字を加算し、結果を返します.numbers
スライスはSum
関数の内部でのみ使用されるため、コンパイラはスタックではなくスタックに100個の整数を格納します.numbers
が戻ったときに自動的に放出されるため、Sum
をゴミ回収する必要もありません.逃走分析を調べる
証明しろ!
脱出分析に関するコンパイラの決定を印刷するには、
-m
フラグを使用します.% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: main ... argument does not escape
8行目は、コンパイラが
make([]int, 100)
の結果を正しく推定してもスタックに逃げないことを示しています.22行目は、
answer
が可変関数であるため、fmt.Println
がスタックに脱出したことを示している.可変パラメータ関数のパラメータは、この例では[]interface{}
であるため、answer
が呼び出されることによって参照されるため、インタフェース値としてfmt.Println
に割り当てられる.Go 1.6(可能)から、ゴミ収集器はインタフェースを通じて伝達されるすべての値がポインタである必要があります.コンパイラは次のように見えます.var answer = Sum()
fmt.Println([]interface{&answer}...)
識別
-gcflags="-m -m"
を使用して、この点を決定することができます.次のように返されます.examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: from ... argument (arg to ...) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: from *(... argument) (indirection) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: from ... argument (passed to call[argument content escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main ... argument does not escape
とにかく、22行目を心配しないでください.これは私たちの議論にとって重要ではありません.
脱出分析-例2
この例は私たちがシミュレーションしたものです.本物のコードではなく、ただの例です.
package main
import "fmt"
type Point struct{ X, Y int }
const Width = 640
const Height = 480
func Center(p *Point) {
p.X = Width / 2
p.Y = Height / 2
}
func NewPoint() {
p := new(Point)
Center(p)
fmt.Println(p.X, p.Y)
}
NewPoint
は、*Point
ポインタ値p
を作成します.p
をCenter
関数に渡し、この関数は点を画面の中心の位置に移動します.最後に、p.X
とp.Y
の値を印刷します.% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:10:6: can inline Center
examples/esc/center.go:17:8: inlining call to Center
examples/esc/center.go:10:13: Center p does not escape
examples/esc/center.go:18:15: p.X escapes to heap
examples/esc/center.go:18:20: p.Y escapes to heap
examples/esc/center.go:16:10: NewPoint new(Point) does not escape
examples/esc/center.go:18:13: NewPoint ... argument does not escape
# command-line-arguments
p
はnew
を使用して割り当てられていますが、Center
がインラインされているため、p
の参照がないとCenter
関数に逸脱します.インライン
Goでは、関数呼び出しに一定のオーバーヘッドがある.スタックとプリエンプトチェック.
ハードウェアブランチ予測器は、いくつかの機能を改善したが、機能サイズとクロックサイクルについては、依然としてコストである.
インラインは、これらのコストを回避するための古典的な最適化方法です.
インラインはリーフ関数にのみ有効であり、リーフ関数は他の関数を呼び出さない.その理由は次のとおりです.
もう1つの原因は、深刻なインラインがスタック情報を追跡しにくくすることです.
インライン-例1
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
-gcflags = -m
識別子を再び使用して、コンパイラ最適化決定を表示します.% go build -gcflags=-m examples/max/max.go
# command-line-arguments
examples/max/max.go:3:6: can inline Max
examples/max/max.go:12:8: inlining call to Max
コンパイラは2行の情報を印刷しました.
Max
の声明は、Max
の本体が12行目の呼び出し者に組み込まれていることを示した.インラインはどんなものですか。
max.go
をコンパイルし、最適化バージョンのF()
がどのようになったかを見てみましょう.% go build -gcflags=-S examples/max/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=1 args=0x0 locals=0x0
0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10) TEXT "".F(SB), NOSPLIT, $0-0
0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 () RET
0x0000 c3
Max
がここに組み込まれると、これがFの本体です.この関数は何もしていません.私はスクリーンに無駄な文字がたくさんあることを知っていますが、私を信じて、唯一起こったのはRET
です.実際にはF
はfunc F() {
return
}
注意:
-S
の出力は、バイナリファイルに入る最終マシンコードではありません.リンクは、最後のリンクフェーズでいくつかの処理を行います.FUNCDATA
およびPCDATA
のような行は、リンク時に他の位置に移動するゴミ収集器のメタデータである.-S
の出力を読み込んでいる場合は、FUNCDATA
行とPCDATA
行を無視します.最終バイナリの一部ではありませんインライン・レベルの調整
-gcflags=-l
IDを使用して、インライン・レベルを調整します.困惑することに、1つの-l
を渡すと、インラインが無効になり、2つ以上がより急進的な設定でインラインが有効になります.-gcflags=-l
、インラインを無効にします.-gcflags='-l -l'
インラインレベル2は、より積極的で、より速く、より大きなバイナリファイルを作成する可能性があります.-gcflags='-l -l -l'
インラインレベル3は、再び急進的で、バイナリファイルはもっと大きく、もっと速いかもしれませんが、バグがあるかもしれません.-gcflags=-l=4
(4個の-l
)は、Go 1.11において実験的な中間スタックインライン最適化をサポートする.デッドコード消去
なぜ
a
とb
は定数が重要なのですか?何が起こったのかを理解するために、コンパイラが
Max
をF
に接続したときに何を見たのかを見てみましょう.コンパイラから簡単にこれを得ることはできませんが、直接手動で完成します.Before:
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
After:
func F() {
const a, b = 100, 20
var result int
if a > b {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
a
とb
は定数であるため、コンパイラはコンパイル時に分岐が永遠に偽物ではないことを証明することができる.100
は常に20
より大きい.したがって、F
をさらに最適化することができます.func F() {
const a, b = 100, 20
var result int
if true {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
分岐の結果が分かった以上、結果の内容も分かります.これを分岐消去と言います.
func F() {
const a, b = 100, 20
const result = a
if result == b {
panic(b)
}
}
分岐が除去され,結果は常に
a
に等しく,a
が定数であるため,結果は定数であることが分かった.コンパイラはこの証明書を2番目のブランチに適用します.func F() {
const a, b = 100, 20
const result = a
if false {
panic(b)
}
}
さらにブランチ除去を再利用し、
F
の最終形態はこのように減少した.func F() {
const a, b = 100, 20
const result = a
}
最後は
func F() {
}
デッドコード消去(続き)
ブランチ除去はデッドコード除去と呼ばれる最適化である.実際には、静的証明を使用して、コードのセグメントが永遠に達成できないことを示します.通常、デッドコードと呼ばれます.したがって、最終的なバイナリファイルでコンパイル、最適化、または発行する必要はありません.
デッドコード除去は,ループと分岐によって生成されるコードの数を減らすためにインラインと共に動作することを見出し,これらのループと分岐は到達不可能であることを実証した.
これを利用して、高価なデバッグを実現し、隠すことができます.
const debug = false
コンストラクションタグと組み合わせると、これは非常に役に立つかもしれません.
さらに読む
コンパイラID練習
コンパイラIDは次のとおりです.
go build -gcflags=$FLAGS
次のコンパイラ機能の操作を検討します.
-S
コンパイル中のパケットのアセンブリコード-l
インライン動作を制御する.-l
はインラインを禁止し、-l -l
は-l
を増加させる(より多くの-l
はコンパイラのコードインラインに対する強度を増加させる).試験コンパイル時間、プログラムサイズ、実行時間の違い.-m
は、内部接続、脱出分析などの最適化決定の印刷を制御する.-m
は、コンパイラの考え方の詳細を印刷します.-l -N
すべての最適化を無効にします.注意:If you find that subsequent runs of
go build ...
produce no output,delete the ./max
binary in your working directory.