Suốt sê-ri này, mỗi bài đều "đo thật" — nhưng cách đo cũng quan trọng như cái đo. Nếu bạn đổi một tinh chỉnh HĐH (bật huge page, tăng buffer, đổi scheduler…) rồi bấm giờ một lần trước và một lần sau, con số bạn thấy rất có thể sai — không phải vì tinh chỉnh không có tác dụng, mà vì phép đo đầy nhiễu. Bài áp chót này đúc kết kỷ luật đo đã làm cho 43 phép đo trước đáng tin, và tôi đo chính sự nhiễu đó trong container gcc:13 để cho thấy vì sao một phép đo đơn lẻ lừa dối, và cách lấy được con số thật.
Bốn nguyên tắc, và vì sao lấy min
Phép đo hiệu năng trên một máy thật luôn có nhiễu: cache lạnh lần đầu, bộ lập lịch tước CPU giữa chừng, tần số CPU thay đổi, tiến trình khác chen vào. Bốn nguyên tắc chống lại nó:
- Cô lập một biến. Đổi đúng một thứ, giữ mọi thứ khác cố định — cùng dữ liệu, cùng tải, cùng trạng thái cache. Đổi hai thứ cùng lúc thì bạn không biết cái nào gây khác biệt.
- Warmup. Bỏ vài lần chạy đầu: lần đầu chậm giả vì cache, page cache, JIT còn lạnh (đúng cái readahead/cache của phần 40).
- Lặp N lần và lấy MIN, không lấy mean. Đây là điểm phản trực giác nhất, nên tôi đo để chứng minh.
- Chống trình biên dịch tối ưu mất phép đo (
volatile), và báo trung thực khi không quan sát được (như NUMA phần 37).
Vì sao min mà không phải mean? Vì nhiễu chỉ làm phép đo chậm đi, không bao giờ làm nó nhanh hơn giá vật lý thật. Không có lực nào khiến một getpid chạy nhanh hơn cái giá phần cứng của nó; nhưng có vô số lực làm nó chậm (một cú chuyển ngữ cảnh xen vào, một cache miss). Nên trong N lần đo, lần nhanh nhất (min) là lần sạch nhất — lần mà nhiễu không kịp chen vào — và nó xấp xỉ giá thật. Mean và max thì bị các lần "bẩn" kéo lên.
Đo: min tái lập, mean/max thì không
Tôi đo thời gian một getpid (lấy trung bình 50.000 lần cho một "phép đo"), chạy N=50 phép đo, và in phân bố. Rồi chạy hai lần độc lập để xem con số nào tái lập:
Đo 1 getpid (ns), N=50 phép đo, MÁY RẢNH:
lần A: MIN=99,8 median=100,2 MEAN=102,8 MAX=197,9
lần B: MIN=99,8 median=100,2 MEAN=100,3 MAX=104,5
Nhìn cột MIN: lần A và lần B đều 99,8 — khớp chính xác qua hai lần chạy độc lập. Median cũng ổn định (100,2). Nhưng MEAN (102,8 so với 100,3) và MAX (197,9 so với 104,5) dao động mạnh giữa hai lần. Cái MAX 197,9 ở lần A là một cú "hiccup" ngẫu nhiên — một lần đo mà bộ lập lịch tình cờ xen vào — làm nó chậm gấp đôi. Nếu bạn chỉ đo một lần và xui trúng cái 197,9, bạn kết luận getpid tốn ~198 ns, gấp đôi sự thật (99,8). Mean cũng bị outlier đó kéo lên. Chỉ min cho con số thật và tái lập được.
Giờ bật 12 luồng ngốn CPU cạnh tranh (nproc=10) và đo lại:
Đo 1 getpid (ns), CÓ TẢI NỀN (12 hog / 10 lõi):
MIN ~175 median ~256 MEAN ~270 MAX ~380 (mọi con số đều tăng)
Dưới tải, mọi con số tăng vọt — kể cả MIN (99,8 → ~175), vì oversubscription khiến ngay cả lần "sạch nhất" cũng dính chuyển ngữ cảnh bắt buộc. Đây là bài học cô lập biến: nếu bạn đo "tác động một tinh chỉnh" mà một lần đo lúc máy rảnh, lần kia lúc máy bận, bạn sẽ gán khác biệt 175 vs 100 cho cái tinh chỉnh — trong khi thật ra thủ phạm là tải nền. Phải giữ mọi thứ khác cố định.
Một lần tôi đo hớ: "chạy một lần là đủ"
Tôi vào chủ đề này với thói quen tự nhiên: "đổi tinh chỉnh, chạy một lần trước và sau, so hai con số — thấy nhanh hơn là nó có tác dụng". Đo phá tan: cùng một thao tác không đổi gì cả, hai phép đo riêng lẻ có thể ra 99,8 và 197,9 ns — chênh 2 lần — chỉ vì nhiễu. Nếu tôi "so một lần trước/sau", cái chênh 2 lần từ nhiễu đó có thể lấn át hoàn toàn tác động thật của tinh chỉnh (thường chỉ vài %), khiến tôi kết luận sai theo bất kỳ chiều nào.
Bài học đo lường — cũng là bài học nền của cả sê-ri: một phép đo đơn lẻ đầy nhiễu và không đáng tin; phải warmup, lặp nhiều lần và lấy min (nhiễu chỉ thêm, lần nhanh nhất là sạch nhất), cô lập đúng một biến, và báo trung thực khi không quan sát được. Min tái lập (99,8 = 99,8 hai lần độc lập) trong khi mean/max dao động — nên min là ước lượng đúng cho giá "sạch". Nếu tôi tin "chạy một lần là đủ", mọi kết luận về mọi tinh chỉnh của tôi đều là tung đồng xu. Đây chính là lý do suốt sê-ri tôi luôn warmup, lấy min, cô lập biến, và nói thẳng khi môi trường không cho thấy hiệu ứng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng bao giờ kết luận từ một phép đo đơn lẻ. Chạy nhiều lần, lấy min (cho độ trễ "sạch") hoặc nhìn cả phân bố (min/median/p99) — median và p99 hữu ích khi bạn quan tâm hành vi dưới nhiễu thực tế, còn min cho giá thuần. Nhưng mean của vài lần chạy thì gần như luôn là con số tệ nhất để báo cáo, vì nó vừa bị outlier kéo, vừa không tái lập.
Hệ quả thứ hai: cô lập biến — đổi một thứ mỗi lần. Khi thử một tinh chỉnh, giữ mọi thứ khác hệt nhau: cùng dữ liệu, cùng máy rảnh, cùng trạng thái cache (warmup cả hai phía). Một khác biệt bạn thấy chỉ có nghĩa nếu đó là biến duy nhất thay đổi. Rất nhiều "tối ưu có tác dụng" hóa ra là confounder (máy lúc đó rảnh hơn, dữ liệu đã trong cache) — chỉ lộ ra khi bạn kiểm soát biến.
Hệ quả thứ ba là tinh thần đo lường: tin con số bạn đo được, không con số bạn muốn thấy — và biết phép đo của mình nhiễu tới đâu. Con số mang theo: một phép đo đơn lẻ đầy nhiễu (cùng getpid ra 99,8 hay 197,9 ns, chênh 2× tùy scheduler xen); phải WARMUP + LẶP N + lấy MIN vì nhiễu chỉ THÊM thời gian, lần nhanh nhất = sạch nhất = giá thật; MIN tái lập (99,8=99,8 hai lần độc lập) còn MEAN/MAX dao động vì outlier. Và phải CÔ LẬP một biến — tải nền làm mọi con số tăng (100→270 ns), không giữ mọi thứ cố định thì khác biệt bị gán nhầm cho tinh chỉnh trong khi thật ra là confounder; báo trung thực khi không quan sát được. Đo đúng cách là điều kiện để mọi con số khác trong sê-ri này đáng tin.
Thử ba mươi giây
Viết một vòng đo một thao tác nhỏ (một getpid, hay một phép tính) lấy trung bình vài chục nghìn lần, gọi nó là "một phép đo"; chạy nó 50 lần và in min, median, mean, max. Bạn sẽ thấy min và median ổn định, còn max (và đôi khi mean) nhảy loạn — đó là nhiễu của hệ. Chạy cả chương trình hai lần và so: min gần như y hệt, max thì khác nhau mỗi lần. Rồi mở một chương trình ngốn CPU ở nền và đo lại: mọi con số tăng — minh họa vì sao bạn phải giữ máy ở trạng thái giống nhau khi so hai tinh chỉnh. Ba mươi giây đó dạy bạn điều nền tảng nhất của đo hiệu năng: một con số đơn lẻ không nói lên gì; chỉ phân bố của nhiều lần đo, với một biến được cô lập, mới cho bạn sự thật — và đó là điều làm nên khác biệt giữa "đo" và "đoán".