Introduction

Many kernels are "memory-bound", which means that the kernel's runtime is determined by how long it takes to move data to and from the SM. Changing the way data is laid out in memory can significantly affect a kernel's effective memory throughput, which results in a noticeable impact on the runtime.

So, a natural question people ask when writing CUDA code is: how should I lay out data in memory to work best on the GPU?

If you search the internet for that question, you find a lot of papers and forum discussions (and even AI overviews) that make the same claim: "GPUs work best when you store data in a Struct-of-Array (SoA) layout".

ai_soa_recommendation

 

In this document, we'll investigate whether that recommendation is actually true. We will consider 3 of the most common data access patterns found in kernels, and compare the effectiveness of the "Array-of-Structs" and "Struct-of-Arrays" layouts.

Let's quickly review what is meant by these two terms before diving in to the performance experiment.

Array of Structs

A very common pattern in C++ is to define custom POD types and put them in standard containers. For example:

If we look at the memory locations for the p.data() buffer we see something like

{p[0].x,p[0].y,p[0].z,p[1].x,p[1].y,p[1].z,}

That is, all of the data from p[0], followed by all of the data from p[1] and so on. We use the term "Array of Structs" to describe this layout, since it arises naturally from putting POD structs in arrays.

Struct of Arrays

The other pattern we'll consider in this document is one where data is laid out in the following way:

{p[0].x,p[1].x,p[N-1].x,p[0].y,p[1].y,p[N-1].y,p[0].z,p[1].z,p[N-1].z}

This layout groups the data by component, rather than struct. This layout tends to be less common in regular C++, as the language currently lacks a natural way to generate it from a general struct definition (although it is possible with circle, and should be possible with C++26 reflection).

Experiment

We'll be considering two basic kernels for this study.

The first kernel takes a list of vectors and translates each of them by some constant:

This kernel represents memory-bound calculations where data access is strided but predictable.


The second kernel takes a triangle mesh and computes the surface normal for each triangle:

The triangles array holds the indices of which nodes correspond to each triangle. For example, {1, 13, 2} might be one of the vec3i entries in that array for the mesh below:

mesh_data_access

This kernel has an unstructured data access pattern for the vertex coordinates (which account for most of the global memory accesses).


The kernels above are written in terms of the AoS layouts. The equivalent SoA versions are given below.

Note: the SoA implementations are more complicated, although some of that complexity can be mitigated with appropriate abstractions.

 

Results

First, let's look at the translate kernel. The timings below are measurements from an Intel 13900K and an A100 for a mesh with 2621442 vertices and 5242880 triangles:

Transposing the data layout from AoS to SoA did not lead to any improvement on the GPU kernel! Both CUDA kernels achieve around 80% of theoretical memory throughput.

Interestingly: AoS, which is often assumed to be the "CPU" layout, performs worse on the CPU.


Next, let's look at the surface normal calculation.

Once again, we see no benefit when using the SoA layout on the GPU and a small performance regression when using SoA instead of AoS on the CPU.

 

Explanation

The experimental results seem to contradict the overwhelming guidance that SoA is better on GPU. What's going on?

It's important to remember that:

  1. memory addresses are grouped into contiguous 128-byte chunks called cache lines.

  2. reading data the first time from global memory is expensive, but reading from L1$ is relatively cheap.


Translation Kernel

With those in mind, let's think about the first kernel. The vector increment

compiles to the following SASS code

That is: the kernel loads the x, y, and z components of the vector separately, increments them, and then writes the new values back out to global memory, component-by-component. If we visualize the data access pattern for the first load (each square is 4 bytes, ⬜ = data is not in cache, 🟨 = data is in cache, 🟩 = data being accessed)

translate_AoS_0

we see that the access pattern is not coalesced (i.e. adjacent threads in the warp do not access adjacent locations in memory) and not in cache, resulting in reading 6 cache lines from global memory. However, the memory transactions for the next two loads (the y- and z-components of each vec3) are already in cache from the first load.

translate_AoS

This means that for this stride, the AoS layout does not result in redundant reads from global memory. Because of this, its performance ends up being comparable to the SoA data access pattern:

translate_SoA

Important
What would the performance of this translation kernel look like if the vector had 4 components? 16 components? 64 components?

 

Surface Normal Kernel

The defining feature of kernel 2 is its irregular data access pattern that comes from the triangle to node connectivity array. Unstructured data access patterns are often impossible to coalesce perfectly, but that doesn't mean we can't achieve high performance.

When the mesh nodes and elements are numbered in a way such that

nearby-in-spacenearby-in-memory

then a given cache line is likely to still be accessed by multiple threads within a warp (or in a block). This means many of the vertex data accesses hit in L1$ or L2$.

translate_SoA
Even irregular data access patterns can perform well when multiple threads touch the same cachelines

 

There are many equivalent ways to encode the connectivity of a mesh or graph. Some mathematically equivalent representations of the same graph perform much better than others. For example, here is one representation of the mesh shown before, where the nodes are numbered randomly.

mesh_random_numbering
A graph with random node numbering and a plot of its Kirchhoff matrix.

As we can see, the random numbering produces triangles that need to access very different parts of the vertex position array. These sorts of accesses are unlikely to be on the same cache lines. In contrast, the mesh below is ordered in a way that respects spatial locality.

mesh_random_numbering
A graph with spatially-ordered nodes and a plot of its Kirchhoff matrix.

The triangles in this mesh have node ids that are "close together", which helps improve cache hit rate.

Important
How would this surface normal kernel perform for the mesh with randomly-numbered nodes? How would the size of the mesh affect the performance of SoA vs AoS?

 

Followup Questions

Let's explore the follow-up questions posed in the previous section:

  1. What would the performance of the translation kernel look like if the vector had 4 components? 8 components? 16 components?


    Let's update our kernel to accept a template parameter:

    and plot the performance of this kernel (relative to the theoretical maximum) as a function of dim:

    translate_kernel_perf

    SoA layout has the most consistent performance across structs of different sizes. Unaligned AoS is competitive for small vectors, but slows down as the vector size increases. Adding the following alignment statement to the vec class improves the AoS performance considerably for even sizes:

    If one needs to work with a vector with an odd size like 13, AoS with alignment and minimal padding is a simple way to get competitive performance with only a small amount of wasted space.


 

  1. How would this surface normal kernel perform for the mesh with randomly-numbered nodes? How would the size of the mesh affect the performance of SoA vs AoS?


    The plot below shows the runtimes associated with AoS and SoA implementations on meshes of different sizes and node numbering conventions:

    surface_normal_perf

    There are several trends worth noting here:

    1. Randomizing the mesh's numbering significantly increases the runtime (by about 10x)

    2. There is no appreciable benefit to SoA over AoS for the original meshes

    3. On the randomized meshes, SoA was slower than AoS, and the discrepancy is more pronounced once the problem size exceeds the L2$ capacity.

      • For random data access patterns, AoS wins because the different components in the struct are contiguous in memory (so at least some of the accesses hit in L1$). On the other hand, grouping data by components means that SoA layouts practically never hit in L1$ for random access patterns.

 

Summary

There is a lot of misinformation surrounding struct-of-array vs. array-of-struct layouts. Some of the commonly held beliefs like "CPUs prefer AoS" or "GPUs prefer SoA" are not generally true. Although the SoA layout is frequently useful, it is not a requirement to achieve high performance on a GPU. In fact, refactoring a code to adopt an SoA layout can take a considerable amount of effort and frequently does not help performance.

A more useful rule of thumb is: the appropriate data layout depends on the data access pattern.

In summation, many of the dogmatic statements about SoA and AoS that one finds on the internet and in the literature are outdated. In 2010, GPU programming was very different than it is today. Modern GPU hardware has large L2 caches, and tends to be much more forgiving of strided and random data access patterns than the old GPUs.

So, as of 2026 if you find yourself considering how to choose a data layout for your application, please make the decision based on how the data is used and how it interacts with the memory hierarchy.

The sources used in this document are available here.