Layout: struct-of-arrays and data-oriented design in Rust
Introduction
Layout turns a plain struct into a struct of arrays with one derive. Instead of
storing whole structs back to back in a Vec<T>, it stores each field in its own
contiguous array, so a pass over one field loads only that field's memory.
This crate is a hard fork of soa-derive
with no_std support and extra features like impl block and compact bool and enums.
Add #[derive(SOA)] to a struct:
The derive generates CheeseVec:
CheeseVec has the same API as Vec<Cheese>, plus helper types that mirror how
you borrow a Cheese:
| Helper | Stands in for |
|---|---|
CheeseSlice |
&[Cheese] |
CheeseSliceMut |
&mut [Cheese] |
CheeseRef |
&Cheese |
CheeseRefMut |
&mut Cheese |
Every derived struct implements the SOA trait. Use <Cheese as SOA>::Type when
you need the generated type generically instead of naming CheeseVec.
Auto-generated methods on refs (#[soa_impl])
A method written on Cheese will not run on CheeseRef unless you write it
twice. #[soa_impl] copies an impl block onto the generated reference types
for you.
use ;
ParticleRef holds references (&T rather than T), so the macro inserts
dereferences where a method reads or writes a field by value:
| Source | Generated |
|---|---|
self.mass * 2.0 |
(*self.mass) * 2.0 |
self.mass *= factor |
*self.mass *= factor |
self.mass = val |
*self.mass = val |
self.name.len() |
self.name.len() (auto-deref) |
-self.x |
-(*self.x) |
self.x as i32 |
(*self.x) as i32 |
Compact columns (Compact<T>)
A bool column costs a byte per row. A small enum costs four or eight.
Compact<T> shrinks narrow columns to the minimum width: bool and one-bit
enums take one bit, larger fieldless enums take 2 or 4.
use ;
// 4 variants -> 2 bits
A fieldless enum opts in with #[derive(CompactRepr)] and an unsigned
#[repr(uN)]. The derive rejects enums whose variants carry data, and it sizes
storage from the largest discriminant, so { A = 1, B = 255 } uses eight bits.
Keep the import names.
#[derive(SOA)]recognizes a compact column by matching the path-segment nameCompact/CompactBool(a proc-macro limitation: derive macros see tokens, not resolved types). A renamed or re-exported import —use layout::Compact as Packed;with a fieldPacked<bool>— is not recognized and silently falls back to a plainVec<Packed<bool>>(full byte per element, no error, no warning). Use the namesCompact/CompactBooldirectly, or a fully-qualified path (::layout::Compact<bool>) — both keep the last segment asCompact.
Read and write through get and set:
let mut entities = new;
entities.push;
if entities.get.unwrap.active.get
// count a value across the whole column
let active = entities.active.count;
let enemies = entities.kind.count;
count encodes the value once and scans the packed words. For one-bit types it
lowers to count_ones / count_zeros, which LLVM turns into POPCNT. Counting
the active flag over 100k entities takes ~1.6 µs versus ~4.9 µs for
Vec<bool>::iter().filter().count(), and the column drops from ~97 KiB to
~12 KiB.
Reach for Compact<T> when many rows carry a narrow flag or tag: entity active
bits, tile or voxel types, collision layers, visibility masks. A packed column
that fits in L1 lets a later pass run faster. The cost shows up in a tight loop
that reads or writes the bit every iteration alongside other fields, because
extracting one bit costs more than loading one byte. If a flag sits on your hot
path, measure it with cargo bench --bench game.
Usage
Add #[derive(SOA)] to each struct you want to convert. Pass extra traits for
the generated types through #[layout(...)]:
To attach an attribute to a single generated type (say
#[cfg_attr(test, derive(PartialEq))] on CheeseVec), use #[soa_attr]:
Serialization (serde)
Enable the serde cargo feature and pass Serialize, Deserialize through
#[layout(...)] to (de)serialize the generated Vec as a struct of arrays.
Compact columns (Compact<T>, CompactVec<T>) round-trip as their decoded
values; the feature works with no_std + alloc.
[]
= { = "0.0.1", = ["serde"] }
= { = "1", = ["derive"] }
// EntityVec serializes to: {"id":[1,2],"active":[true,false]}
The first argument picks the target type:
| Token | Generated type |
|---|---|
Vec |
CheeseVec |
Slice |
CheeseSlice |
SliceMut |
CheeseSliceMut |
Ref |
CheeseRef |
RefMut |
CheeseRefMut |
Ptr |
CheesePtr |
PtrMut |
CheesePtrMut |
API and caveats
The generated code carries its own documentation, so cargo doc renders every
struct and function. In most cases you can swap Vec<Cheese> for CheeseVec.
The exceptions come from how Vec leans on references and Deref.
CheeseVec cannot implement Deref<Target = CheeseSlice>, because Deref must
return a reference and CheeseSlice is not one. The same holds for Index and
IndexMut, which would have to return CheeseRef / CheeseRefMut. You cannot
index into a CheeseVec. A few methods come in two forms, and some calls need
as_ref() or as_mut() to reach the slice type.
Iteration
Iterate a CheeseVec like any collection:
let mut vec = new;
vec.push;
vec.push;
for cheese in vec.iter
iter() runs about as fast as reading the fields by hand: LLVM drops the loads
for fields you don't read in release builds.
The point of struct-of-arrays is loading only the fields you need. Borrow a single column:
for name in &vec.name
Walk several columns together with the soa_zip! macro:
for in soa_zip!
Nested struct of arrays
Nest one struct-of-arrays inside another with #[nested_soa]:
This produces nested vectors rather than Vec<Point>:
The helper types nest too: PointSlice lives inside ParticleSlice.
Benchmarks
The benchmarks compare two layouts:
- AoS (Array of Structures): a plain
Vec<T>storing whole structs. - SoA (Structure of Arrays): the layout from this crate, one array per field.
Reads run up to 3x faster on the SoA side.
test aos_big_do_work_100k ... bench: 161,151 ns/iter (+/- 57,573)
test aos_big_do_work_10k ... bench: 6,979 ns/iter (+/- 158)
test aos_big_push ... bench: 58 ns/iter (+/- 27)
test aos_small_do_work_100k ... bench: 66,672 ns/iter (+/- 599)
test aos_small_push ... bench: 16 ns/iter (+/- 7)
test soa_big_do_work_100k ... bench: 69,611 ns/iter (+/- 2,165)
test soa_big_do_work_10k ... bench: 6,708 ns/iter (+/- 117)
test soa_big_do_work_simple_100k ... bench: 76,656 ns/iter (+/- 1,675)
test soa_big_push ... bench: 42 ns/iter (+/- 4)
test soa_small_do_work_100k ... bench: 66,586 ns/iter (+/- 1,238)
test soa_small_push ... bench: 6 ns/iter (+/- 3)
Each test has an AoS and an SoA variant, on a 24-byte struct and a 240-byte
struct. Run them yourself with cargo bench.
License
Dual-licensed under MIT or Apache-2.0, at your option. Contributions are welcome; open an issue first to discuss the change.
Thanks to Guillaume Fraux (@Luthaf) for soa-derive, of which this crate is a hard fork.
Thanks to @maikklein for the initial idea: https://maikklein.github.io/soa-rust/