Move evaluation to use a stack

This commit is contained in:
Dennis Kobert
2026-08-05 21:04:10 +02:00
parent f01429ff0b
commit 18225d0c12

View File

@@ -49,7 +49,7 @@ nesting levels. Items whose element types agree can merge regardless of
their attribute sets, with missing values filled from name-specific
defaults. Names resolve at graph compile time, with a dynamic escape
hatch for runtime-shaped data. A wire without attributes costs what a
wire costs today, and an attribute that is constant across a domain
plain wire costs, and an attribute that is constant across a domain
costs one slot rather than one per element. Batch access is the case to
optimize, and scalar access should not require a second representation
with conversions between the two.
@@ -108,15 +108,15 @@ fn opacity<T>(
`Attr<Opacity>` as a parameter is a read (it yields the declared default
if nothing upstream wrote the attribute), in the return tuple it is a
write, and the same marker on both sides is a modify. Note what is gone
compared to today: the trait bounds and the implementations list. The
first parameter after the context is the primary input, and an unbounded
write, and the same marker on both sides is a modify. The first
parameter after the context is the primary input, and an unbounded
generic `element: T` that is returned in the first tuple position means
"I pass the element through unchanged". The compiler lowers this to a
byte copy (often to nothing, see below), and a single compiled instance
covers every element type. A node that actually computes on the element
uses a concrete type or a bound instead and monomorphizes per its
implementations list, as today. `_: ()` still means "no primary input".
covers every element type, with no trait bounds and no implementations
list. A node that actually computes on the element uses a concrete type
or a bound instead and monomorphizes per its implementations list.
`_: ()` means "no primary input".
## Levels: before vs. after a structure node
@@ -209,7 +209,7 @@ defaults per branch. A lazy input with a concrete output type is an
ordinary value input: its value flows, the attributes on its wire do
not.
Everything else about authoring is unchanged. Categories, per-parameter
The rest of the authoring surface composes. Categories, per-parameter
doc comments, `#[default]`, `#[hard]`, `#[expose]`, widget overrides, and
the kernel dialects (`Result<_, Interrupt>` with `?`, `GPoll` returns,
async sources) all compose with the forms above.
@@ -234,8 +234,8 @@ and level), computed at graph compile time. Some consequences:
- Reads resolve to `Option<offset>` at wiring. Present means a field
access, and absent means the macro emits the default constant. Writes
always resolve. The runtime does no name lookup, no hashing, and no
downcasting. In our benchmarks a resolved read costs the same as a
native struct field access (0.43ns for both).
downcasting. A resolved read costs the same as a native struct field
access (0.43ns).
- Writes that are never read are diagnosed. Eliding them is a permitted
whole-graph optimization but not required. Keeping them in the layout
is what keeps the layout a pure function of the upstream cone.
@@ -281,26 +281,32 @@ item.
Storage is level-resident and columnar, and the contiguous record is a view.
Per-lane consumers get the view assembled across levels and columns into
a compiler-assigned buffer. Reads across a level boundary use the same
their activation frames. Reads across a level boundary use the same
index decomposition the structure nodes already perform, and in batches
that decomposition is hoisted per run.
## Runtime representation
- Every node's per-lane output gets a fixed slot in a per-graph frame,
assigned at compile time (all layout sizes are static). There is one
frame instance per worker. The base pointer travels in the operational
half of the context next to the arena, the offsets sit in node state.
"Allocating" a result is pointer arithmetic. Slots are overwritten
each lane, transients never touch the arena, and publishing into a
cache copies out of the frame.
- When an edge has a single consumer and the layout is unchanged,
producer and consumer share a slot and the record carry disappears.
An elementwise node then costs its arithmetic plus dispatch.
- This imposes one rule: a borrow of a slot must not survive a sibling
evaluation within the same pull. Consuming by copy is always fine.
The compiler knows the fan-out statically and inserts a copy or a
cache where sharing actually occurs.
- Every node's per-lane output is an activation frame on a per-thread
record stack, the shape of an ordinary call stack: an evaluation
claims its frame at the stack pointer, evaluates its carrier beyond
it, and releases on completion, leaving the returned record readable
until the next claim. A node with several record sources lays their
regions side by side, so values held across sibling evaluations
survive. "Allocating" a result is pointer arithmetic; frames are
overwritten each lane, transients never touch the arena, and
publishing into a cache copies out of the stack.
- No global slot assignment exists: a node's wiring state is its own
frame size, so incremental recompiles and instance reuse cannot
invalidate storage, and the stack belongs to whichever thread runs
the evaluation, created lazily in thread-local storage, so worker
counts never enter wiring. The total stack bound is derived by the
wiring layer from the same layouts it computes (own frame plus
carrier need, maxed over value inputs, summed over sources) and
reserved once per evaluation.
- This imposes one rule: a borrow of a released frame must not survive
the next claim. Consuming by copy is always fine, and the per-source
regions above make kernel-held record values safe by construction.
- Batch results are per-field columns, each statically Varying (an
array) or Uniform (a single value) per the residency analysis. A node
that does not touch a column forwards the pointer, so bypass costs
@@ -309,7 +315,7 @@ that decomposition is hoisted per run.
descriptor, and crossing from a batched producer to a per-lane consumer
costs about 1.5ns per lane through a lane-view adapter.
- Alignment padding only exists in the per-lane view. In a row, a `u8`
element costs the same as a `u64` (we measured them identical), while
element costs the same as a `u64`, while
packed columns keep the cost proportional to the element size (2x
cheaper than rows when cache-resident, around 8x when memory-bound).
Columns are the storage format, so the proportional cost holds
@@ -327,7 +333,7 @@ that decomposition is hoisted per run.
| `x: Attr<A>` | attribute read | offset read, or the default constant |
| `Attr<A>` in the return tuple | attribute write | offset write into the output record |
| `keys: List<K>` | whole-extent input | wired edge, evaluated over its extent into a view |
| plain parameters | wired value inputs | as today |
| plain parameters | wired value inputs | ordinary wired edges |
| `impl Node<Context<'_>, Output = Concrete>` | lazy value input | the value flows, attributes do not |
| `impl Node<Context<'_>, Output = T>` (unbounded) | source of an opaque record family | routing, see below |
| `-> List<W>` with `level_extent =` | per-lane level production | structural skeleton emitted by the macro |
@@ -338,9 +344,7 @@ that decomposition is hoisted per run.
the extent formula and the matching index decomposition from this one
declaration, which is what keeps them consistent. `emit(...)` is an
optional tail marker for the per-lane form whose parentheses double as
the tuple's, so multi-write lanes pay no extra nesting. A literal
`yield` would have been nicer but is not available: stable rustc records
the feature gate while parsing the item, before attribute macros run.
the tuple's, so multi-write lanes pay no extra nesting.
The rule behind all the lazy forms: kernels control whether, when, and
at which index their inputs are evaluated, but never how the records
@@ -385,10 +389,9 @@ unchanged context and yields a value carrying the resulting record,
either through the plan into that source's own buffer, or, when the
source's layout already equals the union, by forwarding the record
pointer untouched. The forwarding case compiles to a conditional move
plus a tail call. In our measurements the routing itself is nearly free
and the observed +4.7ns per lane for a two-branch switch is the
condition and ordinary branch misprediction. A translating source costs
+6.5ns per lane at eight attributes.
plus a tail call; the +4.7ns per lane of a two-branch switch is the
condition and ordinary branch misprediction, and a translating source
costs +6.5ns per lane at eight attributes.
The kernel routes these values as ordinary Rust values. It can evaluate
any source any number of times, hold several results at once (per-source
@@ -412,20 +415,17 @@ collapses through nullification, a varying one selects per lane.
The one-source shape also covers the registry's infrastructure rows.
Monitor, context modification, memoize, and the lend and clone adapters
are all `T -> T` passthroughs with a side effect, and each is listed in
the registry today once per wire type, several hundred hand-maintained
rows in total. Over the record family they become single generic nodes:
the record forwards, and the side effect is orthogonal to the type (a
reflective snapshot through the layout descriptor, a derived context, or
a persistence copy sized by the layout). Persisting a non-Copy element
needs a clone and drop function per element type, registered once beside
the type itself rather than once per infrastructure node, so the
per-type surface shrinks from types times nodes to types plus nodes.
Compiler-inserted infrastructure then splices one generic proto node
without naming value types, which was the wiring layer's stated goal.
The genuine conversion rows (the Into and Convert matrix) remain,
because those do real per-type work; the type-erased attribute
conversion rows disappear with the representation they serve.
are all `T -> T` passthroughs with a side effect. Over the record family
each is a single generic node: the record forwards, and the side effect
is orthogonal to the type (a reflective snapshot through the layout
descriptor, a derived context, or a persistence copy sized by the
layout). Persisting a non-Copy element needs a clone and drop function
per element type, registered once beside the type itself rather than
once per infrastructure node, so the per-type surface is types plus
nodes rather than types times nodes, and compiler-inserted
infrastructure splices one generic proto node without naming value
types. The genuine conversion rows (the Into and Convert matrix)
remain, because those do real per-type work.
A kernel that modifies the index on the context evaluates an input at a
lane other than its own, which makes index-computable reorders plain
@@ -463,7 +463,7 @@ Everything happens at graph compile time. The census is assembled from
the marker declarations (names, types, defaults, combine rules). Each
wire's layout is constructed from its upstream write set, and residency
comes from the index-invariance analysis. Offsets are resolved into node
state, the frame layout is computed with slots coalesced, and union and
state, the stack bound is folded from the layouts, and union and
translation plans are built at selectors and merges. A per-name
dependency analysis feeds the cache keys. The diagnostics produced along
the way are unknown or misspelled names (checked against the census,