Vậy là hết. Bốn mươi lăm bài, hàng trăm phép đo, và nếu bạn quên sạch mọi con số tôi đã bày ra thì cũng không sao — miễn là còn giữ lại đúng một câu: đo, đừng đoán.
Cả sê-ri này thật ra chỉ đi chứng minh một điều, rằng cái niềm tin tưởng như hiển nhiên — "hiệu năng thì cứ nhìn thông số phần cứng với độ phức tạp Big-O là suy ra được" — sai, và sai xa. Lần nào cũng vậy: cùng một thao tác, cùng số phép tính y hệt, mà thời gian chạy chênh nhau từ vài lần tới cả nghìn lần, chỉ vì những chi tiết chẳng cuốn sách thuật toán nào thèm nhắc tới. Bài này tôi không đo gì mới. Chỉ ngồi lại, gom những gì các con số đã dạy, và rủ bạn tiếp tục tự đo.
Thứ Big-O không nói cho bạn
Big-O đếm số phép tính. Nó cố tình lờ đi cái hằng số ẩn nhân vào đó — mà hằng số ấy mới là thứ quyết định code chạy nhanh hay chậm, và nó nhảy múa dữ dội. Vài con số tôi thấy ấn tượng nhất, tất cả đo trên cùng một máy ARM:
| Hiện tượng | Chênh lệch |
|---|---|
| Đọc một ô nhớ ở L1 so với ở RAM | ~113 lần |
| Duyệt ma trận theo cột so với theo hàng | 17,9 lần |
| Nhân ma trận đổi thứ tự vòng lặp (cùng số phép) | 3,1 lần |
| Ném ngoại lệ so với một vòng lặp thường | 1.184 lần |
| memcpy so với vòng chép từng byte | 15 lần |
| Đọc vắt qua ranh giới trang | 29 lần |
Không con số nào trong bảng này đọc được từ datasheet của CPU, cũng chẳng đếm được bằng Big-O. Chúng chỉ chịu lộ mặt khi ta thật sự đo.
Không có con số nào đúng cho mọi máy
Đây là cái bẫy thứ hai, tinh vi hơn. Giả sử bạn đã chịu đo — nhưng đo trên một máy thì cũng chưa cho biết gì về máy khác.
Suốt sê-ri tôi đo trên một lõi ARM Apple Silicon chạy trong máy ảo Linux, và nó lệch khỏi x86 ở đúng những chỗ khiến khối "mẹo tối ưu" quen thuộc hóa thành thừa hoặc sai. Dòng cache của nó 128 byte chứ không phải 64. Nó không có SMT lẫn NUMA, nên mọi mưu ghim luồng, ghim node đều vô nghĩa ở đây. Truy cập lệch căn trong một trang gần như miễn phí, kể cả khi cắt ngang dòng cache — chỉ cắt qua trang mới bị phạt, ngược hẳn nỗi ám ảnh "line-split" bên x86. Và memory_order_relaxed nhiều khi chạy ngang seq_cst, false sharing cũng chỉ tệ khoảng 2,6 lần chứ không dữ như trên Intel.
Bê một quy tắc tối ưu từ kiến trúc khác về mà không đo lại, bạn rất dễ đang tối ưu cho một cỗ máy không tồn tại. Con số đúng chỉ có một nguồn: chính cái máy code bạn sẽ chạy.
Hai lần tôi tưởng mình đúng
Mỗi bài trong loạt này đều mở ra từ một lần tôi đoán trật — một trực giác nghe rất hợp lý rồi bị phép đo bác thẳng. Đến bài cuối, hai cú trật lớn nhất gói lại gần như toàn bộ tinh thần cả sê-ri.
Cú thứ nhất, tôi từng nghĩ đọc thông số phần cứng cộng với biết Big-O là đủ để đoán code nhanh chậm. Trật, mà trật tới hàng chục hàng nghìn lần, vì cái hằng số ẩn kia — dữ liệu nằm tầng cache nào, nhánh có đoán được không, truy cập tuần tự hay ngẫu nhiên — mới cầm trịch. Hai đoạn cùng O(n) chênh nhau trăm lần là chuyện thường. Không suy trên giấy được, phải đo.
Cú thứ hai kín đáo hơn: "rồi, tôi đo trên máy tôi rồi, giờ thì biết rồi". Không. Đo một máy không suy ra được máy khác — ARM khác x86, laptop khác server, máy ảo lại giấu đi cả PMU lẫn NUMA như tôi vấp phải mấy bài gần đây. Số tôi đo đúng cho máy tôi, còn bạn vẫn phải chạy lại trên máy bạn, vì đó mới là nơi code bạn sống.
Rồi thì làm gì
Khi hiệu năng thật sự quan trọng, đừng ngồi cãi nhau "cái này chắc nhanh hơn". Viết mười lăm dòng benchmark cho tử tế — ghi kết quả vào một biến volatile để trình biên dịch khỏi xóa vòng lặp, lấy dữ liệu khởi tạo lúc chạy, hâm nóng vài lượt, lặp thật nhiều rồi chia ra, lấy con số nhỏ nhất — và chạy nó. Ba mươi giây đo thường thắng ba tiếng tranh luận. Và hễ thấy con số khó tin, quá nhanh hay quá chậm, hãy nghi cái benchmark trước khi nghi phần cứng; tôi đã dành hẳn một bài kể những cú lừa mà trình biên dịch bày ra.
Bạn cũng không cần thuộc lòng mọi chi tiết. Chỉ cần nắm vài hiện tượng cốt lõi — cache phân tầng, dự đoán nhánh, tuần tự so với ngẫu nhiên, chuyện chia sẻ dữ liệu giữa các luồng — là đủ để nghi đúng chỗ. Code chậm bất thường mà số phép không đổi ư? Nghĩ tới cache và dự đoán nhánh trước tiên, rồi đo để chắc.
Điều tôi thích nhất ở việc đo là nó cứ đều đặn cho tôi thấy thế giới không như mình tưởng. Gần như bài nào, con số cũng làm tôi bất ngờ một lần. Nên nếu có một thói quen đáng mang theo sau 45 bài, thì đó là: đừng tin trực giác hiệu năng của chính mình — kể cả bây giờ, kể cả sau khi đọc hết chỗ này.
Thử luôn cho nóng đi: chọn một điều bạn đang đinh ninh là "nhanh hơn" hay "chẳng đáng kể" — duyệt mảng theo hàng hay theo cột, std::vector hay std::list, một hàm ảo có đáng ngại không, memcpy hay tự chép tay. Rồi đừng tin nó nữa. Viết phép đo, chạy trên máy bạn, đọc con số. Nó sẽ làm bạn ngạc nhiên, và một khi đã quen cái cảm giác ấy thì khó bỏ lắm. Đo, đừng đoán — trên máy của bạn, ngay hôm nay.