AIF layout selection
How Prismio chooses object and container layout from access evidence, field temperature, flatness and conversion cost — and why the obvious hot/cold cut can be wrong.
Last verified
A struct's field order is free to change, because nothing in ordinary Prismio source names an offset. AIF — the Adaptive Inference Framework — uses that freedom to reorder fields, and even split a struct into a frequently-touched ("hot") part and a rarely-touched ("cold") part behind one pointer, when the traffic it can prove justifies the cost of the split itself. The purpose is not to make every type exotic. It is to choose a representation whose total construction, traversal, mutation, and destruction cost is lower than declaration order would have given you — and to prove that before committing to it, because a locally cheaper split can make a container globally slower.
See it work
tests/test_61_layout_cost_model.psm builds a 13-field struct, Sample, with three real access
groups: two fields (g, h) touched three times per element by one loop, six fields (a..f)
touched once each by another loop, and four fields never read after construction. --layout prints
every candidate split it scored:
prismio aif test_61_layout_cost_model.psm --layoutaif-layout 1
source test_61_layout_cost_model.psm
profile static
#
# LAYOUT 7.2 argmin over candidates(tau). `*` is the model's choice;
# `emitted` is what codegen actually produced. The two differ when a
# type is vetoed -- for representation cost or soundness -- and when
# --force-layout names a different candidate (LAYOUT 8).
#
# type fields trav candidate hot B cold B modelled emitted
Sample 13 2 unsplit 104 0 100
Sample 13 2 split 2/13 24 88 413
Sample 13 2 split 3/13 32 80 210
Sample 13 2 split 6/13 56 56 258
Sample 13 2 split 9/13 * 80 32 76 emittedA traced worked example: why the obvious cut loses
The tempting rule is "rank fields by access count, cut where the count drops." Ranked that way,
g and h (touched three times per pass by settle) come out ahead of a..f (touched once each
by advance), so the first frequency boundary falls after {tag, g, h} — a 2/13 split. The
table above scores that candidate at 413, worse than not splitting at all (100).
The reason is what that split does to advance: all six of its fields land in the cold half,
behind the pointer a split introduces, so a loop that used to walk one flat array now chases a
pointer per element for every field it touches — six times, once per field, in the hot loop that
runs every frame. The candidate the model actually picks, 9/13 ({tag, a..f, g, h} hot, four
never-read fields cold), scores 76: cheaper than not splitting, because it is the only candidate
that keeps both loops' fields on the side they are read from. Reading access counts off a table
would have picked the four-times-worse layout with complete confidence. This is why layout selection
is a cost model over whole candidates, not a ranking of individual fields.
Explicit layout operations
Reordering and splitting are automatic. soa() and DataView<T> are the operations that make a
layout change visible to the programmer on purpose, for the case where you want a structure-of-arrays
container yourself rather than waiting for the compiler to infer a split:
struct Cell { x: Int, y: Float }
fn main() -> Int {
let mut rows: Vec<Cell> = []
rows.push(Cell { x: 1, y: 2.0 })
let view: DataView<Cell> = soa(rows)
if (view.length != 1) { return 1 }
return 0
}prismio run test_81_data_view_drop.psm --verifyBuilt test_81_data_view_drop
aif-verify: 6 allocated, 6 released, 0 leaked, 0 violation(s)
aif-memory: 204 allocated bytes, 204 released bytes, 0 live bytes, 204 peak live bytes
aif-memory-sizes: <=16:2 <=32:1 <=64:3 <=128:0 <=256:0 <=512:0 <=1024:0 <=4096:0 >4096:0
aif-arena: 0 object(s), 0 byte(s), 0 region(s) on reporting threadA DataView you never convert back to rows is still an owned value: it releases every column array
when its scope ends, which is what the ledger above (6 allocated, 6 released) is confirming for a
one-element view of a two-field struct.
What failure looks like
soa() only accepts a Vec of a flat struct with no owned fields — it cannot make a
structure-of-arrays view when a field itself needs its own release:
struct OwnedRow { name: String }
fn main() -> Int {
let mut rows: Vec<OwnedRow> = list_new()
list_push(rows, OwnedRow { name: "owned" })
let view = soa(rows)
return 0
}prismio build neg_36_soa_non_flat.psm -o neg_36.llerror[P4001]: soa requires a flat struct with no owned fields; OwnedRow contains non-flat storage
--> neg_36_soa_non_flat.psm:9:20
|
9 | let view = soa(rows)
| ^^^^
error: aborting due to 1 previous errorString is owned storage, so a column of OwnedRow would need its own release path per column
rather than one release for the struct — exactly the case the automatic layout optimizer also
excludes (see Object and container context, below). The fix is not to force it: restructure so the
owned field lives in its own Vec alongside a flat DataView of the rest, or keep the rows as an
ordinary Vec<OwnedRow>.
Forcing a candidate for testing
--force-layout=<Type>:<hot-field-count> overrides the model's choice, for testing a specific
candidate's codegen rather than trusting the search:
prismio aif test_61_layout_cost_model.psm --layout --force-layout=Sample:2# type fields trav candidate hot B cold B modelled emitted
Sample 13 2 unsplit 104 0 100
Sample 13 2 split 2/13 24 88 413 emitted
Sample 13 2 split 3/13 32 80 210
Sample 13 2 split 6/13 56 56 258
Sample 13 2 split 9/13 * 80 32 76Naming a hot-field count no real candidate has does not fail the build — it warns and falls back to the model's own choice:
prismio aif test_61_layout_cost_model.psm --force-layout=Sample:999warning[P1003]: --force-layout did not apply: no candidate of `Sample` keeps 999 fields hot (see `aif --layout` for the candidates)Object and container context
A locally cheaper object split can be globally worse when it turns one flat container element into
two allocations connected by a pointer — the advance loop above is exactly that failure mode, and
current evidence includes real cases of it. Layout work must price allocation and release traffic,
flat storage eligibility, pointer chasing, copying, and vectorization together, not one at a time.
Automatic layout remains constrained by observable identity, FFI (foreign function interface)
boundaries, debug information, and safe element views. soa/DataView push past those constraints
only where the programmer states the intent explicitly, and only for types that qualify, as above.
Acceptance
A cost-model change needs a discriminating layout test (one where the naive rule and the real model disagree, as above), a runtime value test, a manifest assertion, and an A/B workload that measures the representation actually selected. A predicted improvement is not a measured improvement.
Deliberate omissions
This page covers the field-order and hot/cold decision and the explicit soa/DataView
operations. It does not cover measured workload profiles (aif_profile_load, distinguishing
observed access counts from the static estimate used throughout this page) in worked-example form —
that machinery is real and referenced under "If you are changing this" below, but demonstrating it
needs a two-build measure-then-recompile workflow outside this page's scope.
If you are changing this
Inputs
The layout model consumes type size and alignment, field access frequency, traversal structure,
container use, and workload annotations where available. src/aif/layout.psm defines the
self-hosted model, while runtime/aif_support.c contains supporting evaluation machinery.
Type graph and exact sizing
aifRegisterTypeEdges records nominal containment/reference edges for every struct and enum.
The native aif_compute_type_acyclic computes strongly connected components so recursive types
are not classified from a shallow field check.
aifComputeSizes walks declarations through aifComputeSizesOf.
aifComputeStructSize uses aifTypeBytes, aifStructFieldBytes, aifTypeAlign, and
aifAlignUp to model the selected target. It writes exact sizes through
aif_struct_set_size. The model is checked against backend ir_struct_size,
ir_struct_field_offset, and ir_struct_field_size, which query LLVM target data directly.
aifFieldIsInlineExact distinguishes embedded value storage from pointer/reference fields.
An optional struct is a reference edge; a non-optional struct field is embedded. Getting this
wrong changes both size and lifetime traversal. An Array<T, N> field is embedded too:
aifTypeBytes sizes it at N elements and aifTypeAlign aligns it to its element, as LLVM lays
out [N x T]. aifLayoutVetoInline keeps a struct holding one unsplit — the split's cost model
has never sized an array, and a cold block would be a second place its bytes could live.
Field-order selection
aif_struct_new, aif_enum_new, and aif_struct_add_field register nominal layout inputs.
aif_field_access(type, field, weight) accumulates static or measured access weights.
aif_traversal_begin, aif_traversal_elem, and aif_traversal_end record sequential
access patterns.
aif_layout_select evaluates candidate orders — this is the search that produced the table above.
The report API exposes aif_layout_candidates, aif_layout_best, aif_layout_cand_at_rank,
aif_layout_cand_field_hot, aif_layout_cand_bytes, and aif_layout_cand_ratio. The selected
field order is read with aif_layout_field and aif_layout_field_bytes; aif_layout_reordered
reports whether it differs from source order.
A workload profile enters through aif_profile_load. aif_profile_source and
aif_profile_is_measured distinguish observed data from static estimates — the profile static
line in every --layout output above is this accessor. Field ranges use
aif_field_range_lo, aif_field_range_hi, and aif_field_range_bytes; an observed range
may guide layout but never justify a source-semantic narrowing.
Split selection and vetoes
aif_layout_split_select chooses the hot-field count. aif_layout_hot_count is consumed by
ir_struct_type_split. Before selection, aifLayoutVetoInline,
aifLayoutVetoListElements, and aifLayoutVetoDataViews reject layouts whose address,
container, view, FFI, or full-scan behavior cannot preserve semantics.
Native veto functions include aif_layout_no_split,
aif_layout_no_split_unmodelled, and aif_layout_no_split_list_full_scan.
aif_layout_veto_reason gives the diagnostic explanation.
aif_layout_force(type, hotCount) is the test/diagnostic assertion behind --force-layout above;
the aif_layout_forced_* queries report whether the requested split was legal and applied — the
P1003 warning above is what it means for it not to have been.
Layout tests should compare source order, selected order, exact offsets, hot/cold byte counts, generated GEP (getelementptr, LLVM's address-computation instruction) paths, release of cold-owned fields, debug metadata offsets, list/DataView vetoes, and measured runtime behavior.