Hình dung bạn bấm giờ một vận động viên chạy. Gió ngược, một cú vấp, xuất phát chậm — mọi thứ chỉ cộng thêm thời gian, không bao giờ trừ đi. Nghĩa là qua mười lần chạy, lần nhanh nhất của anh ta gần với tốc độ thật nhất, còn các lần chậm chỉ là nhiễu chồng lên. Giờ tưởng tượng bạn bấm giờ đúng một lần, và trúng ngay lần anh ta tuột dây giày — rồi tuyên bố anh ta chậm hơn đối thủ. Đó chính xác là cái bẫy khi đo hiệu năng code bằng một lần chạy, và tôi vấp đúng nó ngay trên bàn đo — trong lúc đang định viết bài cảnh báo về nó.
Suốt sê-ri này mỗi bài là một phép đo, và tôi liên tục nhắc "đo thật, đừng đoán". Nhưng đo cũng có cách đúng và cách sai — và cách sai còn nguy hiểm hơn không đo, vì nó cho bạn một con số trông có thẩm quyền mà kết luận lại ngược. Bài áp chót này đóng khung chính kỹ năng ấy: khi bạn thực hiện một thay đổi đồng thời (thêm padding chống false sharing, đổi kiểu khóa, gộp lô) và muốn biết nó có đáng không, làm sao đo tác động cho đúng? Tôi đo trong container gcc:13 (ARM).
Bốn quy tắc đo một thay đổi
Muốn so hai phiên bản A và B của cùng một chương trình, có bốn quy tắc.
Một — cô lập một biến. Chỉ đổi đúng một thứ giữa A và B; mọi thứ khác giữ nguyên. Nếu bạn vừa thêm padding vừa đổi thuật toán, con số chênh lệch không cho biết cái nào gây ra.
Hai — warmup. Bỏ vài lần chạy đầu tiên: cache còn lạnh, CPU chưa lên xung nhịp turbo, trang nhớ chưa được cấp. Lần chạy đầu gần như luôn chậm bất thường và không đại diện.
Ba — lặp nhiều lần, lấy min, không phải trung bình. Đây là quy tắc bị hiểu sai nhiều nhất. Nhiễu (lịch hệ điều hành, ngắt, luồng khác) chỉ cộng thêm thời gian, không bao giờ trừ đi — nên lần chạy nhanh nhất là lần ít nhiễu nhất, gần "giá thật" của code hơn cả. Đúng vận động viên không gặp gió ngược. Trung bình thì bị các lần outlier chậm kéo lên, phản ánh cả nhiễu lẫn code.
Bốn — cảnh giác thứ tự chạy. Chạy A rồi B có thể khác B rồi A: bên chạy trước "hâm nóng" cache và bộ dự đoán nhánh cho bên sau, hoặc CPU đã lên turbo.
Đo: một lần chạy đảo ngược kết luận
Để kiểm, tôi lấy một thay đổi có tác động thật và đã biết từ bài false sharing: A = mỗi luồng đếm trên một dòng cache riêng (padded), B = các bộ đếm nằm chung dòng cache (false sharing). Bốn luồng, cùng khối lượng, chỉ khác đúng một thứ. Padded phải nhanh hơn rõ. Tôi đo hai cách.
Cách thứ nhất — chạy một lần mỗi bên (và cố ý chạy B trước, A sau):
CHẠY 1 LẦN: A (padded) = 13,0 ms | B (false-sharing) = 4,5 ms
-> kết luận: "padded CHẬM hơn 2,9 lần"
Con số này nói padded chậm hơn — ngược hẳn sự thật. Lần chạy A đơn lẻ đó trúng một outlier nhiễu (13 ms, trong khi giá thật của nó ~2,9 ms), và thế là kết luận bị lật hoàn toàn. Nếu tôi dừng ở đây, tôi đã "chứng minh bằng số đo" rằng padding làm chậm — rồi gỡ bỏ một tối ưu thật sự tốt.
Cách thứ hai — lặp 15 lần, lấy min:
LẶP 15 LẤY MIN: A (padded) = 2,9 ms | B (false-sharing) = 12,8 ms
-> padded NHANH hơn 4,36 lần (đúng)
Giờ sự thật hiện ra: padded nhanh 4,36 lần. Và đây là chỗ mấu chốt — nhìn độ dao động của riêng A qua 15 lần: min 2,9 .. trung bình 4,1 .. max 5,4 ms, tỉ lệ max/min = 1,83. Một biên độ nhiễu 83% — thừa sức nuốt hoặc đảo một hiệu ứng 4 lần nếu bạn chỉ chạy một lần. Chính cái outlier 13 ms ở lần đo đầu (còn cao hơn cả max của 15 lần "ổn định") đã lật kết luận.
(Môi trường đo: container gcc:13 trên host ARM AArch64, 4 luồng, cùng khối lượng, chỉ khác padding. Con số tuyệt đối đổi theo máy; điều bền vững là: nhiễu đủ lớn để một lần chạy đảo ngược một hiệu ứng 4×, nên phải lặp và lấy min.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin ngầm mà gần như ai cũng có lúc mới đo: "chạy một lần mỗi bên rồi so hai số là đủ". Phép đo đập tan nó ngoạn mục: một lần chạy không chỉ sai lệch con số, nó đảo ngược kết luận — biến một tối ưu nhanh 4,36 lần thành "chậm 2,9 lần". Trớ trêu, tôi vấp đúng lúc đang đo về chuyện đo: lần chạy A đầu tiên tình cờ trúng nhiễu.
Nhưng có một niềm tin ngược cũng sai, tinh vi hơn: "vậy chạy nhiều lần rồi lấy trung bình cho chuẩn". Với loại nhiễu này thì trung bình không phải thống kê đúng — vì nhiễu chỉ cộng, không trừ, trung bình bị các lần chậm kéo lên và phản ánh cả nhiễu lẫn code. Cái bạn muốn là giá thật của code khi ít bị quấy nhất, tức min. (Nếu bạn quan tâm độ trễ đuôi ở production thì p99 mới đáng — nhưng đó là câu hỏi khác: "code nhanh cỡ nào" dùng min, "người dùng chịu chậm cỡ nào" dùng đuôi.) Sự thật gói lại: một phép đo ẩu còn tệ hơn không đo, vì nó cho bạn sự tự tin sai; "số liệu" không tự động đáng tin — chỉ quy trình đo đúng (cô lập một biến, warmup, lặp, lấy min, cảnh giác thứ tự) mới làm số liệu đáng tin. Nếu hiệu ứng thật 4× mà nhiễu 1,83× vẫn đủ đảo khi chạy một lần, thì với một tối ưu tinh tế hơn (10-20%), một phép đo cẩu thả gần như chắc chắn dẫn bạn đi lạc — đây là lý do mọi con số trong sê-ri qua bốn rào, kể cả chặn trình biên dịch tối ưu mất phần đang đo. (Cùng tinh thần đo lường vi mô đúng cách mà cả sê-ri dựa vào.)
Về đến code hằng ngày
Điều đầu tiên: đừng bao giờ kết luận một tối ưu từ một lần chạy. Chạy nhiều lần (chục lần trở lên), lấy min, và nhìn cả độ dao động — nếu min và max chênh nhau gần bằng hiệu ứng bạn đang đo, bạn cần thêm lần chạy hoặc một máy yên tĩnh hơn. Một con số đơn lẻ trên máy có nhiễu là tung đồng xu, không phải phép đo.
Điều thứ hai: cô lập đúng một thay đổi mỗi lần đo. Muốn biết padding có đáng không, chỉ đổi padding — giữ nguyên thuật toán, số luồng, dữ liệu. Đổi nhiều thứ cùng lúc rồi đo thì dù con số đẹp, bạn không biết nhờ cái nào, và không lặp lại được. Đây là cùng kỷ luật một baseline sạch, đổi một biến mà bài scaling đã nhấn.
Chỗ này cả một ngành công cụ sinh ra chỉ để ép bốn quy tắc trên, vì đo tay gần như luôn sai. Java có JMH (Java Microbenchmark Harness) — nó bắt buộc warmup, fork nhiều lần, lặp, và cảnh báo bạn về dead-code elimination (đúng cái "chặn trình biên dịch"); người ta xây hẳn JMH vì micro-benchmark viết tay trên JVM (có JIT hâm nóng) gần như chắc chắn cho số sai. Go có testing.B + benchstat (chạy nhiều lần rồi so thống kê, không so hai số trần). Công cụ dòng lệnh hyperfine mặc định warmup + nhiều lần + báo độ lệch. Và dân đo hiệu năng có câu cửa miệng "benchmarking is hard" chính vì cái bẫy một-lần-chạy này. Mẫu số chung: không ai nghiêm túc so hiệu năng bằng một lần chạy mỗi bên — họ để công cụ lặp, lọc nhiễu, và báo khoảng tin cậy.
Muốn tự thấy thì lần tới khi bạn định nói "thay đổi này nhanh hơn", hỏi: mình đã chạy nó mấy lần? Nếu câu trả lời là "một lần mỗi bên", hãy chạy lại mười lần mỗi bên và lấy con số nhỏ nhất của mỗi bên — rồi xem kết luận có còn giữ không. Rất thường, độ dao động giữa các lần chạy lớn ngang hoặc hơn cả chênh lệch bạn đang đo, và "chiến thắng" của một lần chạy tan biến (hoặc đảo chiều) khi lặp lại. Vài chục giây chạy thêm chín lần đó là ranh giới giữa một phép đo thật và một lần tung đồng xu mà bạn tưởng là bằng chứng — và nó áp cho mọi so sánh hiệu năng bạn từng làm, không riêng đồng thời.