139. Memory layout in Rust
In JavaScript you never learn how many bytes an object takes. In Rust you can ask, and the answer explains a lot: why drip-rs’s values are 16 bytes, why field order can cost a third of a struct, and how many of your records fit in one cache line.
- Measure the size and alignment of any type with size_of and align_of.
- Work out where padding goes in a struct, and why a struct’s size is a multiple of its alignment.
- Say what repr(C) and repr(u8) promise, and when you need them.
- Explain the size of drip-rs’s Value and relate struct sizes to 64-byte cache lines.
How big is a value?
Chapter 49 drew a Drip value as a tag that says what kind of value it is, plus a payload, and let the TypeScript VM borrow JavaScript’s own tags. Chapter 60 squeezed a value into 8 bytes with NaN-boxing. drip-rs keeps the plain tagged enum. How big is it? In TypeScript you could only guess. In Rust you ask.
Think of a car park with bays of fixed widths. Motorbikes can park in any bay, but a lorry only fits where a lorry bay begins, every eighth metre. Park a motorbike, then a lorry, and the gap in between is just wasted tarmac. Rearrange the order you park in, and the gaps change.
size_of and align_of
std::mem::size_of::<T>() gives a type’s size in bytes, and
align_of::<T>() its alignment. Both are worked out at compile time.
use std::mem::{align_of, offset_of};
use std::mem::size_of;
fn show<T>(name: &str) {
let s = size_of::<T>();
let a = align_of::<T>();
println!("{name:<12}{s:>3}{a:>4}");
}
// Like drip-rs's handles and Value.
#[derive(Clone, Copy)]
struct Gc(u32);
#[derive(Clone, Copy)]
struct StrRef {
handle: Gc,
hash: u32,
}
enum Value {
Nil,
Bool(bool),
Number(f64),
String(StrRef),
Closure(Gc),
Instance(Gc),
}
#[repr(C)]
struct C3 {
a: u8,
b: f64,
c: u8,
}
struct R3 {
a: u8,
b: f64,
c: u8,
}
fn main() {
println!("type size align");
show::<u8>("u8");
show::<u32>("u32");
show::<f64>("f64");
show::<char>("char");
show::<&f64>("&f64");
show::<Option<&f64>>("Opt<&f64>");
show::<Option<f64>>("Opt<f64>");
show::<Vec<f64>>("Vec<f64>");
show::<(u8, u32)>("(u8, u32)");
show::<C3>("C3");
show::<R3>("R3");
show::<Gc>("Gc");
show::<StrRef>("StrRef");
show::<Value>("Value");
show::<Option<Value>>("Opt<Value>");
println!("R3: a@{} b@{} c@{}",
offset_of!(R3, a),
offset_of!(R3, b),
offset_of!(R3, c));
}On this machine (x86-64 Linux, rustc 1.97) the run prints, among others:
type size align
u8 1 1
f64 8 8
&f64 8 8
Opt<&f64> 8 8
Opt<f64> 16 8
Vec<f64> 24 8
(u8, u32) 8 4
A few of these deserve a look. A reference is one 8-byte address, and
Option<&f64> is no bigger, because a null address is never a valid
reference, so it can mean None[3]. An
f64 has no such spare pattern: every 64 bits are some number, so
Option<f64> needs a separate tag, which with padding costs 8 bytes.
A Vec is a pointer, a length and a capacity. And (u8, u32) is 8
bytes, not 5. Those 3 extra bytes are the subject of this chapter.
Padding
Here an f64 has alignment 8. So when a field would land at the wrong
offset, the compiler skips bytes. Step through
a u8, an f64 and another u8:
#[repr(C)]struct C3 {a: u8,b: f64,c: u8,}
Padding is bytes the compiler leaves unused inside a struct: before a field, so it starts at a multiple of its alignment, and at the end, so the size is a multiple of the struct’s alignment and the next element of an array is aligned too[1].
The last step is the surprise. Without an attribute, Rust uses its
default representation, which only promises that each field is aligned
and that fields don’t overlap. It may reorder them, and rustc 1.97 did:
the run’s R3 line shows b@0, a@8 and c@9, 16 bytes. Don’t build
on that order, though. The Reference says the default representation
guarantees no particular layout[1].
repr(C) and friends
Sometimes you need the layout pinned down: a C library will read your struct, a WebAssembly host will read bytes in linear memory (chapter 117), or a file format fixes the offsets. For that:
A #[repr(..)] attribute chooses a type’s representation.
#[repr(C)] lays fields out in declaration order using C’s rules, so
the layout is fixed. On an enum without data, a primitive representation
such as #[repr(u8)] makes the tag exactly that integer type.
#[repr(align(N))] raises the alignment to N, and #[repr(packed)]
removes padding (at the cost of misaligned fields)[1].
drip-rs uses one of them. Its opcodes are an enum:
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpCode {
Constant,
Nil,
// ...
}
#[repr(u8)] makes each variant exactly the byte that encodes it, so
writing an instruction into a chunk is op as u8, and the numbering
matches the TypeScript VM’s OP table. A #[repr(align(64))] struct,
by the way, measured 64 bytes with alignment 64 here: one per cache
line.
drip-rs’s Value: 16 bytes
Here is drip-rs’s Value, from value.rs:
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Value {
Nil,
Bool(bool),
Number(f64),
String(StrRef),
Function(Gc<ObjFunction>),
// Native, Closure, Class,
// Instance, BoundMethod: all Gc
}
drip-rs’s own types, measured by linking a small program against the crate (rustc 1.97, release build):
Value 16 align 8
Option<Value> 16
StrRef 8 align 4
Option<StrRef> 12
Gc<ObjClass> 4
ObjInstance 64
The biggest payloads are 8 bytes: an f64, or a StrRef, which is a
4-byte handle (chapter 138) plus the string’s 4-byte hash. Add a tag,
and round up to the alignment of 8: 16. The hash rides along in space
an f64 needed anyway: the README notes that it costs nothing. Option<Value> is
also 16, because the tag has values to spare. Option<StrRef> has no
spare pattern (every u32 is a valid index), so it grows to 12.
Now connect it to chapter 19. Memory moves in 64-byte cache lines, so
one line holds four Values. The VM’s stack is a Vec<Value>,
contiguous, so pushing and popping walks along lines. NaN-boxed
values (chapter 60) would fit eight; drip-rs keeps the plain enum and
pays in cache space. And chapter 140’s particle, six f64s, is
48 bytes: a line holds one and a third of them, and a loop that only
reads x and vx uses 16 of every 48 bytes it pulls in.
drip-rs’s hash table stores its keys and values in two Vecs, as the
TypeScript table does. Layout shows what that buys. One Vec<(Option<StrRef>, Value)> would need 32 bytes per entry,
4 of them padding; the two Vecs need 12 + 16 = 28. And find reads a
value only at an empty key: probing past occupied slots touches the
12-byte keys alone, five to a cache line. That is struct-of-arrays
again, chosen by hand. Chapter 140 does the same for particles.
Your turn
Write c_layout, which takes the fields of a #[repr(C)] struct as (size, align) pairs in declaration order, and returns the struct’s (size, align) using the Reference’s rules. Write the helper round_up too. The tests compare your answer with what size_of and align_of say about real structs.
// Each field is (size, align), in
// declaration order. Return the
// repr(C) struct's (size, align).
fn c_layout(fields: &[(usize, usize)])
-> (usize, usize) {
todo!()
}
// The next multiple of `a` at or
// after `n`.
fn round_up(n: usize, a: usize)
-> usize {
todo!()
}Write tight, which returns the smallest repr(C) size you can get by reordering the fields: sort them by alignment, largest first, and lay them out with c_layout (given). The tests check it against repr(C) structs written in that order.
// Each field is (size, align), in
// declaration order. Return the
// repr(C) struct's (size, align).
fn c_layout(fields: &[(usize, usize)])
-> (usize, usize) {
let mut off = 0;
let mut align = 1;
for &(size, a) in fields {
off = round_up(off, a);
off += size;
align = align.max(a);
}
(round_up(off, align), align)
}
// The next multiple of `a` at or
// after `n`.
fn round_up(n: usize, a: usize)
-> usize {
(n + a - 1) / a * a
}
fn tight(fields: &[(usize, usize)])
-> usize {
todo!()
}What you learned
size_ofandalign_oftell you a type’s size and alignment; a size is always a multiple of the alignment.- Alignment says which addresses a value may start at; an
f64needs a multiple of 8. - Padding fills gaps before misaligned fields and after the last one. Field order changes how much you need.
- The default representation may reorder fields;
#[repr(C)]fixes declaration order,#[repr(u8)]fixes an enum’s tag, andalignandpackedadjust alignment. - drip-rs’s
Valueis 16 bytes, four per cache line;Option<Value>costs nothing extra.
Check your understanding
0/6- 1.
What is
size_of::<(u8, u32)>()on this machine? - 2.
A struct has alignment 8. Which of these could be its size?
- 3.
Why would you write
#[repr(C)]on a struct? - 4.
A
Particlehas sixf64fields. How big is it, and how does it sit in 64-byte cache lines? - 5.
drip-rs’s
Valueis 16 bytes. What doessize_of::<Option<Value>>()give? - 6.
drip-rs’s hash table keeps keys (
Option<StrRef>, 12 bytes) and values (Value, 16 bytes) in twoVecs. OneVecof(Option<StrRef>, Value)pairs would use how many bytes per entry?
References
- [1] The Rust Reference contributors. Type layout. The Rust Reference. doc.rust-lang.org/reference/type-layout.html (checked 2026-10-31)
- [2] The Rust Project Developers. Function core::mem::size_of (std::mem::size_of), Rust standard library documentation. Rust standard library API documentation; source in library/core/src/mem/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/mem/fn.size_of.html (checked 2026-10-31)
- [3] The Rust Project Developers. Module core::option (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/option.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/option/ (checked 2026-10-31)