Hai struct có đúng cùng các field — một int64 và hai bool — nhưng một cái tốn 24 byte, cái kia chỉ 16 byte. Khác biệt duy nhất là thứ tự khai báo field. Đây không phải chuyện lý thuyết: với cấu trúc dữ liệu cấp phát hàng triệu lần, thứ tự field dở có thể phí hàng chục MB. Bài này đo chính xác cơ chế đằng sau — alignment và padding — và cách sắp field để struct gọn nhất mà không đổi một dòng logic.

Alignment: vì sao có byte đệm

Mỗi kiểu dữ liệu có một yêu cầu căn lề (alignment): địa chỉ bộ nhớ của nó phải chia hết cho một số nhất định. Trên máy 64-bit, int64 căn lề 8 (phải nằm ở địa chỉ chia hết cho 8), int32 căn lề 4, bool/byte căn lề 1. Lý do là phần cứng: CPU đọc dữ liệu căn lề trong một thao tác, còn dữ liệu lệch lề có thể cần nhiều thao tác hoặc bị cấm.

Vì field trong struct được đặt theo đúng thứ tự khai báo, trình biên dịch phải chèn byte đệm (padding) giữa các field để mỗi field rơi vào địa chỉ căn lề đúng. Thứ tự field dở tạo ra nhiều đệm phí.

Ảnh chụp đoạn mã Go nền tối minh hoạ struct alignment và padding thứ tự field đổi kích thước, mỗi kiểu có yêu cầu căn lề int64 phải nằm ở địa chỉ chia hết 8 trình biên dịch chèn byte đệm padding để thỏa căn lề thứ tự field dở sinh nhiều đệm phí, type Xau struct thứ tự xấu a bool offset 0 1 byte b int64 cần offset chia hết 8 chèn 7 byte đệm trước c bool offset 16 rồi đệm 7 byte cuối tổng 24, type Tot struct thứ tự tốt field lớn trước b int64 offset 0 a bool offset 8 c bool offset 9 rồi đệm 6 byte tổng 16, kiểm bằng unsafe Sizeof unsafe Alignof unsafe Offsetof, bố cục byte ô vuông là dữ liệu chấm là đệm phí Xau 24B một ô 7 chấm 8 ô một ô 7 chấm bằng 14 byte đệm phí Tot 16B 8 ô một ô một ô 6 chấm bằng 6 byte đệm, quy tắc field được đặt theo đúng thứ tự khai báo mỗi field căn lề theo kích thước của nó tối đa 8 trên máy 64-bit tổng struct căn lề theo field lớn nhất sắp field từ lớn tới nhỏ thường gom được các field nhỏ lại giảm đệm

Hình 1: Cùng ba field. Xau (bool-int64-bool) xen kẽ nên int64 cần 7 byte đệm trước nó → 24 byte. Tot (int64-bool-bool) gom bool lại → 16 byte. Sơ đồ byte cho thấy đệm phí.

Đo thật: cùng field, khác thứ tự, khác kích thước

Dùng unsafe.Sizeof, unsafe.Alignof, và unsafe.Offsetof để soi bố cục thực:

type Xau struct { a bool; b int64; c bool }   // xen kẽ
type Tot struct { b int64; a bool; c bool }   // lớn trước
fmt.Println(unsafe.Sizeof(Xau{}), unsafe.Sizeof(Tot{}))
fmt.Println(unsafe.Offsetof(Xau{}.b))  // vị trí field b

Ảnh chụp bảng kết quả đo thật nền tối unsafe Sizeof Offsetof Go 1.23 arm64 64-bit về struct alignment, cùng 3 field int64 cộng 2 bool chỉ khác thứ tự, Xau Sizeof 24 byte Alignof 8 offset a 0 b 8 c 16 a ở 0 chèn 7 đệm b ở 8 c ở 16 cộng đệm 7, Tot Sizeof 16 byte Alignof 8 offset b 0 a 8 c 9 b ở 0 a ở 8 c ở 9 đệm 6 cuối, tiết kiệm Xau 24 byte Tot 16 byte tiết kiệm 8 byte 33 phần trăm chỉ nhờ đổi thứ tự field, ở quy mô 10 triệu struct Xau tốn 228 MB vs Tot tốn 152 MB chênh 76 MB, cốt lõi đúng cùng ba field nhưng thứ tự khai báo khác nhau cho hai kích thước 24 byte bool int64 bool xen kẽ nên nhiều đệm so với 16 byte int64 bool bool gom nhỏ lại chênh 33 phần trăm mỗi struct thành 76 MB ở 10 triệu struct

Hình 2: Xau = 24 byte (offset a=0, b=8, c=16). Tot = 16 byte (offset b=0, a=8, c=9). Chênh 8 byte, 33% chỉ nhờ đổi thứ tự — thành 228 MB so với 152 MB ở 10 triệu struct.

Đọc offset là thấy rõ chuyện gì xảy ra:

  • Xau (24 byte): a ở offset 0 (1 byte), nhưng b (int64) cần offset chia hết 8 nên phải nhảy tới offset 8 — 7 byte đệm phí ở giữa. c ở offset 16, rồi cả struct phải căn lề 8 nên thêm 7 byte đệm cuối. Tổng đệm phí: 14 byte trên 24.
  • Tot (16 byte): b ở offset 0 (int64 vừa khít), a ở offset 8, c ở offset 9 (hai bool gom liền nhau), rồi 6 byte đệm cuối để đạt 16. Đệm ít hơn hẳn.

Cùng ba field, cùng dữ liệu, chỉ khác thứ tự khai báo — mà Xau tốn nhiều hơn 33%. Ở 10 triệu struct, đó là 228 MB so với 152 MB, chênh 76 MB.

Quy tắc sắp field

Nguyên tắc đơn giản: sắp field từ lớn tới nhỏ. Đặt các field 8-byte (int64, con trỏ, slice header, map, chan) trước, rồi 4-byte (int32, float32), rồi 2-byte, rồi 1-byte (bool, byte) cuối. Cách này gom các field nhỏ vào cuối, để chúng lấp đầy nhau thay vì mỗi cái tạo một khoảng đệm. Alignof của cả struct bằng alignment của field lớn nhất.

Ứng dụng thực tế

Tối ưu bộ nhớ miễn phí cho struct cấp phát nhiều. Nếu bạn có một struct dùng làm phần tử của slice/map lớn, node của cây, hay bản ghi trong hàng triệu, sắp lại field theo thứ tự lớn-tới-nhỏ cắt bộ nhớ ngay mà không đổi logic. Đây là một trong ít tối ưu "chỉ có lợi, không có hại".

Ít bộ nhớ = ít áp lực GC và tốt cache. Struct gọn hơn không chỉ tiết kiệm RAM: nó giảm lượng dữ liệu GC phải quét, và nhiều struct hơn khớp vào một cache line (64 byte) — cải thiện locality khi duyệt mảng struct. Lợi ích lan tỏa hơn con số byte thuần.

Dùng công cụ để phát hiện. go vet có kiểm fieldalignment (qua golang.org/x/tools), và lệnh fieldalignment -fix tự sắp lại field cho struct của bạn. Không cần soi tay từng struct — chạy công cụ trên các struct nóng.

Đánh đổi cần cân nhắc

Thứ tự field theo logic vs theo kích thước. Sắp field theo kích thước đôi khi làm struct khó đọc hơn (các field liên quan bị tách ra). Với struct ít cấp phát, tính dễ đọc quan trọng hơn vài byte — giữ thứ tự logic. Chỉ tối ưu alignment cho struct thật sự nóng về bộ nhớ.

Đừng chèn padding thủ công trừ khi có lý do. Đôi khi bạn cố ý thêm padding (ví dụ chống false sharing — bài sau). Nhưng thêm field _ [n]byte bừa bãi làm struct phình vô ích. Chỉ chèn padding khi đo được lợi ích cụ thể.

Kích thước struct còn phụ thuộc kiến trúc. Trên 32-bit, alignment và kích thước con trỏ khác (con trỏ 4 byte), nên bố cục struct đổi. unsafe.Sizeof cho kết quả đúng của kiến trúc đang biên dịch; đừng hardcode con số kích thước struct vào logic. Bài này đo trên arm64 64-bit.

Ba ý mang về

  1. Thứ tự field đổi kích thước struct vì trình biên dịch chèn byte đệm để căn lề: đo thật, cùng ba field (int64 + 2 bool) nhưng bool-int64-bool tốn 24 byte (14 byte đệm phí) so với int64-bool-bool tốn 16 byte — chênh 33%.
  2. Sắp field từ lớn tới nhỏ để giảm đệm: đặt field 8-byte trước, gom field 1-byte cuối để chúng lấp đầy nhau — dùng unsafe.Sizeof/Offsetof để soi, và go vet fieldalignment -fix để tự sửa.
  3. Tối ưu miễn phí ở quy mô lớn: 10 triệu struct chênh 76 MB chỉ nhờ đổi thứ tự khai báo — vừa tiết kiệm RAM, vừa giảm việc cho GC, vừa tốt cache; nhưng chỉ đáng làm cho struct nóng, struct thường thì ưu tiên thứ tự dễ đọc.

Phần sau ta xét mặt trái của việc nhồi dữ liệu gần nhau: Phần sau mổ xẻ false sharing và cache line — vì sao hai biến gần nhau bị nhiều lõi tranh chấp có thể làm chậm chương trình song song, và khi nào cần thêm padding để tách chúng.