Skip to content

← Performance and Data Layout step 5 of 20

Medium Primitives

repr(Rust) reorders your fields — and that's the point

“Add #[repr(C)] for FFI” is a rule people memorise without ever seeing why. This item makes you watch the compiler silently rearrange your struct, which converts the rule into an observation — and hands you a genuine optimisation insight on the way.

The layout algorithm C uses, and Rust does under repr(C)

Fields sit in declaration order. Each one goes at the next offset that is a multiple of its own alignment, inserting padding to get there. The struct’s alignment is the maximum of its fields’ alignments, and the total size is rounded up to a multiple of that.

struct { uint8_t a; uint32_t b; uint8_t c; };

a at 0. b needs alignment 4, so three bytes of padding and b at 4. c at 8. Size so far 9, rounded up to a multiple of 4 → 12 bytes, three of them wasted, four of them padding in total.

What Rust does when you do not say repr(C)

Whatever it likes. Here, it sorts the fields by descending alignment, puts b first, then packs a and c into the tail:

struct repr(C) repr(Rust)
{ u8, u32, u8 } 12 8
{ u8, u64, u16 } 24 16
{ u16, u8, u16, u8 } 8 6
{ u8, u16, u8, u32 } 12 8

A third of the memory, for free, on every one of them. On a Vec of a million elements that is four megabytes you were not going to notice you had lost.

This is not a theoretical liberty the compiler might exercise. It demonstrably does, today, on this toolchain, on the simplest structs you can write.

What to write

pub fn compare() -> Vec<(usize, usize)>

Four pairs of structs are declared for you. Within each pair the fields and their order are identical — the only difference should be the representation. Right now both members of every pair are repr(Rust). Mark the C* ones correctly, and return (size_of, align_of) for all eight, in the order: CSmall, RSmall, CWide, RWide, CMixed, RMixed, CStagger, RStagger.

How the tests are written, and why you should copy the habit

The grader asserts three things per pair:

  1. the repr(C) size and alignment exactly — these are guaranteed by the ABI, so pinning them is legitimate and will still be true in five years;
  2. repr(Rust) size is not larger than repr(C) size — a relationship, not a number;
  3. both have the same alignment — also guaranteed, since alignment is determined by the fields.

It deliberately does not assert that RSmall is 8 bytes. It is, today. It is not promised to be, and a test that pins it would be a test that breaks on a compiler upgrade for no reason. Assert what is guaranteed; observe the rest.

The rule this makes non-negotiable

Never transmute a repr(Rust) type across an FFI boundary, never memcpy one into a file or a socket, and never assume two repr(Rust) types with the same fields have the same layout — even the same type in two compilations of the same program carries no promise. It “works” right up until it does not, and the failure is silent corruption rather than a crash.

And do not depend on the specific reordering you just measured. Sorting by descending alignment is what rustc happens to do; it is not specified, it has changed before, and randomised layout exists as an unstable compiler flag precisely so that code depending on it can be caught.

#[repr(C)] is for stable layout, not for speed. As the table shows, it usually makes your struct bigger.

The optimisation insight

Since the compiler is already sorting your fields, you get the packing benefit for free in normal Rust. But the moment you write #[repr(C)] — for FFI, for a wire format, for a memory-mapped structure — field order becomes yours to get right. Declare from widest alignment to narrowest and the padding mostly disappears: { u32, u16, u8, u8 } is 8 bytes where { u8, u32, u8, u16 } is 12.

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