Structure of Arrays in Go
AoS vs SoA
Let’s say we’re modeling 3D particles. We define a struct for the particle with its position, velocity, and mass.
type Particle struct {
X, Y, Z float32
VX, VY, VZ float32
Mass float32
}
When we have many particles and update only a subset of fields, a plain []Particle can be less cache-efficient because each iteration still loads the entire struct (including fields we don’t use in that loop).
Instead of the typical []Particle (an Array of Structures, or AoS), we can model a bunch of particles with a ParticleSlice struct that has the same fields as Particle, but where each field is a slice.
type ParticleSlice struct {
X, Y, Z []float32
VX, VY, VZ []float32
Mass []float32
}
With this layout—Structure of Arrays (SoA)—we touch only the fields we need in a tight loop and can ignore the rest.
Go support for SoA
Does Go support SoA out of the box? Not directly. There was a proposal, but it was closed as not planned. In that issue, ajwerner suggested that it could be achieved with a (hypothetical) code generator.
So, I made one.
The code generator and library
github.com/ichiban/soa/cmd/soagen is a code generator you can install as a Go tool (Go 1.24+):
go get -tool github.com/ichiban/soa/cmd/soagen
Then go tool soagen [the file that defines Particle] generates a Go file with ParticleSlice.
Because ParticleSlice is not a single slice value, you can’t manipulate it with slices directly. Instead, you can use github.com/ichiban/soa for helper APIs that mirror common slice operations.
Benchmark
Here’s a simple benchmark that shows a modest difference on my machine:
func BenchmarkGravity(b *testing.B) {
const dt = float32(0.01)
const gravity = float32(-9.8)
const numParticles = 1_000_000
particles := make([]Particle, numParticles)
for i := range particles {
particles[i] = Particle{
X: rand.Float32(),
Y: rand.Float32(),
Z: rand.Float32(),
VX: rand.Float32(),
VY: rand.Float32(),
VZ: rand.Float32(),
Mass: rand.Float32(),
}
}
particlesSoA := Make[ParticleSlice](numParticles, numParticles)
for i := 0; i < numParticles; i++ {
particlesSoA.Set(i, Particle{
X: rand.Float32(),
Y: rand.Float32(),
Z: rand.Float32(),
VX: rand.Float32(),
VY: rand.Float32(),
VZ: rand.Float32(),
Mass: rand.Float32(),
})
}
b.Run("array of structures", func(b *testing.B) {
for n := 0; n < b.N; n++ {
for i := 0; i < numParticles; i++ {
vy := particles[i].VY + gravity*dt
particles[i].VY = vy
particles[i].Y += vy * dt
}
}
})
b.Run("structure of arrays", func(b *testing.B) {
for n := 0; n < b.N; n++ {
for i := 0; i < numParticles; i++ {
vy := particlesSoA.VY[i] + gravity*dt
particlesSoA.VY[i] = vy
particlesSoA.Y[i] += vy * dt
}
}
})
}
$ go test -bench github.com/ichiban/soa -bench BenchmarkGravity
goos: darwin
goarch: arm64
pkg: github.com/ichiban/soa
cpu: Apple M1
BenchmarkGravity/array_of_structures-8 1080 1219608 ns/op
BenchmarkGravity/structure_of_arrays-8 1159 1026934 ns/op
PASS
ok github.com/ichiban/soa 3.974s
https://www.reddit.com/r/golang/comments/1jeyhax/soa_structure_of_arrays_in_go/