Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Optimizing Struct Layouts in Go to Reduce Memory Padding and Cache Misses

Eight bytes per struct. That is what a careless field order cost us in a 1M-record in-memory index we cache in a service that answers every lookup request. Eight bytes is nothing – until you realize it is the difference between fitting three or four records per 64-byte cache line, and the cache-line pressure is what actually sets your p99, not the raw memory bytes.

The compiler pads struct fields to their alignment requirements. It does this silently, and the silent part is why it bites: unsafe.Sizeof returns a number, and nobody checks it until the memory profile says you are using 33% more than the sum of your fields.

How alignment works

Every Go type has a natural alignment: bool and uint8 align to 1 byte, int32/float32 to 4, and pointers, int64, float64, string headers, and slices align to 8 on 64-bit platforms. The compiler inserts padding before a field so it lands on its alignment boundary, and rounds the whole struct size up to its largest member’s alignment. unsafe.Alignof reports the value; unsafe.Sizeof reports the damage.

The consequence is an ordering rule that is trivial to state and easy to forget: sort fields by decreasing alignment (8, 4, 2, 1) and the padding largely disappears. A bool between two 8-byte fields costs 7 bytes of padding each time; a bool after them costs nothing except rounding at the end.

The same fields, two sizes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 40 bytes
type RowBad struct {
	Ready  bool     // 1 byte + 7 padding
	ID     int64    // 8
	Name   string   // 16
	Score  float32  // 4
}

// 32 bytes
type RowGood struct {
	ID     int64    // 8
	Name   string   // 16
	Score  float32  // 4
	Ready  bool     // 1
}

Same data, 25% different footprint. RowGood is what you get from the sort-by-decreasing-alignment rule; RowBad is what happens when you write fields in the order they appear in the business logic.

Why it is not just about memory

The headline numbers are never the bytes. We benchmarked a scan over a 1M-record array of each layout, reading one field per record:

1
2
BenchmarkScanRowBad-16   1415  848074 ns/op   1,608,062 cache misses
BenchmarkScanRowGood-16  2034  590112 ns/op   1,127,204 cache misses

1.4x wall time and ~1.4x fewer cache misses, because 4 records fit per line instead of 2.67. For a hot lookup index this shows up as the difference between a tight CPU-bound loop and one that is constantly waiting on L2. If you are doing anything memory-bound – database indexes, packet buffers, event streams – struct size is a cache-line problem dressed up as a memory problem.

Where the rule breaks

Biggest-first is the 90% case, but it is not a law:

  • Hot-field locality. Sometimes you want the fields touched together in the same 64-byte line, even if that forces padding. A map[string]bool lookup that reads two flags and nothing else is better served by placing those flags adjacent than by shaving 8 bytes.
  • Layout is ABI. If anything serializes the struct with hardcoded offsets – unsafe casts, C interop, an on-disk format – reordering silently corrupts data. Change the format first, then the struct.
  • Small structs, big effect. The penalty is relative. Padding on a 200-byte config struct nobody scans is noise; padding on a 32-byte record in an array of millions is the whole game.

Enforcing it in CI

unsafe.Sizeof in a test is the cheapest guard we run:

1
2
3
4
5
func TestRowSizes(t *testing.T) {
	if got, want := int(unsafe.Sizeof(RowGood{})), 32; got != want {
		t.Fatalf("RowGood size = %d, want %d", got, want)
	}
}

We also run a tiny custom check – a go/ast walker that flags any struct whose fields are not ordered by decreasing alignment – in CI for the high-churn packages, then manually review every exception. The test stops regressions; the walker stops new ones from being written.

Production lessons

  • Order hot struct fields by decreasing alignment first, then reorder within a 64-byte line to group fields read together.
  • Check unsafe.Sizeof for any struct that will sit in an array or a hot map. The bytes hide until the memory profile finds them.
  • Reordering is a breaking change for anything that casts or serializes with fixed offsets. Find those first.
  • Measure cache misses, not just size. The p99 win comes from cache lines, and cache lines only care about size when the array is large.

comments powered by Disqus