Membuat program Go 42% lebih cepat dengan perubahan satu karakter • Harry Marr

Berita25 Dilihat



Jika Anda membaca judulnya dan berpikir “yah, Anda mungkin hanya melakukan sesuatu yang konyol sebelumnya”, Anda benar! Tapi apa pemrograman jika bukan latihan membuat kesalahan konyol? Melacak kesalahan konyol adalah tempat semua kesenangan bisa didapat!

Saya juga akan menyatakan peringatan pembandingan yang biasa di depan: percepatan 42% diukur saat menjalankan program pada data saya di komputer saya, jadi anggaplah angka itu dengan sedikit garam.

Apa yang dilakukan program?

pemilik kode adalah program Go yang mencetak pemilik untuk setiap file dalam repositori sesuai dengan seperangkat aturan yang didefinisikan dalam a GitHub CODEOWNERS mengajukan. Aturan mungkin mengatakan bahwa semua file berakhiran .go dimiliki oleh @gophers tim, atau semua file di docs/ direktori dimiliki oleh @docs tim.

Saat mempertimbangkan jalur tertentu, aturan terakhir yang cocok akan menang. Algoritme pencocokan yang sederhana namun naif mengulang mundur melalui aturan untuk setiap jalur, berhenti saat menemukan kecocokan. Algoritme yang lebih cerdas memang ada, tapi itu untuk hari lain. Inilah apa Ruleset.Match fungsi terlihat seperti:

type Ruleset []Rule

func (r Ruleset) Match(path string) (*Rule, error) {
for i := len(r) - 1; i >= 0; i-- {
rule := r[i]
match, err := rule.Match(path)
if match || err != nil {
return &rule, err
}
}
return nil, nil
}

Menemukan bit lambat dengan pprof dan flamegraphs

Alat ini agak lambat saat menjalankannya di repositori yang cukup besar:

$ hyperfine codeowners
Benchmark 1: codeowners
Time (mean ± σ): 4.221 s ± 0.089 s [User: 5.862 s, System: 0.248 s]
Range (min … max): 4.141 s … 4.358 s 10 runs

Untuk melihat di mana program menghabiskan waktunya, saya merekam profil CPU dengan pprof. Anda bisa mendapatkan profil CPU yang dihasilkan dengan menambahkan potongan ini ke bagian atas Anda main fungsi:

pprofFile, pprofErr := os.Create("cpu.pprof")
if pprofErr != nil {
log.Fatal(pprofErr)
}
pprof.StartCPUProfile(pprofFile)
defer pprof.StopCPUProfile()

Selain: Saya cukup sering menggunakan pprof, jadi saya menyimpan kode itu sebagai cuplikan vscode. Saya hanya mengetik pproftekan tab, dan cuplikan itu muncul.

Go hadir dengan alat visualisasi profil interaktif yang praktis. Saya memvisualisasikan profil sebagai flamegraph dengan menjalankan perintah berikut lalu menavigasi ke tampilan flamegraph di menu di bagian atas halaman.

$ go tool pprof -http=":8000" ./codeowners ./cpu.pprof

Seperti yang saya harapkan, sebagian besar waktu dihabiskan untuk itu Match fungsi. Pola CODEOWNERS dikompilasi ke ekspresi reguler, dan sebagian besar Match waktu fungsi dihabiskan di mesin regex Go. Tetapi saya juga memperhatikan banyak waktu dihabiskan untuk mengalokasikan dan mendapatkan kembali memori. Blok ungu pada gambar nyala di bawah cocok dengan polanya gc|mallocdan Anda dapat melihat secara agregat mereka mewakili bagian yang berarti dari waktu eksekusi program.

Mencari alokasi heap dengan pelacakan escape analysis

Jadi mari kita lihat apakah ada alokasi yang bisa kita singkirkan untuk mengurangi tekanan GC dan waktu yang dihabiskan malloc.

Kompiler Go menggunakan teknik yang disebut escape analysis untuk mencari tahu kapan beberapa memori perlu hidup di heap. Katakanlah suatu fungsi menginisialisasi struct lalu mengembalikan pointer ke sana. Jika struct dialokasikan pada stack, pointer yang dikembalikan akan menjadi tidak valid segera setelah fungsi kembali dan bingkai stack yang sesuai tidak valid. Dalam hal ini, kompiler Go akan menentukan bahwa pointer telah “lolos” dari fungsi, dan memindahkan struct ke heap sebagai gantinya.

Anda dapat melihat keputusan ini dibuat dengan lewat -gcflags=-m ke go build:

$ go build -gcflags=-m *.go 2>&1 | grep codeowners.go
./codeowners.go:82:18: inlining call to os.IsNotExist
./codeowners.go:71:28: inlining call to filepath.Join
./codeowners.go:52:19: inlining call to os.Open
./codeowners.go:131:6: can inline Rule.Match
./codeowners.go:108:27: inlining call to Rule.Match
./codeowners.go:126:6: can inline Rule.RawPattern
./codeowners.go:155:6: can inline Owner.String
./codeowners.go:92:29: ... argument does not escape
./codeowners.go:96:33: string(output) escapes to heap
./codeowners.go:80:17: leaking param: path
./codeowners.go:70:31: []string{...} does not escape
./codeowners.go:71:28: ... argument does not escape
./codeowners.go:51:15: leaking param: path
./codeowners.go:105:7: leaking param content: r
./codeowners.go:105:24: leaking param: path
./codeowners.go:107:3: moved to heap: rule
./codeowners.go:126:7: leaking param: r to result ~r0 level=0
./codeowners.go:131:7: leaking param: r
./codeowners.go:131:21: leaking param: path
./codeowners.go:155:7: leaking param: o to result ~r0 level=0
./codeowners.go:159:13: "@" + o.Value escapes to heap

Outputnya sedikit bising, tetapi Anda dapat mengabaikan sebagian besar. Saat kami sedang mencari alokasi,”moved to heap” adalah ungkapan yang harus kita perhatikan. Melihat kembali ke Match kode di atas, the Rule struct disimpan di dalam Ruleset slice, yang kami yakini sudah ada di heap. Dan saat penunjuk ke aturan dikembalikan, tidak diperlukan alokasi tambahan.

Kemudian saya melihatnya—dengan menugaskan rule := r[i]kami salinan tumpukan yang dialokasikan Rule keluar dari irisan ke tumpukan, lalu dengan kembali &rule kami membuat pointer (melarikan diri) ke salinan struct. Untungnya, memperbaikinya mudah. Kita hanya perlu menaikkan ampersand sedikit sehingga kita mengambil referensi ke struct di dalam slice daripada menyalinnya:

 func (r Ruleset) Match(path string) (*Rule, error) {
for i := len(r) - 1; i >= 0; i-- {
- rule := r[i]
+ rule := &r[i]
match, err := rule.Match(path)
if match || err != nil {
- return &rule, err
+ return rule, err
}
}
return nil, nil
}

Saya memang mempertimbangkan dua pendekatan lain:

  1. Mengubah Ruleset dari menjadi []Rule ke []*Ruleyang berarti kita tidak perlu lagi secara eksplisit mengambil referensi ke aturan tersebut.
  2. Mengembalikan Rule daripada *Rule. Ini masih akan menyalin Ruletetapi harus tetap berada di tumpukan alih-alih pindah ke tumpukan.

Namun, keduanya akan menghasilkan perubahan yang melanggar karena metode ini adalah bagian dari API publik.

Lagi pula, setelah melakukan perubahan itu kita dapat melihat apakah itu memiliki efek yang diinginkan dengan mendapatkan jejak baru dari kompiler dan membandingkannya dengan yang lama:

$ diff trace-a trace-b
14a15
> ./codeowners.go:105:7: leaking param: r to result ~r0 level=0
16d16
< ./codeowners.go:107:3: moved to heap: rule

Kesuksesan! Alokasinya hilang. Sekarang mari kita lihat bagaimana menghapus satu alokasi tumpukan itu memengaruhi kinerja:

$ hyperfine ./codeowners-a ./codeowners-b
Benchmark 1: ./codeowners-a
Time (mean ± σ): 4.146 s ± 0.003 s [User: 5.809 s, System: 0.249 s]
Range (min … max): 4.139 s … 4.149 s 10 runs

Benchmark 2: ./codeowners-b
Time (mean ± σ): 2.435 s ± 0.029 s [User: 2.424 s, System: 0.026 s]
Range (min … max): 2.413 s … 2.516 s 10 runs

Summary
./codeowners-b ran
1.70 ± 0.02 times faster than ./codeowners-a

Karena alokasi itu terjadi untuk setiap jalan yang dicocokkan, menghapusnya mendapat peningkatan kecepatan 1,7x (artinya berjalan 42% lebih cepat) dalam hal ini. Lumayan untuk satu perubahan karakter.

Pembaruan: judul aslinya menyatakan pengoptimalan membuat program berjalan 70% lebih cepat, bukan 42% (dengan perubahan kecepatan 1,7x). Terima kasih @enedil untuk menunjukkannya!


#Membuat #program #lebih #cepat #dengan #perubahan #satu #karakter #Harry #Marr

Source link

Komentar