nupp.mem.soa

Structure-of-arrays storage for reified structs.

Every top-level field of a reified struct gets its own contiguous column, so a loop that walks one field of many rows brings nothing else along.

local soa = nupp.mem.soa

local struct Position
    x: float
    velocity: float
end

local positions = soa.allocate(ffi.typeof<Position>(), 128)
with rows = positions:write() do
    for index = 1, #rows do
        rows[index].x += rows[index].velocity
    end
end

The compiler supplies the private layout argument to allocate and layoutof, and lowers indexed row fields directly to the typed columns kept by these views. This module owns allocation, checked whole-row operations, slicing, and field-span construction; it never exposes the slab or its column pointers in the public types.

See Structure-of-arrays storage for when this layout is the one to reach for, and NEP 10: Structure-of-arrays storage for why the container rather than the declaration chooses it.

Module contents

Types

TypeKindDescription
ArrayrecordOne owned native slab whose top-level struct fields occupy separate segments.
ArrayTokeninterface
FieldLayoutrecordOne stored top-level field in a SoA descriptor.
InstanceLayoutrecordThe count-dependent layout of one complete SoA slab.
LayoutrecordImmutable reflection for a reified struct's SoA representation.
SegmentLayoutrecordOne field segment for a particular element count.
SpaninterfaceA shared checked view over SoA rows.
WritabletypeAn affine exclusive row view, released at its lexical boundary.
WriteSpaninterfaceAn affine exclusive checked view over SoA rows.
WriteTokeninterface

Functions

FunctionKindDescription
allocatefunctionAllocates one SoA slab for a reified struct type.
destroyArrayfunctionFrees a slab, which is what every SoA array's contract names.
destroyWriteSpanfunctionEnds a row writer, which is what every writable row view's contract names.
layoutoffunctionAnswers immutable SoA reflection for a reified struct type without allocating.

Types#

Arrayrecord#

record soa.Array<T> is soa.ArrayToken
    readonly count: integer
    readonly fingerprint: string
    read: function(borrows self: Array<T>): soa.Span<T> borrows (self)
    write: function(
        exclusive self: Array<T>
    ): affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)
    close: nosuspend function(takes self: Array<T>): nil

    drop: nosuspend function(takes self: Array<T>): nil
end

One owned native slab whose top-level struct fields occupy separate segments.

Type parameters

NameDescription
T

Methods

read#
read: function(borrows self: Array<T>): soa.Span<T> borrows (self)

Borrows the rows as a shared checked view.

The borrow lasts as long as the view does, so a writer cannot be taken out while one is live.

Arguments
NameTypeDescription
borrows selfArray<T>
Returns
TypeDescription
soa.Span<T> borrows (self)
write#
write: function(
    exclusive self: Array<T>
): affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)

Borrows the rows as an exclusive checked view.

Exclusive for as long as the writer is live, and affine, so a scope boundary or an explicit drop is what ends it.

Arguments
NameTypeDescription
exclusive selfArray<T>
Returns
TypeDescription
affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)
close#
close: nosuspend function(takes self: Array<T>): nil

Frees the slab. drop and scope exit both reach this.

Arguments
NameTypeDescription
takes selfArray<T>
Returns
TypeDescription
nil
drop#
drop: nosuspend function(takes self: Array<T>): nil
Arguments
NameTypeDescription
takes selfArray<T>
Returns
TypeDescription
nil

Fields

count#
count: integer

How many rows the slab holds.

fingerprint#
fingerprint: string

The layout fingerprint the slab was allocated under.

ArrayTokeninterface#

sealed interface soa.ArrayToken
    close: nosuspend function(takes self: ArrayToken): nil
end

Methods

close#
close: nosuspend function(takes self: ArrayToken): nil
Arguments
NameTypeDescription
takes selfArrayToken
Returns
TypeDescription
nil

FieldLayoutrecord#

record soa.FieldLayout
    readonly name: string
    readonly identity: string
    readonly ctype: string
    readonly ordinal: integer
    readonly elementSize: integer
    readonly alignment: integer
end

One stored top-level field in a SoA descriptor.

Fields

name#
name: string

The field's name, as the struct declared it.

identity#
identity: string

The struct name and the field name joined, which is what a fingerprint entry is filed under.

ctype#
ctype: string

The C type of one element of this column.

ordinal#
ordinal: integer

Where this field sits in the declaration, counted from one.

elementSize#
elementSize: integer

Bytes per element.

alignment#
alignment: integer

The alignment the column starts on.

InstanceLayoutrecord#

record soa.InstanceLayout
    readonly count: integer
    readonly byteSize: integer
    readonly segments: {soa.SegmentLayout}
end

The count-dependent layout of one complete SoA slab.

Fields

count#
count: integer

The row count this layout was computed for.

byteSize#
byteSize: integer

The slab's size in bytes, before alignment padding.

segments#
segments: {soa.SegmentLayout}

One segment per field, in declaration order.

Layoutrecord#

record soa.Layout
    readonly name: string
    readonly fingerprint: string
    readonly alignment: integer
    readonly fields: {soa.FieldLayout}
    function forCount(self, count: integer): soa.InstanceLayout end
end

Immutable reflection for a reified struct's SoA representation.

Methods

forCount#
forCount: function forCount(self, count: integer): soa.InstanceLayout

Computes checked segment offsets for count rows without allocating storage.

Arguments
NameTypeDescription
selfany
countinteger
Returns
TypeDescription
soa.InstanceLayout
Raises
  • when count is negative or the slab size overflows a Lua integer

Fields

name#
name: string

The struct's name.

fingerprint#
fingerprint: string

Every field's identity, type, size and alignment in one string, so two layouts can be compared without walking them.

alignment#
alignment: integer

The alignment the whole slab starts on, which is the widest field's.

fields#

One entry per stored top-level field, in declaration order.

SegmentLayoutrecord#

record soa.SegmentLayout
    readonly field: soa.FieldLayout
    readonly offset: integer
    readonly byteCount: integer
end

One field segment for a particular element count.

Fields

field#

Which field this segment holds.

offset#
offset: integer

Where the segment starts, in bytes from the slab's aligned base.

byteCount#
byteCount: integer

How many bytes it occupies.

Spaninterface#

sealed interface soa.Span<T>
    metamethod __len: function(self: Span<T>): integer
    metamethod __index: function(borrows self: Span<T>, index: integer): T
    slice: function(borrows self: Span<T>, first: integer, last: integer?): soa.Span<T> borrows (self)
    field: function(borrows self: Span<T>, name: string): any borrows (self)
end

A shared checked view over SoA rows.

Type parameters

NameDescription
T

Methods

__len#
__len: function(self: Span<T>): integer
Arguments
NameTypeDescription
selfSpan<T>
Returns
TypeDescription
integer
__index#
__index: function(borrows self: Span<T>, index: integer): T
Arguments
NameTypeDescription
borrows selfSpan<T>
indexinteger
Returns
TypeDescription
T
slice#
slice: function(borrows self: Span<T>, first: integer, last: integer?): soa.Span<T> borrows (self)

Answers a subrange of rows, inclusive at both ends.

The result borrows this view, and omitting last runs through to the end.

Arguments
NameTypeDescription
borrows selfSpan<T>
firstinteger
lastinteger?
Returns
TypeDescription
soa.Span<T> borrows (self)
field#
field: function(borrows self: Span<T>, name: string): any borrows (self)

Answers one field's column as a nupp.mem.span view over this range.

The name must be a string literal naming a stored field, which is what lets the checker give the result that field's element type rather than any. A dynamic string or a missing field reports NUPP2403.

const rows = positions:read()
const velocity: span.Span<float> = rows:field("velocity")
for index = 1, #velocity do
    print(velocity[index])
end
Arguments
NameTypeDescription
borrows selfSpan<T>
namestring
Returns
TypeDescription
any borrows (self)

Writabletype#

type soa.Writable<T> = affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan)

An affine exclusive row view, released at its lexical boundary.

Type parameters

NameDescription
T

WriteSpaninterface#

sealed interface soa.WriteSpan<T>
    metamethod __len: function(self: WriteSpan<T>): integer
    metamethod __index: function(borrows self: WriteSpan<T>, index: integer): T
    metamethod __newindex: function(exclusive self: WriteSpan<T>, index: integer, value: T): nil
    field: function(exclusive self: WriteSpan<T>, name: string): any borrows (self)
    shared: function(borrows self: WriteSpan<T>): soa.Span<T> borrows (self)
    copyFrom: function(
        exclusive self: WriteSpan<T>,
        targetFirst: integer,
        borrows source: soa.Span<T>,
        sourceFirst: integer,
        count: integer
    ): nil
    slice: function(
        exclusive self: WriteSpan<T>,
        first: integer,
        last: integer?
    ): affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)
end

An affine exclusive checked view over SoA rows.

Type parameters

NameDescription
T

Methods

__len#
__len: function(self: WriteSpan<T>): integer
Arguments
NameTypeDescription
selfWriteSpan<T>
Returns
TypeDescription
integer
__index#
__index: function(borrows self: WriteSpan<T>, index: integer): T
Arguments
NameTypeDescription
borrows selfWriteSpan<T>
indexinteger
Returns
TypeDescription
T
__newindex#
__newindex: function(exclusive self: WriteSpan<T>, index: integer, value: T): nil
Arguments
NameTypeDescription
exclusive selfWriteSpan<T>
indexinteger
valueT
Returns
TypeDescription
nil
field#
field: function(exclusive self: WriteSpan<T>, name: string): any borrows (self)

Answers one field's column as a writable nupp.mem.span view.

Two different fields may be projected at once, because the columns do not overlap. The name must be a string literal naming a stored field; a dynamic string or a missing field reports NUPP2403.

do
    local rows = particles:write()
    local xs: span.Writable<float> = rows:field("x")
    local ys: span.Writable<float> = rows:field("y")
    xs[1] = 3.5
    ys[1] = 4.5
    drop xs
    drop ys
    drop rows
end
Arguments
NameTypeDescription
exclusive selfWriteSpan<T>
namestring
Returns
TypeDescription
any borrows (self)
shared#
shared: function(borrows self: WriteSpan<T>): soa.Span<T> borrows (self)

Downgrades this writer to a shared row view for the lifetime of the result.

Arguments
NameTypeDescription
borrows selfWriteSpan<T>
Returns
TypeDescription
soa.Span<T> borrows (self)
copyFrom#
copyFrom: function(
    exclusive self: WriteSpan<T>,
    targetFirst: integer,
    borrows source: soa.Span<T>,
    sourceFirst: integer,
    count: integer
): nil

Copies a row range from another view of the same struct.

Both ranges are checked before the first byte moves, and the copy is one contiguous run per column rather than a row at a time, so no row struct is ever materialized.

with rows = target:write() do
    rows:copyFrom(3, source:read(), 1, 2)
end
Arguments
NameTypeDescription
exclusive selfWriteSpan<T>
targetFirstinteger
borrows sourcesoa.Span<T>
sourceFirstinteger
countinteger
Returns
TypeDescription
nil
slice#
slice: function(
    exclusive self: WriteSpan<T>,
    first: integer,
    last: integer?
): affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)

Answers an affine writable subrange of rows, inclusive at both ends.

The child borrows this view, so the parent is unusable until the child is dropped. Row indexes start at one in the child, as they do in the parent.

Arguments
NameTypeDescription
exclusive selfWriteSpan<T>
firstinteger
lastinteger?
Returns
TypeDescription
affine(soa.WriteToken & soa.WriteSpan<T>, soa.destroyWriteSpan) borrows (self)

WriteTokeninterface#

sealed interface soa.WriteToken
    drop: nosuspend function(takes self: WriteToken): nil
end

Methods

drop#
drop: nosuspend function(takes self: WriteToken): nil
Arguments
NameTypeDescription
takes selfWriteToken
Returns
TypeDescription
nil

Functions#

soa.allocatefunction#

function soa.allocate<T>(element: ctype<T>, count: integer): affine(soa.Array<T>, soa.destroyArray)

Allocates one SoA slab for a reified struct type.

The compiler injects the descriptor, and aliases of this exact function retain the intrinsic identity. The result owns the slab and frees it at its lexical boundary; the bytes are not zeroed.

local positions = soa.allocate(ffi.typeof<Position>(), 128)
with rows = positions:write() do
    rows[1].x = 3.5
end

Type parameters

NameDescription
T

Arguments

NameTypeDescription
elementctype<T>

the ctype of one row

countinteger

how many rows to allocate

Returns

TypeDescription
affine(soa.Array<T>, soa.destroyArray)

the slab, owned by the caller

Raises

  • when count is negative, layout arithmetic overflows, or allocation fails

soa.destroyArrayfunction#

function soa.destroyArray<T is soa.ArrayToken>(takes self: T): nil

Frees a slab, which is what every SoA array's contract names.

Nothing calls this by hand. It is the terminal consumer affine carries, so a scope boundary or an explicit drop reaches it.

Type parameters

NameDescription
T

Arguments

NameTypeDescription
takes selfT

the array, spent by this call

Returns

TypeDescription
nil

soa.destroyWriteSpanfunction#

function soa.destroyWriteSpan<T is soa.WriteToken>(takes self: T): nil

Ends a row writer, which is what every writable row view's contract names.

Nothing calls this by hand. It is the terminal consumer affine carries, so a scope boundary or an explicit drop reaches it.

Type parameters

NameDescription
T

Arguments

NameTypeDescription
takes selfT

the writer, spent by this call

Returns

TypeDescription
nil

soa.layoutoffunction#

function soa.layoutof<T>(element: ctype<T>): soa.Layout

Answers immutable SoA reflection for a reified struct type without allocating.

Only top-level fields are split, so a nested struct or fixed array is one field here whatever it holds.

local layout = soa.layoutof(ffi.typeof<Sample>())
assert(#layout.fields == 2)
assert(layout.fields[1].name == "position")

Type parameters

NameDescription
T

Arguments

NameTypeDescription
elementctype<T>

the ctype of one row

Returns

TypeDescription
soa.Layout

the count-independent descriptor

Raises

  • when compiler lowering is unavailable