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.
Example
One struct shows everything the crate offers: the derive, extra derives for the generated types, a nested struct of arrays, bit-packed columns, and methods that also run on borrowed rows.
use ;
// A fieldless enum with an unsigned repr can be bit-packed:
// four variants fit in 2 bits.
// derives for EntityVec, EntityRef, ...
// Copies the methods onto EntityRef (&self) and EntityRefMut (&mut self).
iter() costs about the same as reading the fields by hand, because LLVM drops
the loads for fields you never read in release builds. Borrowing one column is
the struct-of-arrays payoff: only that array is touched. The
soa_zip! macro walks
several columns together and can zip external iterators as well.
Generated types
#[derive(SOA)] on Entity generates EntityVec, which has the same API as
Vec<Entity> but stores one array per field:
The helper types mirror how you borrow an Entity:
| Helper | Stands in for |
|---|---|
EntitySlice |
&[Entity] |
EntitySliceMut |
&mut [Entity] |
EntityRef |
&Entity |
EntityRefMut |
&mut Entity |
Every derived struct implements the SOA trait, so <Entity as SOA>::Type
names EntityVec in generic code.
#[layout(...)] passes derives through to all generated types. To attach an
attribute to a single one, use #[soa_attr(Target, ...)], for example
#[soa_attr(Vec, cfg_attr(test, derive(PartialEq)))]. Target is one of
Vec, Slice, SliceMut, Ref, RefMut, Ptr or PtrMut.
Methods on rows (#[soa_impl])
#[soa_impl] copies an impl block onto the generated reference types:
&self methods land on EntityRef, &mut self methods on EntityRefMut,
and associated functions or Self-returning methods stay on Entity only.
The reference types hold &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 and a small enum four or eight.
Compact<T> shrinks such columns to the minimum width: bool and one-bit
enums take one bit per row, larger fieldless enums two or four. A fieldless
enum opts in with #[derive(CompactRepr)] and an unsigned #[repr(uN)]. The
derive rejects variants that carry data, sizes storage from the largest
discriminant, and refuses enums that need more than four bits, since at eight
bits a packed column is no smaller than a plain one.
Read and write a packed field through get and set. A CompactVec also
offers count, which 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 ~0.3 us versus
~4.9 us 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.
Keep the import names.
#[derive(SOA)]recognizes a compact column by the path-segment nameCompact/CompactBool, because derive macros see tokens, not resolved types. A renamed import such asuse layout::Compact as Packed;with a fieldPacked<bool>is not recognized and silently falls back to a plain column ofPacked<bool>, a full byte per element with no error or warning. Use the namesCompact/CompactBooldirectly, or a fully-qualified path such as::layout::Compact<bool>.
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 round-trip as their decoded values, and the feature works with
no_std + alloc.
[]
= { = "0.2", = ["serde"] }
= { = "1", = ["derive"] }
With #[layout(Debug, Clone, PartialEq, Serialize, Deserialize)] on the
structs above (and Serialize, Deserialize derived on Kind), EntityVec
serializes column by column:
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<Entity> for EntityVec.
The exceptions come from how Vec leans on references and Deref.
EntityVec cannot implement Deref<Target = EntitySlice>, because Deref must
return a reference and EntitySlice is not one. The same holds for Index and
IndexMut, which would have to return EntityRef / EntityRefMut, so
entities[0] does not compile; use index(0) / index_mut(0) or get(0) /
get_mut(0) instead. A few methods come in two forms, and some calls need
as_slice() or as_mut_slice() to reach the slice type.
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/