Hình dung bạn đo hiệu suất một cỗ máy mới trong xưởng: bao nhiêu sản phẩm mỗi giờ? Nhưng có một trục trặc — không ai thu sản phẩm ở cuối chuyền. Công nhân tinh ý nhận ra: nếu chẳng ai lấy hàng đi, họ khỏi cần làm bước đó. Thế là bạn bấm giờ và ghi được "0 giây mỗi sản phẩm — cỗ máy nhanh vô hạn!" — một con số cụ thể, đo được, và hoàn toàn vô nghĩa, vì cái việc bạn tưởng đang đo đã không hề xảy ra. Đo tác động của một cờ compiler đúng là cái xưởng đó: nếu bạn không "thu sản phẩm" (dùng kết quả), trình biên dịch xóa luôn cả vòng lặp, và bạn bấm giờ một đoạn code không tồn tại.
Suốt 43 phần, mỗi con số trong sê-ri đều đi qua một quy trình đo cẩn thận. Phần này nói thẳng về chính quy trình đó: muốn biết một cờ compiler — -O3, vector hóa, -funroll-loops, -march — thật sự đáng giá bao nhiêu, bạn phải đo đúng cách. Nghe hiển nhiên, nhưng cách đo ngây thơ mà hầu hết mọi người dùng cho ra những con số sai đến mức nguy hiểm. Tôi đo một cờ theo cả hai cách để cho thấy khoảng cách.
Cách ngây thơ, và bốn cái bẫy
Cách đo mà ai cũng làm lần đầu: biên dịch với cờ, chạy một lần, bấm giờ, lấy số. Cách này bị đánh lừa bởi ít nhất bốn thứ. Một, khử mã chết (DCE): nếu kết quả của vòng lặp không được dùng, compiler xóa luôn cả vòng — bạn đo một đoạn code không tồn tại (không ai thu hàng nên công nhân bỏ bước). Hai, hoisting (LICM): một phép tính không đổi giữa các vòng bị kéo ra ngoài, chạy một lần thay vì N lần. Ba, nhiễu: máy ảo, chuyển ngữ cảnh, tần số CPU biến thiên khiến một lần đo lệch xa. Bốn, memory-bound: nếu mảng quá lớn, thời gian bị băng thông bộ nhớ chi phối và giấu mất hiệu ứng vi mô của cờ.
Tôi đo một tích vô hướng số nguyên (s += a[i]*b[i], N=4096) để đánh giá cờ vector hóa (-O3 so với -O2 -fno-tree-vectorize). Đầu tiên đo ngây thơ: gọi hàm nhưng vứt kết quả đi.
NGÂY THƠ (kết quả bị vứt):
scalar : 0,041 ns/lần gọi
vector : 0,042 ns/lần gọi
Cả hai đều ~0,04 ns — nhanh đến vô lý cho một vòng 4096 phép nhân-cộng. Lý do: vì kết quả không dùng, khử mã chết xóa sạch vòng lặp; tôi đang bấm giờ một hàm rỗng. Con số này sai khoảng 25.000 lần so với thực tế. Và tệ hơn, nó báo scalar bằng vector (đều ~0), nên một người tin nó sẽ kết luận: "cờ vector hóa chẳng đổi gì". Kết luận hoàn toàn sai, sinh ra từ một phép đo trông rất hợp lý.
Cách đúng, và một sự thật bất ngờ
Cách đúng cần vài kỷ luật. Cô lập một biến: chỉ đổi đúng cờ đang đo, giữ mọi thứ khác y nguyên (chỉ thay cỗ máy, giữ nguyên chuyền). Chặn khử mã chết bằng một biến volatile SINK để buộc compiler thật sự tính kết quả — chính là công cụ volatile tôi dùng cả sê-ri (bắt buộc mọi sản phẩm phải được thu, để công nhân không bỏ bước). Làm nóng (warmup) rồi lặp nhiều lần lấy giá trị nhỏ nhất (min ít nhiễu hơn trung bình, vì nhiễu chỉ làm chậm chứ không làm nhanh). Biến đổi mảng giữa các vòng để chặn hoisting. Chọn working set hợp lý (đủ nhỏ để nằm trong L1 nếu đo tốc độ tính). Và kiểm assembly để chắc chắn cờ thật sự đổi mã (soi chuyền xem cỗ máy có thật sự chạy).
Đo lại đúng cách:
ĐÚNG (volatile sink + warmup + min 200 rep):
scalar : ~1070 ns/lần gọi
vector : ~1262 ns/lần gọi -> vector CHẬM 1,18 lần
Giờ con số có nghĩa — và có một bất ngờ. objdump xác nhận -O3 thật sự vector hóa (18 lệnh SIMD), nhưng bản vector hóa lại chậm hơn bản scalar 1,18 lần, ổn định qua nhiều lần chạy. Với tích vô hướng int32 × int32 → int64 này, phép nhân-mở-rộng SIMD trên lõi ARM (ảo hóa) chỉ xử lý hai làn mỗi lệnh và tốn thêm chi phí gói/mở, nên nó thua vòng scalar — một ca -O3 chậm hơn -O2. Điều đáng nói: cách ngây thơ giấu mất sự thật này hoàn toàn (nó báo cả hai bằng 0), chỉ cách đo đúng mới phát hiện rằng cờ này có hại ở đúng workload này.
(Môi trường đo: container gcc:13 trên host ARM AArch64, tích vô hướng int N=4096 trong L1, volatile sink + warmup + min 200 rep, kiểm assembly bằng objdump. Con số tuyệt đối đổi theo máy; điều bền vững: đo ngây thơ bị DCE làm sai hàng vạn lần, đo đúng mới cho số thật và ở đây vector hóa chậm hơn scalar.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin: "bật/tắt cờ rồi bấm giờ một lần là biết cờ đáng giá". Và niềm tin đối nghịch, tinh vi hơn: "một con số cụ thể, đo được thì đáng tin". Cả hai gãy ở đây — con số 0,041 ns vừa cụ thể vừa rác. Đo hớ nằm ở chỗ tôi tin vào con số mà không kiểm phương pháp sinh ra nó. Con số 0,041 ns trông cụ thể, khoa học, đáng tin — nhưng nó đo một vòng lặp đã bị xóa.
Bài học đau nhất của đo lường: một con số đo được không tự nó có giá trị; nó chỉ có giá trị khi phương pháp đằng sau đủ vững. Một phép đo sai còn nguy hiểm hơn không đo, vì nó cho bạn sự tự tin sai — ở đây là tự tin rằng "vector hóa vô hại", trong khi thật ra nó làm chậm. Đây cũng là lý do cả sê-ri lặp đi lặp lại bốn rào đo lường (từ đo lường vi mô đúng cách): chặn DCE, chặn hoist, chọn working set, lặp lấy min. Không phải nghi thức thừa — mỗi rào chặn một cách mà đồng hồ có thể nói dối. Bỏ một rào là mở cửa cho một con số đẹp mà sai.
Về đến code hằng ngày
Điều đầu tiên: luôn dùng một harness đo có kỷ luật, đừng bấm giờ tay một lần. Tối thiểu: volatile sink cho kết quả, warmup, lặp nhiều lần lấy min, và biến đổi đầu vào giữa các vòng. Vài dòng khung đó là ranh giới giữa một con số thật và một con số rác trông giống thật.
Điều thứ hai: cô lập đúng một biến khi so cờ. Đổi cờ và đổi kích thước dữ liệu và đổi máy cùng lúc thì bạn không biết cái nào gây ra khác biệt. Giữ mọi thứ cố định, chỉ lật đúng cái cờ đang hỏi, và kiểm assembly để chắc cờ đó thật sự đổi mã.
Chỗ này đáng biết là các nền tảng lớn đã đóng gói chính bốn rào này thành công cụ chuẩn, vì cái bẫy DCE khét tiếng đến mức không ai được phép bấm giờ tay nữa. Java có JMH (Java Microbenchmark Harness) — sinh ra đúng để chống mấy cái bẫy này: Blackhole.consume(kết_quả) chính là volatile sink chặn JIT xóa mã chết, @Warmup/@Measurement lo warmup và lặp, @State chặn hằng-số-hóa; câu châm ngôn trong giới JVM là "không thể vi-benchmark JVM bằng tay" vì JIT sẽ dead-code-eliminate y như GCC ở trên. Go có testing.B: bạn phải gán kết quả vào một biến ngoài hàm (hoặc runtime.KeepAlive) kẻo trình biên dịch xóa vòng — đúng bug 0,041 ns tôi vừa đo, và tài liệu Go cảnh báo thẳng. Mẫu số chung: cái bẫy "compiler xóa mã bạn định đo" phổ biến đến mức mọi hệ sinh thái nghiêm túc đều có một blackhole/sink làm nghiệp vụ cốt lõi — nên với C/C++ hãy tự dựng đúng khung đó, đừng tin time ./a.out chạy một lần.
Điều cuối, tinh thần chốt của sê-ri: đo để biết, và đo cho đúng để biết đúng. Tóm lại: đo một cờ ngây thơ (chạy một lần, vứt kết quả) bị khử mã chết xóa vòng nên ra 0,041 ns — sai ~25.000 lần và báo scalar bằng vector (kết luận sai 'cờ vô hại'); đo đúng (volatile sink chặn DCE + warmup + min nhiều rep + mutate chặn hoist + working set L1) mới ra số thật (scalar ~1070 ns, vector ~1262 ns) và phát hiện vector hóa ở đây thực sự CHẬM 1,18 lần. Một cờ chỉ đáng bật khi bạn đo được nó giúp — và bạn chỉ đo được sự thật nếu phương pháp không tự lừa mình.
Muốn tự thấy thì viết một vòng cộng dồn nặng và bấm giờ nó — nhưng đừng dùng kết quả. Rồi in cả kết quả ra và bấm giờ lại. Rất có thể bản "không dùng kết quả" nhanh đến vô lý (gần 0), vì compiler đã xóa vòng. Đó là cái bẫy khử mã chết trong ba dòng. Giờ thêm volatile long sink = kết_quả;, lặp phép đo 100 lần và lấy giá trị nhỏ nhất — con số sẽ ổn định và thật. Vài phút đó dạy bạn điều quan trọng nhất về đo lường: không phải bạn đo cái gì, mà bạn đo thế nào, mới quyết định con số có nói thật hay không.