Suốt 32 phần của sê-ri này, mỗi con số — từ bậc thang cache tới đường cong theo kích thước — đều lấy theo một công thức lặp đi lặp lại: chạy nóng máy (warmup) vài lần, rồi lấy giá trị nhỏ nhất (min) của nhiều lần đo. Phần này giải thích vì sao — và nó là một bài học đo lường quan trọng ngang bất kỳ bài nào về cache hay pipeline. Lý do nằm ở chỗ CPU không chạy ở một tần số cố định: nó có turbo (tăng nhịp khi tải nhẹ hoặc ngắn), throttling (giảm nhịp khi nóng hoặc tải kéo dài), và cần warmup (vài lần chạy đầu chậm hơn vì tần số chưa lên, cache còn lạnh, mã chưa nóng). Cộng thêm nhiễu từ hệ điều hành (và cái bẫy của bộ đếm chu kỳ), một phép đo thời gian đơn lẻ rất dễ đánh lừa. Tôi đo trong container gcc:13 trên host ARM để xem những hiệu ứng này lớn cỡ nào — và báo cáo trung thực cái nào quan sát được, cái nào không.
CPU không chạy một tần số — nên một phép đo đơn lẻ dễ sai
Tần số CPU biến thiên theo thời gian. Khi bạn vừa giao việc, nhịp có thể còn thấp rồi tăng dần lên mức turbo (warmup). Nếu tải kéo dài và chip nóng, nó có thể giảm nhịp để hạ nhiệt (throttling). Xen giữa, hệ điều hành ngắt luồng của bạn để chạy việc khác, di trú nó sang lõi khác (mất cache nóng), hoặc để một tiến trình nền tranh chỗ. Mọi thứ đó khiến một lần bấm giờ có thể cao hơn tốc độ lõi thật rất nhiều.
Điểm mấu chốt — và là lý do min là ước lượng đúng: các nguồn nhiễu này chỉ làm một lần đo chậm đi, không bao giờ nhanh hơn tốc độ lõi thật. Không có cơ chế nào khiến kernel của bạn chạy 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 một chiều: một sàn cứng (tốc độ lõi 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 do nhiễu. Giá trị nhỏ nhất nằm sát cái sàn đó — gần tốc độ thật nhất. Còn trung bình bị cái đuôi kéo lên cao hơn thật. Tôi đo để thấy đuôi ấy dày cỡ nào.
Đo: warmup +2%, đuôi nhiễu +28%, và không thấy throttling
Tôi chạy cùng một kernel compute-bound (~24 ms mỗi lần) lặp đi lặp lại, và đo ba thứ: nhịp warmup, phân tán của nhiều lần, và diễn biến khi tải kéo dài:
Cùng một kernel ~24 ms, host ARM, g++ -O2 -fno-tree-vectorize:
(a) Warmup — thời gian từng lần chạy liên tiếp (ms):
lần 1 : 24,36 <- chậm hơn
lần 2+ : 23,87 – 23,99, ổn định ngay
(b) Phân tán 200 lần đo (sau khi warmup 20 lần):
min = 23,85 ms
trung bình = 24,08 ms (+1,0% so với min)
max = 30,50 ms (+27,9% so với min!)
(c) Throttling — tải liên tục 16 giây, min mỗi đoạn 1 giây:
đoạn 1..16 : min ~23,85 ms ở MỌI đoạn — phẳng lì, không tăng
Đọc ra ba điều. Một (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 ~23,87–23,99 ms và ổn định ngay. Chênh lệch nhỏ (~2%) trên máy này, nhưng có thật — nếu bạn chỉ đo đúng một lần và trúng lần đầu, bạn báo cáo một con số cao hơn thực 2%. Đây là lý do luôn bỏ vài lần chạy đầu trước khi tin số.
Hai (phân tán) — quan trọng nhất: trên 200 lần đo, min = 23,85 ms, trung bình = 24,08 ms (chỉ cao hơn min 1%), nhưng max = 30,50 ms — cao hơn min tới 27,9%. Nghĩa là dù phần lớn các lần đo bám sát nhau, có những lần lẻ chậm hẳn 28% (một cú ngắt, một lần di trú lõi, một tiến trình nền chen vào). Nếu bạn đo một lần và xui trúng cái đuôi này, bạn báo một con số sai 28%. Và để ý: trung bình (24,08) cao hơn min — bị cái đuôi trên kéo lên. Chỉ min bám sát tốc độ lõi thật, vì không lần nào có thể thấp hơn nó.
Ba (throttling): chạy tải liên tục 16 giây, tôi chia thành 16 đoạn một giây và xem min mỗi đoạn. Kết quả: phẳng lì ~23,85 ms ở mọi đoạn — không thấy dấu hiệu giảm nhịp. Trung thực mà nói: máy này chạy trong một VM (host ARM AArch64), và VM thường trừu tượng hóa quản lý tần số, nên tôi không quan sát được throttling nhiệt trong 16 giây — có thể do VM che, có thể do tải chưa đủ nóng. Điều tôi chắc chắn quan sát được là warmup và cái đuôi nhiễu — và đó là hai lý do đủ để dùng min + warmup.
Một lần tôi đo hớ: "một phép đo là đủ" và "trung bình là chuẩn nhất"
Tôi từng đo với thói quen lười: "chạy một lần, bấm giờ, lấy con số — thế là xong". Đo phá tan: lần chạy đầu dính warmup (+2% ở đây), 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% (max 30,50 vs min 23,85 ms). Một phép đo đơn lẻ là một mẫu từ một phân bố lệch — nó có thể là con số sạch, hoặc là con số dính nhiễu, và bạn không biết là cái nào. Nên phải warmup rồi đo nhiều lần.
Nhưng khi đã chịu đo nhiều lần, tôi lại mắc lỗi ngược mà thống kê học đường dạy: "đo nhiều lần thì lấy trung bình là chuẩn nhất, min chỉ là một điểm may mắn". Sai — và sai có hệ thống: trung bình chỉ là ước lượng trung tâm đúng khi nhiễu đối xứng (kéo cả lên lẫn xuống). Ở đây nhiễu một chiều: mọi nguồn (ngắt OS, di trú lõi, tần số thấp lúc đầu) chỉ làm một lần đo chậm đi, không bao giờ nhanh hơn khả năng vật lý của lõi. Nên trung bình luôn bị cái đuôi trên kéo cao hơn tốc độ thật (24,08 vs min 23,85), còn min mới bám sát cái sàn = tốc độ lõi thật. Với đại lượng chỉ-nhiễu-một-chiều như thời gian chạy kernel, min là ước lượng đúng, không phải trung bình.
Bài học đo lường: CPU không chạy tần số cố định (turbo/throttle/warmup) + nhiễu OS làm mỗi phép đo thời gian LỆCH MỘT CHIỀU (chỉ chậm đi, không nhanh hơn tốc độ lõi thật). Đo: warmup lần 1 +2%; 200 lần cho min 23,85 / trung bình 24,08 (+1%) / max 30,50 (+27,9%) — đuôi trên dày; tải 16s không thấy throttling (VM ẩn tần số). 'Một phép đo đơn lẻ đủ tin' SAI (dễ trúng warmup/đuôi +28%); 'lấy trung bình là chuẩn nhất' SAI (nhiễu một chiều kéo trung bình cao hơn thật — MIN mới gần tốc độ lõi thật). Nếu tin "một lần là đủ" tôi báo số dính nhiễu; nếu tin "trung bình chuẩn nhất" tôi báo số cao hơn thật một cách hệ thống.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn warmup rồi lấy min của nhiều lần khi đo hiệu năng vi mô. Bỏ vài lần chạy đầu (để tần số lên, cache và mã nóng lên), rồi lặp phép đo hàng chục–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 cho một kernel compute-bound — chính công thức cả sê-ri này dùng. Đừng tin một con số bấm giờ duy nhất.
Hệ quả thứ hai: phân biệt "tốc độ lõi thật" và "thời gian thực tế người dùng thấy". Min cho bạn khả năng của lõi (hữu ích để so sánh thuật toán, đo băng thông đỉnh, kiểm tra tối ưu có ăn không). Nhưng nếu bạn quan tâm trải nghiệm thật dưới tải sản xuất — nơi có throttling, tranh chấp, nhiễu — thì phân vị cao (p95, p99) mới là con số cần theo dõi. Hai câu hỏi khác nhau cần hai thống kê khác nhau: min để đo thuật toán, đuôi cao để đo dịch vụ. Đừng lẫn.
Hệ quả thứ ba là tinh thần đo lường: thời gian chạy là một phân bố lệch, không phải một con số — và biết nhiễu lệch chiều nào quyết định bạn nên lấy thống kê nào. Con số mang theo: CPU đổi tần số + nhiễu OS làm phép đo chỉ chậm đi (một chiều); warmup rồi lấy MIN cho tốc độ lõi thật (đo: đuôi nhiễu tới +28%, trung bình +1% so min); dùng phân vị cao khi cần thời gian dịch vụ thật. Một phép đo đơn lẻ là một mẫu may rủi; chỉ lặp nhiều lần và hiểu hình dạng nhiễu mới cho bạn con số đáng tin.
Thử ba mươi giây
Viết một vòng lặp tính toán nặng (một chuỗi phép nhân-cộng vài chục triệu bước, để mỗi lần chạy mất cỡ chục mili-giây), rồi chạy nó 50 lần liên tiếp và in thời gian từng lần. Nhìn danh sách: lần đầu (hoặc vài lần đầu) thường cao hơn — đó là warmup, tần số chưa lên và cache/mã còn lạnh. Bỏ chúng đi, rồi tính min, trung bình, max của phần còn lại: bạn sẽ thấy trung bình cao hơn min một chút, còn max cao hơn min nhiều (vài phần trăm tới vài chục phần trăm) — cái đuôi do một cú ngắt hay di trú lõi. Giờ tự hỏi: nếu bạn 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, cao hơn thật. Ba mươi giây đó cho bạn thấy điều mà "bấm giờ một lần là xong" giấu đi: 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, warmup và nhiễu chỉ đẩy nó lên, nên muốn con số gần tốc độ lõi thật nhất, hãy warmup rồi lấy min của nhiều lần — đúng như mọi phép đo trong sê-ri này đã làm.