Machines and programs
Array of structs vs struct of arrays
How the layout of records in memory decides how many useful bytes each cache line carries, why struct of arrays is faster for scans, and when the opposite layout wins.
Records can be stored two ways. An array of structs (AoS) keeps each record's fields together. A struct of arrays (SoA) keeps each field in its own array. The arithmetic is the same either way. The difference is how many of the bytes you load are bytes you actually use.
Concept
Before you start: a struct is a record of named fields. Cache lines are 64-byte blocks (see cache lines and locality). Every field you do not read still rides along inside any line you load.
The big idea
Think of a warehouse. In AoS, each box holds a whole product, its label and its packing slip. If you only need the labels, you still haul every box. In SoA, all the labels sit on one shelf. You walk the shelf and read only labels.
The analogy breaks down a little. Caches load lines, not boxes, so the win comes from packing the fields you read next to each other.
Walking through it: summing one field
Suppose a simulation keeps a million particles. Each frame it needs the total mass. Here is the AoS version. It is a sketch, with the struct trimmed to the fields that matter:
// Array of structs: each particle is 64 bytes
type Particle struct {
X, Y, Z float64
VX, VY, VZ float64
Mass float64
ID int64
}
particles := make([]Particle, 1_000_000)
total := 0.0
for i := range particles {
total += particles[i].Mass // uses 8 of every 64 bytes loaded
}Each particle is exactly one 64-byte cache line, and the loop uses 8 bytes of it. You pull 1,000,000 × 64 B = 64 MB through the memory system to use 8 MB of data. That is 12.5% utilization.
The SoA version stores each field in its own slice:
// Struct of arrays: each field is its own contiguous slice
type Particles struct {
X, Y, Z []float64
VX, VY, VZ []float64
Mass []float64
ID []int64
}
total := 0.0
for _, m := range ps.Mass {
total += m // every byte loaded is used
}The loop now reads exactly 8 MB, all of it useful, in sequence. The compiler can also use SIMD to add several values per instruction. For loops that touch one or two fields of many records, SoA is often several times faster.
When AoS wins
AoS is better when you usually need all the fields of one record together. Rendering one particle, or loading one user to show a profile page, reads a whole record once. There, keeping the fields together means one cache line serves the whole job.
This is the reason analytical databases such as ClickHouse, DuckDB and BigQuery store data by column, because analytics scans a few columns over many rows. Transactional databases such as Postgres store data by row, because a transaction usually reads or writes whole rows.
Tip
Choose the layout from the access pattern, not from habit. Ask which fields a hot loop reads together. If it reads one or two fields across many records, lean toward SoA. If it reads whole records, keep AoS.
Common mistakes
- Storing a large struct in a slice and scanning one field. Every scan drags the other fields through the cache.
- Switching to SoA everywhere. It makes single-record access, such as
GetUser(id), touch many arrays. - Trusting the layout without measuring. Compilers and allocators can change what really sits next to what.
Check yourself
- Why does AoS waste 87.5% of its bandwidth in the particle loop? Each 64-byte line holds one particle, and the loop uses only the 8-byte mass field.
- Which layout suits a
GetUser(id)call that reads every field? AoS, because one record's fields sit together in one place.
Key takeaways
- A cache line carries whatever sits next to the field you read. Layout decides how much of it is useful.
- SoA wins for scans over one or two fields across many records. AoS wins when you read whole records.
- Analytical engines are column-oriented for this reason. Transactional engines are row-oriented.
Further learning
Next, see how threads sharing one cache line slow each other down, in false sharing and NUMA.
For how this fits into the bigger picture of data storage, see data structures in practice.
Related
Cache lines and locality
Why caches move 64-byte blocks, how sequential access and hardware prefetching make arrays fast, and why walking a 2D array in the wrong order is six times slower.
The memory hierarchy
From registers to SSDs to the network, the latency numbers that dominate performance, and why the tier your data sits in matters more than the code you write.
Data structures in practice
Choosing data structures by memory layout, access pattern and the operations that matter, with the common ones for arrays, hash maps, heaps, trees, caches and graphs.