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.

Đệm/căn lề cấu trúc (padding)

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.

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 a chiếm 1 byte, nhưng int64 b phải căn ở bội số 8, nên trình biên dịch chèn 7 byte đệm sau a; rồi b (8 byte); rồi c (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ồi a, 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).

Đ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à 320 MB so với 640 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 trung thực. #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ột lần tôi đo hớ: "thứ tự trường không ảnh hưởng gì"

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". Đo phá tan: 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 một quyết định hiệu năng. Còn niềm tin ngược, khi tôi thấy pack(1) cho struct nhỏ nhất: "vậy cứ pack(1) cho mọi struct". 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, không phải tối ưu mặc định.

Bài học đo lường: thứ tự khai báo trường đổi KÍCH THƯỚC struct qua đệm căn lề — {char;int64;char}=24B vs {int64;char;char}=16B (phí 33% RAM chỉ vì thứ tự), và duyệt mảng bản 16B nhanh 1,35x vì 4 struct/cache line thay vì 2; sắp trường từ LỚN tới NHỎ để giảm đệm; pack(1) cho nhỏ nhất nhưng lệch lề có thể chậm/không nguyên tử trên kiến trúc khác — dùng có cân nhắc. Nếu tôi tin "thứ tự không quan trọng" và khai báo struct tùy tiện, tôi âm thầm phí hàng trăm MB RAM và làm chậm mọi vòng lặp duyệt — một chi phí không thấy trong code, chỉ thấy khi đo sizeof và bấm giờ.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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. Nhớ rằng 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.

Hệ quả thứ ba là tinh thần đo lường: kích thước và bố cục dữ liệu là hiệu năng, không phải chi tiết cú pháp — hãy đo sizeof. Con số mang theo: thứ tự khai báo trường đổi sizeof qua đệm căn lề: {char;int64;char}=24B vs {int64;char;char}=16B = phí 33% RAM chỉ vì thứ tự; duyệt 40tr struct bản 16B nhanh 1,35x (0,26 vs 0,35 ns) vì 4 struct/cache line thay vì 2. Sắp trường LỚN->NHỎ để giảm đệm. pack(1) cho struct nhỏ nhất (10B) nhưng lệch lề có thể chậm/không nguyên tử trên arch khác — chỉ dùng khi cần bố cục byte chính xác. Đổi một dòng khai báo có thể tiết kiệm một phần ba bộ nhớ và tăng tốc mọi lần duyệt.

Thử ba mươi giây

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í. Ba mươi giây đó 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.