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.
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 layout::{soa_impl, soa_zip, Compact, CompactRepr, SOA};
// A fieldless enum with an unsigned repr can be bit-packed:
// four variants fit in 2 bits.
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, CompactRepr)]
enum Kind {
Player,
Enemy,
Projectile,
Pickup,
}
#[derive(Debug, Clone, PartialEq, SOA)]
#[layout(Debug, Clone, PartialEq)]
struct Position {
x: f32,
y: f32,
}
#[derive(Debug, Clone, PartialEq, SOA)]
#[layout(Debug, Clone, PartialEq)] // derives for EntityVec, EntityRef, ...
struct Entity {
name: String,
mass: f32,
#[nested_soa]
position: Position, // stored as a PositionVec, not a Vec<Position>
active: Compact<bool>, // 1 bit per entity instead of 1 byte
kind: Compact<Kind>, // 2 bits per entity
}
// Copies the methods onto EntityRef (&self) and EntityRefMut (&mut self).
#[soa_impl]
impl Entity {
fn kinetic_energy(&self, velocity: f32) -> f32 {
0.5 * self.mass * velocity * velocity
}
fn scale_mass(&mut self, factor: f32) {
self.mass *= factor;
}
}
fn main() {
let mut entities = EntityVec::new();
entities.push(Entity {
name: "hero".into(),
mass: 80.0,
position: Position { x: 0.0, y: 0.0 },
active: Compact(true),
kind: Compact(Kind::Player),
});
entities.push(Entity {
name: "slime".into(),
mass: 12.0,
position: Position { x: 4.0, y: 1.0 },
active: Compact(false),
kind: Compact(Kind::Enemy),
});
// Whole rows: each item is an EntityRef, with the #[soa_impl] methods.
for entity in entities.iter() {
println!("{}: {} J", entity.name, entity.kinetic_energy(2.0));
}
// One column: only the name array is loaded.
for name in &entities.name {
println!("{name}");
}
// Nested fields are columns as well.
let total_x: f32 = entities.position.x.iter().sum();
// Several columns at once; `mut` yields mutable references.
for (mass, active) in soa_zip!(&mut entities, [mut mass, active]) {
if active.get() {
*mass += 1.0;
}
}
// Row access by index, like vec[i] and &mut vec[i].
entities.index_mut(0).scale_mass(2.0);
if entities.index(1).kind.get() == Kind::Enemy {
entities.index_mut(1).active.set(true);
}
// Packed columns scan whole words at a time.
let active = entities.active.count(true);
let enemies = entities.kind.count(Kind::Enemy);
println!("{active} active, {enemies} enemies, total x {total_x}");
}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.
#[derive(SOA)] on Entity generates EntityVec, which has the same API as
Vec<Entity> but stores one array per field:
struct EntityVec {
name: Column<String>, // derefs to [String]
mass: Column<f32>,
position: PositionVec, // #[nested_soa]: a struct of arrays itself
active: CompactVec<bool>, // bit-packed
kind: CompactVec<Kind>,
}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.
#[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 |
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 ~1.6 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>.
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.
[dependencies]
layout = { version = "0.2", features = ["serde"] }
serde = { version = "1", features = ["derive"] }With #[layout(Debug, Clone, PartialEq, Serialize, Deserialize)] on the
structs above (and Serialize, Deserialize derived on Kind), EntityVec
serializes column by column:
{"name":["hero","slime"],"mass":[80.0,12.0],"position":{"x":[0.0,4.0],"y":[0.0,1.0]},"active":[true,false],"kind":["Player","Enemy"]}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.
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.
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/