Nghĩ về việc xếp vali. Nếu bạn nhét một hộp to, rồi một món nhỏ, rồi lại một hộp to, khoảng trống hình thành quanh mỗi món và vali phồng lên dù đồ chẳng bao nhiêu. Nhưng nếu bạn xếp các món lớn trước, nhỏ sau, mọi thứ khít vào nhau, khoảng thừa dồn hết về một góc — cùng ngần ấy đồ mà vali gọn hẳn. Trình biên dịch xếp các trường của một struct vào bộ nhớ theo đúng logic đó, và cái "khoảng trống quanh mỗi món" có tên là đệm (padding).
Bạn khai báo một struct với ba trường: một int64 và hai char. Thứ tự khai báo chỉ là chuyện gọn gàng code, không ảnh hưởng gì tới máy, đúng không? Hóa ra sai: chỉ cần đảo thứ tự các trường, kích thước struct đổi từ 24 byte xuống 16 byte — và khi bạn có một mảng hàng chục triệu struct, 8 byte thừa đó biến thành RAM lãng phí và tốc độ duyệt chậm hơn. Tôi đo trong container gcc:13, và con số cho thấy một quyết định trông thuần túy thẩm mỹ lại là quyết định hiệu năng thật.
Vì sao trình biên dịch chèn đệm
CPU truy cập dữ liệu nhanh nhất khi nó căn lề (aligned): một int64 (8 byte) nên nằm ở địa chỉ chia hết cho 8, một int32 ở địa chỉ chia hết cho 4, v.v. Để đảm bảo điều đó, trình biên dịch chèn đệm (padding) — những byte trống — vào giữa các trường và ở cuối struct. Đây là những "khoảng trống trong vali".
Và đây là điều bất ngờ: thứ tự khai báo quyết định cần bao nhiêu đệm.
struct { char a; int64_t b; char c; }:char achiếm 1 byte, nhưngint64 bphải căn ở bội số 8, nên trình biên dịch chèn 7 byte đệm saua; rồib(8 byte); rồic(1 byte); rồi 7 byte đệm cuối để cả struct căn lề 8 (cho mảng). Tổng: 1+7+8+1+7 = 24 byte.struct { int64_t b; char a; char c; }:b(8 byte) ở đầu, đã căn; rồia,c(2 byte); rồi 6 byte đệm cuối. Tổng: 8+1+1+6 = 16 byte.
Cùng ba trường, cùng dữ liệu — chỉ đổi thứ tự — tiết kiệm 8 byte, tức 33%. Quy tắc: sắp các trường từ lớn tới nhỏ để trường lớn tự căn lề và đệm dồn về cuối (ít nhất) — đúng cách xếp vali lớn-trước-nhỏ-sau.
Đo: nhỏ hơn 33%, duyệt nhanh hơn 1,35 lần
Tôi in kích thước và duyệt một mảng 40 triệu struct (cộng trường .b):
A. sizeof (cùng 3 trường: 1 int64 + 2 char):
{char; int64; char} : 24 byte (căn lề tệ, đệm nhiều)
{int64; char; char} : 16 byte (sắp gọn)
pack(1) bỏ đệm : 10 byte
-> Bad phí 33% RAM so với Good, chỉ đổi thứ tự trường
B. Duyệt mảng 40 triệu struct, cộng .b:
Bad (24B) : 0,35 ns/phần tử (2 struct vừa trong cache line 64B)
Good (16B) : 0,26 ns/phần tử (4 struct vừa trong cache line)
-> Good nhanh hơn 1,35 lần
C. pack(1) 10B:
Packed(10B): 0,24 ns/phần tử (.b lệch lề, ARM64/x86 không phạt khi duyệt tuần tự)
Nhìn A: cùng ba trường, bản {char;int64;char} tốn 24 byte, bản {int64;char;char} chỉ 16 byte. Với 40 triệu phần tử, đó là 640 MB so với 960 MB — bản tệ phí 320 MB RAM chỉ vì thứ tự khai báo. B cho thấy hệ quả tốc độ: bản 16 byte duyệt nhanh hơn 1,35 lần (0,26 so với 0,35 ns/phần tử), vì cache line 64 byte chứa được 4 struct 16-byte thay vì chỉ 2 struct 24-byte — nên duyệt cả mảng cần nạp ít cache line hơn một nửa. Đây chính là bài học SoA/AoS của phần 12 ở dạng khác: dữ liệu càng đặc, cache càng hiệu quả.
C là chỗ cần nói thẳng cho sòng phẳng. #pragma pack(1) bỏ hết đệm, cho struct nhỏ nhất (10 byte). Trên máy này (ARM64 ảo hóa), đọc trường .b lệch lề khi duyệt tuần tự không bị phạt — thậm chí bản 10 byte còn nhanh hơn chút vì nhỏ nhất. Nhưng đừng vội kết luận "pack(1) luôn tốt": trên nhiều kiến trúc, truy cập lệch lề chậm hơn (CPU phải đọc hai lần rồi ghép), và một thao tác nguyên tử (atomic) trên địa chỉ lệch lề có thể hỏng hoặc không đảm bảo tính nguyên tử. pack(1) chỉ nên dùng khi bạn thật sự cần bố cục byte chính xác (giao thức mạng, định dạng file), không phải để tối ưu bừa.
(Môi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2, mảng 40 triệu struct, mỗi số lấy MIN sau warmup. sizeof là hằng của kiến trúc/ABI; lợi thế tốc độ đến từ số struct mỗi cache line, phổ quát trên mọi máy.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin: "thứ tự khai báo các trường trong struct chỉ là cách viết code, máy hiểu như nhau". Con số bác thẳng: cùng ba trường, đổi thứ tự làm struct từ 24 xuống 16 byte (phí 33% RAM), và duyệt mảng bản gọn nhanh hơn 1,35 lần vì nhét gấp đôi số struct mỗi cache line. Thứ tự khai báo là một quyết định hiệu năng.
Nhưng khi thấy pack(1) cho struct nhỏ nhất, tôi suýt lật sang niềm tin ngược: "vậy cứ pack(1) cho mọi struct cho gọn". Cũng sai — pack(1) tạo trường lệch lề, và tuy máy này không phạt cho đọc tuần tự, nó có thể chậm hoặc phá tính nguyên tử trên kiến trúc khác; nó là công cụ chuyên dụng cho bố cục byte chính xác, không phải tối ưu mặc định. Cách đúng là sắp thứ tự trường, không phải đập bỏ căn lề.
Về đến code hằng ngày
Điều đầu tiên: sắp các trường từ lớn tới nhỏ trong struct. Đặt các trường 8 byte trước, rồi 4 byte, rồi 2, rồi 1 — đệm sẽ dồn về cuối và ít nhất. Đây là tối ưu miễn phí (không đổi logic, chỉ đổi thứ tự dòng khai báo) mà tiết kiệm RAM và tăng tốc duyệt. Với struct dùng trong mảng lớn (game entity, bản ghi, node), luôn kiểm sizeof và cân nhắc thứ tự trường.
Điều thứ hai: loại bỏ trường không cần và gộp cờ. Nếu struct có nhiều bool (mỗi cái tốn 1 byte + có thể kéo đệm), gộp chúng thành bit field hay một trường cờ. Struct nhỏ hơn = nhiều phần tử mỗi cache line = duyệt nhanh hơn. Với cấu trúc dùng số lượng lớn, mỗi byte tiết kiệm được nhân lên hàng triệu lần.
Chỗ này ngôn ngữ cao cấp xử lý khác nhau, và biết cách chúng làm rất đáng. Rust và Go tự sắp xếp lại (Rust) hoặc cho bạn tự sắp (Go tôn trọng thứ tự bạn viết, nên go vet/fieldalignment sẽ nhắc bạn sắp lại) — cùng bài toán padding, chỉ khác ai chịu trách nhiệm. C và C++ thì giữ nguyên thứ tự bạn khai (chuẩn ngôn ngữ cấm sắp lại), nên trách nhiệm hoàn toàn ở lập trình viên — đó là lý do bài này quan trọng với dân C/C++. Java giấu bố cục object đi nhưng JVM cũng làm đúng trò này bên dưới (field reordering + object header + căn lề 8 byte), và các thư viện đo như JOL (Java Object Layout) phơi ra để bạn thấy padding thật. Còn ở database, cùng nguyên lý áp cho thứ tự cột: một số engine cột-lưu (và cả cách Postgres sắp cột trong một hàng) khuyên đặt cột rộng cố định trước để giảm đệm mỗi hàng — nhân với hàng tỷ hàng thì tiết kiệm là thật.
Muốn tự thấy thì khai báo hai struct có y hệt các trường nhưng thứ tự khác nhau — một cái xen kẽ trường lớn và nhỏ (char, long, char, int), một cái sắp từ lớn tới nhỏ (long, int, char, char) — rồi in sizeof cả hai. Bạn sẽ thấy bản xen kẽ lớn hơn, đôi khi tới 30–50%, dù chứa cùng dữ liệu. Rồi tạo một mảng vài chục triệu mỗi loại, cộng một trường qua toàn mảng và bấm giờ: bản nhỏ hơn duyệt nhanh hơn, vì nhét nhiều struct hơn vào mỗi cache line. Cuối cùng thử #pragma pack(1) và xem struct co lại nhỏ nhất — nhưng nhớ rằng đó là đánh đổi, không phải bữa trưa miễn phí. Vài phút đó cho bạn thấy điều mà "chỉ là thứ tự khai báo" giấu đi: bố cục bộ nhớ của một struct là hiệu năng thật, và trình biên dịch âm thầm chèn đệm theo cách bạn sắp trường — sắp khéo thì gọn và nhanh, sắp ẩu thì phí cả RAM lẫn thời gian.