メモリプロファイリングの言語力学

プレリュード



これは、Goのポインター、スタック、ヒープ、エスケープ分析、および値/ポインターのセマンティクスのメカニズムと設計に関する洞察を提供するシリーズの4つの記事の3番目です。この投稿はメモリプロファイリングに関するものです。



記事のサイクルの内容の表:



  1. スタックとポインターの言語力学翻訳
  2. エスケープ分析に関する言語力学翻訳
  3. メモリプロファイリングに関する言語力学
  4. データとセマンティクスに関する設計哲学


このコードのデモを見るには、このビデオをご覧ください:

DGopherCon Singapore(2017)-Escape Analysis



前書き



前回の投稿では、ゴルーチンスタックの値を分割する例を使用してエスケープ分析の基本を教えました。ヒープ値につながる可能性のある他のシナリオは示していません。これを支援するために、予期しない方法で割り当てを行うプログラムをデバッグします。



プログラム



ioパッケージについてもっと知りたいと思ったので、自分でちょっとした作業を思いつきました。バイトのストリームが与えられた場合、文字列elvisを見つけて、大文字の文字列Elvisに置き換えることができる関数を記述します。私たちは王について話しているので、彼の名前は常に大文字にする必要があります。



ソリューションへのリンクは次のとおり

です。play.golang.org / p / n_SzF4Cer4ベンチマークへのリンクは次のとおりです。play.golang.org / p / TnXrxJVfLV



リストには、このタスクを実行する2つの異なる関数が示されています。この投稿では、ioパッケージを使用するalgOne関数に焦点を当てます。algTwo関数を使用して、メモリとプロセッサのプロファイルを自分で試してください。



これが使用する入力とalgOne関数からの期待される出力です。



リスト1



Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis

Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis


以下は、algOne関数のリストです。



リスト2



 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := io.ReadFull(input, buf[:end]); err != nil {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


この機能がどれだけうまく機能し、ヒープにどれだけの圧力がかかるかを知りたいです。調べるために、ベンチマークを実行してみましょう。



ベンチマーク



algOne関数を呼び出してデータストリームの処理を実行するベンチマークを作成しました。



リスト3



15 func BenchmarkAlgorithmOne(b *testing.B) {
16     var output bytes.Buffer
17     in := assembleInputStream()
18     find := []byte("elvis")
19     repl := []byte("Elvis")
20
21     b.ResetTimer()
22
23     for i := 0; i < b.N; i++ {
24         output.Reset()
25         algOne(in, find, repl, &output)
26     }
27 }


このベンチマークは、-bench、-benchtime、および-benchmemスイッチでgotestを使用して実行できます。



リスト4



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        2000000          2522 ns/op       117 B/op            2 allocs/op


ベンチマークを実行した後、algOne関数が2つの値を割り当て、操作ごとに合計117バイトのコストがかかることがわかります。これは素晴らしいことですが、関数内のどのコード行がこれらの割り当てを引き起こしているかを知る必要があります。調べるには、このテストのプロファイリングデータを生成する必要があります。



プロファイリング



プロファイリングデータを生成するには、ベンチマークを再度実行しますが、今回は-memprofileスイッチを使用してメモリプロファイルをクエリします。



リスト5



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op


ベンチマークの完了後、テストツールは2つの新しいファイルを作成しました。



リスト6



~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r--  1 bill  staff      209 May 22 18:11 mem.out       (NEW)
-rwxr-xr-x  1 bill  staff  2847600 May 22 18:10 memcpu.test   (NEW)
-rw-r--r--  1 bill  staff     4761 May 22 18:01 stream.go
-rw-r--r--  1 bill  staff      880 May 22 14:49 stream_test.go


ソースコードは、stream.goファイルのalgOne関数のmemcpuフォルダーと、stream_test.goファイルのベンチマーク関数にあります。作成された2つの新しいファイルには、mem.outとmemcpu.testという名前が付けられます。mem.outファイルにはプロファイルデータが含まれ、フォルダにちなんで名付けられたmemcpu.testファイルには、プロファイルデータを表示するときにシンボルにアクセスするために必要なテストバイナリが含まれています。



プロファイルデータとテストバイナリを配置したら、pprofツールを実行してプロファイルデータを調べることができます。



リスト7



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _


メモリをプロファイリングして、ぶら下がっている果物を探すときは、デフォルトの-inuse_spaceオプションの代わりに-alloc_spaceオプションを使用できます。これにより、プロファイルを取得したときにメモリ内にあるかどうかに関係なく、各割り当てが発生している場所が表示されます。



入力ボックス(pprof)で、listコマンドを使用してalgOne関数を確認できます。このコマンドは、表示する関数を見つけるための引数として正規式を取ります。



リスト8



(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
 335.03MB   335.03MB (flat, cum)   100% of Total
        .          .     78:
        .          .     79:// algOne is one way to solve the problem.
        .          .     80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
        .          .     81:
        .          .     82: // Use a bytes Buffer to provide a stream to process.
 318.53MB   318.53MB     83: input := bytes.NewBuffer(data)
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
  16.50MB    16.50MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])
(pprof) _


このプロファイルに基づいて、inputとbufがヒープに割り当てられていることがわかりました。 inputはポインター変数であるため、プロファイルは実際に、inputポインターが指すbytes.Buffer値が割り当てられていることを示しています。それでは、最初に入力割り当てに焦点を当て、それが発生する理由を理解しましょう。



bytes.NewBufferへの呼び出しがbytes.Buffer値を共有し、それが呼び出しスタックを作成するため、割り当てが行われていると想定する場合があります。ただし、フラット列(pprof出力の最初の列)に値が存在することは、algOne関数が値を分割して積み上げるため、値が割り当てられていることを示しています。



フラット列が関数内の割り当てを表していることはわかっているので、algOneを呼び出すBenchmark関数に対してlistコマンドが何を表示するかを見てください。



リスト9



(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
        0   335.03MB (flat, cum)   100% of Total
        .          .     18: find := []byte("elvis")
        .          .     19: repl := []byte("Elvis")
        .          .     20:
        .          .     21: b.ResetTimer()
        .          .     22:
        .   335.03MB     23: for i := 0; i < b.N; i++ {
        .          .     24:       output.Reset()
        .          .     25:       algOne(in, find, repl, &output)
        .          .     26: }
        .          .     27:}
        .          .     28:
(pprof) _


cum列(2番目の列)には値しかないため、Benchmarkが直接何も割り当てていないことがわかります。すべての割り当ては、このループ内で実行される関数呼び出しから行われます。リストへのこれら2つの呼び出しからのすべての割り当て番号がすべて同じであることがわかります。



bytes.Buffer値が割り当てられる理由はまだわかりません。ここで、gobuildコマンドの-gcflags "-m-m"スイッチが役立ちます。プロファイラーはヒープに移動されている値のみを通知できますが、ビルドはその理由を通知できます。



コンパイラレポート



コード内のエスケープ分析のためにどのような決定を下すかをコンパイラーに尋ねましょう。



リスト10



$ go build -gcflags "-m -m"


このコマンドは多くの出力を生成します。stream.goはこのコードを含むファイルの名前であり、行83にはbytes.buffer値の構成が含まれているため、stream.go:83が持つ出力を検索する必要があります。検索すると、6行が見つかります。



リスト11



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }

./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


stream.go:83を検索して見つけた最初の行に関心があります。



リスト12



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }


これは、bytes.Buffer値がコールスタックにプッシュされたときに消えなかったことを確認します。これは、bytes.NewBuffer呼び出しが発生せず、関数内のコードがインラインであったために発生しました。



問題のコード行は次のとおりです。



リスト13



83     input := bytes.NewBuffer(data)


コンパイラがbytes.NewBuffer関数呼び出しをインライン化することを決定したため、私が書いたコードは次のように変換されます。



リスト14



input := &bytes.Buffer{buf: data}


これは、algOne関数がbytes.Buffer値を直接作成することを意味します。では、問題は、値がalgOneスタックフレームから飛び出す原因は何でしょうか。この答えは、レポートで見つけた他の5行にあります。



リスト15



./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


これらの行は、コードの93行目でヒープエスケープが発生していることを示しています。入力変数はインターフェース値に割り当てられます。



インターフェース



コードでインターフェイス値の割り当てを行ったことをまったく覚えていません。ただし、93行目を見ると、何が起こっているのかが明らかになります。



リスト16



 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }


io.ReadFullを呼び出すと、インターフェイスの割り当てが呼び出されます。io.ReadFull関数の定義を見ると、インターフェイスタイプを介して入力変数を受け入れることがわかります。



リスト17



type Reader interface {
      Read(p []byte) (n int, err error)
}

func ReadFull(r Reader, buf []byte) (n int, err error) {
      return ReadAtLeast(r, buf, len(buf))
}


bytes.Bufferアドレスをコールスタックに渡し、それをReaderインターフェイスの値に格納すると、エスケープが発生するように見えます。インターフェースを使用するコストが高いことがわかりました:割り当てと間接。したがって、インターフェイスがコードをどのように改善するかが正確に明確でない場合は、おそらくそれを使用する必要はありません。コードでのインターフェイスの使用をテストするために従ういくつかのガイドラインを次に示します。



次の場合にインターフェイスを使用します。



  • APIユーザーは、実装の詳細を提供する必要があります。
  • APIには、内部でサポートする必要のあるいくつかの実装があります。
  • 変更される可能性があり、分離が必要なAPIの部分が特定されています。


インターフェイスを使用しないでください:



  • インターフェイスを使用するため。
  • アルゴリズムを一般化する。
  • ユーザーが独自のインターフェースを宣言できる場合。


これで、自分自身に問いかけることができます。このアルゴリズムには本当にio.ReadFull関数が必要ですか?bytes.Bufferタイプには、使用できる一連のメソッドがあるため、答えはノーです。関数が所有する値に対してメソッドを使用すると、割り当てを防ぐことができます。



コードを変更してioパッケージを削除し、入力変数で直接Readメソッドを使用してみましょう。



このコード変更により、ioパッケージをインポートする必要がなくなるため、すべての行番号を同じに保つために、空の識別子を使用してioパッケージをインポートします。これにより、インポートがリストに保持されます。



リスト18



 12 import (
 13     "bytes"
 14     "fmt"
 15     _ "io"
 16 )

 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := input.Read(buf[:end]); err != nil || n < end {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := input.Read(buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := input.Read(buf[:end]); err != nil || n < end {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


このコード変更をベンチマークすると、bytes.Buffer値の割り当てがなくなっていることがわかります。



リスト19



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op


また、パフォーマンスが約29%向上しています。時間は2570ns / opから1814ns / opに変更されました。これが解決されたので、bufに補助スライスを割り当てることに集中できます。作成したばかりの新しいプロファイルデータにプロファイラーを再度使用すると、残りの割り当ての原因を正確に特定できます。



リスト20



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
     11MB       11MB (flat, cum)   100% of Total
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
     11MB       11MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := input.Read(buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])


残りの割り当ては、補助スライスを作成するための89行目だけです。



スタックフレーム



bufの補助スライスに割り当てが行われる理由を知りたいのですが。-gcflags "-m -m"オプションを使用してビルドを再度実行し、stream.go:89を検索してみましょう。



リスト21



$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89:   from make([]byte, size) (too large for stack) at ./stream.go:89


レポートには、補助アレイが「スタックに対して大きすぎる」と記載されています。このメッセージは誤解を招く恐れがあります。重要なのは、配列が大きすぎるということではなく、コンパイラーがコンパイル時に補助配列のサイズを知らないということです。



コンパイラがコンパイル時に値のサイズを知っている場合にのみ、値をスタックにプッシュできます。これは、各関数の各スタックフレームのサイズがコンパイル時に計算されるためです。コンパイラが値のサイズを知らない場合は、ヒープになります。



これを示すために、スライスサイズを一時的に5にハードコーディングして、ベンチマークを再度実行してみましょう。



リスト22



89     buf := make([]byte, 5)


今回はこれ以上の割り当てはありません。



リスト23



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


コンパイラレポートをもう一度見ると、何もヒープに移動されていないことがわかります。



リスト24



$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape


明らかに、スライスサイズをハードコーディングすることはできないため、このアルゴリズムには1つの割り当てを使用する必要があります。



割り当てとパフォーマンス



各リファクタリングで達成したパフォーマンスの向上を比較します。



リスト25



Before any optimization
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op

Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op

Removing the backing array allocation
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


bytes.Bufferの割り当てを削除したため、パフォーマンスが約29%向上し、すべての割り当てを削除した後、約33%の加速が得られました。割り当ては、アプリケーションのパフォーマンスが低下する可能性がある場所です。



結論



Goには、コンパイラがエスケープ分析に関して行う決定を理解するのに役立ついくつかのすばらしいツールがあります。この情報に基づいて、コードをリファクタリングして、ヒープ上にあるべきではない値をスタックに保持するのに役立てることができます。割り当てがゼロのプログラムを作成するべきではありませんが、可能な限り割り当てを最小限に抑えるように努める必要があります。



何を実行すべきかを推測したくないので、コードを書くときにパフォーマンスを最優先にしないでください。コードを記述して最適化し、最優先タスクのパフォーマンスを達成します。これは、主に整合性、読みやすさ、および単純さに焦点を当てることを意味します。動作するプログラムができたら、それが十分に速いかどうかを判断します。そうでない場合は、言語が提供するツールを使用して、パフォーマンスの問題を見つけて修正します。



All Articles