Hai bài đầu đo cách Rust quản lý quyền sở hữu dữ liệu; bài này đo cách nó xếp dữ liệu trong bộ nhớ. Câu hỏi nghe đơn giản — "một struct chiếm bao nhiêu byte?" — nhưng câu trả lời tiết lộ nhiều quyết định thiết kế đáng tiền: trình biên dịch tự sắp lại trường để giảm padding, Option<T> thường không tốn thêm byte nào nhờ "tối ưu niche", và những kiểu không mang dữ liệu thì chiếm đúng 0 byte. Tất cả đo bằng std::mem::size_of và align_of trong container rust:1 (rustc 1.99.1) — những con số này được chốt tại lúc biên dịch, không có phần nào mơ hồ lúc chạy.

Căn lề: vì sao 15 byte dữ liệu không vừa trong 15 byte

Trước khi nói struct, cần một khái niệm phần cứng: căn lề (alignment). CPU đọc bộ nhớ hiệu quả nhất khi một giá trị N byte nằm ở địa chỉ chia hết cho N. Nên mỗi kiểu có một align_of: u8 căn lề 1 (ở đâu cũng được), u32 căn lề 4, u64 căn lề 8.

u8   : size 1 / align 1
u32  : size 4 / align 4
u64  : size 8 / align 8

Hệ quả: khi xếp nhiều trường cạnh nhau, trình biên dịch phải chèn byte đệm (padding) để mỗi trường rơi đúng mốc căn lề của nó. Một struct có năm trường u8, u64, u8, u32, u8 chỉ mang 15 byte dữ liệu thật (1+8+1+4+1), nhưng nếu xếp đúng thứ tự khai báo thì padding chen vào khắp nơi.

Rust tự sắp lại trường: 16 byte thay vì 32

Đây là chỗ Rust khác C. Mặc định (repr(Rust)), trình biên dịch không hứa giữ thứ tự khai báo — nó được tự do sắp lại các trường để gói chặt nhất. Ta đo ba cách xếp cùng một tập trường:

struct XauThuTu { a: u8, b: u64, c: u8, d: u32, e: u8 }   // repr Rust (mặc định)

#[repr(C)]
struct TheoC    { a: u8, b: u64, c: u8, d: u32, e: u8 }   // giữ nguyên thứ tự như C

struct TuSap    { b: u64, d: u32, a: u8, c: u8, e: u8 }   // tay sắp lớn→nhỏ
XauThuTu (repr Rust, trình biên dịch tự sắp) : 16 byte
TheoC    (repr C, giữ nguyên thứ tự khai báo) : 32 byte
TuSap    (tay sắp lớn→nhỏ, repr Rust)         : 16 byte

Chênh lệch gấp đôi. repr(C) giữ y nguyên thứ tự khai báo nên phải đệm: sau a: u8 cần 7 byte đệm để b: u64 rơi vào mốc chia hết 8, rồi lại đệm quanh c, d, e — tổng 32 byte, hơn một nửa là padding chết. repr(Rust) thấy mình được tự do nên gom các trường lớn lại trước, nhỏ sau, đúng như khi ta tay sắp TuSap: cả hai cho 16 byte. Rust làm giúp bạn việc mà lập trình viên C phải tự nhớ. (Vẫn có #[repr(C)] khi bạn cần bố cục ổn định — để gọi FFI sang C, hay ánh xạ một cấu trúc nhị phân — đó là chủ đề của bài cuối sê-ri.)

Ảnh chụp sơ đồ nền tối so sánh bố cục bộ nhớ struct repr C so với repr Rust, phía trên cùng một struct năm trường a u8 b u64 c u8 d u32 e u8 chứa đúng 15 byte dữ liệu thật, bên trái repr C giữ nguyên thứ tự khai báo nên phải chèn byte đệm padding sau a bảy byte đệm để b u64 rơi vào mốc chia hết 8 rồi đệm quanh c d e tổng cộng 32 byte hơn một nửa là padding chết tô màu xám, bên phải repr Rust trình biên dịch tự sắp lại trường gom lớn tới nhỏ b u64 rồi d u32 rồi a c e ba byte u8 liền nhau chỉ còn một ít đệm cuối tổng 16 byte, ở giữa quy tắc căn lề mỗi kiểu N byte phải nằm địa chỉ chia hết N u8 căn 1 u32 căn 4 u64 căn 8, kết luận Rust tiết kiệm 16 byte tức 50 phần trăm so với bố cục C mà không cần lập trình viên tự sắp

Hình 1: Cùng 15 byte dữ liệu — repr(C) giữ thứ tự khai báo tốn 32 byte vì padding, còn repr(Rust) tự sắp lại trường gói còn 16 byte.

Tối ưu niche: Option thường miễn phí

Trong nhiều ngôn ngữ, bọc một giá trị vào kiểu "có thể vắng" (nullable, optional) tốn thêm một cờ boolean — tức thêm byte và thêm padding. Rust có tối ưu niche: nếu kiểu T có sẵn một "khoảng trống" (niche) — một mẫu bit mà giá trị hợp lệ không bao giờ dùng tới — thì Option<T> mượn chính mẫu bit đó làm None, không tốn thêm byte nào:

&i32      : 8  | Option<&i32>     : 8    (con trỏ không bao giờ null → null = None)
Box<i32>  : 8  | Option<Box<i32>> : 8    (Box cũng là con trỏ không null)
bool      : 1  | Option<bool>     : 1    (bool chỉ dùng 0/1, còn 2..=255 trống)
u64       : 8  | Option<u64>      : 16   (u64 dùng HẾT mọi bit → phải thêm 1 byte cờ)

Ba dòng đầu là tối ưu niche hoạt động: tham chiếu và Box trong Rust được bảo đảm không bao giờ null, nên mẫu bit toàn số 0 đang "bỏ trống" — Option lấy nó làm None. bool chỉ hợp lệ với 0 và 1, nên 2 có thể làm None. Kết quả: Option<&i32> đúng 8 byte, bằng &i32 trần — bọc vào Option miễn phí hoàn toàn. Dòng cuối là ngoại lệ cho thấy cơ chế thật: u64 dùng hết 2^64 mẫu bit làm giá trị hợp lệ, không còn khoảng trống nào, nên Option<u64> buộc phải thêm một byte cờ — rồi căn lề đẩy nó lên 16 byte. Chính đây là lý do bài 1 đo được Option<&i32> = 8: không phải tình cờ, mà là tối ưu niche.

Enum: biến thể lớn nhất cộng cờ phân biệt

enum của Rust là union có thẻ (tagged union): nó chứa được một trong nhiều biến thể, mỗi biến thể có dữ liệu riêng, cộng một cờ phân biệt (discriminant) để biết đang là biến thể nào. Kích thước = biến thể lớn nhất + cờ (sau căn lề):

enum Hinh { Diem, Tron(f64), ChuNhat(f64, f64) }
Hinh (Diem | Tron(f64) | ChuNhat(f64,f64)) : 24 byte

ChuNhat(f64, f64) chiếm 16 byte — biến thể lớn nhất. Cộng cờ phân biệt và căn lề 8 thành 24 byte. Mọi giá trị Hinh, kể cả Diem (không mang dữ liệu), đều chiếm 24 byte, vì mọi biến thể phải vừa trong cùng một ô. Hiểu điều này giúp tránh một bẫy hiệu năng thật: một enum có một biến thể khổng lồ khiến mọi giá trị phình theo — khi đó người ta Box biến thể lớn lại để enum chỉ giữ một con trỏ 8 byte.

Kiểu kích thước 0: chiếm đúng 0 byte

Rust đưa ý tưởng "ít byte nhất" tới tận cùng: kiểu không mang thông tin thì chiếm 0 byte. Đây là ZST (zero-sized type):

()        : 0 byte          (kiểu unit)
[u8; 0]   : 0 byte          (mảng rỗng)
Vec<()> với 1000 phần tử → capacity = 18446744073709551615 (usize::MAX)

() — kiểu "unit" — không có giá trị nào để lưu, nên chiếm 0 byte. Hệ quả thú vị: một Vec<()> chứa "một nghìn phần tử" không cấp phát heap gì cả — vì mỗi phần tử 0 byte, nó chỉ cần đếm số lượng, và capacity báo về usize::MAX. ZST nghe như trò lạ, nhưng rất hữu dụng: HashSet<T> chính là HashMap<T, ()> — giá trị () không tốn byte nào, nên một tập hợp đúng bằng một map chỉ có khóa. "Không chiếm chỗ" là một kiểu dữ liệu hạng nhất trong Rust.

Ảnh chụp sơ đồ nền tối tổng hợp output thật đo bố cục bộ nhớ trong rust 1.99.1, khối một kiểu cơ bản u8 size 1 align 1 u32 size 4 align 4 u64 size 8 align 8, khối hai struct cùng 15 byte dữ liệu XauThuTu repr Rust 16 byte TheoC repr C 32 byte TuSap tay sắp 16 byte trình biên dịch tự sắp lại trường tiết kiệm một nửa, khối ba tối ưu niche tham chiếu i32 8 byte Option tham chiếu i32 cũng 8 byte Box i32 8 Option Box i32 8 bool 1 Option bool 1 nhưng u64 8 Option u64 16 vì u64 dùng hết mọi bit phải thêm cờ, khối bốn enum Hinh với Diem Tron f64 ChuNhat hai f64 bằng 24 byte biến thể lớn nhất 16 cộng cờ 8, khối năm kiểu kích thước 0 unit 0 byte mảng u8 0 phần tử 0 byte Vec unit nghìn phần tử capacity bằng usize max không cấp phát heap

Hình 2: Năm nhóm số đo thật trong rust 1.99.1 — căn lề kiểu cơ bản, struct tự sắp lại trường, tối ưu niche của Option, cỡ enum, và kiểu kích thước 0.

Gói lại

Bố cục bộ nhớ trong Rust không phải chuyện phó mặc: nó được quyết tại lúc biên dịch theo những quy tắc bạn đo được bằng size_of. Trình biên dịch tự sắp lại trường để gói chặt (16 byte thay vì 32), tối ưu niche làm Option của con trỏ miễn phí (8 byte, không thêm gì), enum lấy cỡ biến thể lớn nhất cộng cờ, và ZST chiếm 0 byte thật. Đây là nền cho bài sau: khi ta nói Rust có "trừu tượng chi phí bằng không" — iterator, closure, generic biên dịch ra mã nhanh ngang vòng lặp viết tay — một phần lý do nằm ở chính đây, rằng các trừu tượng đó không kéo theo byte thừa hay lớp gián tiếp nào trong bộ nhớ. Bài tới sẽ đo điều đó bằng cách so assembly và thời gian chạy.

Phần sau