"Căn lề dữ liệu" (alignment) là một trong những lời khuyên hiệu năng cũ kỹ nhất: đặt biến ở địa chỉ chia hết cho kích thước của nó, kẻo CPU đọc chậm hoặc lỗi. Nhưng CPU ngày nay đọc lệch (unaligned) khá thoải mái — nên nhiều người kết luận căn lề là chuyện quá khứ. Sự thật, như mọi khi, nằm ở phép đo. Vì CPU nạp bộ nhớ theo từng dòng cache 128 byte (trên host ARM này), câu hỏi thật không phải "lệch hay không" mà là "có bắc cầu qua ranh giới dòng cache không". Tôi đo trong container gcc:13 trên host ARM, và ba tình huống cho ba kết cục rất khác nhau — kể cả một cú crash.
Lệch trong dòng, hay lệch bắc cầu hai dòng?
Một biến int64 chiếm 8 byte. "Căn lề" nghĩa là địa chỉ của nó chia hết cho 8. "Lệch" (unaligned) là địa chỉ không chia hết — ví dụ một int64 đặt ở byte thứ 4 của một khối. Ngày xưa, CPU đọc lệch phải làm hai lần truy cập rồi ghép, rất chậm; nhiều kiến trúc còn cấm hẳn, đọc lệch là lỗi.
Nhưng CPU hiện đại (ARMv8, x86) xử lý đọc lệch trong phần cứng gần như miễn phí — với một điều kiện. Vì bộ nhớ được nạp theo dòng cache (128 byte trên máy này), điều thật sự quan trọng là 8 byte của bạn có nằm gọn trong một dòng hay vắt qua ranh giới sang dòng kế. Nếu gọn trong một dòng, một lần nạp dòng là đủ. Nếu bắc cầu, CPU phải chạm hai dòng cache cho một giá trị. Để đo tách bạch, tôi đọc một int64 ở nhiều vị trí (offset) khác nhau trong mỗi khối 128 byte, theo thứ tự ngẫu nhiên (chặn prefetch đoán trước).
Đo: lệch trong dòng miễn phí, bắc cầu thì tốn
Đọc int64 tại offset trong block 128B, thứ tự ngẫu nhiên, host ARM, ns/truy cập:
A. Buffer 96 KB (trong L1)
offset 0 / 4 / 64 / 120 (trong 1 dòng) : 0,1786 ns <- y hệt nhau
offset 124 / 127 (bắc cầu 2 dòng) : 0,2371 ns <- +33%
B. Buffer 400 MB (RAM, mỗi truy cập ~1 miss)
offset 0 .. 120 (trong 1 dòng) : 3,6 – 3,7 ns
offset 124 / 127 (bắc cầu 2 dòng) : 8,0 ns <- ~2,2x
Nhìn tình huống A (L1): đọc lệch ở offset 0, 4, 64, 120 — tất cả đều nằm gọn trong một dòng 128 byte — cho ns y hệt nhau: 0,1786 ns. Lệch hay căn lề không quan trọng miễn còn trong một dòng. Chỉ khi offset là 124 hay 127 (8 byte vắt qua mốc 128) thời gian mới nhảy lên 0,2371 ns — chậm thêm 33% — vì mỗi lần đọc phải chạm hai dòng L1 thay vì một.
Tình huống B (RAM) khuếch đại điều đó: khi mỗi truy cập là một lần chạm bộ nhớ thật, đọc trong-dòng tốn 3,6–3,7 ns, còn bắc-cầu tốn 8,0 ns — gấp ~2,2 lần. Lý do rõ ràng: một giá trị vắt hai dòng cache buộc nạp hai dòng từ bộ nhớ, gấp đôi lưu lượng. (Con số ~3,6 ns mỗi truy cập ngẫu nhiên, thấp hơn nhiều so với độ trễ RAM ~108 ns ở phần 11, là vì các truy cập ở đây độc lập nên nhiều lần chạm chạy song song — nhưng bắc cầu vẫn nhân đôi công việc.)
Atomic bắc cầu dòng: không chậm, mà chết
Có một loại truy cập không tha thứ cho lệch: atomic. Tôi thử một atomic<int64>::fetch_add đặt ở các offset khác nhau:
atomic fetch_add tại offset (host ARM):
offset 0 (căn lề) : OK
offset 8 (căn lề, trong 16B) : OK
offset 12 (vắt qua ranh giới 16B) : BUS ERROR (SIGBUS) — chương trình CHẾT
offset 124 (vắt qua dòng cache) : BUS ERROR (SIGBUS) — chương trình CHẾT
Kết quả dứt khoát: trên ARM này (Apple Silicon), một atomic vắt qua ranh giới 16 byte (hay dòng cache) không chỉ chậm — nó ném SIGBUS và giết chương trình. Kiến trúc yêu cầu atomic phải căn lề tự nhiên; phần cứng không thể đảm bảo tính nguyên tử cho một truy cập trải hai dòng, nên nó từ chối thẳng. (Trên x86, atomic lệch chạy được nhưng phải khóa bus toàn cục, chậm khủng khiếp — xem chi phí đồng bộ, phần 9.)
Một lần tôi đo hớ: "căn lề là chuyện cũ" và "mọi truy cập lệch đều chậm"
Tôi vào đo với niềm tin thời thượng: "CPU nay đọc lệch thoải mái, căn lề là lo lắng lỗi thời". Đo cho thấy đúng một nửa: đọc lệch mà vẫn trong một dòng cache thật sự miễn phí (offset 0 tới 120 ns y hệt nhau). Nhưng nửa kia phá tan sự tự mãn: một giá trị bắc cầu hai dòng cache tốn thật — RAM chậm 2,2 lần vì gấp đôi lưu lượng — và một atomic lệch không chậm mà crash thẳng (SIGBUS). "Căn lề là chuyện cũ" khiến tôi coi thường hai cạm bẫy vẫn còn nguyên: cấu trúc đóng gói sai làm trường vắt dòng, và biến atomic lệch làm sập chương trình.
Nhưng đo cũng phá một niềm tin ngược, cái mà người cẩn thận thái quá dễ mang: "mọi truy cập lệch đều chậm, phải căn lề tất". Sai: offset 0, 4, 64, 120 cho đúng cùng thời gian — lệch trong một dòng không tốn gì. Chỉ khi bắc cầu ranh giới dòng cache mới đau. Căn lề tất cả một cách mù quáng (thêm padding khắp nơi) chỉ phí bộ nhớ mà không đổi tốc độ cho phần lớn truy cập; điều cần là căn lề đúng chỗ — tránh vắt dòng và bắt buộc với atomic.
Bài học đo lường: CPU nạp theo DÒNG CACHE (128B), nên cái quan trọng không phải 'lệch hay không' mà là 'bắc cầu 2 dòng hay không'. Lệch mà TRONG một dòng = miễn phí (đo: offset 0..120 y hệt, L1 0,1786 ns). BẮC CẦU 2 dòng = tốn: L1 +33%, RAM ~2,2x (gấp đôi lưu lượng). ATOMIC lệch qua ranh giới 16B/dòng = BUS ERROR (SIGBUS) trên ARM, chương trình chết (x86 thì khóa bus, rất chậm). 'Căn lề là chuyện cũ' và 'mọi lệch đều chậm' đều SAI. Nếu tin "căn lề lỗi thời" tôi để atomic lệch làm sập app; nếu tin "mọi lệch đều chậm" tôi padding thừa vô ích.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: thiết kế struct để trường không vắt dòng cache, và LUÔN căn lề biến atomic. Đọc/ghi thường lệch trong một dòng thì cứ thoải mái, nhưng một trường 8 byte đặt ở byte 124 của một struct (do đóng gói #pragma pack cẩu thả, hay ép kiểu từ mảng byte) sẽ vắt dòng và chậm. Với biến atomic hay khóa, căn lề là bắt buộc — đặt lệch không chỉ chậm mà có thể crash trên ARM. Dùng alignas cho chúng.
Hệ quả thứ hai: đừng padding mù quáng vì sợ lệch. Vì lệch trong-dòng miễn phí, không cần căn lề mọi trường một cách máy móc (điều đó chỉ phình struct, giảm số phần tử mỗi dòng cache, hại nhiều hơn lợi). Sắp xếp trường theo kích thước giảm dần để đóng gói chặt và tự nhiên tránh vắt dòng thường là đủ. Đo mới biết trường nào thực sự vắt ranh giới.
Hệ quả thứ ba là tinh thần đo lường: "căn lề" thật ra là "đừng bắc cầu dòng cache", và ngoại lệ chết người là atomic. Con số mang theo: lệch trong 1 dòng miễn phí (offset 0..120 y hệt); bắc cầu 2 dòng tốn (L1 +33%, RAM ~2,2x); atomic lệch qua ranh giới = SIGBUS crash trên ARM. Căn lề đúng chỗ (tránh vắt dòng, bắt buộc cho atomic), đừng padding mù. Cùng một phép đọc, đặt ở byte 120 thì miễn phí, đặt ở byte 124 thì đắt gấp đôi hoặc chết — chỉ ranh giới dòng cache quyết định.
Thử ba mươi giây
Cấp một buffer căn lề, rồi đọc một int64 (bằng memcpy để cho phép lệch) tại các offset 0, 4, 64, 120, 124, 127 trong mỗi khối 128 byte, theo thứ tự ngẫu nhiên, bấm giờ ns mỗi lần đọc. Bạn sẽ thấy offset 0 tới 120 cho cùng thời gian — lệch trong một dòng chẳng tốn gì — rồi offset 124 và 127 (nơi 8 byte vắt qua mốc 128) nhảy lên, rõ nhất khi buffer lớn hơn cache (mỗi giá trị bắc cầu buộc nạp hai dòng). Rồi thử điều nguy hiểm: đặt một std::atomic<int64_t> tại một địa chỉ vắt qua ranh giới 16 byte và gọi fetch_add — trên ARM chương trình của bạn sẽ chết với Bus Error, không phải chỉ chậm. Ba mươi giây đó cho bạn thấy điều mà cả "căn lề lỗi thời rồi" lẫn "phải căn lề mọi thứ" đều nói sai: chỉ ranh giới dòng cache mới đáng lo cho đọc/ghi thường, còn atomic thì căn lề là luật sống còn.