"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 — tôi từng trong số đó — kết luận căn lề là chuyện của quá khứ. Sự thật, như mọi khi, nằm ở phép đo.
Chìa khóa để hiểu đúng là nhớ CPU nạp bộ nhớ theo từng dòng cache 128 byte (trên host ARM này). Hình dung dữ liệu như những dòng chữ in trên các trang giấy khổ cố định. Đọc một câu nằm gọn trong một trang thì liếc một cái là xong, dù câu đó bắt đầu ở giữa dòng hay đầu dòng chẳng quan trọng. Nhưng một câu vắt từ cuối trang này sang đầu trang sau buộc bạn phải lật, cầm hai tờ giấy cùng lúc mới đọc trọn. Nên câu hỏi thật không phải "biến lệch hay không" mà là "nó có vắt qua ranh giới trang — 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" 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 (một trang) hay vắt qua ranh giới sang dòng kế. Gọn trong một dòng thì một lần nạp là đủ; bắc cầu thì CPU phải chạm hai dòng 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ả 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 — đúng cảnh phải cầm hai tờ giấy. (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.)
Hai niềm tin đối nghịch, cùng sai
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 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 nếu vì thế mà quay sang cực kia — "mọi truy cập lệch đều chậm, phải căn lề tất" — thì cũng 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 (nhồi 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.
Về đến code hằng ngày
Việc đầ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.
Việc 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.
Cái ranh giới "đừng vắt dòng" này vọng lên cả những tầng bạn không ngờ. Trình biên dịch của Java, Go, Rust đều tự căn lề trường trong struct/object và chèn padding cho bạn — một lý do thứ tự khai báo trường ảnh hưởng kích thước object trong Go (sắp sai thì phình vì padding), và vì sao Java có bố cục object riêng do JVM quyết. Cái bẫy atomic thì gắt hơn: bất kỳ ngôn ngữ nào phơi ra thao tác nguyên tử (biến atomic của Go, AtomicLong Java, std::sync::atomic của Rust) đều ngầm dựa vào căn lề tự nhiên — và trên nền tảng khó tính, một con trỏ atomic lệch là lỗi runtime chứ không phải chuyện hiệu năng. Còn định dạng dữ liệu trên đĩa/trên mạng (Protobuf, FlatBuffers, các cấu trúc mmap) cũng phải lo alignment vì cùng lý do phần cứng này. Nguyên tắc gọn lại: "căn lề" thực chất là "đừng để một giá trị vắt qua ranh giới dòng cache", với ngoại lệ chết người là atomic phải căn lề tuyệt đối.
Muốn tự thấy thì 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. 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 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.