Hình dung bạn muốn biết mình chạy 100 mét hết bao lâu. Chạy một lần, bấm giờ — nhưng lần đó gió ngược, hay bạn vấp một cái, hay có người cắt ngang đường. Con số đó cao hơn tốc độ thật của bạn. Điều mấu chốt: mọi thứ nhiễu — gió, vấp, người cắt ngang — chỉ làm bạn chậm lại, không bao giờ khiến bạn chạy nhanh hơn giới hạn thể chất. Nên nếu bạn chạy mười lần, lần nhanh nhất mới gần với năng lực thật, còn lấy trung bình thì bị mấy lần xui kéo lên. Đo hiệu năng máy tính y hệt vậy — và đó là lý do suốt sê-ri này tôi luôn lấy min, không lấy mean.
Suốt sê-ri, 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 — đúng cái logic gió-ngược-với-cú-vấp ở trên. 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.
Hai niềm tin đối nghịch, cùng sai
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". Con số 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.
Còn một niềm tin ngược, tinh vi hơn, cũng cần chỉnh: "vậy thì lấy trung bình nhiều lần cho chắc". Nghe khoa học, nhưng mean bị các lần bẩn kéo lên nên không tái lập (102,8 vs 100,3), và với độ trễ thuần thì nó cao hơn giá thật. Với độ trễ "sạch", min mới đúng; còn nếu bạn quan tâm hành vi dưới nhiễu thực tế thì nhìn median và p99, không phải mean. Trung bình cộng gần như luôn là con số tệ nhất để báo cáo cho một phép đo hiệu năng.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Chỗ này đáng nói là chính các công cụ đo chuẩn của ngành đã nướng những nguyên tắc này vào bên trong, và biết chúng làm gì rất hữu ích. perf stat -r N chạy lại N lần và báo cả độ lệch chuẩn (để bạn thấy phép đo ổn định tới đâu); các framework benchmark như Google Benchmark (C++), JMH (Java), Criterion (Rust) đều tự warmup, lặp nhiều vòng, và cảnh báo khi phương sai quá lớn — đúng bốn nguyên tắc ở trên. JMH thậm chí buộc bạn dùng Blackhole để chống JIT tối ưu mất phép đo, y như volatile trong C. Còn khi đo dịch vụ thật (độ trễ request), cả ngành báo cáo p50/p99/p999 chứ gần như không ai báo mean, đúng vì mean giấu đuôi phân bố. Bài học chung: nếu công cụ nghiêm túc nào cũng warmup-lặp-lấy-phân-bố, thì đo tay cũng nên vậy.
Muốn tự thấy thì 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. Vài phút đó 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".