Skip to content

Field order decides a struct's size

Level: 301 · anyone who keeps a million structs in a slice, or reads them off the wire, and wants to know what each one costs

One line: A struct's size follows from its fields' alignments and their order — {bool, int64, bool} is 24 bytes and {int64, bool, bool} is 16 — and unsafe.Sizeof, Alignof and Offsetof are compile-time constants that show where every byte went, the same on amd64 and arm64.

The program lays out the same three fields in two orders, a struct with a 4-byte and an 8-byte field, two structs that carry a zero-size field, and then every basic type this chapter uses:

Verified output of struct_layout_and_padding_go.go — regenerated by tools/run_examples.py, never hand-typed.

Padded {bool, int64, bool}: Sizeof 24  Alignof 8
  Offsetof: Urgent 0  Cents 8  Paid 16
Packed {int64, bool, bool}: Sizeof 16  Alignof 8
  Offsetof: Cents 0  Urgent 8  Paid 9
Mixed {int32, int64}: Sizeof 16  Offsetof At 8
[1000]Padded: 24000 bytes   [1000]Packed: 16000 bytes
TrailingMark {int64, struct{}}: 16   LeadingMark {struct{}, int64}: 8
len(raw), an array sized by unsafe.Sizeof(Packed{}): 16

type       Sizeof  Alignof
bool            1        1
int32           4        4
int64           8        8
int             8        8
float64         8        8
*Packed         8        8
string         16        8
[]byte         24        8
any            16        8
[3]int64       24        8
struct{}        0        1
[0]int64        0        8

Reading the output

Padded is 24 bytes for 10 bytes of data. The spec's size and alignment guarantees ↗ say that a struct's alignment is the largest alignment of any of its fields, so Padded is aligned to 8 because Cents is. Urgent sits at offset 0; Cents cannot start at 1, because an int64 must start at a multiple of 8, so it starts at 8 and 7 bytes of padding go in between. Paid follows at 16, and the struct is then padded to 24 so that its size stays a multiple of its alignment — which is what keeps Cents aligned in the second element of an array.

Packed is 16 bytes for the same data. With the 8-byte field first, the two bools land at 8 and 9, and only the 6 bytes after them are padding. The fields did not change; their order did. In an array of a thousand, that is 8,000 bytes, and in a slice of a million it is 8 MB the collector has to walk past.

Mixed {int32, int64} is 16, with At at 8. Four bytes of padding follow Sensor, for the same reason. An int32 next to an int64 is the commonest way a struct grows by a third.

A zero-size field at the end costs 8 bytes; at the start it costs nothing. struct{} has size 0, and LeadingMark is 8 bytes. TrailingMark is 16, because the compiler pads a struct that ends in a zero-size field by one byte, which alignment then rounds up to 8. The reason is in cmd/compile/internal/types/size.go ↗: taking the address of that last field must not produce a pointer to the next object in the heap. Put a struct{} marker field first, or leave it out.

Sizeof is a constant. var raw [unsafe.Sizeof(Packed{})]byte compiles, and len(raw) is 16. The spec ↗ makes the three functions return a constant of type uintptr whenever the argument's type has a fixed size, which is every type except a type parameter — the one case that fails is on Memory: the compiler errors.

The table. A pointer, an int and an int64 are 8 bytes here and on the arm64 runner; a string is a pointer and a length, 16; a slice adds a capacity, 24; any is two words, 16 (An interface is two words); a map value is one pointer. struct{} and [0]int64 are both size 0, but the array keeps its element's alignment of 8 — the spec's third rule, that an array is aligned as its element is. The spec also allows two distinct zero-size variables to have the same address, which is why a cleanup on a zero-size object is not guaranteed to run (A cleanup runs after the last reference).

struct_layout_and_padding_go.go

struct_layout_and_padding_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// unsafe.Sizeof, Alignof and Offsetof are compile-time constants that show
// where the compiler put each field and how much padding it added. Every
// number here is the same on amd64 and arm64.
//
//  go build struct_layout_and_padding_go.go && ./struct_layout_and_padding_go
package main

import (
    "fmt"
    "unsafe"
)

// Padded lists its fields in the order a reader might write them:
// 1 + 8 + 1 bytes of data, and a 24-byte struct.
type Padded struct {
    Urgent bool
    Cents  int64
    Paid   bool
}

// Packed holds the same fields, largest first: 16 bytes.
type Packed struct {
    Cents  int64
    Urgent bool
    Paid   bool
}

// Mixed pads after the int32 so that the int64 starts at a multiple of 8.
type Mixed struct {
    Sensor int32
    At     int64
}

// TrailingMark ends with a zero-size field, which the compiler pads as if
// it were one byte, so that a pointer to it cannot point past the struct.
type TrailingMark struct {
    Cents int64
    mark  struct{}
}

// LeadingMark puts the zero-size field first and pays nothing for it.
type LeadingMark struct {
    mark  struct{}
    Cents int64
}

// Sizeof is a constant, so it can size an array.
var raw [unsafe.Sizeof(Packed{})]byte

func main() {
    var p Padded
    var q Packed
    var m Mixed
    fmt.Println("Padded {bool, int64, bool}: Sizeof", unsafe.Sizeof(p), " Alignof", unsafe.Alignof(p))
    fmt.Println("  Offsetof: Urgent", unsafe.Offsetof(p.Urgent), " Cents", unsafe.Offsetof(p.Cents), " Paid", unsafe.Offsetof(p.Paid))
    fmt.Println("Packed {int64, bool, bool}: Sizeof", unsafe.Sizeof(q), " Alignof", unsafe.Alignof(q))
    fmt.Println("  Offsetof: Cents", unsafe.Offsetof(q.Cents), " Urgent", unsafe.Offsetof(q.Urgent), " Paid", unsafe.Offsetof(q.Paid))
    fmt.Println("Mixed {int32, int64}: Sizeof", unsafe.Sizeof(m), " Offsetof At", unsafe.Offsetof(m.At))
    fmt.Println("[1000]Padded:", unsafe.Sizeof([1000]Padded{}), "bytes   [1000]Packed:", unsafe.Sizeof([1000]Packed{}), "bytes")
    fmt.Println("TrailingMark {int64, struct{}}:", unsafe.Sizeof(TrailingMark{}), "  LeadingMark {struct{}, int64}:", unsafe.Sizeof(LeadingMark{}))
    fmt.Println("len(raw), an array sized by unsafe.Sizeof(Packed{}):", len(raw))
    fmt.Println()
    fmt.Println("type       Sizeof  Alignof")
    fmt.Printf("bool       %6d  %7d\n", unsafe.Sizeof(false), unsafe.Alignof(false))
    fmt.Printf("int32      %6d  %7d\n", unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0)))
    fmt.Printf("int64      %6d  %7d\n", unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0)))
    fmt.Printf("int        %6d  %7d\n", unsafe.Sizeof(int(0)), unsafe.Alignof(int(0)))
    fmt.Printf("float64    %6d  %7d\n", unsafe.Sizeof(float64(0)), unsafe.Alignof(float64(0)))
    fmt.Printf("*Packed    %6d  %7d\n", unsafe.Sizeof(&q), unsafe.Alignof(&q))
    fmt.Printf("string     %6d  %7d\n", unsafe.Sizeof(""), unsafe.Alignof(""))
    fmt.Printf("[]byte     %6d  %7d\n", unsafe.Sizeof([]byte(nil)), unsafe.Alignof([]byte(nil)))
    fmt.Printf("any        %6d  %7d\n", unsafe.Sizeof(any(nil)), unsafe.Alignof(any(nil)))
    fmt.Printf("[3]int64   %6d  %7d\n", unsafe.Sizeof([3]int64{}), unsafe.Alignof([3]int64{}))
    fmt.Printf("struct{}   %6d  %7d\n", unsafe.Sizeof(struct{}{}), unsafe.Alignof(struct{}{}))
    fmt.Printf("[0]int64   %6d  %7d\n", unsafe.Sizeof([0]int64{}), unsafe.Alignof([0]int64{}))
}

What to do

  • Order fields by alignment, largest first, in a struct that is stored by the thousand — slices, arrays, caches, the rows of a table. For a struct that exists a few times, keep the order that reads best.
  • Keep an int32 away from an int64 unless two of them sit together; four bytes of padding per struct is the usual price.
  • Never write a struct's bytes to a file or a socket. The padding and the byte order are the machine's; the C library's A record on the wire ↗ shows what goes wrong, and encoding/binary writes the fields one by one.
  • Let a tool do the arithmetic. The fieldalignment analyzer in golang.org/x/tools ↗ reports structs that would shrink if reordered; it is not part of go vet and is not run here.

Practice

A Reading struct was written in the order its fields came to mind: Valid bool, Value float64, Sensor int32, Flagged bool, At int64. Reorder the fields into a Compact struct that is as small as the alignment rules allow, and print, for both: the bytes of field data, Sizeof, the padding (the difference), each field's Offsetof, and what an array of a thousand costs.

Solution

struct_layout_and_padding_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: a Reading struct is written in the order the fields came to mind.
// Reorder its fields so that the struct is as small as it can be, and print
// how many bytes of padding each version carries and what an array of a
// thousand costs.
//
//  go build struct_layout_and_padding_kata_go.go && ./struct_layout_and_padding_kata_go
package main

import (
    "fmt"
    "unsafe"
)

// Reading, as first written: 1 + 8 + 4 + 1 + 8 = 22 bytes of data.
type Reading struct {
    Valid   bool
    Value   float64
    Sensor  int32
    Flagged bool
    At      int64
}

// Compact holds the same fields, sorted by alignment, largest first.
type Compact struct {
    Value   float64
    At      int64
    Sensor  int32
    Valid   bool
    Flagged bool
}

func main() {
    var r Reading
    var c Compact
    data := unsafe.Sizeof(r.Valid) + unsafe.Sizeof(r.Value) + unsafe.Sizeof(r.Sensor) + unsafe.Sizeof(r.Flagged) + unsafe.Sizeof(r.At)
    fmt.Println("bytes of field data in either struct:", data)
    fmt.Println("Reading: Sizeof", unsafe.Sizeof(r), " padding", unsafe.Sizeof(r)-data)
    fmt.Println("  Offsetof: Valid", unsafe.Offsetof(r.Valid), " Value", unsafe.Offsetof(r.Value), " Sensor", unsafe.Offsetof(r.Sensor), " Flagged", unsafe.Offsetof(r.Flagged), " At", unsafe.Offsetof(r.At))
    fmt.Println("Compact: Sizeof", unsafe.Sizeof(c), " padding", unsafe.Sizeof(c)-data)
    fmt.Println("  Offsetof: Value", unsafe.Offsetof(c.Value), " At", unsafe.Offsetof(c.At), " Sensor", unsafe.Offsetof(c.Sensor), " Valid", unsafe.Offsetof(c.Valid), " Flagged", unsafe.Offsetof(c.Flagged))
    fmt.Println("[1000]Reading:", unsafe.Sizeof([1000]Reading{}), "bytes   [1000]Compact:", unsafe.Sizeof([1000]Compact{}), "bytes")
}

Verified output of struct_layout_and_padding_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

bytes of field data in either struct: 22
Reading: Sizeof 32  padding 10
  Offsetof: Valid 0  Value 8  Sensor 16  Flagged 20  At 24
Compact: Sizeof 24  padding 2
  Offsetof: Value 0  At 8  Sensor 16  Valid 20  Flagged 21
[1000]Reading: 32000 bytes   [1000]Compact: 24000 bytes

In other languages

  • Rust — the Rust library's What is a record, in memory? ↗: Rust's compiler reorders fields itself, so {bool, i64, bool} is already 16 bytes, and #[repr(C)] is how you ask for Go's and C's declared order; size_of and align_of are the counterparts of Sizeof and Alignof. A reference is aligned to its referent ↗ measures alignment from the pointer's side.
  • C — the same rules, the same sizeof and offsetof, and no reordering; the C library's A record on the wire ↗ is the page to read before sending a struct anywhere.
  • Python — an object has no fixed layout to order: sys.getsizeof ↗ reports an object's own bytes, and a struct.pack format string is where Python spells out a C layout.

Sources