Blog Go
Benchmark dự đoán được hơn với testing.B.Loop
Các lập trình viên Go đã viết benchmark bằng gói
testing có thể đã gặp phải một số
bẫy phổ biến. Go 1.24 giới thiệu một cách viết benchmark mới vừa dễ sử dụng,
vừa mạnh mẽ hơn nhiều:
testing.B.Loop.
Theo truyền thống, benchmark Go được viết bằng vòng lặp từ 0 đến b.N:
func Benchmark(b *testing.B) {
for range b.N {
... code to measure ...
}
}
Thay thế bằng b.Loop chỉ là một thay đổi nhỏ:
func Benchmark(b *testing.B) {
for b.Loop() {
... code to measure ...
}
}
testing.B.Loop có nhiều lợi ích:
- Nó ngăn chặn các tối ưu hóa trình biên dịch không mong muốn bên trong vòng lặp benchmark.
- Nó tự động loại trừ mã thiết lập và dọn dẹp khỏi thời gian đo benchmark.
- Mã không thể vô tình phụ thuộc vào tổng số lần lặp hoặc lần lặp hiện tại.
Tất cả những điều này đều là lỗi dễ mắc phải với benchmark kiểu b.N
và sẽ dẫn đến kết quả benchmark sai mà không có cảnh báo nào. Ngoài ra, benchmark kiểu b.Loop
còn hoàn thành trong thời gian ngắn hơn!
Hãy khám phá những ưu điểm của testing.B.Loop và cách sử dụng hiệu quả.
Các vấn đề với vòng lặp benchmark cũ
Trước Go 1.24, dù cấu trúc cơ bản của benchmark đơn giản, các benchmark phức tạp hơn đòi hỏi nhiều sự chú ý hơn:
func Benchmark(b *testing.B) {
... setup ...
b.ResetTimer() // if setup may be expensive
for range b.N {
... code to measure ...
... use sinks or accumulation to prevent dead-code elimination ...
}
b.StopTimer() // if cleanup or reporting may be expensive
... cleanup ...
... report ...
}
Nếu thiết lập hoặc dọn dẹp không tầm thường, lập trình viên cần bao quanh vòng lặp benchmark
bằng các lời gọi ResetTimer và/hoặc StopTimer. Những thứ này dễ bị quên,
và ngay cả khi lập trình viên nhớ chúng có thể cần thiết, cũng khó để đánh giá xem liệu thiết lập hoặc
dọn dẹp có “đủ tốn kém” để cần chúng hay không.
Nếu không có những thứ này, gói testing chỉ có thể đo thời gian toàn bộ hàm benchmark. Nếu một
hàm benchmark bỏ qua chúng, mã thiết lập và dọn dẹp sẽ được tính vào
thời gian đo tổng thể, làm sai lệch kết quả benchmark cuối cùng mà không có thông báo.
Có một bẫy khác, tinh tế hơn, đòi hỏi hiểu biết sâu hơn: (Nguồn ví dụ)
func isCond(b byte) bool {
if b%3 == 1 && b%7 == 2 && b%17 == 11 && b%31 == 9 {
return true
}
return false
}
func BenchmarkIsCondWrong(b *testing.B) {
for range b.N {
isCond(201)
}
}
Trong ví dụ này, người dùng có thể thấy isCond thực thi trong thời gian dưới nanosecond.
CPU nhanh, nhưng không nhanh đến vậy! Kết quả bất thường này xuất phát
từ thực tế rằng isCond được inline, và vì kết quả của nó không bao giờ được dùng, trình biên dịch
loại nó ra như là dead code. Kết quả là, benchmark này không đo isCond
chút nào; nó đo thời gian để không làm gì. Trong trường hợp này, kết quả dưới nanosecond
là một dấu hiệu cảnh báo rõ ràng, nhưng trong các benchmark phức tạp hơn, việc loại một phần dead code
có thể cho ra kết quả trông hợp lý nhưng vẫn không đo đúng thứ cần đo.
testing.B.Loop giúp ích như thế nào
Khác với benchmark kiểu b.N, testing.B.Loop có thể theo dõi khi nào nó được gọi lần đầu
trong một benchmark và khi nào lần lặp cuối kết thúc. b.ResetTimer ở đầu vòng lặp
và b.StopTimer ở cuối được tích hợp vào testing.B.Loop, loại bỏ nhu cầu
quản lý timer benchmark thủ công cho mã thiết lập và dọn dẹp.
Hơn nữa, trình biên dịch Go giờ đây phát hiện các vòng lặp mà điều kiện chỉ là một lời gọi đến
testing.B.Loop và ngăn chặn việc loại dead code trong vòng lặp. Trong Go 1.24, điều này
được thực hiện bằng cách không cho phép inline vào thân của vòng lặp như vậy, nhưng chúng tôi dự định
cải thiện điều này trong tương lai.
Một tính năng hay khác của testing.B.Loop là cách tiếp cận khởi động một lần. Với benchmark kiểu b.N,
gói testing phải gọi hàm benchmark nhiều lần với các giá trị khác nhau
của b.N, tăng dần cho đến khi thời gian đo đạt ngưỡng. Ngược lại, b.Loop
có thể đơn giản chạy vòng lặp benchmark cho đến khi đạt ngưỡng thời gian, và chỉ cần gọi
hàm benchmark một lần. Nội bộ, b.Loop vẫn sử dụng quá trình khởi động để phân bổ
chi phí đo lường, nhưng điều này được ẩn khỏi bên gọi và có thể hiệu quả hơn.
Một số ràng buộc của vòng lặp kiểu b.N vẫn áp dụng cho vòng lặp kiểu b.Loop.
Người dùng vẫn có trách nhiệm quản lý timer trong vòng lặp benchmark,
khi cần thiết:
(Nguồn ví dụ)
func BenchmarkSortInts(b *testing.B) {
ints := make([]int, N)
for b.Loop() {
b.StopTimer()
fillRandomInts(ints)
b.StartTimer()
slices.Sort(ints)
}
}
Trong ví dụ này, để benchmark hiệu năng sắp xếp tại chỗ của slices.Sort, cần một mảng
được khởi tạo ngẫu nhiên cho mỗi lần lặp. Người dùng vẫn phải
quản lý timer thủ công trong những trường hợp như vậy.
Hơn nữa, vẫn cần có đúng một vòng lặp như vậy trong thân hàm benchmark
(vòng lặp kiểu b.N không thể cùng tồn tại với vòng lặp kiểu b.Loop), và mỗi lần lặp của
vòng lặp nên làm cùng một việc.
Khi nào nên dùng
Phương thức testing.B.Loop hiện là cách ưu tiên để viết benchmark:
func Benchmark(b *testing.B) {
... setup ...
for b.Loop() {
// optional timer control for in-loop setup/cleanup
... code to measure ...
}
... cleanup ...
}
testing.B.Loop cung cấp benchmark nhanh hơn, chính xác hơn và
trực quan hơn.
Lời cảm ơn
Xin gửi lời cảm ơn chân thành đến tất cả mọi người trong cộng đồng đã đưa ra phản hồi về vấn đề đề xuất và báo cáo lỗi khi tính năng này được phát hành! Tôi cũng biết ơn Eli Bendersky vì những tổng kết blog hữu ích của ông. Và cuối cùng xin cảm ơn Austin Clements, Cherry Mui và Michael Pratt vì sự đánh giá, công việc chu đáo về các lựa chọn thiết kế và cải thiện tài liệu. Cảm ơn tất cả vì những đóng góp của bạn!
Bài tiếp theo: Kiểm tra Bảo mật Mật mã học Go
Bài trước: Tạm biệt core types, chào Go như ta biết và yêu mến!
Mục lục blog