Go có benchmark và profiler trong thư viện chuẩn. Bài 90 sê-ri Java cần cả một dự án Maven với JMH để làm điều tương đương; ở đây là hai cờ dòng lệnh.

Benchmark

func BenchmarkNoiCong(b *testing.B) {
	for i := 0; i < b.N; i++ {
		NoiCong(1000)
	}
}
go test -bench=. -benchmem -run=XXX
  BenchmarkNoiCong-16           14571     83869 ns/op   530277 B/op   999 allocs/op
  BenchmarkNoiBuilder-16       918501      1153 ns/op     3320 B/op     9 allocs/op
  BenchmarkNoiCapPhatTruoc-16 1670778     711.7 ns/op     1024 B/op     1 allocs/op

118 lần giữa dòng đầu và dòng cuối.

Đọc các cột:

-16GOMAXPROCS. Con số đầu là số lần chạy Go tự chọn để đủ ổn định. ns/op là thời gian mỗi lần.

allocs/op là cột tôi nhìn trước tiên. 999 lần cấp phát cho một hàm nối 1000 ký tự nghĩa là mỗi vòng lặp tạo một chuỗi mới — đúng vấn đề chuỗi bất biến ở bài 22.

strings.Builder giảm còn 9 lần (nó tự nhân đôi bộ đệm). Thêm b.Grow(n) để cấp một lần: còn 1.

Giảm số lần cấp phát thường là cách tối ưu hiệu quả nhất trong Go, vì nó giảm luôn áp lực lên bộ thu gom rác.

-run=XXX là mẹo để bỏ qua test thường và chỉ chạy benchmark.

Ba quy tắc viết benchmark đúng

Đừng để trình biên dịch xoá mã. Kết quả không dùng thì có thể bị loại bỏ:

var kq string
func BenchmarkX(b *testing.B) {
	var s string
	for i := 0; i < b.N; i++ { s = Noi(1000) }
	kq = s        // gán vào biến package -> không xoá được
}

Đây là cùng vấn đề với Blackhole của JMH ở bài 90 sê-ri Java.

Loại phần chuẩn bị khỏi phép đo:

func BenchmarkY(b *testing.B) {
	du := dungDuLieuLon()
	b.ResetTimer()
	for i := 0; i < b.N; i++ { XuLy(du) }
}

Đừng đo chỉ một kích thước. Kết luận thường đổi theo quy mô:

for _, n := range []int{10, 1000, 100000} {
	b.Run(fmt.Sprint(n), func(b *testing.B) { ... })
}

So sánh hai phiên bản: benchstat

Chạy benchmark một lần rồi so hai con số là không đủ — nhiễu đo dễ lớn hơn khác biệt thật.

go test -bench=. -count=10 > cu.txt
# sửa mã
go test -bench=. -count=10 > moi.txt
benchstat cu.txt moi.txt

benchstat cho biết khác biệt có ý nghĩa thống kê hay không. Đây là thứ tương đương cột ± error của JMH, và nó là ranh giới giữa đo và đoán.

pprof: hồ sơ CPU

go test -bench=X -cpuprofile=cpu.out -run=XXX
go tool pprof -top -nodecount=8 cpu.out
      flat  flat%   cum   cum%
    1380ms 28.34%  1380ms 28.34%  runtime.memmove
     290ms  5.95%  2930ms 60.16%  runtime.concatstrings
     280ms  5.75%  1220ms 25.05%  runtime.mallocgc
     180ms  3.70%   470ms  9.65%  runtime.scanobject

runtime.memmove chiếm 28% — đó là chi phí chép chuỗi. concatstrings 60% tích luỹ, mallocgc 25%, scanobject là bộ thu gom rác dọn rác do chính vòng lặp sinh ra.

Bốn dòng này kể trọn câu chuyện mà không cần đọc mã.

Phân biệt hai cột: flat là thời gian trong chính hàm đó; cum là cả hàm nó gọi. Hàm có cum cao mà flat thấp là hàm điều phối — đi sâu vào con của nó.

pprof: hồ sơ bộ nhớ

go tool pprof -top -sample_index=alloc_space mem.out
   16.55GB   100%   tien.NoiCong (inline)

16,55 GB cấp phát trong một lần chạy benchmark, và nó chỉ thẳng ra hàm. Không cần đoán.

Bốn chỉ số bộ nhớ, chọn theo câu hỏi:

alloc_spacetổng đã cấp, kể cả đã giải phóng. Dùng để tìm chỗ tạo rác nhiều. inuse_space — đang giữ. Dùng để tìm rò rỉ. alloc_objectsinuse_objects — theo số lượng thay vì dung lượng.

Xem trên trình duyệt

go tool pprof -http=:8080 cpu.out

Mở giao diện web với biểu đồ ngọn lửa và đồ thị lời gọi. Cách đọc giống bài 89 sê-ri Java: chiều ngang là thời gian, chiều dọc là độ sâu ngăn xếp, tìm cao nguyên rộng ở phía trên.

pprof trên dịch vụ đang chạy

import _ "net/http/pprof"

go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap
curl 'localhost:6060/debug/pprof/goroutine?debug=1'

Chi phí gần bằng không khi không lấy hồ sơ, nên bật thường trực là hợp lý — đúng lập luận về JFR ở bài 89 sê-ri Java.

Endpoint goroutine là công cụ tìm rò rỉ ở bài 38. Nhớ chỉ mở trên localhost hoặc sau xác thực.

Thứ tự đúng khi tối ưu

Hồ sơ trước, benchmark sau. pprof cho biết chỗ nào nóng; benchmark cho biết sửa có ăn thua không.

Làm ngược lại là tối ưu một hàm chiếm 0,1% thời gian chạy — và đó là cách phổ biến nhất để lãng phí một tuần.

Thử ba mươi giây

go test -bench=. -benchmem ./... | sort -k4 -rn | head

Sắp benchmark theo cột B/op. Hàm đứng đầu là chỗ sinh rác nhiều nhất trong dự án của bạn — và như phép đo ở đầu bài, giảm số lần cấp phát thường cho khác biệt lớn hơn mọi tối ưu vi mô khác.

Ngày mai: Go module nâng cao — replace, vendor, và workspace.