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.
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)
Cara aman untuk selalu menjamin nol memori adalah dengan melakukan ini secara eksplisit:
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 )
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:
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:
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 {
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: