Mekanika bahasa profil memori

Pendahuluan



Ini adalah artikel ketiga dari empat artikel dalam seri yang akan memberikan wawasan tentang mekanisme dan desain pointer, tumpukan, heaps, analisis escape, dan semantik nilai / pointer di Go. Posting ini tentang profil memori.



Daftar isi siklus artikel:



  1. Mekanika Bahasa Pada Tumpukan Dan Penunjuk ( terjemahan )
  2. Mekanika Bahasa Pada Analisis Escape ( terjemahan )
  3. Mekanika Bahasa Pada Profil Memori
  4. Filosofi Desain Pada Data Dan Semantik


Tonton video ini untuk melihat demo kode ini:

DGopherCon Singapore (2017) - Escape Analysis



pengantar



Di posting sebelumnya, saya mengajarkan dasar-dasar analisis pelarian menggunakan contoh yang membagi nilai pada tumpukan goroutine. Saya belum menunjukkan kepada Anda skenario lain yang dapat mengarah ke nilai heap. Untuk membantu Anda dalam hal ini, saya akan men-debug program yang membuat alokasi dengan cara yang tidak terduga.



Program



Saya ingin mempelajari lebih lanjut tentang paket io, jadi saya datang dengan sedikit tugas untuk diri saya sendiri. Dengan adanya aliran byte, tulis fungsi yang dapat menemukan string elvis dan menggantinya dengan string Elvis yang dikapitalisasi. Kita berbicara tentang seorang raja, jadi namanya harus selalu menggunakan huruf besar.



Berikut ini tautan ke solusi: play.golang.org/p/n_SzF4Cer4

Berikut ini tautan ke benchmark: play.golang.org/p/TnXrxJVfLV



Daftar menunjukkan dua fungsi berbeda yang menyelesaikan tugas ini. Posting ini akan fokus pada fungsi algOne karena menggunakan paket io. Gunakan fungsi algTwo untuk bereksperimen dengan memori dan profil prosesor sendiri.



Berikut adalah masukan yang akan kita gunakan dan keluaran yang diharapkan dari fungsi algOne.



Daftar 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


Di bawah ini adalah daftar fungsi algOne.



Daftar 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 }


Saya ingin tahu seberapa baik fungsi ini bekerja dan seberapa besar tekanannya pada heap. Untuk mengetahuinya, mari kita jalankan benchmark.



Pembandingan



Saya menulis benchmark yang memanggil fungsi algOne untuk melakukan pemrosesan pada aliran data.



Daftar 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 }


Kita dapat menjalankan benchmark ini menggunakan go test dengan switch -bench, -benchtime dan -benchmem.



Daftar 4



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


Setelah menjalankan benchmark, kita melihat bahwa fungsi algOne mengalokasikan 2 nilai dengan total biaya 117 byte per operasi. Ini bagus, tetapi kita perlu mengetahui baris kode mana dalam fungsi yang menyebabkan alokasi ini. Untuk mengetahuinya, kita perlu membuat data profil untuk pengujian ini.



Profiling



Untuk menghasilkan data profil, jalankan benchmark lagi, tetapi kali ini kita akan menanyakan profil memori menggunakan sakelar -memprofile.



Daftar 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


Setelah menyelesaikan benchmark, alat pengujian membuat dua file baru.



Daftar 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


Kode sumber ada di folder memcpu dalam fungsi algOne dari file stream.go dan fungsi benchmark di file stream_test.go. Dua file baru yang dibuat diberi nama mem.out dan memcpu.test. File mem.out berisi data profil, dan file memcpu.test, dinamai menurut folder, berisi biner percobaan yang kita perlukan untuk mengakses simbol saat melihat data profil.



Dengan data profil dan biner uji di tempat, kita dapat menjalankan alat pprof untuk memeriksa data profil.



Daftar 7



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


Saat membuat profil memori dan mencari buah yang tergantung rendah, Anda dapat menggunakan opsi -alloc_space alih-alih default -inuse_space. Ini akan menunjukkan kepada Anda di mana setiap alokasi terjadi, apakah itu dalam memori atau tidak ketika Anda mengambil profil.



Di kotak input (pprof) kita dapat memeriksa fungsi algOne dengan perintah daftar. Perintah ini mengambil ekspresi reguler sebagai argumen untuk menemukan fungsi yang ingin Anda lihat.



Daftar 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) _


Berdasarkan profil ini, kita sekarang tahu bahwa input dan buf dialokasikan di heap. Karena input adalah variabel pointer, profil benar-benar mengatakan bahwa nilai bytes. Buffer yang ditunjukkan oleh input dialokasikan. Jadi mari kita fokus pada alokasi input terlebih dahulu dan pahami mengapa itu terjadi.



Kita mungkin berasumsi bahwa alokasi terjadi karena panggilan ke byte.NewBuffer berbagi nilai byte.Buffer yang membuat tumpukan panggilan. Namun, keberadaan nilai di kolom datar (kolom pertama di keluaran pprof) memberi tahu saya bahwa nilai dialokasikan karena fungsi algOne membaginya dengan cara yang membuatnya menumpuk.



Saya tahu kolom datar mewakili alokasi dalam fungsi, jadi lihatlah apa yang diperlihatkan oleh perintah daftar untuk fungsi Benchmark yang memanggil algOne.



Daftar 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) _


Karena hanya ada nilai di kolom cum (kolom kedua), ini memberi tahu saya bahwa Tolok Ukur tidak mengalokasikan apa pun secara langsung. Semua alokasi berasal dari panggilan fungsi yang dijalankan dalam loop ini. Anda dapat melihat bahwa semua nomor alokasi dari dua panggilan ke daftar ini semuanya sama.



Kami masih tidak tahu mengapa nilai bytes.Buffer dialokasikan. Di sinilah sakelar -gcflags "-m -m" dari perintah go build berguna. Profiler hanya dapat memberi tahu Anda nilai mana yang dipindahkan ke heap, sedangkan build dapat memberi tahu Anda alasannya.



Pelaporan penyusun



Mari kita tanyakan kepada compiler keputusan apa yang dibuatnya untuk escape analysis dalam kode.



Daftar 10



$ go build -gcflags "-m -m"


Perintah ini menghasilkan banyak sekali keluaran. Kita hanya perlu mencari keluaran untuk apa pun yang dimiliki stream.go: 83, karena stream.go adalah nama file yang berisi kode ini, dan baris 83 berisi konstruksi nilai bytes.buffer. Setelah mencari, kami menemukan 6 baris.



Daftar 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


kami tertarik pada baris pertama yang kami temukan dengan mencari stream.go: 83.



Listing 12



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


Ini mengonfirmasi bahwa nilai bytes.Buffer tidak hilang saat didorong ke tumpukan panggilan. Hal ini terjadi karena panggilan bytes.NewBuffer tidak pernah terjadi, kode di dalam fungsi tersebut sebaris.



Berikut adalah baris kode yang dimaksud:



Daftar 13



83     input := bytes.NewBuffer(data)


Karena kompilator memutuskan untuk menyebariskan pemanggilan fungsi bytes.NewBuffer, kode yang saya tulis mengubahnya menjadi ini:



Listing 14



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


Ini berarti bahwa fungsi algOne membuat nilai bytes.Buffer secara langsung. Jadi sekarang pertanyaannya adalah, apa yang menyebabkan nilai keluar dari bingkai tumpukan algOne? Jawaban ini ada di 5 baris lain yang kami temukan di laporan.



Daftar 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


Baris ini memberitahu kita bahwa pelolosan heap terjadi di baris 93 kode. Variabel masukan ditetapkan ke nilai antarmuka.



Antarmuka



Saya tidak ingat melakukan tugas nilai antarmuka sama sekali dalam kode. Namun, jika Anda melihat baris 93, menjadi jelas apa yang sedang terjadi.



Daftar 16



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


Panggilan io.ReadFull memanggil penetapan antarmuka. Jika Anda melihat definisi dari fungsi io.ReadFull, Anda dapat melihat bahwa ia menerima variabel input melalui tipe antarmuka.



Daftar 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))
}


Sepertinya meneruskan alamat bytes.Buffer ke bawah tumpukan panggilan dan menyimpannya dalam nilai antarmuka Reader menyebabkan pelolosan. Kita sekarang tahu bahwa biaya menggunakan antarmuka itu tinggi: alokasi dan tipuan. Jadi jika tidak jelas bagaimana sebuah antarmuka membuat kode Anda lebih baik, Anda mungkin tidak perlu menggunakannya. Berikut adalah beberapa pedoman yang saya ikuti untuk menguji penggunaan antarmuka dalam kode saya.



Gunakan antarmuka saat:



  • Pengguna API harus memberikan detail implementasi.
  • API memiliki beberapa implementasi yang harus mereka dukung secara internal.
  • Bagian dari API telah diidentifikasi yang mungkin berubah dan memerlukan pemisahan.


Jangan gunakan antarmuka:



  • demi menggunakan antarmuka.
  • untuk menggeneralisasi algoritme.
  • ketika pengguna dapat mendeklarasikan antarmuka mereka sendiri.


Sekarang kita bisa bertanya pada diri kita sendiri, apakah algoritma ini benar-benar membutuhkan fungsi io.ReadFull? Jawabannya tidak, karena tipe bytes.Buffer memiliki sekumpulan metode yang bisa kita gunakan. Menggunakan metode terhadap nilai yang dimiliki fungsi dapat mencegah alokasi.



Mari ubah kode untuk menghapus paket io dan gunakan metode Baca langsung pada variabel input.



Perubahan kode ini menghilangkan kebutuhan untuk mengimpor paket io, jadi untuk menjaga agar semua nomor baris tetap sama, saya menggunakan pengenal kosong untuk mengimpor paket io. Ini akan membuat impor tetap dalam daftar.



Daftar 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 }


Ketika kita mengukur perubahan kode ini, kita dapat melihat bahwa tidak ada lagi alokasi untuk nilai byte.Buffer.



Daftar 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


Kami juga melihat peningkatan kinerja sekitar 29%. Waktu berubah dari 2570 ns / op menjadi 1814 ns / op. Sekarang setelah ini diselesaikan, kita bisa fokus pada mengalokasikan slice tambahan untuk buf. Jika kami menggunakan profiler lagi untuk data profil baru yang baru saja kami buat, kami dapat menentukan apa sebenarnya yang menyebabkan alokasi yang tersisa.



Daftar 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])


Alokasi yang tersisa hanya pada baris 89, yang untuk membuat irisan tambahan.



Bingkai tumpukan



Kami ingin tahu mengapa alokasi terjadi untuk auxiliary slice untuk buf? Mari kita jalankan build lagi dengan opsi -gcflags "-m -m" dan cari stream.go: 89.



Listing 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


Laporan tersebut menyatakan bahwa auxiliary array "terlalu besar untuk stack". Pesan ini menyesatkan. Intinya bukanlah bahwa array tersebut terlalu besar, tetapi kompilator tidak mengetahui ukuran array tambahan pada waktu kompilasi.



Nilai hanya dapat didorong ke tumpukan jika kompilator mengetahui ukuran nilai pada waktu kompilasi. Ini karena ukuran setiap bingkai tumpukan untuk setiap fungsi dihitung pada waktu kompilasi. Jika kompilator tidak mengetahui ukuran suatu nilai, itu akan di-heaped.



Untuk menunjukkan ini, mari kita buat hardcode sementara ukuran slice menjadi 5 dan jalankan benchmark lagi.



Daftar 22



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


Tidak ada alokasi lagi kali ini.



Daftar 23



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


Jika Anda melihat lagi laporan kompilator, Anda dapat melihat bahwa tidak ada yang dipindahkan ke heap.



Daftar 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


Jelas, kami tidak dapat melakukan hardcode ukuran slice, jadi kami harus menggunakan 1 alokasi untuk algoritme ini.



Alokasi dan kinerja



Bandingkan perolehan kinerja yang telah kami capai dengan setiap pemfaktoran ulang.



Daftar 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


Kami mendapat peningkatan kinerja sekitar 29% karena fakta bahwa kami menghapus byte. Alokasi buffer dan ~ 33% akselerasi setelah menghapus semua alokasi. Alokasi adalah tempat kinerja aplikasi bisa terganggu.



Kesimpulan



Go memiliki beberapa alat luar biasa untuk membantu Anda memahami keputusan yang dibuat oleh compiler tentang analisis pelolosan. Berdasarkan informasi ini, Anda dapat memfaktorkan ulang kode Anda untuk membantu menjaga nilai di tumpukan yang seharusnya tidak ada di heap. Anda tidak boleh menulis program dengan alokasi nol, tetapi Anda harus berusaha untuk meminimalkan alokasi bila memungkinkan.



Jangan menjadikan kinerja sebagai prioritas utama saat menulis kode, karena Anda tidak ingin menebak apa yang seharusnya dilakukan. Tulis kode dan optimalkan untuk mencapai kinerja untuk tugas prioritas pertama. Ini berarti berfokus terutama pada integritas, keterbacaan, dan kesederhanaan. Setelah Anda memiliki program kerja, tentukan apakah itu cukup cepat. Jika tidak, gunakan alat yang disediakan oleh bahasa tersebut untuk menemukan dan memperbaiki masalah kinerja.



All Articles