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