Có một quy tắc được nhắc đi nhắc lại: dữ liệu phải căn lề (aligned) — một long 8 byte nên nằm ở địa chỉ chia hết cho 8, một int ở địa chỉ chia hết 4. Đặt lệch (misaligned) thì, theo lời đồn, hoặc chương trình crash (SIGBUS), hoặc chạy chậm thảm họa. Tôi vào đo trên ARM AArch64 để xem lời đồn đúng tới đâu — và phát hiện nó gần như không còn đúng trên phần cứng hôm nay, thậm chí quy tắc "căn lề cho nhanh" có khi làm chậm.

Lệch lề không thảm họa

Căn lề là gì và vì sao từng đáng sợ

Bộ nhớ được CPU đọc theo từng khối, và một số kiến trúc cũ chỉ đọc được ở địa chỉ căn lề — đọc một long vắt qua ranh giới khối là một lỗi phần cứng, gây SIGBUS. Ngay cả nơi cho phép, đọc lệch lề từng phải làm hai lần nạp rồi ghép lại, đắt gấp đôi. Từ đó ra nỗi sợ: lệch lề = crash hoặc chậm khủng khiếp, nên luôn căn lề, luôn padding struct cho tròn.

Tôi kiểm ba mặt trong container gcc:13 (ARM AArch64, Apple Silicon): đọc vô hướng lệch lề, đọc vắt qua ranh giới cache line 64 byte, và phép atomic lệch lề. Kết quả buộc tôi xếp lại toàn bộ trực giác.

Đo: lệch lề gần như miễn phí

Đọc vô hướng. Tôi đọc một long 8 byte ở mỗi block, thay đổi độ lệch trong block, dữ liệu nằm gọn trong L1 để đo đúng chi phí truy cập chứ không phải băng thông bộ nhớ:

căn lề  (offset 0)          : 0,273 ns/đọc
lệch    (offset 1)          : 0,273 ns/đọc   (bằng)
vắt qua ranh giới 64B (60)  : 0,275 ns/đọc
vắt qua ranh giới 64B (63)  : 0,276 ns/đọc   (chậm ~1%)

Đọc lệch một byte bằng đúng đọc căn lề. Ngay cả khi 8 byte đó vắt qua ranh giới cache line — trường hợp đáng lo nhất, vì về lý thuyết phải chạm hai dòng cache — cũng chỉ chậm khoảng 1%. Phần cứng ARM hiện đại xử lý lệch lề gần như trong suốt; nỗi sợ "chậm gấp đôi" là di sản của một thế hệ chip khác.

Atomic. Đây là nơi tôi chắc chắn sẽ thấy phạt nặng — atomic thường đòi căn lề nghiêm ngặt, lệch lề có thể trap. Đo __atomic_fetch_add trên một long căn lề so với một long lệch 1 byte:

atomic căn lề  : 1,594 ns/op
atomic lệch lề : 1,593 ns/op   (bằng, KHÔNG crash)

Không crash, không chậm. Trên chip này, atomic lệch lề vẫn chạy ngon. (Trên kiến trúc khác kết quả có thể khác — đây là điểm phải đo trên máy đích, không suy diễn.)

Đo hớ: padding để "căn lề" lại làm chậm

Tới đây tôi đã bỏ nỗi sợ crash. Nhưng còn một niềm tin nữa: "cho struct căn lề theo cache line (padding lên 64 byte) để mỗi bản ghi gọn trong một dòng cache, truy cập nhanh hơn". Nghe hợp lý — một bản ghi vắt qua hai dòng cache thì tốn hai lần nạp. Tôi đo một mảng 4 triệu bản ghi, truy cập ngẫu nhiên (để nghẽn ở cache miss), so hai cách bố trí:

bản ghi padding 64B (mảng 256MB) : 3,301 ns/đọc
bản ghi packed  40B (mảng 160MB) : 3,155 ns/đọc   -> packed NHANH hơn!

Bản packed 40 byte — mà một số bản ghi vắt qua ranh giới cache line — lại nhanh hơn bản padding 64 byte căn lề đẹp. Đây là đo hớ của tôi: padding lên 64 byte làm mỗi bản ghi to hơn, nên cả mảng phình từ 160MB lên 256MB. Với truy cập ngẫu nhiên nghẽn ở bộ nhớ, mảng to hơn nghĩa là nhiều cache miss hơn, và cái giá đó lớn hơn nhiều so với phạt vắt-cache-line ~1% mà padding tránh được. Tôi tưởng đang tối ưu, thực ra đang làm chậm — vì tối ưu sai trục (căn lề truy cập) mà bỏ qua trục thật (kích thước dữ liệu, như bố cục dữ liệu và cache đã đo).

Vậy trên máy này, căn lề dữ liệu vô hướng gần như không phải thứ đáng lo. Phần cứng nuốt lệch lề rẻ, và padding vì "căn lề" thường hại nhiều hơn lợi.

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

Hệ quả đầu tiên: đừng padding struct chỉ để căn lề "cho nhanh". Với dữ liệu đọc nhiều, truy cập ngẫu nhiên, kích thước bản ghi quan trọng hơn việc nó có vắt cache line hay không — bản ghi gọn cho nhiều phần tử vào cache hơn. struct chặt (sắp trường lớn trước, nhỏ sau, để trình biên dịch chèn ít padding) thường thắng một struct độn cho tròn 64 byte. Đo trước khi thêm alignas.

Hệ quả thứ hai: căn lề thật sự quan trọng ở hai chỗ, và không phải chỗ bạn nghĩ. Một là false sharing: khi nhiều lõi CPU ghi vào các biến khác nhau nằm chung một cache line, chúng giành nhau quyền sở hữu dòng đó và làm chậm nhau khủng khiếp — ở đây căn lề mỗi biến sang một cache line riêng (padding 64B có chủ đích) là cứu tinh. Hai là SIMD và atomic trên vài kiến trúc đòi căn lề như một yêu cầu đúng đắn, không phải tốc độ (vector hóa còn thêm mã căn lề ở prologue). Chú ý: false sharing là chuyện ghi đồng thời, ngược hẳn với "đọc lệch lề chậm" mà lời đồn nói tới.

Hệ quả thứ ba là bài học đo lường quen thuộc: một quy tắc đúng ở thế hệ phần cứng này có thể sai ở thế hệ khác. "Luôn căn lề kẻo crash/chậm" đúng với chip thập niên trước và với vài kiến trúc nhúng nghiêm ngặt hôm nay, nhưng trên ARM/x86 hiện đại thì lệch lề vô hướng gần như miễn phí. Con số mang theo: trên ARM AArch64 này, truy cập lệch lề gần như vô hại — đọc long lệch bằng căn lề (0,273 ns), vắt ranh giới cache line 64B chỉ chậm ~1%, atomic lệch lề không crash và cùng tốc độ; và padding struct lên 64B để "căn lề cho nhanh" còn làm CHẬM hơn (3,30 so 3,16 ns) vì mảng phình gây nhiều cache miss — căn lề đáng lo nằm ở false sharing và SIMD/atomic, không phải truy cập vô hướng. Đừng theo quy tắc căn lề như phản xạ; đo trên máy đích.

Thử ba mươi giây

Cấp một buffer, đọc một long ở địa chỉ căn lề (buf) và ở địa chỉ lệch (buf+1) bằng memcpy trong một vòng lặp lớn, bọc clock_gettime. Trên máy ARM hay x86 hiện đại, hai thời gian gần như bằng nhau — lệch lề không phạt. Rồi thử hai struct: một cái alignas(64) độn cho tròn 64 byte, một cái để nguyên gọn (ví dụ 40 byte), tạo một mảng lớn của mỗi loại và cộng một trường theo thứ tự ngẫu nhiên. Bản gọn thường nhanh hơn — vì nó nhỏ hơn, vào cache tốt hơn. Ba mươi giây đó cho bạn thấy trực giác "căn lề luôn nhanh hơn" đã lỗi thời tới mức nào trên phần cứng bạn đang dùng.