Viscosity
Review
Part IX · Rust for TypeScript developers · Day 35

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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.

Try itMeasuring sizes and alignments
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));
}
Open in Rust Playground ↗

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.

Definition

size_of::<T>() is the number of bytes between one T and the next in an array, padding included[2]. A type’s alignment is a power of two, and a value of that type may only be stored at an address that is a multiple of it. A type’s size is always a multiple of its alignment[1].

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:

Step throughPadding, one byte at a time
#[repr(C)]
struct C3 {
a: u8,
b: f64,
c: u8,
}
A grid of 24 bytes, 8 per row, showing where each field and each padding byte goesrepr(C): declaration order0816a· = padding (0 bytes)
repr(C) lays fields out in the order you wrote them. a is a u8: size 1, alignment 1, so it goes at offset 0.
1/6
Definition

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:

Definition

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.

Under the hood

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

ExerciseLay out a repr(C) struct

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!()
}
Open in Rust Playground ↗
Extra practiceReorder to shrink

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!()
}
Open in Rust Playground ↗

What you learned

  • size_of and align_of tell 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 f64 needs 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, and align and packed adjust alignment.
  • drip-rs’s Value is 16 bytes, four per cache line; Option<Value> costs nothing extra.

Check your understanding

0/6
  1. 1.

    What is size_of::<(u8, u32)>() on this machine?

  2. 2.

    A struct has alignment 8. Which of these could be its size?

  3. 3.

    Why would you write #[repr(C)] on a struct?

  4. 4.

    A Particle has six f64 fields. How big is it, and how does it sit in 64-byte cache lines?

  5. 5.

    drip-rs’s Value is 16 bytes. What does size_of::<Option<Value>>() give?

  6. 6.

    drip-rs’s hash table keeps keys (Option<StrRef>, 12 bytes) and values (Value, 16 bytes) in two Vecs. One Vec of (Option<StrRef>, Value) pairs would use how many bytes per entry?

References

  1. [1] The Rust Reference contributors. Type layout. The Rust Reference. doc.rust-lang.org/reference/type-layout.html (checked 2026-10-31)
  2. [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. [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)