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:
-16 là GOMAXPROCS. 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_space — tổ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_objects và inuse_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.