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. Nếu bạn muốn tự kiểm chứng một thay đổi cấu trúc dữ liệu ("đổi list sang vector có nhanh hơn không?"), một benchmark ngây thơ sẽ 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ỏ.

Đo tác động một thay đổi CTDL đúng cách

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. 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.

Một lần tôi đo hớ: "chạy một lần bấm giờ là biết" và "cứ lấy trung bình nhiều lần"

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. Đo phá tan: 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 đo cũng sửa một niềm tin ngược mà người cẩn thận hơn hay mắc: "vậy chạy nhiều lần rồi lấy trung bình cho ổn định". 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.

Bài học đo lường: một benchmark tin được cần cả BỐN: (1) WARMUP — bỏ lần chạy đầu (cache lạnh + page fault chậm ~1,5x); (2) LẤY MIN không mean — nhiễu OS chỉ làm chậm (max +14% do ngắt), min (0,2271 ns) gần tín hiệu CPU thật nhất; (3) CHỐNG -O2 XÓA — ghi kết quả vào volatile SINK, thiếu nó vòng lặp bị xóa và đo ra 0 (số bịa); (4) LẶP N chia N khi thao tác nhỏ hơn đồng hồ (đo 1 lần = 84 ns rác vs thật 0,23 ns, sai ~370x). Thiếu một cái là con số sai hàng trăm lần hoặc bằng 0 mà vẫn trông hợp lý. Nếu tôi tin "chạy một lần là biết" tôi tin số 0 bịa; nếu tin "lấy trung bình" tôi đo cả nhiễu.

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

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường — khép lại cả sê-ri: đo là kỹ năng có kỹ thuật, không phải chỉ "bấm giờ rồi đọc số". Con số mang theo: benchmark tin được = warmup (bỏ lần đầu) + min (không mean, nhiễu chỉ làm chậm) + volatile SINK (chống -O2 xóa, nếu không đo ra 0) + lặp-chia-N (thao tác nhỏ hơn đồng hồ). Thiếu một cái, con số sai hàng trăm lần hoặc bằng 0. Suốt 44 phần, mỗi phép đo chỉ đáng tin nó tuân bốn quy tắc này — và đó là điều biến "trực giác" thành "đo thật".

Thử ba mươi giây

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 để tự thấy các bẫy. 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 bạn 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.