5.1.50. Structure-of-Arrays (SOA)

This tutorial covers daslib/soa — a compile-time macro that transforms regular structs into a Structure-of-Arrays layout. The [soa] annotation generates parallel arrays for every field, plus all the container operations you need (push, erase, pop, clear, resize, reserve, swap, from_array, to_array).

Prerequisites: familiarity with structs and arrays.

options gen2
options no_unused_function_arguments = false

require daslib/soa

5.1.50.1. What is SOA?

Normally a struct is stored as Array-of-Structures (AOS):

[ {x,y,z,w}, {x,y,z,w}, {x,y,z,w}, ... ]

Structure-of-Arrays (SOA) rearranges this into parallel arrays:

xs: [x, x, x, ...]
ys: [y, y, y, ...]
...

This is friendlier to CPU caches when iterating over a single field (e.g. updating positions), because the data is contiguous.

The [soa] annotation automates this transformation. For a struct Particle with fields pos, vel, life, color, it generates Particle`SOA where each field is an array<FieldType>.

5.1.50.2. Basic SOA

Annotate a struct with [soa] and the macro generates the SOA layout plus all access functions:

[soa]
struct Particle {
    pos   : float3
    vel   : float3
    life  : float
    color : float4
}

Declare and use the SOA container:

def demo_basic_soa() {
    print("=== basic SOA ===\n")
    var particles : Particle`SOA

    particles |> push <| Particle(
        pos = float3(0.0), vel = float3(1.0, 0.0, 0.0),
        life = 3.0, color = float4(1.0, 0.0, 0.0, 1.0))
    particles |> push <| Particle(
        pos = float3(5.0), vel = float3(0.0, 1.0, 0.0),
        life = 5.0, color = float4(0.0, 1.0, 0.0, 1.0))
    particles |> push <| Particle(
        pos = float3(10.0), vel = float3(0.0, 0.0, 1.0),
        life = 2.0, color = float4(0.0, 0.0, 1.0, 1.0))
    print("  count: {length(particles)}\n")

    // Indexed access — macro rewrites soa[i].field to soa.field[i]
    print("  particles[0].pos  = {particles[0].pos}\n")
    print("  particles[1].life = {particles[1].life}\n")
    print("  particles[2].vel  = {particles[2].vel}\n")
}

5.1.50.3. Iteration

The SoaForLoop macro transforms for (it in soa) into a multi-source loop over the individual column arrays. Only the fields you actually access are iterated:

def demo_iteration() {
    print("\n=== iteration ===\n")
    var particles : Particle`SOA
    for (i in range(4)) {
        particles |> push <| Particle(
            pos = float3(float(i)), life = float(4 - i))
    }

    for (it in particles) {
        print("    pos={it.pos} life={it.life}\n")
    }

    // Mixed iteration with an index counter
    for (idx, it in count(), particles) {
        print("    [{idx}] pos={it.pos}\n")
    }
}

5.1.50.4. Container operations

push, push_clone, emplace, erase, pop, and clear all work the same as on regular arrays:

def demo_container_ops() {
    print("\n=== container operations ===\n")
    var soa : Particle`SOA

    // push — move semantics
    soa |> push <| Particle(pos = float3(1.0), life = 10.0)
    soa |> push <| Particle(pos = float3(2.0), life = 20.0)
    soa |> push <| Particle(pos = float3(3.0), life = 30.0)

    // push_clone — copy from a const value
    let p = Particle(pos = float3(4.0), life = 40.0)
    soa |> push_clone(p)

    // erase — remove by index
    soa |> erase(1)

    // pop — remove last element
    soa |> pop()

    // clear — remove all elements
    soa |> clear()
}

5.1.50.5. Sizing — resize, reserve, capacity

Pre-allocate memory with reserve, query with capacity, and change the element count with resize:

def demo_sizing() {
    print("\n=== sizing ===\n")
    var soa : Particle`SOA

    soa |> reserve(100)
    print("  capacity={capacity(soa)}\n")

    for (i in range(10)) {
        soa |> push <| Particle(
            pos = float3(float(i)), life = float(i))
    }

    // resize — truncate or extend with defaults
    soa |> resize(5)
    soa |> resize(8)
}

5.1.50.6. Swap and sorting

swap exchanges all fields of two elements at once. Combine it with any sorting algorithm:

[soa]
struct SortItem {
    key : int
    tag : string
}

def demo_swap_and_sort() {
    var soa : SortItem`SOA
    soa |> push <| SortItem(key = 30, tag = "gamma")
    soa |> push <| SortItem(key = 10, tag = "alpha")
    soa |> push <| SortItem(key = 20, tag = "beta")

    soa |> swap(0, 2)

    // Bubble sort
    let n = length(soa)
    for (pass_idx in range(n)) {
        for (k in range(n - 1)) {
            if (soa[k].key > soa[k + 1].key) {
                soa |> swap(k, k + 1)
            }
        }
    }
}

5.1.50.7. Bulk conversion — from_array, to_array

Convert between AOS (array<T>) and SOA (T`SOA) layouts:

[soa]
struct Vec2 {
    x : float
    y : float
}

def demo_conversion() {
    var arr : array<Vec2>
    arr |> push <| Vec2(x = 1.0, y = 2.0)
    arr |> push <| Vec2(x = 3.0, y = 4.0)
    arr |> push <| Vec2(x = 5.0, y = 6.0)

    // AOS → SOA
    var soa : Vec2`SOA
    soa |> from_array(arr)

    // SOA → AOS
    var result <- to_array(soa)
}

5.1.50.8. Particle simulation

A complete example: spawn particles, simulate physics, remove dead ones:

def demo_particle_sim() {
    var particles : Particle`SOA
    particles |> reserve(100)

    for (i in range(5)) {
        let fi = float(i)
        particles |> push <| Particle(
            pos   = float3(fi * 2.0, 0.0, 0.0),
            vel   = float3(0.0, 1.0 + fi * 0.5, 0.0),
            life  = 3.0 + fi,
            color = float4(fi / 4.0, 1.0 - fi / 4.0, 0.5, 1.0)
        )
    }

    let dt = 1.0
    for (step in range(3)) {
        for (it in particles) {
            it.pos += it.vel * dt
            it.life -= dt
        }
        // Remove dead — iterate backwards
        var i = length(particles) - 1
        while (i >= 0) {
            if (particles[i].life <= 0.0) {
                particles |> erase(i)
            }
            i --
        }
    }
}

5.1.50.9. Game entity table

SOA works well for game entity tables with mixed field types:

[soa]
struct GameEntity {
    id     : int
    name   : string
    health : float
    alive  : bool
}

def demo_entity_table() {
    var entities : GameEntity`SOA
    entities |> push <| GameEntity(
        id = 1, name = "warrior", health = 100.0, alive = true)
    entities |> push <| GameEntity(
        id = 2, name = "mage",    health = 60.0,  alive = true)
    entities |> push <| GameEntity(
        id = 3, name = "archer",  health = 80.0,  alive = true)

    // Apply damage
    for (it in entities) {
        it.health -= 55.0
        if (it.health <= 0.0) {
            it.alive = false
        }
    }

    // Convert to AOS for serialization
    var snapshot <- to_array(entities)
}

5.1.50.10. Full source

The complete tutorial source is in tutorials/language/50_soa.das.

Run it with:

daslang.exe tutorials/language/50_soa.das

See also

Full source: tutorials/language/50_soa.das

SOA (Structure of Arrays) transformation — SOA module reference.

Previous tutorial: Async / Await