2.Goパフォーマンスの最適化——コンパイル最適化


テキストリンク:https://github.com/sxs2473/go...
この文書では、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を作成します.pCenter関数に渡し、この関数は点を画面の中心の位置に移動します.最後に、p.Xp.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
    pnewを使用して割り当てられていますが、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行の情報を印刷しました.
  • 最初の3行目、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において実験的な中間スタックインライン最適化をサポートする.

  • デッドコード消去


    なぜabは定数が重要なのですか?
    何が起こったのかを理解するために、コンパイラがMaxFに接続したときに何を見たのかを見てみましょう.コンパイラから簡単にこれを得ることはできませんが、直接手動で完成します.
    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) 
            }
    }
    abは定数であるため、コンパイラはコンパイル時に分岐が永遠に偽物ではないことを証明することができる.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 

    コンストラクションタグと組み合わせると、これは非常に役に立つかもしれません.

    さらに読む

  • Using//+build to switch between debug and release builds
  • How to use conditional compilation with the go build tool

  • コンパイラ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.