"Phải căn lề dữ liệu, nếu không truy cập bộ nhớ sẽ chậm" — lời khuyên này nghe khắp nơi, thường kèm hình ảnh đáng sợ về "cache-line split". Nhưng nó đúng tới đâu, và trên máy nào? Một lệnh load/store không căn lề (unaligned) là khi địa chỉ không chia hết cỡ dữ liệu — ví dụ đọc 8 byte tại một địa chỉ lẻ. Kiến trúc cũ (một số RISC đời đầu) cấm hẳn, ném lỗi khi gặp; x86 và ARMv8 thì cho phép, nhưng phần cứng có thể phải làm thêm việc. Câu hỏi đo lường: cái giá đó là bao nhiêu, và khi nào nó xuất hiện? Tôi đo trong container gcc:13 trên host ARM (dòng cache 128 byte, trang 4 KB), và con số lật đổ trực giác quen thuộc theo một cách bất ngờ.
Ba mức "lệch": trong dòng, cắt dòng, cắt trang
Không phải mọi "lệch căn" giống nhau. Có ba mức, tùy địa chỉ vắt qua ranh giới nào:
- Lệch nhưng nằm gọn trong một dòng cache: ví dụ đọc 8 byte tại offset 3 — không chia hết 8, nhưng cả 8 byte vẫn trong cùng một dòng cache. Phần cứng chỉ cần một lần nạp dòng.
- Cắt ngang ranh giới dòng cache (line-split): 8 byte vắt qua mốc 128 byte — nửa nằm ở dòng này, nửa ở dòng kia. Về lý thuyết phải chạm hai dòng. Đây là ca mà folklore x86 dọa là "chậm".
- Cắt ngang ranh giới trang (page-split): 8 byte vắt qua mốc 4096 byte — nửa ở trang này, nửa ở trang kia. Phải dịch hai lần TLB (hai trang ảo khác nhau) và có thể đi một đường xử lý đặc biệt.
Tôi đo cả ba (cùng dữ liệu nằm trong L1 để loại nhiễu cache), so với truy cập căn lề chuẩn, cho cả load lẫn store.
Đo: lệch trong trang gần miễn phí, cắt trang phạt 29–34 lần
Truy cập 8 byte (đều trong L1), host ARM, dòng cache 128B, trang 4KB, g++ -O2:
trường hợp | LOAD (ns) | STORE (ns) | so với căn lề
-----------------------------------|-----------|------------|---------------
căn lề (địa chỉ chia hết 8) | 0,227 | 0,225 | 1,0x (gốc)
lệch 3 byte, trong cùng dòng cache | 0,228 | — | 1,01x
cắt ngang DÒNG cache (128B) | 0,227 | 0,225 | 1,00x
cắt ngang TRANG (4KB, page-split) | 6,63 | 7,56 | 29–34x (!)
Đọc ra hai điều — và điều thứ hai gây sốc. Một (lệch trong trang gần như miễn phí): đọc 8 byte tại địa chỉ lệch 3 byte tốn 0,228 ns, đúng bằng căn lề (0,227 ns) — 1,01x. Và bất ngờ hơn: cắt ngang ranh giới dòng cache (line-split) cũng chỉ 0,227 ns — 1,00x, không phạt gì cả. Trên host ARM này (Apple Silicon), phần cứng gộp truy cập cắt dòng trong suốt, không tốn thêm. Cái "cache-line split" mà folklore x86 dọa, ở đây không tồn tại như một chi phí đo được.
Hai (cắt ngang trang phạt kinh khủng): đọ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 load lẽ ra 0,23 ns (một nhịp) hóa thành ~29 chu kỳ. Vì 8 byte đó nằm ở hai trang ảo khác nhau: CPU phải dịch hai mục TLB, và truy cập vượt trang đi một đường chậm của phần cứng (một số vi kiến trúc xử lý page-crossing bằng một cơ chế riêng, đắt hơn hẳn). Đây là ranh giới duy nhất mà lệch căn thật sự trả giá đắt trên máy này — và giá đó rất đắt.
Vậy trực giác "line-split chậm" là sai trên ARM, còn "lệch căn chẳng bao giờ đáng lo" cũng sai — vì page-split thì thảm khốc. Ranh giới quyết định dòng cache hay trang cho ra hai kết quả cách nhau gần 30 lần.
Một lần tôi đo hớ: "lệch căn luôn chậm" và "căn lề chẳng bao giờ quan trọng"
Tôi vào đo với định kiến cẩn thận thái quá mang từ tài liệu x86: "truy cập lệch căn luôn chậm, phải căn lề mọi cấu trúc, đặc biệt tránh cache-line split". Đo phá tan 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í (1,00–1,01x). Phần cứng ARM xử lý nó trong suốt. Việc căn lề vi mô để né line-split, một nghi thức phổ biến trong code tối ưu x86, ở đây thừa — bạn tốn công padding, sắp xếp trường để tránh một chi phí không tồn tại trên máy này.
Nhưng đo cũng chỉnh niềm tin ngược, buông thả hơn: "thời nay CPU lo hết chuyện căn lề, khỏi bao giờ phải nghĩ tới". Sai — cắt ngang ranh giới trang phạt 29–34 lần: một truy cập L1 lẽ ra tức thì hóa thành ~30 chu kỳ. Nếu một cấu trúc dữ liệu hay buffer của bạn vô tình đặt một trường 8 byte vắt qua mốc 4 KB (dễ xảy ra với buffer lớn cấp phát thô, hay khi bạn tự tính offset), mỗi lần chạm nó là một cú page-split đắt đỏ. Và như phần 13 đã gặp: một phép toán nguyên tử (atomic) trên địa chỉ lệch căn có thể SIGBUS hoặc mất tính nguyên tử — ở đó căn lề là bắt buộc, không phải tùy chọn.
Bài học đo lường: ARM64 cho phép truy cập lệch căn; lệch TRONG một trang — KỂ CẢ cắt ngang dòng cache 128B — gần như miễn phí (đo 1,00–1,01x, folklore 'line-split chậm' KHÔNG đúng trên ARM). Nhưng CẮT ngang ranh giới TRANG 4KB (page-split) phạt 29x (load 0,23->6,63 ns) / 34x (store 7,56 ns): hai lần dịch TLB + đường chậm phần cứng. Và atomic lệch có thể SIGBUS/mất nguyên tử (phần 13). 'Lệch căn luôn chậm, căn lề mọi thứ' SAI (lệch trong trang free); 'căn lề chẳng bao giờ quan trọng' SAI (page-split 29-34x + atomic). Nếu tin "lệch luôn chậm" tôi padding thừa cho line-split không tồn tại; nếu tin "khỏi lo căn lề" tôi để buffer vắt qua trang và trúng phạt 30 lần.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: trên ARM, đừng padding/căn lề vi mô để né cache-line split — nó gần như miễn phí. Bạn không cần bẻ cong bố cục struct, chèn byte đệm, hay sắp trường theo cỡ chỉ để tránh một trường vắt qua ranh giới dòng cache. Điều đó là nghi thức x86; trên ARM nó không mang lại gì. (Vẫn nên gói dữ liệu gọn vì lý do cache line và băng thông, nhưng không vì sợ line-split.)
Hệ quả thứ hai: tránh để dữ liệu vắt qua ranh giới TRANG, và luôn căn lề cho atomic. Với buffer lớn cấp phát thô mà bạn tự tính offset (parser nhị phân, vùng đệm mạng, serialize thủ công), coi chừng một trường nhiều byte vô tình nằm vắt qua mốc 4 KB — đó là cú page-split 30 lần. Dùng alignas, đọc/ghi qua memcpy (trình biên dịch lo), hoặc căn cấu trúc để trường không vượt trang. Riêng biến atomic, std::atomic và bộ cấp phát đã căn lề sẵn — đừng tự đặt atomic ở địa chỉ lệch.
Hệ quả thứ ba là tinh thần đo lường: "lệch căn" không phải một chi phí đồng nhất — nó phụ thuộc ranh giới nào bị vắt qua, và câu trả lời khác nhau theo kiến trúc. Con số mang theo: ARM: lệch trong trang (cả cắt dòng cache) ~miễn phí (1,0x); page-split phạt 29–34x; atomic lệch nguy hiểm. Đừng padding chống line-split trên ARM; tránh vắt qua trang; căn lề atomic. Một quy tắc tối ưu chép từ kiến trúc khác có thể vừa thừa (line-split) vừa bỏ sót (page-split) — chỉ đo trên máy đích mới cho bạn ranh giới thật.
Thử ba mươi giây
Cấp một buffer căn lề trang (aligned_alloc(4096, ...)), rồi đọc 8 byte tại vài offset và bấm giờ mỗi lần đọc (lặp nhiều triệu lần, dữ liệu trong L1): tại offset chia hết 8 (căn lề), tại offset 3 (lệch trong dòng), tại offset 124 (8 byte vắt qua mốc 128 — cắt dòng cache), và tại offset 4092 (8 byte vắt qua mốc 4096 — cắt trang). Trên một máy ARM, bạn sẽ thấy ba trường hợp đầu gần như bằng nhau — lệch căn trong trang, kể cả cắt dòng cache, gần như miễn phí — nhưng trường hợp cắt trang chậm hàng chục lần. Rồi thử trên một máy x86 nếu có: line-split ở đó có thể tốn thêm chút, khác ARM. Cuối cùng, nhớ rằng với một biến atomic, lệch căn không chỉ chậm mà có thể sai (SIGBUS, mất nguyên tử). Ba mươi giây đó cho bạn thấy điều mà "phải căn lề mọi thứ" giấu đi: chi phí lệch căn nằm ở việc vắt qua ranh giới trang (và ở atomic), không phải ở mọi địa chỉ lẻ — nên đừng padding vì sợ hãi, hãy căn lề đúng chỗ mà đo lường chỉ ra.