"Phải căn lề dữ liệu, không thì truy cập bộ nhớ chậm" — lời khuyên này nghe nhan nhản, thường kèm hình ảnh dọa dẫm về "cache-line split". Nhưng đúng tới đâu, và trên máy nào?
Một lệnh đọc/ghi không căn lề là khi địa chỉ không chia hết cỡ dữ liệu — ví dụ đọc nguyên 8 byte tại một địa chỉ lẻ. Hình dung như đọc một câu văn trong sách. Nếu cả câu nằm gọn trong một trang, mắt bạn liếc một cái là xong dù nó bắt đầu ở giữa dòng. Nhưng nếu câu bị chia đôi, nửa cuối trang này nửa đầu trang sau, bạn phải đọc nửa đầu, lật trang, rồi ghép nốt — phiền hơn hẳn. CPU cũng vậy: chuyện lệch căn tốn hay không hoàn toàn tùy nó có vắt qua một ranh giới nào không, và vắt qua ranh giới gì.
Có ba mức. Lệch nhưng cả 8 byte vẫn nằm trong một dòng cache. Lệch tới mức cắt ngang ranh giới dòng cache 128 byte — cái mà folklore x86 dọa là chậm. Và lệch tới mức cắt ngang ranh giới trang 4 KB, khi đó 8 byte nằm ở hai trang ảo khác nhau, CPU phải dịch hai lần TLB. Tôi đo cả ba, giữ dữ liệu trong L1 để loại nhiễu cache, trên một máy ARM (dòng cache 128 byte, trang 4 KB).
Con số lật ngược trực giác
| Truy cập 8 byte (trong L1) | Đọc | Ghi |
|---|---|---|
| Căn lề chuẩn | 0,227 ns | 0,225 ns |
| Lệch 3 byte, cùng dòng cache | 0,228 ns | — |
| Cắt ngang dòng cache (128B) | 0,227 ns | 0,225 ns |
| Cắt ngang trang (4KB) | 6,63 ns | 7,56 ns |
Điều thứ nhất làm tôi hơi bất ngờ: lệch căn trong cùng một trang gần như miễn phí. Lệch 3 byte cho 0,228 ns, đúng bằng căn lề. Và cái đáng lẽ phải đắt — cắt ngang dòng cache — cũng chỉ 0,227 ns, không phạt một chút nào. Phần cứng ARM gộp cú truy cập cắt dòng đó lại một cách trong suốt. Cái "cache-line split" mà tài liệu x86 bắt người ta né tránh, ở đây đo ra bằng không.
Điều thứ hai thì đúng là cú sốc. Đọc 8 byte vắt qua ranh giới trang 4 KB tốn 6,63 ns — chậm 29 lần; ghi còn tệ hơn, 7,56 ns, chậm 34 lần. Một cú load lẽ ra một nhịp bỗng hóa thành gần 30 chu kỳ. Bởi 8 byte đó nằm ở hai trang ảo, CPU phải tra hai mục TLB và đi một đường xử lý riêng, chậm hẳn. Đây là ranh giới duy nhất mà lệch căn thật sự trả giá trên máy này — nhưng trả rất đắt.
Hai định kiến, cả hai cùng trật
Tôi bước vào với cái đầu cẩn thận thái quá vay mượn từ x86: lệch căn là luôn chậm, phải căn lề mọi cấu trúc, phải tránh cache-line split bằng mọi giá. Đo xong mới thấy trên ARM, lệch căn trong một trang — kể cả cắt ngang dòng cache — gần như miễn phí. Cái nghi thức chèn byte đệm, sắp trường theo cỡ để né line-split, ở đây là công cốc: bạn tối ưu để tránh một chi phí không tồn tại.
Nhưng buông thả kiểu ngược lại — "thời nay CPU lo hết, khỏi nghĩ tới căn lề" — cũng sai. Cắt ngang trang phạt gần 30 lần. Nếu một cấu trúc hay buffer của bạn vô tình để một trường 8 byte vắt qua mốc 4 KB — chuyện dễ xảy ra với buffer thô mà bạn tự tính offset — thì mỗi lần chạm nó là một cú page-split đắt đỏ. Và như phần về căn lề dòng cache đã gặp, một phép toán nguyên tử trên địa chỉ lệch còn có thể SIGBUS hoặc mất luôn tính nguyên tử — chỗ đó căn lề là bắt buộc, không phải tùy chọn.
Ngoài đời thì sao
Trên ARM, đừng phí công padding để né cache-line split — nó gần như miễn phí, và cái nghi thức ấy vốn là của x86. Gói dữ liệu gọn thì vẫn nên, nhưng vì lý do cache line và băng thông, chứ không phải vì sợ line-split.
Cái thật sự cần tránh là để dữ liệu vắt qua ranh giới trang, và luôn căn lề cho biến nguyên tử. Với buffer thô tự tính offset — parser nhị phân, đệm mạng, serialize tay — hãy để ý đừng để một trường nhiều byte rơi vắt qua mốc 4 KB; dùng alignas hoặc đọc/ghi qua memcpy cho compiler lo. May là ở ngôn ngữ bậc cao bạn hiếm khi phải nghĩ tới: JVM và Go tự căn lề trường trong struct/object. Nhưng cái bẫy nguyên tử thì có thật và nổi tiếng — trên Go, một biến int64 mà bạn atomic thao tác phải nằm ở địa chỉ chia hết 8; trên nền 32-bit, để nó lệch là chương trình panic, và tài liệu Go ghi hẳn cảnh báo này. Java thì AtomicLong lo giúp bạn chuyện đó.
Muốn tự thấy, cấp một buffer căn lề trang rồi đọc 8 byte ở vài vị trí: chia hết 8, lệch 3 byte, vắt qua mốc 128, và vắt qua mốc 4096. Trên ARM, ba cái đầu gần như bằng nhau, còn cái cắt trang chậm hàng chục lần. Thử lại trên x86 nếu có, line-split ở đó có thể tốn thêm chút — khác ARM. Điểm mấu chốt là "lệch căn" không phải một cái giá cố định: nó tùy vào việc bạn vắt qua ranh giới nào, và câu trả lời đổi theo kiến trúc. Một quy tắc chép từ máy khác có khi vừa thừa vừa bỏ sót — chỉ đo trên đúng máy mình chạy mới biết ranh giới thật nằm đâu.