Benchmark

Fungsi benchmark, b.N, dan pengenalan profil pprof.

Daftar isi

Pengujian memeriksa kebenaran, sedangkan benchmark mengukur kecepatan. Paket testing menyediakan kerangka benchmark, yaitu pengukuran kinerja yang diulang berkali-kali oleh alat bantu Go untuk menghitung rata-rata waktu per operasi. Paket testing juga menyediakan jembatan menuju pprof, yaitu alat profil bawaan Go yang menunjukkan fungsi mana yang paling banyak menghabiskan waktu. Topik ini membahas penulisan fungsi benchmark, peran b.N dan opsi -benchtime, cara membaca keluarannya, pengukuran alokasi memori, dan pengenalan perekaman profil CPU pada pengujian. Angka waktunya bergantung mesin, sehingga seluruh keluaran pada topik ini dinormalisasi terlebih dahulu.

Fungsi benchmark dan b.N#

Fungsi benchmark ditempatkan pada berkas _test.go yang sama dengan pengujian dan diberi nama diawali Benchmark diikuti huruf besar, misalnya BenchmarkGabung, dengan satu parameter bertipe *testing.B. Isinya selalu berupa perulangan dari nol sampai b.N, yaitu banyaknya eksekusi yang ditetapkan kerangka secara dinamis agar rata-ratanya layak dipercaya. Contoh berikut membandingkan dua cara mengulang sebuah kata, yaitu penggabungan dengan += yang menyalin seluruh isi string pada setiap putaran, dan strings.Builder yang menumpuk teks pada satu penyangga:

go gabung.go
package main

import (
	"fmt"
	"strings"
)

// Gabung mengulang kata "kata" sejumlah n kali dengan operator +=.
// Setiap penggabungan menyalin seluruh isi string yang sudah terkumpul.
func Gabung(n int) string {
	hasil := ""
	for i := 0; i < n; i++ {
		hasil += "kata "
	}
	return hasil
}

// GabungBuilder mengulang kata "kata" sejumlah n kali melalui
// strings.Builder yang menumpuk teks pada satu penyangga.
func GabungBuilder(n int) string {
	var b strings.Builder
	for i := 0; i < n; i++ {
		b.WriteString("kata ")
	}
	return b.String()
}

func main() {
	fmt.Printf("%q\n", Gabung(3))
	fmt.Printf("%q\n", GabungBuilder(3))
}
text Keluaran
"kata kata kata "
"kata kata kata "

Fungsi main memperagakan bahwa kedua implementasi menghasilkan string yang persis sama untuk masukan yang sama. Kesamaan hasil itu dibuktikan pula oleh pengujian kebenaran pada berkas berikutnya, karena mengukur kecepatan implementasi yang salah tidak bermakna apa pun.

go gabung_test.go
// tidak-dijalankan: dijalankan melalui go test

package main

import "testing"

func TestGabung(t *testing.T) {
	ingin := "kata kata kata "
	if hasil := Gabung(3); hasil != ingin {
		t.Errorf("Gabung(3) = %q, seharusnya %q", hasil, ingin)
	}
	if hasil := GabungBuilder(3); hasil != ingin {
		t.Errorf("GabungBuilder(3) = %q, seharusnya %q", hasil, ingin)
	}
}

func BenchmarkGabung(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Gabung(200)
	}
}

func BenchmarkBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		GabungBuilder(200)
	}
}

Berkas pengujian itu memuat satu pengujian kebenaran dan dua fungsi benchmark, dan pengujian kebenarannya sengaja didahulukan. Badan perulangan benchmark berisi satu pemanggilan yang ingin diukur, dan banyaknya putaran tidak pernah ditetapkan manual. Dengan -benchtime=1x, kerangka menetapkan b.N bernilai satu sehingga setiap fungsi benchmark dieksekusi tepat satu kali, sedangkan tanpa opsi itu setiap benchmark dijalankan sekurang-kurangnya satu detik dengan b.N yang dinaikkan bertahap.

Membaca keluaran benchmark#

Benchmark dijalankan dengan opsi -bench=. yang mencocokkan semua nama benchmark, dan angka-angka waktunya dirapikan dengan sed karena berubah-ubah antar eksekusi maupun antar mesin:

bash
# salin berkas contoh ke folder uji
rm -rf /tmp/gotest-bench && mkdir -p /tmp/gotest-bench && cp *.go /tmp/gotest-bench/ && cd /tmp/gotest-bench
# buat modul sementara tanpa mencatat pesannya
go mod init contoh >/dev/null 2>&1
# jalankan seluruh benchmark satu kali per fungsi, lalu rapikan angkanya
go test -bench=. -benchtime=1x 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/^(Benchmark[A-Za-z]*)-[0-9]+[[:space:]]+[0-9]+/\1-N ITERASI/; s/[0-9]+(\.[0-9]+)? ns\/op/WAKTU ns\/op/; s/[[:space:]]{2,}/ /g; /^cpu:/d'
text Keluaran
goos: linux
goarch: amd64
pkg: contoh
BenchmarkGabung-N ITERASI WAKTU ns/op
BenchmarkBuilder-N ITERASI WAKTU ns/op
PASS
ok contoh

Tiga baris pertama menyatakan sistem operasi, arsitektur, dan paket yang diukur, kemudian baris model prosesornya dihilangkan dari keluaran di atas. Setiap baris benchmark memuat nama fungsi, akhiran -N yang menyatakan jumlah CPU logis tempat benchmark dijalankan, jumlah eksekusi yang di sini disamarkan menjadi ITERASI, dan WAKTU ns/op yang berarti rata-rata nanodetik per satu operasi. Pada pengukuran nyata, Gabung tampil dengan nilai ns/op berkali-kali lipat dibanding Builder, karena setiap += menyalin string yang sudah terkumpul, sedangkan Builder menulis ke satu penyangga yang tumbuh sekali-sekali.

Alokasi memori dengan -benchmem#

Selisih kecepatan pada contoh ini berakar pada alokasi memori, dan opsi -benchmem memperlihatkannya secara langsung. Opsi itu menambahkan dua kolom pada setiap baris benchmark, yaitu banyaknya byte yang dialokasikan per operasi dan banyaknya alokasi per operasi:

bash
# jalankan benchmark beserta statistik alokasi memorinya
go test -bench=. -benchtime=1x -benchmem 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/^(Benchmark[A-Za-z]*)-[0-9]+[[:space:]]+[0-9]+/\1-N ITERASI/; s/[0-9]+(\.[0-9]+)? ns\/op/WAKTU ns\/op/; s/[0-9]+ B\/op/MEMORI B\/op/; s/[0-9]+ allocs\/op/ALOKASI allocs\/op/; s/[[:space:]]{2,}/ /g; /^cpu:/d'
text Keluaran
goos: linux
goarch: amd64
pkg: contoh
BenchmarkGabung-N ITERASI WAKTU ns/op MEMORI B/op ALOKASI allocs/op
BenchmarkBuilder-N ITERASI WAKTU ns/op MEMORI B/op ALOKASI allocs/op
PASS
ok contoh

Pada pengukuran nyata, Gabung melakukan alokasi hampir sebanyak putarannya, yaitu satu penyimpanan baru untuk setiap += yang menyalin seluruh isi string terkumpul. Builder hanya melakukan segelintir alokasi untuk menumbuhkan penyangganya secara bertahap. Nilai persisnya bergantung versi Go dan mesin, tetapi urutan besarnya stabil, dan kolom allocs/op itulah yang menegaskan penyebab selisih ns/op pada bagian sebelumnya. Alokasi bukan sekadar biaya memori, karena setiap alokasi juga membebani garbage collector, sehingga menurunkan banyaknya alokasi hampir selalu memperbaiki kecepatan pula.

Profil CPU dengan pprof#

Benchmark menjawab seberapa cepat, sedangkan profil menjawab mengapa. Opsi -cpuprofile merekam profil CPU selama benchmark berjalan ke dalam berkas, dan opsi -run '^$' melompati pengujian biasa karena yang dibutuhkan hanya benchmark Builder:

bash
# jalankan benchmark Builder selama sekitar satu detik sambil merekam profil CPU
go test -run '^$' -bench Builder -cpuprofile cpu.out 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/^(Benchmark[A-Za-z]*)-[0-9]+[[:space:]]+[0-9]+/\1-N ITERASI/; s/[0-9]+(\.[0-9]+)? ns\/op/WAKTU ns\/op/; s/[[:space:]]{2,}/ /g; /^cpu:/d'
text Keluaran
goos: linux
goarch: amd64
pkg: contoh
BenchmarkBuilder-N ITERASI WAKTU ns/op
PASS
ok contoh

Perintah itu menghasilkan berkas cpu.out di folder kerja beserta biner pengujian yang profilnya berkaitan dengan berkas tersebut. Isi profilnya kemudian diperiksa dengan go tool pprof:

bash
# tidak-dijalankan
# urutan baris, persentase, dan nilainya berubah antar eksekusi, sehingga blok
# ini ditandai tidak-dijalankan dan bentuk keluarannya dijelaskan pada prosa
go tool pprof -top cpu.out

Perintah go tool pprof -top cpu.out menampilkan daftar fungsi yang terurut dari yang paling banyak memakan waktu CPU. Setiap barisnya memuat kolom flat berupa waktu yang dihabiskan langsung di fungsi itu, kolom cum berupa waktu yang mencakup pula seluruh fungsi yang dipanggilnya, beserta persentase masing-masing. Pada profil contoh ini, baris teratasnya menunjuk fungsi penulisan strings.Builder dan fungsi-fungsi runtime yang menyediakan memori, yang menegaskan bahwa biaya utama implementasi ini berada pada alokasi dan penyalinan. Bentuk grafis yang lebih mudah dijelajahi diperoleh dengan go tool pprof -http=: cpu.out, tetapi perintah itu membuka browser sehingga memerlukan antarmuka grafis dan tidak dijalankan di sini.

Kesalahan umum#

Dua kesalahan berikut paling sering muncul ketika benchmark pertama ditulis.

  1. Menetapkan banyaknya putaran sendiri alih-alih memakai b.N. Perulangan dengan batas tetap, misalnya seribu putaran, membuat hasilnya tidak dapat dibandingkan dengan benchmark lain, karena satu operasi yang berbeda beban dihitung dengan harga yang sama. Selalu ulangi dari nol sampai b.N, dan atur lama pengukurannya melalui -benchtime apabila diperlukan.
  2. Menaruh persiapan yang berat di dalam perulangan. Waktu pembentukan data uji ikut terukur menjadi bagian dari ns/op, padahal yang ingin diukur adalah pekerjaan intinya. Persiapan dipindahkan ke luar perulangan, kemudian b.ResetTimer() dipanggil agar waktu yang sudah berjalan tidak ikut dihitung:
go Persiapan di luar perulangan dengan b.ResetTimer
// tidak-dijalankan
func BenchmarkDataBesar(b *testing.B) {
        data := siapkanDataBesar()
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
                proses(data)
        }
}

Ringkasan#

  • Fungsi benchmark bernama BenchmarkXxx dengan parameter *testing.B, dan badannya selalu berupa perulangan dari nol sampai b.N yang ditetapkan kerangka secara dinamis.
  • Opsi -bench=. menjalankan seluruh benchmark, -benchtime=1x membatasi satu eksekusi per fungsi untuk contoh cepat, dan b.ResetTimer memisahkan waktu persiapan dari waktu yang diukur.
  • Kolom ns/op bermakna hanya pada mesin yang sama, sedangkan -benchmem menambahkan B/op dan allocs/op yang memperlihatkan biaya alokasi memori beserta beban garbage collector-nya.
  • Opsi -cpuprofile merekam profil CPU ke berkas, kemudian go tool pprof -top menampilkan fungsi terboros dengan kolom flat dan cum.

Referensi#