Suốt 43 phần, mỗi con số trong sê-ri này ra đời từ một benchmark. Phần này mổ xẻ chính việc benchmark — vì đo tốc độ vi kiến trúc là một bãi mìn, nơi cả trình biên dịch lẫn phần cứng âm thầm lừa mắt bạn. Viết một vòng lặp, bọc now() hai đầu, chia cho số phần tử — nghe đơn giản, nhưng nếu làm ngây thơ, con số bạn nhận có thể vô lý (0 nano-giây!) hoặc sai gấp mấy lần. Tôi đo trong container gcc:13 trên host ARM, cố tình viết các benchmark sai để chúng cho số vô lý, rồi bản đúng để so — và đúc kết công thức mà cả sê-ri đã lặng lẽ dùng ở mọi phần.

Benchmark đúng khi vi kiến trúc lừa mắt

Bốn cái bẫy, cùng một kernel

Tôi lấy một kernel thật (tính một đa thức ~11 phép trên mỗi phần tử của mảng) và đo nó bốn cách:

  • Bẫy 1 — dead-code elimination: nếu bạn tính kết quả nhưng không dùng nó, trình biên dịch nhận ra toàn bộ tính toán là vô íchxóa sạch vòng lặp. Bạn bấm giờ một khoảng trống → 0 nano-giây.
  • Bẫy 2 — constant folding: nếu input là hằng số biết lúc biên dịch, trình biên dịch tính sẵn kết quả ngay khi dịch. Vòng lặp không còn làm phép tính bạn tưởng — bạn đo nhầm thứ khác.
  • Bẫy 3 — thiếu warmup và không lặp: lần chạy đầu chậm (tần số chưa lên, cache lạnh); và đo một thao tác cỡ nano-giây thì nhiễu của đồng hồ lấn át hoàn toàn — phải lặp N lần rồi chia N.
  • Bẫy 4 — volatile quá tay: để ngăn bẫy 1, người ta thêm volatile; nhưng rắc nó trong vòng lặp nóng lại chặn tối ưu thật (pipelining, giữ thanh ghi), khiến bạn đo chậm hơn thực tế.

Cách đúng: ghi kết quả vào một biến volatile ở ranh giới (một SINK, để trình biên dịch không xóa), dùng input runtime (để không fold), warmup vài lần, lặp N lần chia N, và lấy min của nhiều lần đo. Đo cả bốn:

Đo: 0,00 / 0,57 / 0,68 / 2,62 — chỉ một bản đúng

Host ARM, g++ -O2, kernel ~11 phép/phần tử:

   cách đo                              | ns/phần tử | vấn đề
   -------------------------------------|------------|------------------------------
   (1) dead-code (không dùng kết quả)   |   0,00     | -O2 XÓA vòng lặp -> cần SINK
   (2) const-fold (input hằng 3.14)     |   0,57     | fold mất kernel -> cần runtime
   (3) ĐÚNG (SINK+runtime+warmup+min+N) |   0,68     | số THẬT
   (4) volatile trong vòng nóng         |   2,62     | 3,9x chậm hơn thật (chặn tối ưu)

Đọc ra ba điều. Một (dead-code = 0): khi tôi tính đa thức nhưng không ghi kết quả đi đâu, đo ra 0,00 ns/phần tử — vô lý, "nhanh vô hạn". Trình biên dịch thấy acc không bao giờ được dùng nên xóa toàn bộ vòng lặp; tôi bấm giờ một khoảng trống. Đây là bẫy phổ biến nhất, và số 0 rõ ràng vô lý — nhưng nếu kernel phức tạp hơn, DCE có thể xóa một phần và cho số thấp giả trông có vẻ thật.

Hai (const-fold = 0,57, nguy hiểm hơn): khi tôi cho input là hằng 3.14f, đo ra 0,57 nskhông phải 0, trông có vẻ hợp lý! Nhưng nó sai: trình biên dịch tính sẵn poly(3.14) thành một hằng số duy nhất, nên 0,57 ns không đo kernel 11 phép của tôi — nó chỉ đo phép cộng dồn còn sót lại. Đây là bẫy nguy hiểm hơn dead-code: số không vô lý lộ liễu, mà sai một cách âm thầm vì bạn đang đo một thứ hoàn toàn khác.

Ba (đúng = 0,68, volatile quá tay = 2,62): bản đúng — SINK ở ranh giới, input từ mảng khởi tạo lúc chạy, warmup, min, lặp chia N — cho 0,68 ns/phần tử, con số thật. Còn khi tôi quá tay để accumulator là volatile trong vòng lặp, mỗi lần cộng phải ghi-đọc bộ nhớ, chuỗi bị serialize, đo ra 2,62 ns — chậm 3,9 lần thực tế. Volatile chống được dead-code, nhưng đặt sai chỗ thì thổi phồng con số.

Một lần tôi đo hớ: "bấm giờ là đo được" và "cứ volatile cho chắc"

Tôi đã vấp cả hai bẫy này ngay trong sê-ri — và kể thật. Ở phần đo bộ đếm phần cứng, kernel nhánh đầu tiên của tôi bị trình biên dịch biến thành branchless nên đo ra "không có branch-miss" (1,0x) — sai; phải thêm -fno-if-conversion mới giữ được nhánh thật. Ở phần memset, phép memset đầu bị xóa (dead-code) vì buffer bị ghi đè ngay sau, cho số 0 vô lý — phải đọc lại buffer để "neo" nó. Niềm tin "viết vòng lặp bấm giờ là đo được" sai: trình biên dịch xóa (dead-code → 0) hoặc fold (input hằng → đo nhầm), và bạn báo một con số không đo cái bạn nghĩ. Phải SINK kết quả, dùng input runtime, warmup, min, lặp chia N.

Nhưng đo cũng chỉnh niềm tin ngược: "vậy cứ thêm volatile khắp nơi cho chắc, khỏi lo bị xóa". Sai — volatile là con dao hai lưỡi. Đặt một SINK ở ranh giới kết quả (ngoài vòng lặp) là đủ để ngăn dead-code. Nhưng rắc volatile vào trong vòng nóng — biến tích lũy, mảng, biến trung gian — sẽ chặn chính những tối ưu bạn muốn đo (pipelining, giữ thanh ghi, vector hóa), khiến bạn đo chậm hơn thực tế (ở đây 3,9 lần). Volatile đúng chỗ (ranh giới) là bạn; volatile sai chỗ (vòng nóng) là kẻ phá.

Bài học đo lường: compiler + vi kiến trúc lừa mắt benchmark ngây thơ. Đo (cùng kernel ~11 phép): dead-code (không dùng kết quả) 0,00 ns (compiler xóa vòng); const-fold (input hằng) 0,57 ns (fold mất kernel, đo nhầm phép khác); volatile trong vòng nóng 2,62 ns (3,9x chậm hơn thật); chỉ bản ĐÚNG (SINK ở ranh giới + input runtime + warmup + min + lặp-chia-N) cho 0,68 ns thật. 'Viết vòng lặp bấm giờ là đo được' SAI (bị xóa/fold, số vô lý); 'cứ volatile khắp nơi cho chắc' SAI (volatile trong vòng nóng chặn tối ưu, đo quá chậm). Nếu tin "bấm giờ là đủ" tôi báo số 0 vô lý hoặc số fold sai; nếu tin "volatile khắp nơi" tôi đo chậm hơn thực tế nhiều lần.

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

Hệ quả đầu tiên: mỗi benchmark phải có SINK ở ranh giới, input runtime, warmup, min, và lặp-chia-N — năm thứ, đủ cả. Ghi kết quả vào một biến volatile (hoặc dùng DoNotOptimize của thư viện benchmark) ngoài vòng lặp để chống dead-code; lấy input từ dữ liệu khởi tạo lúc chạy (không phải hằng) để chống const-fold; chạy nóng vài lần rồi bỏ; lặp thao tác đủ nhiều (để tổng thời gian ≫ độ phân giải đồng hồ) rồi chia; và lấy min của nhiều lần đo (nhiễu chỉ làm chậm). Thiếu bất kỳ thứ nào là mở cửa cho một con số sai.

Hệ quả thứ hai: dùng thư viện benchmark tốt và kiểm assembly khi nghi ngờ. Các thư viện như Google Benchmark có sẵn benchmark::DoNotOptimize/ClobberMemory làm đúng việc SINK mà không rắc volatile bừa. Khi một con số quá đẹp (0 ns, hay nhanh bất thường), hãy xem assembly (-S hoặc objdump) để chắc vòng lặp thật sự tồn tại và làm đúng việc — trình biên dịch rất giỏi xóa việc vô ích.

Hệ quả thứ ba là tinh thần đo lường: con số benchmark chỉ đáng tin khi bạn đã loại hết cách trình biên dịch và phần cứng lừa bạn — số vô lý (quá nhanh hoặc quá chậm) là dấu hiệu benchmark sai, không phải code nhanh/chậm. Con số mang theo: SINK ở ranh giới + input runtime + warmup + min + lặp-chia-N; nghi ngờ số quá đẹp; đừng rải volatile trong vòng nóng. Đây là công thức đã cho mọi con số đáng tin trong 44 phần — và là điều đầu tiên cần kiểm khi một benchmark cho kết quả khó tin.

Thử ba mươi giây

Viết một vòng lặp tính gì đó nặng (một đa thức trên mảng) và bấm giờ, nhưng đừng dùng kết quả — chỉ tính rồi bỏ. Biên dịch -O2 và đo: rất có thể bạn thấy 0 nano-giây, vì trình biên dịch xóa sạch vòng lặp vô ích. Giờ thêm một dòng ghi kết quả vào biến volatile ngoài vòng lặp và đo lại: đột nhiên có một con số thật. Rồi thử cho input là hằng số (ví dụ poly(3.14f) thay vì poly(a[i])): con số tụt xuống bất thường, vì trình biên dịch tính sẵn — bạn đang đo nhầm. Cuối cùng, thử để biến tích lũy là volatile trong vòng lặp: con số phình lên vài lần, vì volatile chặn pipelining. Chỉ khi bạn SINK ở ranh giới, dùng input runtime, warmup, lấy min và lặp-chia-N thì con số mới đúng. Ba mươi giây đó cho bạn thấy điều mà "viết vòng lặp bấm giờ là xong" giấu đi: giữa code của bạn và cái đồng hồ là một trình biên dịch tối ưu và một CPU tinh ranh — và chỉ khi bạn hiểu chúng lừa mắt thế nào, con số bạn đo mới là sự thật.