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
int32away from anint64unless 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/binarywrites the fields one by one. - Let a tool do the arithmetic. The
fieldalignmentanalyzer ingolang.org/x/tools↗ reports structs that would shrink if reordered; it is not part ofgo vetand 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.
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_ofandalign_ofare the counterparts ofSizeofandAlignof. A reference is aligned to its referent ↗ measures alignment from the pointer's side. - C — the same rules, the same
sizeofandoffsetof, 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 astruct.packformat string is where Python spells out a C layout.
Sources¶
- The Go specification: Size and alignment guarantees ↗ and Package unsafe ↗.
unsafe.Sizeof,AlignofandOffsetof↗.cmd/compile/internal/types/size.goat go1.25.5 ↗ — the layout algorithm and the trailing zero-size padding, with issue 9401 ↗ as its reason.fieldalignment↗ ingolang.org/x/tools.- Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 13, "Low-Level Programming" — section 13.1, "unsafe.Sizeof, Alignof, and Offsetof".