bài trước ta căn lề dữ liệu. Có một loại căn lề khác, ít ai để ý: căn lề . Trình biên dịch chèn các lệnh nop để đưa đầu một vòng nóng hay đầu một hàm vào một ranh giới địa chỉ đẹp (16, 32, 64 byte), giúp bộ nạp lệnh của CPU lấy về gọn ghẽ. Nghe như một nút chỉnh tốc độ nữa. Tôi vào đo, và phát hiện nó ít là một nút chỉnh, mà nhiều là một nguồn nhiễu — thứ làm hỏng chính các phép đo của cả sê-ri này nếu không cẩn thận.

Căn lề mã và nhiễu

Compiler căn lề mã như thế nào

CPU không đọc lệnh từng byte một; nó nạp cả một khối (thường 16 hoặc 32 byte) mỗi lần từ bộ đệm lệnh. Nếu đầu một vòng nóng nằm ngay trước một ranh giới khối, khối đầu tiên chỉ chứa vài lệnh của vòng, phần còn lại phải nạp thêm — lãng phí. Căn lề đầu vòng vào ranh giới khối tránh chuyện đó.

Trình biên dịch làm việc này tự động. Trong container gcc:13 (ARM AArch64), biên dịch -O2 một hàm có vòng lặp, objdump cho thấy rõ:

.p2align 4,,11      <- căn hàm vào ranh giới 16 byte
hot:
    ...
.p2align 3,,7       <- căn đầu vòng vào ranh giới 8 byte
.L3:                <- thân vòng
    ...

Và trong mã máy thật, một lệnh nop được chèn để đệm cho vòng bắt đầu đúng chỗ. Có hai cờ chỉnh mức: -falign-loops=N (căn đầu vòng) và -falign-functions=N (căn đầu hàm), cả hai bật sẵn ở -O2. Đặt -falign-loops=1 là tắt căn lề vòng. Nghe hợp lý là "căn lề nhiều hơn thì chạy ổn định và nhanh hơn". Tôi đo để kiểm.

Đo: tác động nhỏ và không đơn điệu

Tôi lấy đúng một vòng nóng, biên dịch nó với các mức -falign-loops khác nhau, đo lần nhanh nhất qua nhiều vòng:

-falign-loops=1  : 0,2885 ns/phần tử
-falign-loops=8  : 0,2894 ns/phần tử
-falign-loops=16 : 0,2905 ns/phần tử
-falign-loops=32 : 0,2888 ns/phần tử
-falign-loops=64 : 0,2848 ns/phần tử

Chênh lệch cả dải chỉ khoảng 2%, và — quan trọng hơn — nó không đơn điệu: -falign-loops=64 lại nhanh hơn -falign-loops=16, ngược hẳn với trực giác "căn nhiều hơn thì tốt hơn". Không có một hướng rõ ràng để chỉnh; con số nhảy lên xuống theo cách không đoán được. Trên ARM này (bộ nạp lệnh rộng, xử lý mã lệch ranh giới tốt), lợi ích của căn lề mã vừa nhỏ vừa thất thường — và -falign-loops lớn đôi khi còn phình mã (thêm nhiều nop) mà chẳng nhanh hơn, đúng kiểu đánh đổi kích thước ở bài -O3 so -O2.

Đó đã là một đo hớ nhỏ. Nhưng phép đo tiếp theo mới là điều đáng nhớ thật sự.

Đo hớ: căn lề mã là nguồn nhiễu

Tôi giữ vòng nóng y hệt — không đổi một ký tự nào trong nó — và chỉ chèn vào chương trình một hàm chết (không bao giờ được gọi, nhưng compiler vẫn sinh mã), độ dài tăng dần. Hàm chết này chẳng liên quan gì tới vòng nóng, nhưng vì nó nằm trước vòng trong mã, nó dịch địa chỉ của vòng nóng đi một chút. Kết quả:

hàm chết  0 dòng : 0,2821 ns/phần tử
hàm chết  1 dòng : 0,2909 ns/phần tử
hàm chết  2 dòng : 0,2893 ns/phần tử
hàm chết  3 dòng : 0,2896 ns/phần tử
hàm chết  5 dòng : 0,2800 ns/phần tử
hàm chết  8 dòng : 0,2920 ns/phần tử

Cùng một vòng, cùng dữ liệu, cùng cờ biên dịch — khác duy nhất vị trí của vòng trong bộ nhớ, do một hàm vô can đẩy nó tới lui. Vậy mà thời gian nhảy từ 0,2800 tới 0,2920 ns, một dải ~4%. Và để ý: dải nhiễu từ vị trí (~4%) này còn lớn hơn cả tác động của việc cố ý chỉnh -falign-loops (~2%). Bạn không thể tinh chỉnh căn lề mã để lấy tốc độ; nhưng căn lề mã lại tự động tiêm vài phần trăm nhiễu vào mọi phép đo vi mô.

Đây chính là hiện tượng khét tiếng "thêm một dòng code vô can làm benchmark chậm đi 3%". Không phải dòng code đó tốn thời gian — nó không bao giờ chạy — mà là nó dịch mã nóng sang một vị trí căn lề khác. Người mới đo dễ kết luận nhầm rằng thay đổi của mình làm code nhanh/chậm, trong khi thật ra chỉ là nhiễu căn lề.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đừng chỉnh -falign-loops/-falign-functions bằng tay để tìm tốc độ. Mặc định của -O2 đã hợp lý; tác động nhỏ và không đoán được, nên "tinh chỉnh" nó là đuổi theo nhiễu. Nếu thật sự cần, dùng PGO để compiler căn lề theo hồ sơ chạy thật — đó là cách có cơ sở, khác hẳn đoán mò một con số.

Hệ quả thứ hai, và là bài học đo lường cốt lõi: một phần dao động trong benchmark vi mô của bạn là nhiễu căn lề, không phải tín hiệu. Khi bạn đo "phiên bản A so với B" và thấy chênh 2-3%, hãy nghi ngờ: có thể A và B khác nhau chỉ vì mã nóng rơi vào vị trí căn lề khác, chứ không phải vì logic. Đây là lý do sâu xa của quy tắc đo lường vi mô đúng cách: đo nhiều lần, lấy min hoặc trung vị, và coi những khác biệt vài phần trăm là đáng ngờ trừ khi lặp lại được qua nhiều cách bố trí mã.

Hệ quả thứ ba: để so sánh công bằng, cố định càng nhiều thứ càng tốt. Khi thật sự cần biết A hay B nhanh hơn, biên dịch cả hai trong cùng một binary, hoán vị thứ tự đo, chạy nhiều lần — để nhiễu căn lề trung bình hóa đi thay vì thiên vị một bên. Con số mang theo: căn lề mã (nop đẩy đầu vòng/hàm vào ranh giới fetch) được gcc -O2 làm sẵn, nhưng tác động nhỏ (~2%) và không đơn điệu trên ARM này (-falign-loops=64 nhanh hơn =16); quan trọng hơn, nó là nguồn nhiễu benchmark — cùng hot loop y hệt, chỉ chèn một hàm chết để dịch địa chỉ, thời gian đổi ~4%, lớn hơn cả việc cố ý chỉnh -falign — nên phải đo nhiều lần lấy min/trung vị và nghi ngờ mọi khác biệt vài phần trăm. Đừng chỉnh căn lề mã; hãy biết nó tồn tại để không bị nó lừa.

Thử ba mươi giây

Viết một vòng nóng đơn giản (cộng một mảng), đo thời gian bằng clock_gettime, lấy min qua 20 lần chạy. Ghi lại con số. Giờ thêm vào file một hàm hoàn toàn vô can — ví dụ int khong_dung(int x){ return x*7+1; } — mà bạn không gọi ở đâu cả, biên dịch lại -O2 và đo lại. Nếu con số đổi đi một hai phần trăm dù bạn không đụng gì tới vòng nóng, bạn vừa chứng kiến nhiễu căn lề mã: hàm mới đẩy vòng nóng sang một địa chỉ khác. Đó là lời nhắc rằng một phép đo lẻ không đáng tin, và vì sao mọi con số trong sê-ri này đều lấy min qua nhiều lần chạy.