Metode Sederhana untuk Mengoptimalkan Program Go

Saya selalu peduli dengan kinerja. Saya tidak tahu persis mengapa. Tapi saya hanya kesal dengan layanan dan program yang lambat. Sepertinya saya tidak sendiri .

Dalam pengujian A / B, kami mencoba memperlambat output halaman dalam peningkatan 100 milidetik dan menemukan bahwa bahkan keterlambatan yang sangat kecil pun menyebabkan penurunan signifikan dalam pendapatan. - Greg Linden, Amazon.com

Dari pengalaman, produktivitas yang rendah dimanifestasikan dalam satu dari dua cara:

  • Operasi yang berkinerja baik dalam skala kecil menjadi tidak dapat digunakan dengan meningkatnya jumlah pengguna. Biasanya ini adalah operasi O (N) atau O (NΒ²). Ketika basis pengguna kecil, semuanya berfungsi dengan baik. Produk ini terburu-buru untuk dibawa ke pasar. Ketika basis tumbuh, semakin banyak situasi patologis yang tak terduga muncul - dan layanan berhenti.
  • Banyak sumber individual pekerjaan suboptimal, "kematian dari seribu luka."

Untuk sebagian besar karir saya, saya belajar ilmu data dengan Python atau membuat layanan di Go. Dalam kasus kedua, saya memiliki lebih banyak pengalaman dalam optimasi. Go biasanya bukan hambatan dalam layanan yang saya tulis - program basis data sering dibatasi oleh I / O. Namun, dalam pipa batch pembelajaran mesin yang saya kembangkan, program ini sering dibatasi oleh CPU. Jika Go menggunakan prosesor terlalu banyak, ada berbagai strategi.

Artikel ini menjelaskan beberapa metode yang dapat digunakan untuk meningkatkan produktivitas secara signifikan tanpa banyak usaha. Saya sengaja mengabaikan metode yang membutuhkan upaya signifikan atau perubahan besar dalam struktur program.

Sebelum Anda mulai


Sebelum membuat perubahan apa pun pada program, luangkan waktu untuk membuat garis dasar yang sesuai untuk perbandingan. Jika tidak, maka Anda akan berkeliaran dalam gelap, bertanya-tanya apakah ada manfaat dari perubahan yang dilakukan. Pertama, tulis tolok ukur dan ambil profil untuk digunakan di pprof. Yang terbaik adalah menulis patokan juga di Go : ini membuatnya lebih mudah untuk menggunakan profil pprof dan memori. Juga gunakan benchcmp: alat yang berguna untuk membandingkan perbedaan kinerja antara tes.

Jika kodenya tidak terlalu kompatibel dengan tolok ukur, mulailah saja dengan sesuatu yang dapat diukur. Anda dapat membuat profil kode secara manual dengan runtime / pprof .

Jadi mari kita mulai!

Gunakan sync.Pool untuk menggunakan kembali objek yang dipilih sebelumnya


sync.Pool mengimplementasikan daftar rilis . Hal ini memungkinkan Anda untuk menggunakan kembali struktur yang sebelumnya dialokasikan dan mengamortisasi distribusi objek melalui banyak kegunaan, mengurangi pekerjaan pengumpul sampah. APInya sangat sederhana. Menerapkan fungsi yang mengalokasikan instance baru dari objek. API akan mengembalikan tipe pointer.

var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }} 

Setelah itu, Anda dapat melakukan Get() objek dari kolam dan Put() kembali ketika Anda selesai.

 // sync.Pool returns a interface{}: you must cast it to the underlying type // before you use it. b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) // Now, go do interesting things with your byte buffer. buf := bytes.NewBuffer(b) 

Ada nuansa. Sebelum Go 1.13, kolam dibersihkan dengan setiap pengumpulan sampah. Ini dapat mempengaruhi kinerja program yang mengalokasikan banyak memori. Pada 1,13, tampaknya lebih banyak objek bertahan setelah GC .

!!! Sebelum mengembalikan objek ke kolam, pastikan untuk mengatur ulang bidang struktur.

Jika tidak, maka Anda bisa mendapatkan objek kotor dari kumpulan yang berisi data dari penggunaan sebelumnya. Ini adalah risiko keamanan yang serius!

 type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) // If we don't hit this if statement, we might return data from other users! if blah { rsp.UserID = "user-1" rsp.Token = "super-secret" } return rsp 

Cara aman untuk selalu menjamin nol memori adalah dengan melakukan ini secara eksplisit:

 // reset resets all fields of the AuthenticationResponse before pooling it. func (a* AuthenticationResponse) reset() { a.Token = "" a.UserID = "" } rsp := authPool.Get().(*AuthenticationResponse) defer func() { rsp.reset() authPool.Put(rsp) }() 

Satu-satunya kasus ketika ini bukan masalah adalah ketika Anda menggunakan memori persis yang Anda tulis. Sebagai contoh:

 var ( r io.Reader w io.Writer ) // Obtain a buffer from the pool. buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) // We only write to w exactly what we read from r, and no more. nr, er := r.Read(buf) if nr > 0 { nw, ew := w.Write(buf[0:nr]) } 

Hindari menggunakan struktur yang mengandung pointer sebagai kunci untuk peta besar


Fuh, aku terlalu verbose. Maafkan aku Mereka sering berbicara (termasuk mantan kolega saya Phil Pearl ) tentang kinerja Go dengan ukuran besar . Selama pengumpulan sampah, runtime memindai objek dengan pointer dan melacaknya. Jika Anda memiliki map[string]int sangat besar, maka GC harus memeriksa setiap baris. Ini terjadi pada setiap pengumpulan sampah, karena garis mengandung pointer.

Dalam contoh ini, kita menulis 10 juta elemen untuk map[string]int dan mengukur durasi pengumpulan sampah. Kami mengalokasikan peta kami di area paket untuk menjamin alokasi memori dari heap.

 package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Menjalankan program, kita akan melihat hal berikut:

  inthash β†’ buka instal && inthash
 gc mengambil: 98.726321ms
 gc mengambil: 105.524633ms
 gc mengambil: 102.829451ms
 gc mengambil: 102.71908ms
 gc mengambil: 103.084104ms
 gc mengambil: 104.821989ms 

Ini waktu yang cukup lama di negara komputer!

Apa yang bisa dilakukan untuk mengoptimalkan? Menghapus pointer di mana-mana adalah ide yang baik, agar tidak memuat pengumpul sampah. Ada petunjuk di baris ; jadi mari kita terapkan ini sebagai map[int]int .

 package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Menjalankan program lagi, kita melihat:

  inthash β†’ buka instal && inthash
 gc mengambil: 3.608993ms
 gc mengambil: 3.926913ms
 gc mengambil: 3.955706ms
 gc mengambil: 4.063795ms
 gc mengambil: 3,91519ms
 gc mengambil: 3.75226ms 

Jauh lebih baik. Kami telah mempercepat pengumpulan sampah sebanyak 35 kali. Ketika digunakan dalam produksi, akan perlu untuk mengaitkan string ke dalam bilangan bulat sebelum memasukkan ke dalam kartu.

Omong-omong, ada banyak lagi cara untuk menghindari GC. Jika Anda mengalokasikan array raksasa dari struktur, int, atau byte yang tidak berarti, GC tidak akan memindai ini : artinya, Anda menghemat waktu GC. Metode seperti itu biasanya memerlukan revisi substansial dari program, jadi hari ini kita tidak akan membahas topik ini.

Seperti halnya optimasi apa pun, efeknya dapat bervariasi. Lihat utas tweet dari Damian Gryski untuk contoh menarik tentang cara menghapus garis dari peta besar yang mendukung struktur data yang lebih pintar sebenarnya meningkatkan konsumsi memori. Secara umum, baca semua yang dia terbitkan.

Membuat kode generasi untuk menghindari refleksi runtime


Membungkam dan menghapus struktur Anda ke dalam berbagai format serialisasi, seperti JSON, adalah operasi tipikal, terutama saat membuat layanan microser. Untuk banyak layanan mikro, ini biasanya merupakan satu-satunya pekerjaan. Fungsinya seperti json.Marshal dan json.Unmarshal mengandalkan refleksi dalam runtime untuk membuat serial bidang struktur menjadi byte dan sebaliknya. Ini dapat bekerja lambat: refleksi tidak seefisien kode eksplisit.

Namun, ada opsi pengoptimalan. Mekanik marsaling JSON terlihat seperti ini:

 package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) } 

Jika kita mengetahui proses virtualisasi di JSON, kita memiliki petunjuk untuk menghindari refleksi dalam runtime. Tetapi kami tidak ingin menulis secara manual semua kode virtualisasi, jadi apa yang harus dilakukan? Biarkan komputer membuat kode ini! Pembuat kode seperti easyjson melihat struktur dan menghasilkan kode yang sangat optimal yang sepenuhnya kompatibel dengan antarmuka marshaling yang ada seperti json.Marshaller .

Unduh paket dan tulis perintah berikut dalam $file.go , yang berisi struktur yang Anda inginkan untuk menghasilkan kode.

  easyjson -semua $ file.go 

File $file_easyjson.go harus dibuat. Karena easyjson mengimplementasikan antarmuka json.Marshaller untuk Anda, fungsi-fungsi ini akan dipanggil secara default alih-alih refleksi. Selamat: Anda baru saja mempercepat kode JSON Anda tiga kali. Ada banyak trik untuk lebih meningkatkan produktivitas.

Saya merekomendasikan paket ini karena saya telah menggunakannya sendiri sebelumnya, dan berhasil. Tapi hati-hati. Tolong jangan menganggap ini sebagai undangan untuk memulai debat agresif dengan saya tentang paket JSON tercepat.

Pastikan untuk membuat kembali kode marshaling ketika struktur berubah. Jika Anda lupa melakukan ini, bidang yang baru ditambahkan tidak akan diserialisasi, yang akan menyebabkan kebingungan! Anda dapat menggunakan go generate untuk tugas-tugas ini. Untuk menjaga sinkronisasi dengan struktur, saya lebih suka menempatkan generate.go di root paket, yang menyebabkan go generate untuk semua file paket: ini dapat membantu ketika Anda memiliki banyak file yang perlu menghasilkan kode tersebut. Kiat utama: untuk memastikan bahwa struktur diperbarui, panggil go generate di CI dan periksa bahwa tidak ada perbedaan dengan kode terdaftar.

Gunakan string. Builder untuk membangun string


Di Go, string tidak dapat diubah: anggap saja sebagai byte read-only. Ini berarti bahwa setiap kali Anda membuat string, Anda mengalokasikan memori dan berpotensi membuat lebih banyak pekerjaan untuk pengumpul sampah.

Go 1.10 menerapkan string. Builder sebagai cara yang efisien untuk membuat string. Secara internal, ia menulis ke buffer byte. Hanya ketika memanggil String() di builder yang benar-benar membuat string. Dia mengandalkan beberapa trik tidak aman untuk mengembalikan byte yang mendasarinya sebagai string dengan alokasi nol: lihat blog ini untuk studi lebih lanjut tentang cara kerjanya.

Bandingkan kinerja kedua pendekatan:

 // main.go package main import "strings" var strs = []string{ "here's", "a", "some", "long", "list", "of", "strings", "for", "you", } func buildStrNaive() string { var s string for _, v := range strs { s += v } return s } func buildStrBuilder() string { b := strings.Builder{} // Grow the buffer to a decent length, so we don't have to continually // re-allocate. b.Grow(60) for _, v := range strs { b.WriteString(v) } return b.String() } 

 // main_test.go package main import ( "testing" ) var str string func BenchmarkStringBuildNaive(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrBuilder() } 

Berikut adalah hasil pada Macbook Pro saya:

  strbuild -> go test -bench =.  -benchmem
 goos: darwin
 goarch: amd64
 pkg: github.com/sjwhitworth/perfblog/strbuild
 BenchmarkStringBuildNaive-8 5.000.000 255 ns / op 216 B / op 8 allocs / op
 BenchmarkStringBuildBuilder-8 20.000.000 54,9 ns / op 64 B / op 1 allocs / op 

Seperti yang Anda lihat, strings.Builder adalah 4,7 kali lebih cepat, menyebabkan alokasi delapan kali lebih sedikit dan memakan memori empat kali lebih sedikit.

Ketika masalah kinerja, gunakan strings.Builder . strings.Builder . Secara umum, saya sarankan menggunakannya di mana-mana, kecuali untuk kasus string bangunan yang paling sepele.

Gunakan strconv sebagai ganti fmt


fmt adalah salah satu paket paling terkenal di Go. Anda mungkin menggunakannya di program pertama Anda untuk menampilkan "halo, dunia". Tetapi ketika datang untuk mengkonversi bilangan bulat dan mengapung ke string, itu tidak seefisien adiknya strconv . Paket ini menunjukkan kinerja yang baik dengan sedikit perubahan pada API.

fmt pada dasarnya mengambil interface{} sebagai argumen fungsi. Ada dua kelemahan:

  • Anda kehilangan keamanan tipe. Bagi saya itu sangat penting.
  • Ini dapat meningkatkan jumlah sekresi yang dibutuhkan. Melewati jenis tanpa pointer sebagai interface{} biasanya menghasilkan alokasi tumpukan. Posting blog ini menjelaskan mengapa demikian.
  • Program berikut menunjukkan perbedaan dalam kinerja:

     // main.go package main import ( "fmt" "strconv" ) func strconvFmt(a string, b int) string { return a + ":" + strconv.Itoa(b) } func fmtFmt(a string, b int) string { return fmt.Sprintf("%s:%d", a, b) } func main() {} 

     // main_test.go package main import ( "testing" ) var ( a = "boo" blah = 42 box = "" ) func BenchmarkStrconv(b *testing.B) { for i := 0; i < bN; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { for i := 0; i < bN; i++ { box = fmtFmt(a, blah) } a = box } 

    Tolak ukur pada Macbook Pro:

      strfmt β†’ lanjutkan uji -bench =.  -benchmem
     goos: darwin
     goarch: amd64
     pkg: github.com/sjwhitworth/perfblog/strfmt
     BenchmarkStrconv-8 30.000.000 39,5 ns / op 32 B / op 1 allocs / op
     BenchmarkFmt-8 10.000.000 143 ns / op 72 B / op 3 allocs / op 

    Seperti yang Anda lihat, opsi strconv adalah 3,5 kali lebih cepat, menyebabkan alokasi tiga kali lebih sedikit dan menghabiskan setengah memori.

    Alokasikan slice tank dengan membuat untuk menghindari redistribusi


    Sebelum beralih ke peningkatan kinerja, mari kita segera perbarui informasi yang diiris dalam memori. Sepotong adalah konstruksi yang sangat berguna di Go. Ini menyediakan array yang dapat diskalakan dengan kemampuan untuk menerima tampilan yang berbeda dalam memori dasar yang sama tanpa realokasi. Jika Anda melihat di bawah tenda, maka irisan terdiri dari tiga elemen:

     type slice struct { // pointer to underlying data in the slice. data uintptr // the number of elements in the slice. len int // the number of elements that the slice can // grow to before a new underlying array // is allocated. cap int } 

    Apa bidang-bidang ini?

    • data : penunjuk ke data yang mendasarinya di slice
    • len : jumlah elemen saat ini di slice
    • cap : jumlah elemen yang dapat ditumbuhkan oleh irisan sebelum didistribusikan ulang

    Bagian di bawah kap adalah array dengan panjang tetap. Ketika nilai maksimum ( cap ) tercapai, array baru dengan nilai ganda dialokasikan, memori disalin dari potongan lama ke yang baru, dan array lama dibuang.

    Saya sering melihat kode seperti ini di mana irisan dengan kapasitas batas nol dialokasikan jika kapasitas irisan diketahui sebelumnya:

     var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Dalam hal ini, irisan dimulai dengan nol ukuran len dan batas kapasitas batas nol. Setelah menerima jawaban, kami menambahkan elemen ke slice, pada saat yang sama kami mencapai kapasitas batas: array basis baru dipilih, di mana cap dua kali lipat, dan data disalin ke sana. Jika kita mendapatkan 8 elemen dalam jawaban, ini mengarah ke 5 redistribusi.

    Metode berikut ini jauh lebih efisien:

     userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Di sini kami secara eksplisit mengalokasikan kapasitas untuk irisan menggunakan make. Sekarang kita dapat menambahkan data dengan aman di sana, tanpa redistribusi dan penyalinan tambahan.

    Jika Anda tidak tahu berapa banyak memori yang dialokasikan, karena kapasitasnya dinamis atau kemudian dihitung dalam program, ukur distribusi akhir ukuran slice setelah program berjalan. Saya biasanya mengambil persentil ke-90 atau ke-99 dan mengkode nilai dalam program. Dalam kasus di mana CPU lebih mahal daripada RAM untuk Anda, tetapkan nilai ini lebih tinggi dari yang Anda anggap perlu.

    Tip juga berlaku untuk map: make(map[string]string, len(foo)) akan mengalokasikan memori yang cukup untuk menghindari redistribusi.

    Lihat artikel ini tentang cara kerja irisan.

    Gunakan metode untuk mentransfer irisan byte


    Saat menggunakan paket, gunakan metode yang memungkinkan transmisi byte byte: metode ini biasanya memberikan kontrol lebih besar atas distribusi.

    Contoh yang baik adalah membandingkan time.Format dan time.AppendFormat . Yang pertama mengembalikan string. Di bawah tenda, ini memilih sepotong byte baru dan memanggil time.AppendFormat . Yang kedua mengambil buffer byte, menulis representasi waktu yang diformat, dan mengembalikan sepotong byte yang diperluas. Ini sering ditemukan dalam paket lain di perpustakaan standar: lihat strconv.AppendFloat atau bytes.NewBuffer .

    Mengapa ini meningkatkan produktivitas? Nah, sekarang Anda bisa meneruskan irisan byte yang Anda terima dari sync.Pool , alih-alih mengalokasikan buffer baru setiap kali. Atau Anda dapat meningkatkan ukuran buffer awal ke nilai yang lebih cocok untuk program Anda untuk mengurangi jumlah salinan slice yang diulang.

    Ringkasan


    Anda dapat menerapkan semua metode ini ke basis kode Anda. Seiring waktu, Anda akan membangun model mental untuk alasan tentang kinerja dalam program Go. Ini akan sangat membantu dalam desain mereka.

    Tapi gunakan itu tergantung situasinya. Ini adalah nasihat, bukan Injil. Ukur dan periksa semuanya dengan tolok ukur.

    Dan tahu kapan harus berhenti. Meningkatkan produktivitas adalah latihan yang baik: tugas itu menarik, dan hasilnya langsung terlihat. Namun, manfaat peningkatan produktivitas tergantung pada situasi. Jika layanan Anda memberikan jawaban dalam 10 ms, dan penundaan jaringan adalah 90 ms, Anda mungkin sebaiknya tidak mencoba mengurangi 10 ms hingga 5 ms ini: Anda masih memiliki 95 ms. Bahkan jika Anda mengoptimalkan layanan hingga maksimal 1 ms, total penundaan masih menjadi 91 ms. Mungkin makan ikan yang lebih besar.

    Optimalkan dengan bijak!

    Referensi


    Jika Anda ingin informasi lebih lanjut, berikut adalah sumber inspirasi yang hebat:

Source: https://habr.com/ru/post/id457004/


All Articles