Suốt bốn mươi ba bài, sê-ri này đo tốc độ để tách sự thật khỏi trực giác. Nhưng có một câu hỏi ta chưa quay ống kính vào: chính phép đo có đáng tin không? Viết một vòng lặp, kẹp đồng hồ hai đầu, đọc con số — nghe đơn giản, và đó là cách hầu hết người ta "benchmark". Bài này đo bốn cách mà chính con số ấy nói dối, và chúng không phải lý thuyết: mỗi con số trong bài mở màn đến bài trước đều phải vượt qua đúng bốn cái bẫy này mới đáng ghi ra.

Đo lường vi mô đúng cách

Bẫy 1: compiler xóa mất việc bạn định đo

Trình biên dịch tối ưu rất giỏi loại bỏ mã vô dụng. Nếu nó thấy kết quả một vòng lặp không được ai dùng, nó có quyền — và sẽ — xóa cả vòng lặp. Bạn kẹp đồng hồ quanh một vòng tính 200 triệu phép, nhưng nếu không "quan sát" kết quả, con số đọc ra là thời gian của... không gì cả:

Cùng vòng lặp 200 triệu phép tính:
  KHÔNG quan sát kết quả : 0,0000 ns/vòng   (compiler xóa vòng -> số GIẢ)
  CÓ sink (volatile)     : 0,3053 ns/vòng   (số THẬT)

Bản trên đo ra 0 nano giây — không phải vì code nhanh vô hạn, mà vì nó không hề chạy. Bản dưới ghi kết quả vào một biến volatile (compiler buộc phải giữ), và ra con số thật 0,3 ns. Đây gọi là dead-code elimination (xóa mã chết), và nó là cái bẫy phổ biến nhất của benchmark tự viết. Chính nó là lý do mọi phép đo trong sê-ri này đều cộng kết quả vào một biến SINK volatile hay in checksum — như bài SIMDdự đoán nhánh đã phải làm để compiler đừng hoisting hay xóa mất vòng đo. Không có sink, con số đẹp một cách đáng ngờ thường là con số của hư vô.

Bẫy 2: lần chạy đầu đo một thứ khác

Ngay cả khi vòng lặp thật sự chạy, lần đầu nó chạy trong điều kiện khác hẳn các lần sau: cache còn lạnh (dữ liệu chưa ở cache), trang bộ nhớ có thể chưa được ánh xạ vào RAM, CPU chưa kịp tăng tần số. Tôi đo thời gian quét một mảng 64 MB sáu lần liên tiếp:

Quét mảng 64 MB, thời gian từng lần:
  lần 1: 4,62 ms   <- chậm (cache lạnh, trang chưa map)
  lần 2: 3,63 ms
  lần 3: 3,65 ms
  lần 4..6: ~3,64 ms  (đã ổn định)

Lần đầu chậm hơn 27% so với trạng thái ổn định. Nếu bạn chỉ chạy một lần rồi ghi số, bạn đã đo cái warmup chứ không đo thuật toán. Cách chữa là bỏ vài lần đầu (hay lặp tới khi số ổn định) rồi mới đo — đúng thứ mà chữ "lặp tới ổn định" trong mọi phép đo của sê-ri nghĩa là. Ngược lại cũng cần cẩn thận: nếu cái bạn muốn đo chính là hiệu năng cache-lạnh (lần chạm đầu), thì phải cố tình xóa cache trước mỗi lần — đo đúng cái mình định đo.

Bẫy 3: một phép đo lẻ dễ trúng cú vọt của máy ảo

Máy đo bài này (và hầu hết máy chủ đám mây) là máy ảo dùng chung: hệ điều hành và hàng xóm thỉnh thoảng chen ngang, khiến một lần chạy đột ngột chậm vọt. Tôi đo cùng một việc 4000 lần và nhìn phân bố:

4000 lần đo cùng một việc (VM):
  min 430 µs | median 467 µs | mean 468 µs | p99 494 µs | max 1266 µs

Phần lớn các lần quanh 467 µs, nhưng lần chậm nhất vọt lên 1266 µs — gấp 2,7 lần trung vị. Cái đuôi nhọn này là bản chất của nhiễu trên máy dùng chung. Hệ quả: nếu bạn chỉ đo một lần và xui rơi trúng cú vọt, con số của bạn sai 2-3 lần. Và nếu bạn lấy trung bình của vài lần chạy, chỉ một outlier cũng kéo lệch con số (mean nhạy với đuôi). Cách bền là lấy trung vị (median) hay min của một lô lớn — chúng bỏ qua cái đuôi và cho con số ổn định. Đây chính là vì sao sê-ri luôn "lấy trung vị nếu nhiễu" và lặp nhiều lần: không phải cầu kỳ, mà vì một điểm đo lẻ trên VM là không đáng tin.

Bẫy 4: đồng hồ cũng tốn thời gian

Cái bẫy cuối tinh vi nhất: bản thân việc gọi đồng hồ mất thời gian. Tôi đo chi phí một lần gọi clock_gettime:

clock_gettime: ~16,4 ns mỗi lần gọi

Mười sáu nano giây. Giờ hãy tưởng tượng bạn muốn đo một phép tính chỉ tốn 1-2 ns (một phép cộng, một lần truy cập cache). Nếu bạn kẹp clock_gettime ngay trước và sau nó, thì 16 ns overhead của hai lần gọi đồng hồ lấn át hoàn toàn 1-2 ns bạn định đo — con số đọc ra chủ yếu là chi phí của chính cái đồng hồ, tức là rác. Cách chữa là đo theo lô: chạy phép tính đó n triệu lần trong một vòng, kẹp đồng hồ quanh cả vòng, rồi chia cho n. Chi phí một lần gọi đồng hồ chia cho hàng triệu thành không đáng kể, và bạn đo được chi phí thật mỗi phép. Đó là lý do mọi phép đo "ns mỗi thao tác" trong sê-ri đều là tổng-của-nhiều-triệu chia ra, không bao giờ là đo một thao tác đơn lẻ.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: một con số benchmark không kèm phương pháp là một con số đáng ngờ. Khi ai đó nói "cái này chạy 5 ns", hãy hỏi: có sink chống dead-code không, đã bỏ warmup chưa, là trung vị của bao nhiêu lần hay chỉ một lần, và việc đo có lớn hơn overhead đồng hồ không. Bốn câu đó tách một phép đo thật khỏi một con số vô nghĩa — và rất nhiều "benchmark chứng minh X nhanh hơn Y" trên mạng trượt ít nhất một trong bốn.

Hệ quả thứ hai: dùng công cụ đã xử lý sẵn các bẫy này khi có thể. Những khung benchmark nghiêm túc — Google Benchmark (C++), JMH (Java), criterion (Rust), pytest-benchmark/timeit (Python) — đều tự chống dead-code (có hàm DoNotOptimize/sink), tự warmup, tự chạy nhiều lần và báo cáo trung vị kèm độ lệch. Chúng tồn tại chính vì bốn cái bẫy này đủ phổ biến để đáng đóng gói giải pháp. Tự viết vòng đo bằng tay được, nhưng phải nhớ đủ bốn rào.

Hệ quả thứ ba là bài học tổng kết của cả sê-ri, quay vào chính nó: "đo thật" không phải là kẹp đồng hồ rồi đọc số — nó là một kỹ thuật, và con số chỉ đáng tin sau khi vượt qua những cái bẫy làm nó nói dối. Con số mang theo: bỏ sink thì compiler xóa vòng và đo ra 0 ns cho việc chưa chạy; lần đầu 4,62 ms vì cache lạnh so với 3,64 ms ổn định; trên VM một lần vọt lên 1266 µs so với median 467 µs (2,7×); và clock_gettime tốn 16 ns nên đo việc vài ns bằng cách kẹp clock là đọc ra rác — cách chữa cả bốn là quan sát kết quả (volatile), lặp tới ổn định, lấy trung vị của lô lớn, và đo lô rồi chia. Mọi con số trong bốn mươi bốn bài trước đều đã qua bốn rào này; đó là điều làm "đo thật" khác với "đọc bừa một số từ đồng hồ".

Thử ba mươi giây

Viết một vòng lặp đơn giản (ví dụ cộng dồn i*i một triệu lần), kẹp đồng hồ quanh nó, in thời gian — nhưng đừng dùng kết quả tổng. Biên dịch với -O2 và chạy: rất có thể bạn thấy một con số nhỏ đến vô lý (hoặc đúng 0), vì compiler đã xóa vòng. Giờ thêm một dòng printf("%d", tong) (hay gán tổng vào một biến volatile) và chạy lại: con số nhảy lên giá trị thật. Bạn vừa tự tay thấy dead-code elimination biến một phép đo thành lời nói dối, và một dòng "quan sát kết quả" cứu nó — bài học đầu tiên và quan trọng nhất của việc đo lường vi mô, gói trong ba mươi giây.