Bài này định viết theo lối quen: liệt kê bốn cái bẫy kinh điển của microbenchmark, mỗi cái một ví dụ hỏng.
Tôi dựng đủ bốn ví dụ, đo bằng JMH để đối chiếu, và ba trong bốn cái không hề xảy ra như sách vở nói. Phần thú vị của bài nằm ở đó.
Phép đo tự viết, và kết quả của nó
long t = System.nanoTime();
for (int i = 1; i <= N; i++) tinh(i); // bỏ kết quả đi
System.out.printf("%.2f ns/phép", (double)(System.nanoTime()-t)/N);
với tinh(x) = Math.sqrt(x) + Math.log(x+1) + Math.sin(x).
bỏ kết quả đi : 38,61 ns/phép
có dùng kết quả : 39,79 ns/phép
đầu vào hằng : 5,48 ns/phép
đầu vào đổi : 7,53 ns/phép
Bốn con số, và ba kết luận sai nằm sẵn trong đó.
Cái bẫy thật: thiếu làm nóng
Nhìn kỹ bảng trên: hai dòng đầu ra ~39 ns, hai dòng sau ra ~6 ns. Cùng một hàm tinh.
Nghi ngờ đầu tiên của tôi là thứ tự đo — các vòng lặp sau hưởng lợi từ việc JIT đã biên dịch xong. Kiểm bằng cách đảo thứ tự trong hai lần chạy riêng:
chạy A: đo ĐỔI trước 8,28 ns -> rồi HẰNG 5,41 ns
chạy B: đo HẰNG trước 5,40 ns -> rồi ĐỔI 7,54 ns
Thứ tự không đổi kết quả — hằng luôn ~5,4, đổi luôn ~7,5. Nên chênh lệch giữa hai nhóm không phải do nhiễm bẩn thứ tự.
Vậy 38,61 ns ở dòng đầu là gì? Là vòng lặp đầu tiên chạy trên mã chưa được biên dịch. Đúng đường cong ở bài 84: lần chạy đầu chậm hơn hai mươi lần.
Đây là cái bẫy có thật, lớn nhất, và cũng dễ mắc nhất — vì phép đo trông hoàn toàn hợp lý.
JMH cho con số đúng:
B.traVe avgt 10 7,928 ± 0,176 ns/op
7,93 so với 38,61. Sai gần năm lần.
Loại bỏ mã chết: không xảy ra
Sách vở nói: bỏ kết quả đi thì JIT thấy phép tính vô dụng và xoá luôn, nên bạn đo một vòng lặp rỗng.
Phép đo nói khác:
B.boKetQua avgt 10 7,695 ± 0,153 ns/op <- bỏ kết quả
B.traVe avgt 10 7,928 ± 0,176 ns/op <- trả về kết quả
B.blackhole avgt 10 7,745 ± 0,157 ns/op <- dùng Blackhole
Ba con số như nhau trong khoảng sai số.
Lý do: Math.log và Math.sin là hàm nội tại ánh xạ xuống lời gọi thư viện toán học, và C2 không mạnh dạn xoá chúng. Với một phép tính thuần số học đơn giản hơn — cộng, nhân trên biến cục bộ — thì nó xoá thật.
Nên cái bẫy này có thật nhưng phụ thuộc phép tính. Vấn đề là bạn không biết trước phép tính của mình thuộc loại nào, và đó chính là lý do vẫn phải dùng Blackhole:
@Benchmark public void b(Blackhole bh) { bh.consume(tinh(x)); }
Blackhole.consume được viết sao cho JIT không chứng minh được là vô dụng. Trả về giá trị từ phương thức @Benchmark cũng đủ trong phần lớn trường hợp — JMH tự tiêu thụ giá trị trả về.
Gấp hằng số: cũng không xảy ra
Đây là chỗ tôi kết luận sai và JMH sửa lại cho.
Từ bảng đầu bài, tôi đã định viết: "đầu vào hằng 5,48 ns so với đầu vào đổi 7,53 ns — JIT đã tính sẵn tinh(42) lúc biên dịch".
Nên tôi đo tách bạch, ba cách truyền cùng giá trị 42:
C.a_hangViet avgt 10 5,484 ± 0,086 ns/op <- tinh(42)
C.b_hangStatic avgt 10 5,550 ± 0,039 ns/op <- tinh(HANG_STATIC)
C.c_tuState avgt 10 5,535 ± 0,114 ns/op <- tinh(truongCuaState)
C.d_khongLamGi avgt 10 0,276 ± 0,003 ns/op <- mốc dưới
Ba dòng đầu giống hệt nhau. Nếu có gấp hằng, dòng đầu phải xuống gần 0,276 như dòng cuối.
Vậy chênh lệch 5,4 so với 7,5 trong phép đo tự viết đến từ đâu? Từ chính phép i % 1000 + 1 — một phép chia lấy dư trên số nguyên, cộng một phép cộng. Tôi đã đo thêm công việc rồi gán cho nó cái tên "gấp hằng".
Dòng d_khongLamGi với 0,276 ns cũng đáng để ý: nó là sàn đo của JMH, và cho thấy phép đo không bị xoá mất — nếu benchmark của bạn ra con số cỡ đó, gần như chắc chắn JIT đã loại bỏ nó.
Còn gấp hằng thì sao?
Nó có thật, chỉ là ví dụ của tôi không kích hoạt được vì HotSpot không gấp Math.log và Math.sin.
Cách phòng vẫn nên làm, và JMH cho sẵn hai công cụ:
@State — đặt dữ liệu vào trường của một đối tượng trạng thái, JIT không biết giá trị lúc biên dịch.
@Param — khai nhiều giá trị đầu vào, JMH chạy benchmark một lần cho mỗi giá trị.
@State(Scope.Thread)
public class B {
@Param({"10", "1000", "100000"}) int n;
@Benchmark public double b() { return tinh(n); }
}
@Param còn có lợi ích thứ hai quan trọng hơn: nó buộc bạn đo trên nhiều kích thước đầu vào, và rất nhiều kết luận hiệu năng chỉ đúng ở một kích thước.
Cái bẫy thứ tư: chung một JVM
Cái này tôi không tái hiện được trực tiếp, nhưng cơ chế thì rõ và JMH xử lý bằng thiết kế.
Chạy hai benchmark trong cùng một JVM thì cái thứ hai kế thừa hồ sơ JIT của cái thứ nhất: một điểm gọi đã thấy hai kiểu sẽ không còn được tối ưu đơn hình nữa, đúng cơ chế khử tối ưu ở bài 84.
Đó là lý do JMH mặc định @Fork(1) trở lên — mỗi benchmark chạy trong một JVM mới tinh. Tôi dùng @Fork(2) trong bài này, và đó cũng là lý do cột Cnt hiện 10 chứ không phải 5: hai fork nhân năm lần đo.
Đừng bao giờ đặt @Fork(0). Nó chạy trong JVM của chính JMH, nhanh hơn nhiều nhưng kết quả không tin được.
Đọc kết quả JMH
Benchmark Mode Cnt Score Error Units
B.traVe avgt 10 7,928 ± 0,176 ns/op
± 0,176 là khoảng tin cậy 99,9%. Đây là thứ phép đo tự viết không bao giờ cho bạn, và nó quan trọng: hai benchmark 7,9 ± 0,2 và 8,0 ± 0,2 là không phân biệt được, dù con số khác nhau.
Quy tắc tôi dùng: chênh lệch phải lớn hơn tổng hai sai số thì mới đáng nói. Sai số lớn hơn 5% giá trị nghĩa là phép đo nhiễu — tăng số vòng hoặc kiểm xem máy có đang chạy thứ khác không.
Bốn chế độ đo, chọn theo câu hỏi:
Mode.AverageTime — thời gian trung bình mỗi thao tác. Mặc định hợp lý.
Mode.Throughput — số thao tác mỗi giây. Hợp khi so hai thiết kế khác nhau về bản chất.
Mode.SampleTime — cho cả phân vị, đúng thứ cần khi quan tâm độ trễ đuôi như bài 86.
Mode.SingleShotTime — đo đúng một lần, không làm nóng. Dùng để đo chi phí khởi động lạnh.
Khuôn tối thiểu
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class B {
@Benchmark public double b() { return tinh(x); }
}
Chạy bằng mvn package && java -jar target/bench.jar. Cấu hình mặc định của JMH (5 fork, 5 vòng làm nóng, 5 vòng đo) chuẩn hơn nhưng chậm; con số trên là mức tối thiểu tôi thấy còn tin được.
Nhưng đừng dùng nó cho mọi thứ
JMH đo một đoạn mã cô lập. Nó không cho bạn biết đoạn đó có phải chỗ nghẽn của hệ thống không.
Thứ tự đúng: hồ sơ trước (bài 89) để tìm chỗ nóng, JMH sau để so hai cách viết cho chính chỗ đó. Làm ngược lại là tối ưu một hàm chiếm 0,1% thời gian chạy.
Và như bài này cho thấy, ngay cả với công cụ tốt, việc giải thích con số vẫn là chỗ dễ sai nhất. Đo được chênh lệch là một chuyện; biết vì sao có chênh lệch đó là chuyện khác.
Thử ba mươi giây
Lấy một phép đo System.nanoTime() bất kỳ trong dự án của bạn, chạy vòng lặp đó hai lần liên tiếp, và in cả hai kết quả.
Nếu lần thứ hai nhanh hơn đáng kể, phép đo cũ của bạn đang đo trình thông dịch — và mọi kết luận rút ra từ nó cần xem lại. Đó chính là khoảng cách giữa 38,61 và 7,93 ở đầu bài.
Ngày mai bắt đầu chặng công cụ: Maven — các pha trong vòng đời build, phạm vi phụ thuộc, và cách đọc cây phụ thuộc để gỡ xung đột phiên bản.