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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho hai struct sau, đo được trên arm64 64-bit: unsafe.Sizeof(Xau{}) = 24, unsafe.Sizeof(Tot{}) = 16.

type Xau struct { a bool; b int64; c bool }
type Tot struct { b int64; a bool; c bool }

Bài viết cho biết offset của Xau là a=0, b=8, c=16; offset của Tot là b=0, a=8, c=9. Hãy giải thích từng byte đệm trong Xau xuất hiện ở đâu và vì sao.

Đáp án

Xau: a (bool, 1 byte, căn lề 1) nằm ở offset 0. b (int64, căn lề 8) cần offset chia hết cho 8, nên phải nhảy từ offset 1 lên offset 8 — chèn 7 byte đệm giữa a và b. b chiếm offset 8-15. c (bool) nằm ở offset 16, chiếm 1 byte (offset 16). Toàn bộ struct phải có kích thước là bội số của alignment lớn nhất trong struct (8, do int64), nên từ offset 17 tới 24 cần thêm 7 byte đệm cuối. Tổng: 7 (giữa) + 7 (cuối) = 14 byte đệm trên tổng 24 byte, tức phần dữ liệu thật chỉ 10 byte (1+8+1).

Tot gom b (int64) lên đầu (offset 0-7, khít, không cần đệm trước), rồi a (offset 8) và c (offset 9) là hai bool liền kề không cần đệm giữa chúng, cuối cùng đệm 6 byte (offset 10-15) để đạt bội số của 8. Tổng đệm chỉ 6 byte trên 16 byte.

Bài 2 (sửa lỗi). Một struct trong dự án được định nghĩa như sau và bị đánh giá là "tốn bộ nhớ dư thừa" khi review code. Áp dụng quy tắc "sắp field từ lớn tới nhỏ" từ bài viết để sắp lại field cho gọn nhất, không đổi field nào cả (chỉ đổi thứ tự).

type Diem struct {
    XacNhan bool    // 1 byte
    ID      int64   // 8 byte
    ChuDong bool    // 1 byte
    Diem    float64 // 8 byte
    Cap     int32   // 4 byte
}
Đáp án

Sắp theo thứ tự kích thước giảm dần (8 byte trước, rồi 4 byte, rồi 1 byte), gom các field nhỏ lại cuối để chúng lấp đầy nhau:

type Diem struct {
    ID      int64   // 8 byte, offset 0
    Diem    float64 // 8 byte, offset 8
    Cap     int32   // 4 byte, offset 16
    XacNhan bool    // 1 byte, offset 20
    ChuDong bool    // 1 byte, offset 21
    // đệm cuối 2 byte để đạt offset 24 (bội số của 8)
}

Bản gốc (thứ tự xen kẽ bool-int64-bool-float64-int32) sẽ sinh nhiều đệm ở mỗi chỗ chuyển từ field nhỏ sang field lớn hơn; bản sắp lại nhóm hai int64/float64 (8 byte) liền nhau không cần đệm, int32 (4 byte) tiếp theo khít luôn (offset 16 chia hết 4), rồi hai bool gom liền nhau ở cuối chỉ cần đệm tối thiểu để căn lề tổng thể.

Bài 3 (vận dụng). Bài viết đo: 10 triệu struct Xau (24 byte) tốn 228 MB, còn Tot (16 byte) tốn 152 MB, chênh 76 MB (33%). Nếu một service của bạn giữ trong bộ nhớ một slice gồm 50 triệu bản ghi dạng struct tương tự Xau (bool-int64-bool), hãy ước tính RAM tiết kiệm được nếu đổi sang thứ tự field kiểu Tot, và giải thích vì sao đây được xem là "tối ưu chỉ có lợi, không có hại".

Đáp án

Theo tỷ lệ đo được (8 byte/struct chênh lệch ở quy mô 10 triệu → 76 MB), với 50 triệu bản ghi: 8 byte × 50.000.000 = 400.000.000 byte ≈ 381 MB tiết kiệm được (tính theo MiB) chỉ bằng cách đổi thứ tự khai báo field, không cần đổi logic xử lý nào. Bài viết gọi đây là tối ưu "chỉ có lợi, không có hại" vì: nó không thay đổi hành vi hay dữ liệu của struct (vẫn đủ 3 field, cùng giá trị), không cần thêm code, và ngoài việc giảm RAM trực tiếp còn có lợi ích lan tỏa: 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 giúp cải thiện locality khi duyệt mảng struct tuần tự.

Bài 4 (bẫy/đánh đổi). Bài viết nói: "Sắp field theo kích thước đôi khi làm struct khó đọc hơn... Với struct ít cấp phát, tính dễ đọc quan trọng hơn vài byte." Nêu một ví dụ tình huống cụ thể mà bạn KHÔNG nên áp dụng tối ưu alignment, và công cụ nào bài viết gợi ý dùng để tự động phát hiện/sửa cho các struct thực sự cần tối ưu.

Đáp án

Ví dụ: một struct cấu hình (Config) chỉ được tạo một lần khi khởi động chương trình, có vài chục field liên quan logic với nhau (ví dụ nhóm field về database đặt cạnh nhau, nhóm field về HTTP đặt cạnh nhau) để dễ đọc và bảo trì. Struct này không được cấp phát hàng loạt nên vài chục byte đệm dư không đáng kể so với lợi ích đọc hiểu — không nên sắp lại theo kích thước vì sẽ làm các field liên quan bị tách rời nhau trong code, khó review.

Công cụ bài viết gợi ý: go vet có kiểm fieldalignment (từ gói golang.org/x/tools), và lệnh fieldalignment -fix có thể tự động sắp lại field cho struct. Nên chạy công cụ này trên các struct thực sự "nóng" (cấp phát nhiều, ví dụ node cây, phần tử slice/map lớn) thay vì soi tay từng struct hay áp dụng tràn lan cho mọi struct.

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.