Skip to content

← Performance and Data Layout step 4 of 20

Medium Primitives

Type sizes, enum layout and niche optimisation

Enum size is a silent cost multiplier. Every Result<T, E> in a hot loop carries max(size_of::<T>(), size_of::<E>()) bytes, whether or not the error case ever happens. Every Vec<Message> pays for the fattest variant on every element. And because nothing warns you at the call site, the cost is invisible until you measure it.

size_of is a const fn, so unlike most performance questions, layout is provable at compile time. That makes it one of the few topics where you can test your understanding exactly rather than argue about it.

How an enum is laid out

enum Message {
    Ping,
    Code(u32),
    Blob([u8; 512]),
}

A value of this type must be able to hold any variant, so it needs room for the largest one, plus a discriminant telling you which it currently is, plus padding to satisfy alignment. That is 520 bytes for a Ping. Move it and you memcpy 520 bytes. Put a thousand of them in a Vec and you have allocated half a megabyte to store mostly nothing.

The fix is to Box the offending variant. The rare large case pays a heap allocation and a pointer chase; every other case gets a type that fits in a register pair. clippy’s large_enum_variant exists precisely to point this out, and result_large_err is the same lint aimed at the error half of a Result.

Niches, which are the elegant part

size_of::<Option<&u8>>() == size_of::<&u8>()

Eight bytes, not sixteen. There is no separate discriminant at all.

A reference is never null, so the bit pattern 0 is an invalid value for it — a niche. Option notices, and encodes None as that otherwise-illegal pattern. Some(x) is just x. This is why Option<&T> is a zero-cost nullable pointer and why FFI can pass one where C expects T*.

The same applies to Box<T>, &mut T, NonZeroU32, char (which has invalid scalar values above 0x10FFFF), and — importantly here — enums with spare discriminant values. A three-variant enum whose tag is stored in a byte has 253 unused patterns, so Option<Message> can hide None in one of them and stay the same size.

Option<u64> gets no such luck: every one of the 2^64 patterns is a valid u64, so there is nowhere to hide and the type grows to 16 bytes (8 for the value, 1 for the tag, 7 for padding).

What to write

Restructure Message so that both of these hold, without changing describe at all:

const _: () = assert!(size_of::<Message>() <= 16);
const _: () = assert!(size_of::<Option<Message>>() == size_of::<Message>());

Those are const assertions. They run during compilation, and when one fails you get E0080, evaluation of constant value failed — a unit test that executes before your program exists. Get used to them; they are the cheapest regression test in the language.

Then fill in the report:

pub fn layout_report() -> Vec<(String, usize, usize)>

returning (name, size_of, align_of) for these eight types, in this order: Message, Option<Message>, Box<[u8; 512]>, Option<Box<[u8; 512]>>, &u8, Option<&u8>, u64, Option<u64>.

Why the tests assert relationships, not numbers

The grader checks size_of::<Option<&u8>>() == size_of::<&u8>(), not size_of::<Foo>() == 24. That is deliberate and it is the habit to copy.

repr(Rust) layout carries no guarantees. The compiler may reorder fields, choose a different discriminant width, or change its mind in the next release. Every relationship asserted here is guaranteed — the niche optimisations for references and Box are documented — while the absolute numbers are not. Write the assertion that stays true.

(-Zprint-type-sizes prints the compiler’s actual layout decisions, and it is nightly-only. On stable, size_of probes like these are the tool.)

An honest caveat about the payoff

It is tempting to promise that shrinking an enum makes your program faster. Measured, on this toolchain: Result<u64, [u8; 512]> against Result<u64, u8> in a tight loop came out 0.4825 ms versus 0.4795 — indistinguishable, because LLVM hoisted the error construction out of the loop entirely.

Large variants are a real problem for stack pressure, memory footprint and API ergonomics. They are not reliably a microbenchmark problem. Do not promise a speedup you cannot show; the honest claim is the one about memory and about Vec density, and it is strong enough on its own.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…