Tưởng tượng bạn bấm đồng hồ đo một vận động viên chạy một trăm mét — nhưng bấm từ lúc họ còn đang cúi buộc dây giày. Con số bạn ghi được là thật, đồng hồ không nói dối, nhưng nó không phải tốc độ chạy. Đo hiệu năng Java bằng một vòng lặp nanoTime tự viết đúng là kiểu bấm giờ đó: lần chạy đầu tiên bao gồm cả cái phần "buộc dây giày" của JIT, và tệ hơn, đôi khi bạn đo được một chênh lệch có thật rồi gán cho nó sai nguyên nhân.

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 ở đó. (Mọi con số dưới đây đo trên máy tôi với JDK 21 — tuyệt đối thì tuỳ máy, nhưng các tỷ lệ mới là thứ đáng đọc.)

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 cái đoạn buộc dây giày. Đú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".

Đây là cái bẫy nguy hiểm nhất của việc tự đo, và nó không nằm trong danh sách kinh điển: bạn đo được một chênh lệch thật, rồi gán cho nó một nguyên nhân sai. Con số đúng, câu chuyện sai, và không có gì báo cho bạn biết.

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.

Muốn thấy khoảng cách đó bằng 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, 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, giữa bấm giờ lúc buộc dây giày và bấm giờ lúc xuất phát.

Mẫu số chung

Cái đồng hồ bấm sai không phải bệnh riêng của Java — nó là cái giá của mọi runtime thông minh, và mỗi hệ sinh thái đều phải đẻ ra một bộ đo tử tế để chống lại chính sự thông minh đó. Go có testing.B với b.N mà runtime tự tăng dần tới khi đủ ổn định, cộng benchstat để so hai lần chạy bằng kiểm định thống kê thay vì so hai con số trần — và nó cũng có đúng cái bẫy loại-bỏ-mã-chết, chữa bằng cách gán vào một biến package-level. Rust dùng Criterion: tự làm nóng, tự vẽ phân bố, có black_box() đúng vai Blackhole. JavaScript thì tệ nhất vì V8 tối ưu và khử tối ưu liên tục, nên benchmark.js phải chạy nhiều mẫu rồi lấy thống kê. Ngay cả Python — vốn không JIT trong CPython — cũng cần timeit để nuốt chi phí khởi động và biến động bộ nhớ đệm.

Điểm chung, và là thứ đáng mang theo: một vòng lặp đo-thời-gian-quanh-nó tự viết gần như luôn đo sai trên bất kỳ nền tảng nào có JIT hoặc bộ nhớ đệm — nó đo trạng thái lạnh, đo tâm trạng của trình tối ưu, hoặc đo một thứ đã bị xoá mất. Ba kẻ thù luôn là ba đứa giống nhau: thiếu làm nóng, mã chết bị loại, và nhiễm bẩn giữa các phép đo. Nhưng kẻ thù thứ tư — gán sai nguyên nhân cho một chênh lệch có thật — thì không công cụ nào chữa được cho bạn. Nó đòi cái mà JMH chỉ có thể hỗ trợ: sự thành thật khi bạn chưa biết vì sao con số lại như vậy.

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.