Suốt cả sê-ri này, mỗi con số tôi đưa ra đều theo một công thức lặp đi lặp lại: chạy nóng máy vài lần rồi lấy giá trị nhỏ nhất của nhiều lần đo. Bài này giải thích vì sao — và nó quan trọng ngang bất cứ bài nào về cache hay pipeline, vì nếu đo sai thì mọi con số đẹp đẽ kia đều vô nghĩa.
Hãy nghĩ về việc đo tốc độ tối đa của một vận động viên chạy 100 mét. Bạn không bấm giờ đúng một lần rồi kết luận. Vì hàng loạt thứ có thể làm anh ta chậm lại — vấp một cái, gió thổi ngược, một người cắt ngang đường, xuất phát hụt — nhưng tuyệt nhiên chẳng có gì làm anh ta chạy nhanh hơn khả năng thật của cơ thể. Nên nếu bạn bấm giờ mười lần, cái thành tích tốt nhất mới phản ánh sức thật; còn lấy trung bình thì bị mấy lần vấp kéo tụt xuống. Đo tốc độ một lõi CPU y hệt vậy. Tần số của nó không cố định: lúc mới giao việc nhịp còn thấp rồi tăng dần lên turbo (nên vài lần chạy đầu chậm hơn), tải kéo dài nóng máy thì nó giảm nhịp, và xen giữa thì hệ điều hành ngắt luồng của bạn, đá nó sang lõi khác làm mất cache nóng, hay để một tiến trình nền chen vào. Tất cả những thứ đó chỉ đẩy một lần bấm giờ lên cao so với tốc độ lõi thật.
Đây là điểm mấu chốt: mọi nguồn nhiễu chỉ làm phép đo chậm đi, không bao giờ nhanh hơn khả năng vật lý của lõi. Nên phân bố các lần đo lệch hẳn một bên: một cái sàn cứng (tốc độ thật, đạt được ở lần chạy sạch nhất) với một cái đuôi kéo lên trên. Số nhỏ nhất nằm sát cái sàn đó, gần sự thật nhất. Trung bình thì bị cái đuôi kéo lên. Tôi đo để xem cái đuôi ấy dày cỡ nào.
Con số
Chạy cùng một kernel nặng chừng 24 ms, lặp đi lặp lại, trên máy ARM:
Về warmup, lần chạy đầu tiên mất 24,36 ms, rồi từ lần thứ hai trở đi rơi xuống quanh 23,87–23,99 ms và ổn định ngay. Chênh nhỏ, khoảng 2%, nhưng có thật — đo trúng đúng lần đầu là bạn báo một con số cao hơn thực 2%.
Về phân tán, tôi đo 200 lần sau khi đã warmup: nhỏ nhất 23,85 ms, trung bình 24,08 (chỉ hơn min 1%), nhưng lớn nhất tận 30,50 ms — cao hơn min 28%. Nghĩa là phần lớn các lần bám sát nhau, nhưng có những lần lẻ chậm hẳn gần một phần ba, do một cú ngắt hay một lần bị đá sang lõi khác. Đo trúng đúng cái lần xui đó là bạn báo một con số sai 28%. Và để ý: trung bình cao hơn min, vì nó bị cái đuôi trên kéo lên; chỉ min mới bám sát tốc độ lõi thật, đơn giản vì không lần nào chạy nhanh hơn nó được.
Còn throttling thì tôi phải trung thực: chạy tải liên tục 16 giây, min mỗi đoạn một giây vẫn phẳng lì ~23,85 ms, không thấy giảm nhịp. Máy này chạy trong một máy ảo, mà VM thường trừu tượng hóa việc quản lý tần số, nên tôi không quan sát được throttling nhiệt ở đây — có thể do VM che, có thể do tải chưa đủ nóng. Hai thứ tôi chắc chắn thấy là warmup và cái đuôi nhiễu, và chỉ chừng đó đã đủ lý do để dùng min cộng warmup.
Hai định kiến, cả hai cùng sai
Định kiến lười: chạy một lần, bấm giờ, lấy số, xong. Sai — lần đầu dính warmup, và một lần bất kỳ có thể rơi vào đuôi nhiễu cao hơn min tới 28%. Một phép đo đơn lẻ là một mẫu rút từ một phân bố lệch; nó có thể là con số sạch hoặc con số dính nhiễu, mà bạn không biết là cái nào.
Định kiến ngược, đến từ thống kê học đường: đo nhiều lần thì lấy trung bình là chuẩn nhất, min chỉ là điểm may mắn. Cũng sai, và sai có hệ thống. Trung bình chỉ đúng khi nhiễu đối xứng, kéo cả lên lẫn xuống. Ở đây nhiễu một chiều: chỉ làm chậm, không làm nhanh. Nên trung bình luôn bị cái đuôi trên kéo cao hơn tốc độ thật, còn min mới bám cái sàn. Với một đại lượng chỉ-nhiễu-một-chiều như thời gian chạy kernel, min mới là ước lượng đúng, không phải trung bình.
Ngoài đời thì sao
Quy tắc khi đo hiệu năng vi mô: warmup vài lần cho tần số lên và cache/mã nóng, rồi lặp hàng chục tới trăm lần và lấy giá trị nhỏ nhất. Đó là ước lượng gần nhất với tốc độ lõi thật, chính là công thức cả sê-ri này dùng. Đừng bao giờ tin một con số bấm giờ duy nhất.
Nhưng có một chỗ tinh tế đáng phân biệt: min trả lời câu hỏi "lõi này có thể nhanh cỡ nào" — hữu ích để so sánh hai thuật toán, kiểm xem một tối ưu có ăn không. Còn nếu bạn quan tâm trải nghiệm thật của người dùng dưới tải sản xuất, nơi có đủ throttling, tranh chấp, nhiễu, thì cái đáng theo dõi lại là phân vị cao — p95, p99 — chứ không phải min. Hai câu hỏi khác nhau cần hai con số khác nhau: min để đo thuật toán, đuôi cao để đo dịch vụ. Đây cũng là lý do các công cụ benchmark tử tế không đưa cho bạn mỗi một số: JMH của Java báo cả trung bình, độ lệch, lẫn các phân vị; và hệ giám sát production thì đo độ trễ bằng histogram p99 (kiểu Prometheus) chứ tuyệt nhiên không dùng min, vì cái người dùng cảm nhận là những lần chậm nhất, không phải lần nhanh nhất.
Muốn tự thấy thì viết một vòng tính toán nặng cỡ chục mili-giây, chạy 50 lần liên tiếp và in thời gian từng lần. Nhìn danh sách: vài lần đầu cao hơn (warmup), bỏ đi, rồi tính min, trung bình, max của phần còn lại. Trung bình cao hơn min một chút, còn max cao hơn min nhiều — cái đuôi do một cú ngắt. Rồi tự hỏi: nếu chỉ đo một lần, bạn sẽ báo con số nào? Rất có thể là một con số dính nhiễu. Thời gian chạy không phải một con số mà là một phân bố lệch một chiều, và nhiễu chỉ đẩy nó lên — nên muốn con số gần sự thật nhất, hãy warmup rồi lấy min.