Suốt cả sê-ri này, mỗi con số đều đến từ cùng một cách đo — và cách đo đó không hiển nhiên như tôi từng tưởng.

Đo hiệu năng ở mức nano-giây giống như cân một hạt gạo trên một cái cân dùng để cân người: nếu không cẩn thận, thứ bạn đọc được không phải cân nặng hạt gạo, mà là độ rung của sàn nhà, sai số của kim cân, và cả cái áo bạn đang mặc. Một benchmark ngây thơ lừa bạn theo những cách rất tinh vi: đôi khi sai hàng chục lần, đôi khi cho đúng số 0, mà kết quả vẫn trông hợp lý. Phần cuối cùng về kỹ thuật này tổng kết bốn cái bẫy tôi đã né suốt sê-ri, chứng minh từng cái bằng đo trong container gcc:13. Bốn kỹ thuật: warmup, lấy min, chống -O2 xóa (SINK), và lặp khi thao tác nhỏ.

Bốn cái bẫy khiến benchmark nói dối

  • Warmup: lần chạy đầu tiên luôn chậm hơn — cache lạnh, các trang bộ nhớ chưa được ánh xạ (page fault khi chạm lần đầu), CPU chưa tăng xung. Đo lần đầu là đo cả những chi phí một-lần đó.
  • Lấy min, không lấy trung bình: nhiễu từ hệ điều hành (ngắt, chuyển ngữ cảnh, luồng khác) chỉ làm chậm thêm, không bao giờ làm nhanh hơn. Nên giá trị nhỏ nhất gần với "tín hiệu thật" của CPU nhất; trung bình và tối đa bị cái đuôi nhiễu kéo lên.
  • Chống -O2 xóa (dead-code elimination): nếu kết quả của vòng lặp không được dùng, trình biên dịch chứng minh được vòng lặp vô nghĩa và xóa sạch — bạn đo ra ~0. Đây là cái bẫy tôi vấp nhiều lần trong sê-ri. Cách chữa: ghi kết quả vào một biến volatile (SINK) để trình biên dịch phải chạy thật.
  • Lặp khi thao tác nhỏ: một thao tác nhanh hơn độ phân giải đồng hồ thì đo một lần ra số rác (toàn là overhead của chính lệnh đọc giờ). Phải lặp N lần rồi chia N.

Đo: từng cái bẫy hiện ra bằng số

Tôi đo một phép sum trên mảng 80 MB (vượt cache), dùng steady_clock, và cố tình dựng lại từng cái bẫy:

Sum mảng 80 MB, g++ -O2, steady_clock:

A. WARMUP (ns mỗi phần tử khi ghi cả mảng):
   lần 1 (cache lạnh + page fault) : 0,173 ns
   lần 2..6 (đã nóng)              : 0,116 ns  -> lần đầu chậm ~1,5x, PHẢI bỏ

B. MIN vs MEAN vs MAX (1000 lần chạy cùng phép sum):
   min  : 0,2271 ns  (gần tín hiệu CPU thật nhất)
   mean : 0,2283 ns  (+0,5%, bị nhiễu kéo)
   max  : 0,2596 ns  (+14% — một lần bị ngắt/lịch biểu)

C. -O2 XÓA vòng lặp nếu kết quả không dùng (DCE):
   vòng KHÔNG dùng kết quả  : 0,00000 ns  (compiler xóa sạch -> SỐ BỊA)
   vòng CÓ volatile SINK    : 0,22783 ns  (số thật)

D. Đo QUÁ NHỎ:
   đo 1 lần truy cập  : 84,0 ns  (toàn overhead đồng hồ -> VÔ NGHĨA)
   lặp N lần chia N   : 0,227 ns  (số đáng tin)

Nhìn A — warmup: lần ghi đầu tiên tốn 0,173 ns/phần tử vì gặp page fault (trang chưa map) và cache lạnh; từ lần thứ hai xuống 0,116 ns — nhanh hơn 1,5 lần. Nếu tôi đo lần đầu, tôi báo một cấu trúc "chậm hơn 1,5 lần" trong khi thật ra chỉ là chi phí khởi động một-lần. Luôn chạy vài lần và bỏ lần đầu.

Nhìn B — min vs mean: chạy đúng phép sum 1000 lần, giá trị nhỏ nhất là 0,2271 ns và rất ổn định; trung bình nhỉnh hơn 0,5%; nhưng tối đa vọt lên 0,2596 ns — cao hơn 14%, vì một lần chạy đó bị hệ điều hành ngắt (chuyển ngữ cảnh). Nhiễu chỉ thêm thời gian, không bao giờ bớt — nên min là ước lượng tốt nhất cho tốc độ thật của CPU, còn mean/max bị đuôi nhiễu bóp méo.

Nhìn C — chỗ nguy hiểm nhất: một vòng lặp cộng mảng mà không dùng kết quả đo ra 0,00000 ns — vì -O2 chứng minh được kết quả vô dụng và xóa toàn bộ vòng lặp. Con số 0 đó trông như "nhanh vô hạn" nhưng là bịa hoàn toàn. Cùng vòng lặp đó, khi ghi kết quả vào volatile SINK, đo ra 0,22783 ns — số thật. Đây là lý do mọi benchmark trong sê-ri này đều có một volatile SINK.

Nhìn D — đo quá nhỏ: đo một lần truy cập mảng cho 84 ns — nhưng đó không phải thời gian truy cập (thật ra ~0,23 ns), mà là overhead của chính hai lệnh đọc đồng hồ bao quanh nó. Đồng hồ thô hơn thao tác cần đo — đúng cái cân người đo hạt gạo. Lặp 20 triệu lần rồi chia cho 20 triệu mới ra 0,227 ns — số đáng tin. Một phép đo đơn lẻ trên thao tác nano-giây luôn là rác.

Hai thái cực đều sai

Tôi từng benchmark kiểu ngây thơ: bọc clock() quanh một đoạn, chạy một lần, đọc số, kết luận. Con số cho thấy một lần bấm giờ có thể sai hàng trăm lần hoặc cho đúng số 0. Warmup làm lần đầu chậm 1,5 lần (tôi tưởng cấu trúc chậm, thật ra là cache lạnh). -O2 xóa vòng lặp không dùng kết quả và cho 0 ns — nếu tin, tôi "chứng minh" được một thao tác miễn phí trong khi nó tốn thật. Và đo một thao tác nano-giây cho 84 ns rác — sai gần 370 lần so với 0,23 ns thật, vì đồng hồ thô hơn thứ cần đo. "Chạy một lần là biết" là công thức để tin vào những con số vô nghĩa.

Nhưng nếu cẩn thận hơn một chút mà rơi vào "vậy chạy nhiều lần rồi lấy trung bình cho ổn định" thì cũng sai với vi chuẩn CPU. Nhiễu hệ điều hành chỉ làm chậm thêm — không lần nào CPU tự dưng chạy nhanh hơn khả năng của nó. Nên phân bố thời gian có một sàn cứng (tốc độ thật) và một đuôi dài lên trên (những lần bị ngắt). Trung bình gộp cả đuôi đó vào, cho số cao hơn thực tế; tối đa còn tệ hơn (+14% trong đo). Giá trị đáng tin là min — nó gần cái sàn nhất. Lấy trung bình nghe "khoa học" nhưng với micro-benchmark là đo cả nhiễu OS lẫn tín hiệu.

Về đến code hằng ngày

Việc đầu tiên: trước khi tin bất kỳ benchmark nào — của bạn hay của người khác — kiểm bốn thứ. Nó có warmup không? Lấy min hay mean? Kết quả có được dùng (chống DCE) không? Thao tác có đủ lớn so với đồng hồ, hay đã lặp-và-chia? Thiếu một cái là con số có thể sai hoàn toàn. Rất nhiều "X nhanh hơn Y 10 lần" trên mạng vỡ vụn khi kiểm bốn điều này — thường là lỗi DCE (một bên bị xóa) hoặc thiếu warmup.

Việc thứ hai: dùng công cụ có sẵn thay vì tự viết vòng bấm giờ khi có thể. Google Benchmark, perf, Criterion tự lo warmup, lấy min/percentile, và có DoNotOptimize() để chống DCE. Nếu tự viết (như tôi làm trong sê-ri để minh họa), nhớ đủ bốn: chạy nhiều lần lấy min, volatile SINK, lặp-chia-N, bỏ lần đầu. Và đo trên hệ yên tĩnh (đóng ứng dụng khác) để giảm nhiễu đuôi.

Bốn cái bẫy này không phải chuyện riêng của C — chúng đúng ở mọi ngôn ngữ, và ở vài ngôn ngữ còn gắt hơn. JMH của Java sinh ra chính để né chúng: nó buộc warmup (JIT cần vài nghìn vòng mới biên dịch tối ưu — bỏ warmup thì bạn đo mã thông dịch), có Blackhole để nuốt kết quả chống JIT xóa dead code, và báo cáo phân bố chứ không chỉ một số. Benchmark Java mà tự bọc System.nanoTime() gần như luôn sai vì quên warmup JIT. Go có testing.B với b.N tự tăng — đúng "lặp-chia-N" thể chế hóa — và b.ResetTimer() để bỏ phần khởi tạo. Ngay cả đo một API hay truy vấn database, ba trong bốn quy tắc vẫn áp: bỏ request đầu (kết nối lạnh, cache lạnh), nhìn p50/p99 thay vì chỉ trung bình (đuôi mới giết bạn), chạy đủ nhiều để qua nhiễu. Nguyên tắc chung: đo là kỹ năng có kỹ thuật, không phải "bấm giờ rồi đọc số" — và một con số sai còn nguy hiểm hơn không có số, vì nó cho bạn tự tin sai lầm.

Muốn tự thấy thì viết một vòng lặp cộng một mảng lớn và bấm giờ nó bốn cách. Một: đo lần chạy đầu rồi so với lần thứ hai — lần đầu chậm hơn (page fault, cache lạnh). Hai: chạy 1000 lần, in min, mean, max — bạn sẽ thấy min ổn định còn max thỉnh thoảng vọt lên vì hệ điều hành ngắt, chứng minh vì sao nên lấy min. Ba: viết một vòng lặp không dùng kết quả và bấm giờ với -O2 — nó ra gần 0 vì trình biên dịch xóa sạch; rồi thêm một volatile biến nhận kết quả và đo lại — giờ mới ra số thật. Bốn: bấm giờ một lần truy cập mảng — bạn sẽ thấy một số vô lý (toàn overhead đồng hồ); rồi lặp hàng triệu lần chia ra — mới đáng tin. Ba mươi giây đó cho thấy điều mà "chạy một lần bấm giờ là biết" giấu đi: đo hiệu năng ở mức nano-giây là một kỹ thuật có quy tắc, và bốn quy tắc — warmup, min, SINK, lặp-chia-N — là ranh giới giữa một con số thật và một con số bịa trông y hệt.