Suốt cả sê-ri này, mỗi con số tôi khoe với bạn đều bước ra từ một benchmark. Nên bài này công bằng mà nói là bài quan trọng nhất: nó mổ xẻ chính cái việc benchmark. Bởi đo tốc độ ở mức vi kiến trúc là đi qua một bãi mìn, mà kẻ gài mìn không phải phần cứng — là chính cái trình biên dịch bạn tin tưởng.

Hãy hình dung compiler như một trợ lý thông minh quá mức. Bạn bảo nó "tính cái đa thức này một triệu lần rồi vứt kết quả đi" — nó liếc qua, thấy kết quả cuối cùng chẳng ai dùng, và lặng lẽ... không tính gì cả. Bạn bấm giờ, thấy con số đẹp mê hồn, rồi hí hửng đi khoe. Chỉ có điều bạn vừa đo tốc độ của việc không làm gì.

Tôi dựng lại đúng cái bẫy đó trong container gcc:13 trên một máy ARM, cố tình viết vài benchmark sai để xem chúng nói dối tới mức nào, rồi so với bản làm đúng.

Một kernel, bốn kiểu đo

Tôi lấy một việc thật để đo: tính một đa thức khoảng 11 phép trên mỗi phần tử của mảng. Rồi đo nó bốn cách, và bốn cách cho bốn con số khác hẳn nhau:

Cách đo ns/phần tử Chuyện gì xảy ra
Tính rồi vứt kết quả 0,00 -O2 xóa sạch vòng lặp
Cho input là hằng 3.14 0,57 compiler tính sẵn, đo nhầm phép khác
Làm đúng bài bản 0,68 con số thật
Rắc volatile khắp vòng nóng 2,62 chặn tối ưu, chậm gấp 3,9 lần thật

Cái 0,00 đầu tiên là kinh điển. Tôi tính đa thức nhưng không ghi kết quả vào đâu cả, thế là g++ nhìn thấy biến tích lũy chẳng bao giờ được đọc, và xóa nguyên cả vòng lặp. Tôi ngồi bấm giờ một khoảng trống rỗng. Nếu kernel đơn giản, số 0 lòi ra ngay nên còn dễ nghi; nhưng với kernel phức tạp, compiler có khi chỉ xóa một phần, cho ra một con số thấp giả trông y như thật — và đó mới là lúc bạn tin nhầm.

Cái 0,57 mới thực sự nguy hiểm, vì nó không vô lý. Khi tôi cho đa thức ăn một hằng số 3.14, compiler tính luôn poly(3.14) ra một giá trị cố định ngay lúc dịch, nên vòng lặp chỉ còn cộng đi cộng lại một con số. 0,57 ns đó là tốc độ của phép cộng, không phải của kernel 11 phép mà tôi tưởng mình đang đo. Số trông hợp lý, nằm gọn trong khoảng "tin được", nhưng sai hoàn toàn về bản chất.

Cái 0,68 là con số thật, khi tôi làm đủ năm việc: ghi kết quả vào một biến volatile đặt ngoài vòng lặp để compiler không dám xóa; lấy dữ liệu từ mảng khởi tạo lúc chạy để nó không đoán trước được; hâm nóng vài lượt; lặp thật nhiều rồi chia; và lấy con số nhỏ nhất qua nhiều lần đo.

Còn cái 2,62 là bài học ngược. Sợ bị xóa vòng lặp, nhiều người phản xạ rắc volatile lên mọi thứ. Nhưng đặt volatile vào biến tích lũy trong vòng nóng thì mỗi phép cộng buộc phải ghi rồi đọc lại bộ nhớ, chuỗi tính bị serialize hết. Kết quả: đo ra chậm gấp 3,9 lần so với thực tế. volatile đặt đúng chỗ (ranh giới kết quả) là bạn; rải bừa vào vòng lặp là kẻ phá.

Chính tôi cũng vấp

Chỗ này tôi phải thú thật, vì nó không phải chuyện lý thuyết. Ngay trong sê-ri này tôi đã dính cả hai bẫy. Ở bài về bộ đếm phần cứng, cái vòng lọc có nhánh của tôi bị g++ biến thành code không nhánh, thế là đo ra "chẳng có branch-miss nào" — sai, phải thêm -fno-if-conversion mới giữ được nhánh thật. Ở bài memset, lần memset đầu tiên bị xóa vì buffer bị ghi đè ngay sau đó, cho ra con số 0 vô lý, phải đọc lại buffer để "neo" nó lại. Kể ra đây không phải để tự trách, mà vì đây là những cú vấp bạn gần như chắc chắn cũng sẽ gặp.

Ngoài đời thì sao

Rút gọn lại, một benchmark đáng tin cần đủ năm thứ: SINK kết quả ra ngoài vòng lặp, input lấy lúc chạy, hâm nóng, lặp nhiều rồi chia, và lấy min. Thiếu một là mở cửa cho một con số sai.

May là bạn hiếm khi phải tự tay làm hết. Trong C++, thư viện Google Benchmark có sẵn benchmark::DoNotOptimize và ClobberMemory để chặn compiler xóa code mà không cần rắc volatile. Bên Java, đây chính xác là lý do JMH tồn tại — nó có Blackhole để "nuốt" kết quả và cả một cơ chế chống dead-code, constant-fold y hệt, vì JIT của JVM cũng ranh ma đúng như g++. Go thì có testing.B với biến Sink được export để giữ kết quả sống. Khác ngôn ngữ, cùng một nỗi sợ: cái tầng biên dịch bên dưới sẽ tối ưu mất chính thứ bạn muốn đo.

Và khi một con số đẹp đến khó tin — 0 nano-giây, hay nhanh bất thường — phản xạ đúng là nghi cái benchmark trước khi mừng cho cái code. Mở assembly ra (-S, hay dán lên Godbolt) mà xem vòng lặp có thật sự còn đó không. Rất nhiều lần "tối ưu thần kỳ" hóa ra chỉ là compiler đã xóa việc giùm bạn.

Nếu muốn tự tay bắt quả tang: viết một vòng lặp tính đa thức nặng trên mảng rồi vứt kết quả, biên dịch -O2, đo — nhiều khả năng bạn thấy 0 nano-giây. Thêm một dòng ghi kết quả vào biến volatile ngoài vòng, con số thật hiện ra. Đổi input thành hằng số, nó tụt xuống một cách khó hiểu (đo nhầm rồi đấy). Cuối cùng nhét volatile vào biến tích lũy trong vòng, nó phình lên gấp mấy lần. Bốn con số đó, đặt cạnh nhau, dạy bạn nhiều hơn bất kỳ lời khuyên nào: giữa code của bạn và cái đồng hồ luôn có một trình biên dịch tinh ranh đứng chắn, và chỉ khi biết nó giở trò gì thì con số bạn đo mới đáng tin.