Memory · Lesson 15.7

Layout

Source
Measure a type's size and alignment with sizeof and alignof, and see the padding that field order puts inside a struct.

You have been multiplying by sizeof since Raw memory. This lesson looks at what that number really is, at its partner alignof, and at the hidden bytes that make a struct bigger than the sum of its fields.

Size and alignment

Every type has two numbers:

  • its size — how many bytes one value occupies, from sizeof(T);
  • its alignment — every value of the type must sit at an address that is a multiple of this number, from alignof(T).
PrintLine("int64   {}     {}", sizeof(int64), alignof(int64));

For the plain number types the two are the same: a uint8 is one byte and may sit anywhere, an int64 is eight bytes and must sit at a multiple of eight. The processor reads aligned values fastest, and on some machines can only read them aligned at all.

Padding inside a struct

A struct keeps its fields in the order they are written, and each field must sit at an address that suits its own alignment. Look at Loose:

struct Loose {
    flag: bool;
    total: int64;
    mark: uint8;
}

flag takes byte 0. total needs a multiple of eight, so it cannot start at byte 1: seven unused bytes of padding go in between, and total starts at 8. mark lands at 16. Then the whole struct is rounded up to a multiple of its largest alignment, 8, so that in an array the next element lines up too. That makes 24 bytes for 10 bytes of data.

Tight has the same three fields, widest first:

struct Tight {
    total: int64;
    flag: bool;
    mark: uint8;
}
flowchart TB
    subgraph loose["Loose — 24 bytes"]
        direction LR
        l1["flag<br/>0"] --- l2["padding<br/>1–7"] --- l3["total<br/>8–15"] --- l4["mark<br/>16"] --- l5["padding<br/>17–23"]
    end
    subgraph tight["Tight — 16 bytes"]
        direction LR
        t1["total<br/>0–7"] --- t2["flag<br/>8"] --- t3["mark<br/>9"] --- t4["padding<br/>10–15"]
    end

Putting the widest fields first usually leaves the least padding.

Measuring where a field landed

The offsets in the output are not guessed — the program measures them. Converting an address to uint turns it into a number, so the distance from the start of the value to a field is a subtraction:

var loose = Loose { flag: true, total: 1, mark: 2 };
let start = @loose as uint;
PrintLine("Loose: flag at {}, total at {}, mark at {}", (@loose.flag as uint) - start,
    (@loose.total as uint) - start, (@loose.mark as uint) - start);

Arrays have no gaps between elements

An array is its elements side by side, each one sizeof apart, with nothing in between. The padding already inside each element is what keeps the next one aligned:

PrintLine("Loose[4] takes {} bytes, Tight[4] takes {}", sizeof(Loose[4]), sizeof(Tight[4]));

Four Loose values cost 96 bytes; four Tight ones 64. In a large array, field order is real memory.

Facts about this build

The numbers in the output are for a 64-bit target. Sizes and alignments are decided by the target the program is built for, so treat them as facts about this build, not as constants of the language — and let sizeof and alignof tell you rather than writing the numbers down.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// Every type has a size, the bytes one value occupies, and an alignment: its address must be a
// multiple of that number. `sizeof(T)` and `alignof(T)` ask the compiler for both.
//
// A struct keeps its fields in the order they are written, and each field must sit at an
// address that suits its own alignment. So when a one-byte `bool` is followed by an eight-byte
// `int64`, seven unused bytes of padding go between them. The whole struct is then rounded up
// to its largest alignment, so that in an array every element lines up too.
//
// The same three fields can therefore cost different amounts depending on their order. Putting
// the widest fields first usually leaves the least padding.
//
// The numbers below are for a 64-bit target. Sizes and alignments are decided by the target the
// program is built for, so treat them as facts about this build, not constants of the language.
import Io::PrintLine;

struct Loose {
    flag: bool;
    total: int64;
    mark: uint8;
}

struct Tight {
    total: int64;
    flag: bool;
    mark: uint8;
}

func Main() -> int {
    PrintLine("type    size  align");
    PrintLine("uint8   {}     {}", sizeof(uint8), alignof(uint8));
    PrintLine("int32   {}     {}", sizeof(int32), alignof(int32));
    PrintLine("int64   {}     {}", sizeof(int64), alignof(int64));
    PrintLine("int     {}     {}", sizeof(int), alignof(int));
    PrintLine("Loose   {}    {}", sizeof(Loose), alignof(Loose));
    PrintLine("Tight   {}    {}", sizeof(Tight), alignof(Tight));

    // Where each field landed, measured from the start of the value. The gaps are padding.
    var loose = Loose { flag: true, total: 1, mark: 2 };
    let start = @loose as uint;
    PrintLine("Loose: flag at {}, total at {}, mark at {}", (@loose.flag as uint) - start,
        (@loose.total as uint) - start, (@loose.mark as uint) - start);

    var tight = Tight { total: 1, flag: true, mark: 2 };
    let base = @tight as uint;
    PrintLine("Tight: total at {}, flag at {}, mark at {}", (@tight.total as uint) - base,
        (@tight.flag as uint) - base, (@tight.mark as uint) - base);

    // An array is its elements side by side, each one `sizeof` apart, with nothing in between.
    PrintLine("Loose[4] takes {} bytes, Tight[4] takes {}", sizeof(Loose[4]), sizeof(Tight[4]));
    return 0;
}

Run it

cd Examples/Memory/Layout
rux run
type    size  align
uint8   1     1
int32   4     4
int64   8     8
int     8     8
Loose   24    8
Tight   16    8
Loose: flag at 0, total at 8, mark at 16
Tight: total at 0, flag at 8, mark at 9
Loose[4] takes 96 bytes, Tight[4] takes 64

This output is from a 64-bit build; sizes and alignments depend on the target.

Common mistakes

Adding up the fields.
Loose holds 1 + 8 + 1 bytes of data but occupies 24. Allocating "10 bytes per Loose" would leave every element short. Ask sizeof(Loose).
Writing sizes down as numbers.
count * 8 for an array of int is right on a 64-bit target and wrong elsewhere. count * sizeof(int) is right everywhere.
Expecting the compiler to reorder fields.
Rux keeps fields in the order you write them, padding and all. If size matters, order the fields yourself, widest first.

Try it yourself

  1. Declare struct Mixed { a: uint8; b: int32; c: uint8; d: int16; }. Predict its size and alignment, check with sizeof and alignof, then reorder the fields to make it 8 bytes.
  2. Print sizeof(*int) and sizeof(char8[..]). Why is a slice twice the size of a pointer?
  3. A struct of three uint8 fields: what are its size and alignment? Is there any padding?

Learn more