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 — và vấp đúng cái bẫy tôi định cảnh báo, ngay trên bàn đo, trong container gcc:13 (ARM).

Đo tác động một thay đổi đồng thời đúng cách

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ả. 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ột lần tôi đo hớ: chạy một lần là đủ?

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 trên đập tan nó một cách 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". Và 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.

Bài học đo lường, cũng là tinh thần của đo lường vi mô đúng cách mà cả sê-ri dựa vào: 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 mới làm số liệu đáng tin. Cụ thể với chênh lệch nhỏ trên nền nhiễu lớn: cô lập một biến, warmup, lặp nhiều lần và lấy min (min lọc nhiễu cộng; trung bình thì không), và để ý thứ tự chạy. Nếu hiệu ứng thật (4 lần) mà nhiễu (1,83 lần) vẫn đủ đảo được khi chạy một lần, thì với những thay đổi tinh tế hơn — một tối ưu 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 này đều qua bốn rào: warmup, lặp, lấy min, và chặn trình biên dịch tối ưu mất phần đang đo.

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 một tối ưu đồng thời 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.

Hệ quả 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 đo tác động một thay đổi mà bài scaling đã nhấn: một baseline sạch, đổi một biến.

Hệ quả thứ ba là tinh thần đo lường: quy trình đo làm nên độ tin của số, không phải bản thân con số. Con số mang theo: để đo tác động một thay đổi đồng thời cho đúng phải cô lập một biến, warmup, lặp nhiều lần và lấy MIN (min lọc nhiễu cộng, trung bình bị outlier kéo lên), cảnh giác thứ tự chạy — đo được cùng một thay đổi (padded vs false-sharing) khi CHẠY 1 LẦN cho A=13,0 B=4,5 ms nói 'padded chậm 2,9x' (SAI, A trúng outlier), còn LẶP 15 LẤY MIN cho A=2,9 B=12,8 ms tức padded NHANH 4,36x (đúng); riêng A dao động 2,9..5,4 ms (max/min 1,83), đủ để một lần chạy ĐẢO ngược kết luận. Một phép đo ẩu tệ hơn không đo — nó cho bạn niềm tin sai.

Thử ba mươi giây

Lần tới khi bạn định nói "thay đổi này nhanh hơn", hỏi: tôi đã 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. Ba mươi 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.