Memory Layout and Performance: Array of Structs vs. Struct of Arrays

Memory performance is often dictated more by data layout than by asymptotic algorithmic complexity. While a loop may be $O(N)$, the actual execution time varies dramatically based on how data is arranged in memory and how it interacts with the CPU cache hierarchy.

The Impact of Cache Lines on Data Retrieval

CPU performance relies on the principle of spatial and temporal locality. When a CPU reads a single byte from memory, it does not fetch just that byte; it fills an entire cache line (typically 64 bytes) into the cache. This ensures that if the program needs the next few bytes of data, they are already available in the high-speed L1 cache.

Latency increases significantly as data moves further from the CPU core:

Level Typical Size (per core) Latency (Cycles) Latency (Time)
Registers N/A < 1 < 1 ns
L1d Cache ~35 KiB 4-5 1-2 ns
L2 Cache ~2 MiB 12-15 4-5 ns
L3 Cache 12 MiB (shared) 30-40 10-15 ns
DRAM N/A 100-200 60-100 ns

Array of Structs (AoS) vs. Struct of Arrays (SoA)

Data layout determines how many useful pieces of information are loaded into a cache line during a single fetch.

Array of Structs (AoS)

In an AoS layout, all fields for a single object are stored contiguously. For a Monster struct of 64 bytes, one cache line contains exactly one monster. If a program only needs to check a single boolean field (e.g., is_alive) for every monster in a list, the CPU must fetch 64 bytes to access 1 byte of useful data. This results in significant waste of cache bandwidth.

Struct of Arrays (SoA)

In an SoA layout, each field is stored in its own contiguous array. When the CPU fetches a cache line for the is_alive field, it retrieves 64 consecutive boolean values in a single fetch. This maximizes cache efficiency and can lead to performance gains of up to 30x for larger structs (e.g., 1KiB), as the CPU pre-fetcher can efficiently predict and load the next sequence of data.

Random Access and the Working Set

While sequential access benefits from the CPU pre-fetcher, random access patterns (such as those found in hash maps, trees, or pointer-chasing) defeat pre-fetching. In these scenarios, performance is determined by the total size of the working set relative to the cache sizes.

If the total size of the collection exceeds the capacity of a specific cache level, the CPU must fetch data from a slower, larger cache or DRAM, causing a "staircase" effect in latency:

  • L1d Fit: A working set of 32 KiB (e.g., 512 monsters at 64B each) results in ~3 ns latency.
  • L2 Spill: Doubling the struct size to 128B pushes the working set to 64 KiB, spilling into L2 and increasing latency to ~11 ns.
  • DRAM Spill: As the working set grows to 8 MiB or larger, latency jumps to ~160+ ns.

Technical Perspectives and Trade-offs

Industry practitioners highlight several nuances regarding memory optimization:

  • Language Overhead: Managed languages like Java introduce object headers (typically 12 bytes, though reducing to 8 bytes in newer JVM releases), which add overhead to every object and complicate tight memory layouts. Project Valhalla is noted as a potential solution to eliminate headers in certain cases.
  • Development Cost: High-level abstractions and Object-Oriented (OO) patterns prioritize developer productivity over memory efficiency. Designing bit-fields or SoA layouts takes significantly more time than adding a field to a class.
  • Contextual Optimization: SoA is not a universal win; its effectiveness depends on the access pattern. If a program frequently accesses all fields of a single object at once, AoS may be more efficient.

"SoA can be a big win. But so can plain AoS, just depends on the access pattern. Profiling important workloads matters. Without that everything else is guesswork."

  • Advanced Optimizations: For extreme performance, developers may use bit masks for booleans and SIMD (Single Instruction, Multiple Data) instructions to filter data in parallel.

Sources