Bài escape analysis đã dùng allocs/op để đo số lần thoát heap. Nhưng con số đó chỉ đáng tin khi benchmark được viết đúng — và cực kỳ dễ viết sai. Một benchmark có thể tự tin báo "0 cấp phát" trong khi hàm thực sự cấp 1KB mỗi lần, chỉ vì bạn quên một dòng. Bài này mổ xẻ hai cái bẫy phổ biến nhất, đo trực tiếp cách chúng làm benchmark nói dối, và cách viết benchmark cấp phát mà bạn có thể tin.

Hai con số: B/op và allocs/op

Khi chạy go test -bench=. -benchmem, mỗi dòng kết quả có thêm hai cột:

  • B/op: số byte trung bình được cấp phát trên heap cho mỗi thao tác (mỗi vòng lặp b.N).
  • allocs/op: số lần gọi bộ cấp phát heap cho mỗi thao tác.

allocs/op thường quan trọng hơn B/op khi tối ưu, vì mỗi lần cấp phát là một lần chạm allocator + một mẩu việc cho GC về sau. Giảm allocs/op từ 3 xuống 0 thường tăng tốc rõ hơn giảm B/op.

Ảnh chụp đoạn mã Go nền tối minh hoạ đo cấp phát bằng benchmem và bẫy khiến benchmark nói dối, go test cho biết B/op byte cấp phát mỗi thao tác và allocs/op số lần cấp phát nhưng nếu viết sai benchmark báo 0 cấp phát trong khi thực tế có vì compiler xoá code, func taoSlice trả về make byte 1024, var sink byte biến package-level giữ kết quả, bẫy BenchmarkKhongSink kết quả vứt đi compiler xoá cấp phát 0 alloc giả for i từ 0 tới b.N gạch dưới bằng taoSlice vứt đi ngay tối ưu xoá sạch, đúng BenchmarkCoSink gán vào sink compiler không xoá được alloc thật sink bằng taoSlice kết quả thoát ra ngoài, cách khác đếm cấp phát độc lập với testing AllocsPerRun n bằng testing AllocsPerRun 1000 func sink bằng taoSlice, ba luật vàng luôn thêm cờ benchmem hoặc gọi b ReportAllocs không có nó cột B op và allocs op không hiện, luôn gán kết quả vào sink package-level để compiler không xoá cấp phát, dùng testing AllocsPerRun khi chỉ cần đếm số cấp phát chính xác

Hình 1: Hàm taoSlice cấp 1024 byte. Không có sink, compiler xoá cấp phát; có sink (biến package-level), cấp phát tồn tại thật. Ba luật vàng ở khối dưới.

Bẫy 1: thiếu -benchmem thì không thấy gì

Bẫy đầu tiên đơn giản nhất: nếu bạn quên cờ -benchmem (và không gọi b.ReportAllocs() trong code), kết quả benchmark không có cột B/op và allocs/op. Bạn chỉ thấy ns/op và tưởng như thế là đủ — trong khi hàm có thể đang cấp phát điên cuồng mà bạn không biết.

Bẫy 2: thiếu sink thì compiler xoá cấp phát

Đây là cái bẫy nguy hiểm hơn nhiều, vì nó cho ra con số sai chứ không phải thiếu. Xét hai benchmark gọi cùng hàm taoSlice() cấp 1024 byte:

func BenchmarkKhongSink(b *testing.B) {
    for i := 0; i < b.N; i++ { _ = taoSlice() }   // vứt kết quả
}
func BenchmarkCoSink(b *testing.B) {
    for i := 0; i < b.N; i++ { sink = taoSlice() } // gán vào sink
}

Đo thật với -benchmem:

Ảnh chụp bảng kết quả đo thật nền tối cùng hàm hai kết quả khác nhau Go 1.23, không benchmem cột cấp phát không hiện BenchmarkKhongSink 0.23 ns/op BenchmarkCoSink 111.6 ns/op thiếu benchmem thì không biết gì về cấp phát, có benchmem sự thật lộ ra BenchmarkKhongSink 0.23 ns/op 0 B/op 0 allocs/op BenchmarkCoSink 112.4 ns/op 1024 B/op 1 allocs/op KhongSink báo 0 cấp phát nhưng đó là dối compiler xoá cả make byte 1024 vì kết quả bị vứt đi 0.23 ns gần như rỗng CoSink mới là sự thật 1024 byte 1 cấp phát mỗi lần, testing AllocsPerRun đếm độc lập AllocsPerRun 1 cấp phát mỗi lần gọi xác nhận con số của CoSink, cốt lõi benchmark cấp phát dễ nói dối theo hai cách thiếu benchmem thì không thấy cấp phát thiếu sink thì compiler xoá cấp phát và báo 0 sai

Hình 2: BenchmarkKhongSink báo 0 B/op, 0 allocs/op — DỐI, vì compiler xoá cả make([]byte,1024) khi kết quả bị vứt (0,23 ns nghĩa là gần như không làm gì). BenchmarkCoSink mới đúng: 1024 B, 1 cấp phát. AllocsPerRun xác nhận 1.

Kết quả gây sốc: cùng một hàm taoSlice(), nhưng:

  • BenchmarkKhongSink: 0,23 ns/op, 0 B/op, 0 allocs/op. Trông như hàm hoàn hảo không cấp phát gì! Nhưng đó là dối. Vì kết quả taoSlice() bị _ = vứt đi ngay, trình biên dịch nhận ra không ai dùng nó và xoá luôn cả lời gọi (dead code elimination). Con số 0,23 ns (bằng đúng một vòng lặp rỗng) là dấu hiệu: hàm đã bị tối ưu biến mất.
  • BenchmarkCoSink: 112,4 ns/op, 1024 B/op, 1 allocs/op. Đây mới là sự thật. Vì kết quả được gán vào sink (biến package-level), compiler không thể chứng minh nó vô dụng, nên phải giữ lại cấp phát.

Chênh lệch là toàn bộ sự thật về hàm. Nếu bạn tin BenchmarkKhongSink, bạn sẽ kết luận hàm không cấp phát và bỏ qua một nguồn 1KB/lần — sai hoàn toàn.

Cách đúng: sink + AllocsPerRun

Luôn gán kết quả vào một biến package-level (sink). Đây là cách chuẩn để chặn compiler xoá code. Biến phải ở cấp package (không phải local), nếu không compiler vẫn có thể chứng minh nó chết.

Dùng testing.AllocsPerRun khi chỉ cần đếm cấp phát. Hàm này chạy một closure N lần và trả về số cấp phát trung bình — độc lập với cơ chế benchmark, và cũng cần sink bên trong:

n := testing.AllocsPerRun(1000, func() { sink = taoSlice() })
// n = 1 — xác nhận con số của benchmark

Đo thật cho n = 1, khớp với BenchmarkCoSink. Đây là cách kiểm tra chéo tốt khi bạn nghi ngờ benchmark của mình bị tối ưu sai.

Ứng dụng thực tế

Nghi ngờ ngay khi thấy 0 allocs kèm ns/op cực thấp. Nếu một benchmark báo 0 allocs/op và thời gian chỉ ~0,2-0,3 ns (bằng vòng lặp rỗng), gần như chắc chắn hàm đã bị tối ưu biến mất. Một hàm thật sự làm việc mà không cấp phát vẫn tốn thời gian cho công việc đó. 0 allocs + ~0,25 ns = cờ đỏ.

Đưa kết quả ra ngoài benchmark, không chỉ dùng nội bộ. Ngay cả khi gán vào biến local rồi tính toán, compiler đôi khi vẫn tối ưu được. Cách chắc ăn là gán vào biến package-level và (nếu cần) đọc lại nó sau vòng lặp.

Chạy -count=N để kiểm độ ổn định. Một lần chạy có thể nhiễu. go test -bench=. -benchmem -count=10 chạy 10 lần; nếu allocs/op dao động thì kết quả không đáng tin (thường do GC hoặc tối ưu không nhất quán). allocs/op nên là số nguyên ổn định giữa các lần.

Đánh đổi cần cân nhắc

b.ReportAllocs() vs cờ -benchmem. Gọi b.ReportAllocs() trong code benchmark bật báo cáo cấp phát chỉ cho benchmark đó, kể cả khi quên -benchmem. Đây là cách phòng thủ tốt cho các benchmark mà cấp phát là điểm chính. Nhưng cờ -benchmem tiện hơn khi muốn xem mọi benchmark. Dùng cả hai không hại gì.

Sink có chi phí nhỏ. Việc gán vào biến package-level có một chi phí ghi bộ nhớ nhỏ, về lý thuyết làm ns/op cao hơn thực tế một chút. Với đo cấp phát thì không quan trọng (allocs/op không đổi), nhưng khi đo thời gian tinh vi, hãy ý thức rằng sink thêm một phép ghi.

Benchmark micro không phải sự thật toàn cục. allocs/op của một hàm đơn lẻ đúng cho hàm đó, nhưng inlining và tối ưu liên hàm có thể làm nó cấp phát khác khi gọi trong ngữ cảnh thật. Luôn kiểm lại bằng profiling trên chương trình thật (chủ đề pprof sau) khi tối ưu nghiêm túc.

Ba ý mang về

  1. Luôn dùng -benchmem (hoặc b.ReportAllocs()) để thấy B/op và allocs/op — thiếu nó thì benchmark im lặng về cấp phát, và bạn có thể bỏ sót nguồn cấp phát lớn.
  2. Luôn gán kết quả vào một sink package-level: đo thật, cùng hàm cấp 1024 byte báo 0 allocs/op (dối, vì compiler xoá code khi kết quả bị vứt) so với 1 allocs/op (thật, khi gán vào sink) — 0 allocs kèm ~0,25 ns là cờ đỏ hàm bị tối ưu mất.
  3. Dùng testing.AllocsPerRun để đếm chéo và -count=N để kiểm ổn định: allocs/op nên là số nguyên ổn định; dao động nghĩa là kết quả không đáng tin.

Phần sau ta chuyển từ đo cấp phát sang giảm nó: Phần sau mổ xẻ kỹ thuật tái dùng buffer — cắt allocation bằng cách dùng lại slice, các mẫu [:0] và buffer sẵn, đo thật mức tiết kiệm.