Hình dung một cuốn sách bị xếp vắt qua hai ngăn kệ. Người thủ thư thời xưa gặp vậy có thể lắc đầu từ chối, hoặc phải với hai tay hai ngăn mới lấy được — phiền phức thật. Nhưng người thủ thư hiện đại với cái kệ tốt thì cầm ra gọn ơ, chẳng bận tâm cuốn sách nằm vắt chỗ nào. Dữ liệu "lệch lề" trong bộ nhớ đúng là cuốn sách vắt ngăn đó, và cái nỗi sợ "lệch lề là tai họa" hóa ra là ký ức từ thời thủ thư cũ. Tôi đo trên phần cứng hôm nay và phải xếp lại toàn bộ trực giác.
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 đo trên ARM AArch64 (Apple Silicon) trong container gcc:13 để 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.
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: đọ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. Đúng anh thủ thư hiện đại cầm cuốn sách vắt ngăn ra chẳng vấn đề gì.
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.)
Cú vấp: 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à cú vấp 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). Đúng như ép mỗi cuốn sách vào một hộp cứng cho vừa ô kệ, làm cả thư viện dài ra và đi bộ nhiều hơn.
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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và đây là chỗ đáng nhớ, vì "căn lề" nghĩa rất khác nhau tùy tầng. Java giấu hẳn địa chỉ của bạn (không có con trỏ trần), nhưng chính vì thế padding có chủ đích để chống false sharing lại là một kỹ thuật hiệu năng cấp cao được chuẩn hóa: annotation @Contended của JDK đệm một trường sang cache line riêng, dùng trong chính LongAdder/ConcurrentHashMap để các luồng khỏi giẫm chân nhau. Go và Rust cũng có mẫu padding chống false sharing tương tự trong code đồng thời. Ngược lại, ở tầng lưu trữ, "gói gọn cho nhỏ" mới thắng: các định dạng cột (Parquet, ORC) và CSDL nén dữ liệu để ít byte hơn qua I/O, chấp nhận giải nén tốn CPU vì I/O mới là nút thắt — cùng bài học "kích thước quan trọng hơn căn lề" mà tôi vừa đo. Mẫu số chung: căn lề/padding không phải "luôn tốt" hay "luôn xấu" — nó là công cụ cho ghi đồng thời nhiều lõi (tách cache line), còn với đọc tuần tự/ngẫu nhiên một lõi thì gọn-nhẹ gần như luôn thắng; biết mình đang ở tình huống nào mới chọn đúng.
Muốn tự thấy thì 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.