Hoá đơn điện nhà bạn cao bất thường. Cách sai là đi thay hết bóng đèn sang loại tiết kiệm vì đó là thứ dễ làm nhất — trong khi thủ phạm có thể là cái tủ lạnh cũ ngốn tám phần mười. Cách đúng là gắn một cái đồng hồ đo vào từng ổ cắm, để nó chỉ thẳng vào kẻ ngốn điện, rồi mới ra tay. Tối ưu hiệu năng y hệt: pprof là cái đồng hồ đo đó — nó chỉ đúng hàm đang đốt CPU và bộ nhớ — còn benchmark là việc đọc lại hoá đơn sau khi sửa để biết có đỡ thật hay không. Làm ngược lại, đo bằng cảm giác, là cách chắc chắn nhất để phí một tuần thay bóng đèn trong khi tủ lạnh vẫn chạy. Và điều đẹp nhất: Go có cả đồng hồ lẫn sổ hoá đơn 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 — đúng cái đồng hồ đo chỉ vào tủ lạnh.
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. (Cái tủ lạnh, không phải cái bóng đèn.)
Nếu muốn có ngay điểm khởi đầu, chạy một dòng này trên dự án của bạn:
go test -bench=. -benchmem ./... | sort -k4 -rn | head
Nó sắp benchmark theo cột B/op, và hàm đứng đầu là chỗ sinh rác nhiều nhất trong cả dự án. Như phép đo ở đầu bài, giảm số lần cấp phát ở đúng chỗ đó thường cho khác biệt lớn hơn mọi tối ưu vi mô khác gộp lại.
Mẫu số chung
Đo hiệu năng là kỹ năng có cùng hình dạng ở mọi ngôn ngữ — chỉ khác ở chỗ bao nhiêu thứ nằm sẵn trong hộp.
- Go gói cả benchmark lẫn profiler vào
go testvàgo tool pprof— hai cờ dòng lệnh, đó là điều khiến bài này ngắn. - Java tách ra thành add-on: JMH cho benchmark (cả một dự án, như bài 90 nói), async-profiler/JFR cho hồ sơ (bài 89).
- Rust có
criterion— và đáng nói là nó gắn sẵn phân tích thống kê (đúng vaibenchstat), cộngcargo flamegraph. - C/C++ dùng Google Benchmark,
perf, vàvalgrind/massifcho bộ nhớ. Python cótimeit,cProfile,py-spy.
Nhưng công cụ chỉ là vỏ; ba định luật bên dưới thì bất biến qua mọi ngôn ngữ, và chính chúng mới đáng mang theo. Một: đánh bại việc xoá mã chết — trình biên dịch nào cũng muốn vứt kết quả bạn không dùng, nên Blackhole của JMH, biến-package của Go, black_box của Rust là cùng một mẹo. Hai: một lần chạy không phải một phép đo — nhiễu thường lớn hơn khác biệt thật, nên phải có ý nghĩa thống kê (benchstat, ± error của JMH, phần thống kê dựng sẵn của criterion); bỏ bước này là đang đoán mà tưởng mình đang đo. Ba, và quan trọng nhất: đo trước khi tối ưu — trực giác về "chỗ nào chậm" gần như luôn sai, và tối ưu một hàm chiếm 0,1% là phí thời gian ở mọi ngôn ngữ. Cộng một hệ quả riêng cho ngôn ngữ có GC (Go, Java, C#): cắt số lần cấp phát là cái đòn bẩy lớn nhất, vì nó giảm luôn áp lực lên bộ thu gom — cùng một lý do cột allocs/op đáng nhìn trước cột ns/op.
Ngày mai: Go module nâng cao — replace, vendor, và workspace.
Bài tập làm thử
Bài 1 (đọc hiểu). Với bảng kết quả benchmark sau, cột nào bạn nên nhìn trước tiên để tìm nguyên nhân chậm, và tại sao?
BenchmarkNoiCong-16 14571 83869 ns/op 530277 B/op 999 allocs/op
BenchmarkNoiBuilder-16 918501 1153 ns/op 3320 B/op 9 allocs/op
Đáp án
Cột allocs/op nên 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. 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, và strings.Builder đã giảm allocs từ 999 xuống 9, kéo theo thời gian giảm từ 83869 ns xuống 1153 ns.
Bài 2 (sửa lỗi). Benchmark sau có ít nhất hai lỗi khiến kết quả đo sai lệch. Tìm và sửa.
func BenchmarkXuLy(b *testing.B) {
du := dungDuLieuLon()
var s string
for i := 0; i < b.N; i++ {
s = Noi(1000)
}
}
Đáp án
Lỗi 1: dungDuLieuLon() (bước chuẩn bị) chạy trong thời gian đo vì không có b.ResetTimer() sau nó.
Lỗi 2: kết quả s không được dùng ở đâu ngoài vòng lặp, nên trình biên dịch có thể xoá mã tính toán (dead code elimination), khiến benchmark đo sai (nhanh giả tạo).
var ketQua string
func BenchmarkXuLy(b *testing.B) {
du := dungDuLieuLon()
b.ResetTimer()
var s string
for i := 0; i < b.N; i++ {
s = Noi(1000)
}
ketQua = s // gán vào biến package để không bị xoá
_ = du
}
Bài 3 (đọc hiểu). Trong hồ sơ pprof sau, runtime.concatstrings có flat 5.95% nhưng cum 60.16%. Giải thích ý nghĩa của hai cột này và tại sao chênh lệch lớn như vậy.
flat flat% cum cum%
290ms 5.95% 2930ms 60.16% runtime.concatstrings
Đáp án
flat là thời gian chạy trong chính hàm đó (không tính các hàm nó gọi); cum (cumulative) là thời gian của hàm đó cộng với mọi hàm nó gọi xuống. concatstrings bản thân chỉ tốn 290ms (5.95%) để làm việc của nó, nhưng nó gọi xuống các hàm khác (như memmove để chép byte) tốn thêm nhiều thời gian, nâng tổng tích luỹ lên 2930ms (60.16%). Hàm có cum cao mà flat thấp là hàm điều phối — nên đi sâu vào hàm con của nó để tìm nút thắt cổ chai thật sự.
Bài 4 (vận dụng thực tế). Bạn nghi ngờ một dịch vụ Go trong sản xuất đang cấp phát bộ nhớ quá nhiều nhưng không muốn dừng dịch vụ để đo. Viết đoạn mã import cần thiết và câu lệnh để lấy hồ sơ heap từ xa trong 30 giây.
Đáp án
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Sau đó lấy hồ sơ heap bằng:
go tool pprof http://localhost:6060/debug/pprof/heap
(Endpoint profile?seconds=30 dùng cho CPU; với heap thì lấy snapshot ngay không cần tham số thời gian.) Cần nhớ chỉ mở endpoint này trên localhost hoặc sau xác thực, vì 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ý.
Bài 5 (bẫy/đánh đổi). Bài viết nói "hồ sơ trước, benchmark sau" là thứ tự đúng khi tối ưu, và làm ngược lại là "cách phổ biến nhất để lãng phí một tuần". Giải thích bằng chính phép ẩn dụ trong bài, và nêu hệ quả nếu bỏ qua bước đo benchstat khi so sánh hai phiên bản.
Đáp án
Ẩn dụ: hoá đơn điện cao bất thường mà đi thay hết bóng đèn (dễ làm) trong khi thủ phạm thật là cái tủ lạnh cũ ngốn điện — tối ưu mà không đo trước (pprof) rất dễ nhắm nhầm vào một hàm chỉ chiếm 0,1% thời gian chạy, phí công sức mà không cải thiện gì đáng kể. pprof đóng vai "đồng hồ đo tại từng ổ cắm", chỉ thẳng ra kẻ ngốn tài nguyên thật.
Bỏ qua benchstat: chạy benchmark một lần rồi so hai con số là không đủ vì nhiễu đo (do lịch trình hệ điều hành, tần số CPU thay đổi...) dễ lớn hơn khác biệt thật giữa hai phiên bản mã. Không kiểm tra ý nghĩa thống kê thì bạn có thể kết luận "đã nhanh hơn" hoặc "đã chậm đi" trong khi thực ra chỉ là nhiễu ngẫu nhiên — tức là đang đoán mà tưởng mình đang đo.