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.

Đo tác động một cờ đú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. 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ặ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. 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, đủ lớn nếu đo cache). Và kiểm assembly để chắc chắn cờ thật sự đổi mã.

Đ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ột lần tôi đo hớ: con số vô nghĩa nếu phương pháp 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á". Đ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ó là rác, vì đ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ì sao điều này quan trọng khi lập trình

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

Hệ quả thứ hai: cô lập đúng một biến khi so cờ. Đổi cờ đổi kích thước dữ liệu đổ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ã (nhiều cờ im lặng không làm gì ở ngữ cảnh của bạn).

Hệ quả thứ ba là tinh thần chốt của sê-ri: đo để biết, và đo cho đúng để biết đúng. Con số mang theo: đ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.

Thử ba mươi giây

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. Ba mươi giây đó 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.