Align rfc with implementation

This commit is contained in:
Dennis Kobert
2026-08-29 23:00:50 +02:00
parent c01fb080bf
commit 658f37874c

View File

@@ -7,40 +7,50 @@ declare their attribute reads and writes in their signatures, and the
compiler resolves every access to a byte offset during wiring, so there is
no name lookup at runtime. Storage and batch results are per-attribute
columns. The contiguous record only exists as a per-lane view, assembled
into buffers the compiler assigns. All of the machinery that could
into activation frames on a per-thread stack. All of the machinery that could
corrupt a layout is generated code, so getting it wrong is a type error or
a graph compile error rather than undefined behavior.
# Motivation
Attributes currently exist as string-keyed pairs of boxed trait objects
carried inside `List<T>`:
Attributes currently exist as a string-keyed store of type-erased
columns beside the elements:
```rs
pub struct List<T> {
element: Vec<T>,
attributes: Vec<(String, Box<dyn AnyAttributeValue>)>,
attributes: Attributes,
}
struct Attributes {
attributes: Vec<(String, Box<dyn AnyAttribute>)>,
len: usize,
}
```
Every access does a string comparison and a downcast, every value is
boxed, and merging eagerly pads missing attributes with materialized
defaults. On a ten-node chain with eight attributes over 64k items this
costs us around 500ns per item. The design described here measures
between 3.5 and 47ns on the same workload, depending on the execution
mode, and the cost is mostly independent of the attribute count.
So the storage is already columnar: one boxed column per name, each
holding `len` values. What it is not is resolved. Every access searches
the key list by string comparison and downcasts the column, and merging
eagerly pads missing attributes with materialized defaults. Scalar
access boxes per value at the item boundary, where the same store
appears as `ItemAttributeValues(Vec<(String, Box<dyn AnyAttributeValue>)>)`.
On a ten-node chain with eight attributes over 64k items this costs us
around 500ns per item. The design described here measures between 3.5
and 47ns on the same workload, depending on the execution mode, and the
cost is mostly independent of the attribute count.
There is also a cost at compile time and in the node catalog. Because
attributes ride inside `List<T>`, a node that touches a property needs
per-type traits (`MultiplyAlpha` and kin) and an implementations list
enumerating every carrier type. Each row monomorphizes, adding a new
attributes ride inside `List<T>`, a node that touches a property carries
an implementations list enumerating every carrier type: the opacity and
blend-mode nodes each name seven. Each row monomorphizes, adding a new
carrier type means editing every one of these lists, and the duplicated
instantiations show up in the build size. The blending nodes also carry
a TODO ("find a way to make this apply once to the list's parent rather
than applying to each item") that the current representation cannot
express at all: opacity on a group and opacity on each member composite
differently once members overlap, so the difference is semantic, and
there is currently nowhere to put it.
instantiations show up in the build size, measured at 6.16 MB and about
4% of frame rate when the rank work landed. An escape hatch exists for
the narrow case of reading an attribute without the element (`ListDyn`,
which erases the carrier), but it does not extend to nodes that write.
Separately, opacity on a group and opacity on each member composite
differently once members overlap, so the two are semantically distinct
and the representation has nowhere to record which one a node meant.
The requirements, briefly. Attributes are named with strings and work
for all types, and users can author read/write nodes with custom names.
@@ -70,11 +80,17 @@ different thing from the same attribute on the group's members.
An attribute name is declared once, as a marker type:
```rs
#[attribute(name = "opacity", default = 1.)]
pub struct Opacity;
attribute! {
/// How visible the content is.
pub Opacity("opacity"): f64 = 1.;
/// The item's label, parked in the arena by the writer.
pub Label("label"): &str;
}
```
This fixes the name, the value type, and the name-specific default
Each entry expands to the marker struct, its `Attribute` impl, and the
census registration. This fixes the name, the value type, and the
name-specific default
(opacity should default to fully opaque, not to `f64::default()`). The
registry collects the declarations into a census, and a misspelled name
in a document can be diagnosed with a nearest-match suggestion. For
@@ -172,41 +188,77 @@ or a bound instead and monomorphizes per its implementations list.
## Levels: before vs. after a structure node
Where a node sits in the chain decides which nesting level it affects.
Applying the opacity node to a shape and then repeating it gives every
copy its own opacity. Repeating first and then applying opacity sets one
value on the whole group, which composites differently where copies
overlap. The node's code is identical in both cases. Reads and writes
bind to the top level of the wire at the node's position in the chain,
and Repeat pushed a level in one of the two arrangements. Reaching an
inner level from outside is an explicit map/enter construct, so "set on
the parent" and "map over the children" are visibly different graphs.
This part of the design is not settled, and the first implementation
resolves every attribute at a single level. It is written up here
because the requirement is real and the rest of the model has to leave
room for it; see the open question at the end.
Where a node sits in the chain should decide which nesting level it
affects. Applying the opacity node to a shape and then repeating it
would give every copy its own opacity. Repeating first and then applying
opacity would set one value on the whole group, which composites
differently where copies overlap. The node's code is identical in both
cases. Reads and writes bind to the top level of the wire at the node's
position in the chain, and Repeat pushed a level in one of the two
arrangements. Reaching an inner level from outside is an explicit
map/enter construct, so "set on the parent" and "map over the children"
are visibly different graphs.
## Structure nodes
A node that produces a list declares the new level's extent and writes
per-copy values:
per-copy values. The body is one lane of that list. There are two
shapes, and which one applies is decided by whether the copies have to
re-evaluate the content.
The general shape takes the content lazily and evaluates it once per
copy, at that copy's index:
```rs
/// Instances the content a number of times, spaced by the direction vector.
#[node_macro::node(category("Repeat"), extent = repat_extent)]
#[node_macro::node(category("Repeat"), extent(repeat_extent))]
fn repeat<T>(
ctx: impl Ctx + ExtractIndex,
(element, transform): (T, Attr<Transform>),
ctx: impl Ctx + DeriveCtx + ExtractIndex,
content: impl Node<Context<'_>, Output = (T, Attr<Transform>)>,
#[default(1)]
#[hard(1..)]
count: u32,
#[default(100., 100.)]
direction: DVec2,
) -> List<(T, Attr<Transform>)> {
let offset = direction * ctx.innermost_index() as f64;
emit(element, Attr(DAffine2::from_translation(offset) * *transform))
) -> Result<IList<(T, Attr<Transform>)>, Interrupt> {
let inner = content.inner_extent(ctx)?;
let (copy, rest) = ctx.split_innermost(inner);
if copy >= count as u64 {
return Err(GraphError::past_end().into());
}
let offset = direction * copy as f64;
let mut frame = IndexLink { index: 0, outer: None };
let (element, transform) = content.eval(&ctx.push_level(&mut frame, copy, rest))?;
Ok((element, Attr(DAffine2::from_translation(offset) * *transform)))
}
```
The body is one lane of the declared list: the kernel reads its own copy
index and produces that copy's values. The `emit(...)` tail marks the one-lane form and
doubles as the tuple constructor, and it is optional.
The kernel splits the flat index into its own copy index and the index
below it, then evaluates the content at a context with the copy pushed
as a level. `DeriveCtx` is what admits the push, and only a lazy input
can receive a derived context, so this shape is exactly the one where
the copies are allowed to differ: anything upstream that reads the index
sees the copy it is being evaluated for.
Where the copies do not re-derive the content, the input stays an
ordinary carrier and the kernel only computes the copy's own attributes:
```rs
#[node_macro::node(category("Repeat"), extent(repeat_opacity_extent))]
fn repeat_opacity(ctx: impl Ctx + ExtractIndex, element: f64, count: u32) -> IList<(f64, Attr<Opacity>)> {
emit(element, Attr(ctx.index() as f64))
}
```
A carrier is evaluated once for the whole run rather than per lane, so
every copy sees the same element and the variation lives entirely in the
attributes. The `emit(...)` tail marks the one-lane form and doubles as
the tuple constructor, and it is optional.
## Merging
@@ -340,9 +392,15 @@ coercion.
## Levels and residency
Levels are numbered from the innermost out. This keeps layout keys
stable when a structure node pushes a level (nothing renumbers) and
matches how indices are already numbered. The binding rules are:
Layout keys carry a level, and levels are numbered from the innermost
out, which keeps them stable when a structure node pushes a level
(nothing renumbers) and matches how indices are already numbered. Only
level 0 is populated today: the packed-record tier is flat, so the
binding rules below and the residency analysis that follows them are the
intended design rather than the implemented one. The level in the key is
what leaves room for both.
The binding rules are:
- A read binds to the top level of the input wire it is destructured
from at the node's chain position; a write binds to the top level of
@@ -363,11 +421,13 @@ A per-item attribute that only varies per group is bound at level 0 but
resident at level 1, so it gets one slot per group rather than one per
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
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.
Storage is meant to be level-resident and columnar, with the contiguous
record as a view. Per-lane consumers get the view assembled across
levels and columns into 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. Neither
the residency analysis nor the multi-level storage exists yet; a single
level with resolved offsets is the base case both are built on.
## Runtime representation
@@ -399,30 +459,36 @@ that decomposition is hoisted per run.
sibling evaluates. This relies on stack records being single-consumer,
which the frame-memo insertion at fan-out points guarantees by copying
a shared value off the stack rather than holding it across consumers.
- Batch results are per-field columns, each statically Varying (an
- Batch results are a run of lanes behind a resolved offset per field,
and the target form is 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
nothing, and uniform columns give constant attributes their one-slot
cost regardless of lane count. Both execution forms share one layout
descriptor, and crossing from a batched producer to a per-lane consumer
costs about 1.5ns per lane through a lane-view adapter.
- A materialized level-N record carries its item count in one field at a
known offset in the layout, and each level-below column is a thin
pointer into the arena whose length is that count times the field
size. Erased consumers (the Data panel, capture, deep copy) read the
count off the record without reaching into the element type, and
Varying vs Uniform stays static in the layout, a Uniform column
addressing a single value. This is the same picture as a batch result,
so materializing a record and returning a batch are one format. Such
records spill: the count beside the element already fills the inline
budget, while scalar wires keep the two-word inline form.
- Alignment padding only exists in the per-lane view. In a row, a `u8`
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
wherever data accumulates, and the padding only survives in transient view
slots, whose number is bounded by graph depth.
that does not touch a column then forwards the pointer, so bypass
costs nothing, and uniform columns give constant attributes their
one-slot cost regardless of lane count. Both execution forms share one
layout descriptor, and crossing from a batched producer to a per-lane
consumer costs about 1.5ns per lane through a lane-view adapter. The
first implementation lays the run out as an array of records and
resolves each marker to an offset within a lane; moving that to
struct-of-arrays, and then to flat tables, happens behind the same
accessors.
- A materialized level carries its lane count beside its layout and its
storage, which is either an arena-resident run or an owned copy.
Erased consumers (the Data panel, capture, deep copy) read the count
and the fields off that handle without reaching into the element type,
and Varying vs Uniform is meant to stay static in the layout, a
Uniform column addressing a single value. This is the same picture as
a batch result, so materializing a level and returning a batch are one
format. A record value is one pointer wide, and only a record whose
layout is empty rides inside the value itself; everything else spills
to the stack.
- Alignment padding is what the column form buys. In a row, a `u8`
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). Once columns are the
storage format, the proportional cost holds wherever data accumulates
and the padding survives only in transient view slots, whose number is
bounded by graph depth. Until then a run pays a row's padding on every
lane, which is the cost the struct-of-arrays step removes.
## Kernel io lowering
@@ -436,20 +502,24 @@ that decomposition is hoisted per run.
| `(_, a): ((), Attr<A>)` | attribute-only input | wired record edge with unit element; the attribute is the payload |
| `Attr<A>` in the return tuple | attribute write | offset write into the output record |
| `RemoveAttr<A>` in the return tuple | attribute delete | the name leaves the output layout; functionally a write of the default |
| `keys: List<K>` | whole-extent input | wired edge, evaluated over its extent into a view |
| `keys: IList<K>` | whole-extent input | wired edge, evaluated over its extent into a view |
| plain parameters | wired value inputs | ordinary wired edges; attributes on their wires do not flow |
| `impl Node<Context<'_>, Output = Concrete>` | lazy value input | the value flows, attributes do not |
| `impl Node<Context<'_>, Output = (T, Attr<A>, ..)>` | lazy input with attribute reads | each eval yields the element plus the declared reads, offsets resolved against that edge's layout; a read declaration only, and no field pass-through |
| `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>` without | store form | whole-level body, node owns storage |
| `-> IList<W>` with a lazy subject and `DeriveCtx` | per-copy level production | the kernel splits the index and evaluates the subject at the pushed level |
| `-> IList<W>` with a carrier subject | per-lane level production | the carrier binds once for the run; the kernel computes each lane's own fields |
| `-> IList<W>` without `extent(fn)` | store form | whole-level body, node owns storage |
`level_extent =` names a parameter, or a function over the node's values
(author code never receives the node struct). The compiler derives both
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.
`extent(fn)` names a function over the node's inputs and the queried level
(author code never receives the node struct); `extent_raw(fn)` is the
escape hatch for anything that vocabulary cannot say. The declaration
answers what the level's counts are; the kernel performs the matching
index decomposition itself. The two are written separately and have to
agree, which is why the split and push go through shared helpers rather
than open-coded arithmetic. `emit(...)` is an optional tail marker for
the carrier form whose parentheses double as 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
@@ -461,17 +531,21 @@ misalign them, and domain declarations stay with the extent system.
A structure node pairs an extent composition with an index
decomposition. The extent composition is an ordinary extent override:
the node macro's `extent(fn)` attribute names a function with the trait
method's signature (the node and the context to `GPoll<Extent>`), so
the node macro's `extent(fn)` attribute names a function over the node's
inputs and the queried level to `GPoll<Extent>`, with `extent_raw(fn)` as
the escape hatch keeping the raw node, context, and level form, so
multiplicative, additive, and data-dependent extents are one mechanism
rather than a macro taxonomy, and the default stays the meet over the
value inputs.
- Multiplicative (Repeat, map/enter): the extent override multiplies
the new level's count by the carrier's. A flat index splits by
division into the copy index (pushed as a level) and the content
index; the level push and the `emit` tail are the skeleton
declaration. Batches split into maximal per-copy runs.
- Multiplicative (Repeat, map/enter): the extent override answers the
pushed level with the copy count and forwards inner levels to the
content. The kernel splits the flat index into the copy index and the
index below it through the shared split helper, and pushes the copy as
a level on a derived context before evaluating the content there. The
extent declaration and the split are written separately and have to
agree; the helpers are shared so that they can. Batches split into
maximal per-copy runs.
- Additive (Merge): an ordinary routing kernel whose selector condition
is the index. The kernel range-splits the flat index into a segment
and a local index through the shared split helper and evaluates that
@@ -551,11 +625,18 @@ kernels:
/// Reverses the order of the input list.
#[node_macro::node(category("General"))]
fn reverse<T>(
ctx: impl Ctx + DeriveCtx + ModifyIndex,
ctx: impl Ctx + ModifyIndex + Copy,
_: (),
content: impl Node<Context<'_>, Output = T>,
) -> T {
content.eval(&ctx.with_index(content.extent(&ctx)? - 1 - ctx.innermost_index()))
) -> Result<T, Interrupt> {
let total = match content.extent(ctx, Level::Total) {
GPoll::Final(Extent::Exactly(count)) => count as u64,
GPoll::Pending => return Err(Interrupt::Pending),
_ => return Err(GraphError::new("reverse over a non-exact extent").into()),
};
let mut shifted = *ctx;
shifted.set_index(total - 1 - ctx.innermost_index());
content.eval(&shifted)
}
```
@@ -584,9 +665,10 @@ 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,
with nearest-match suggestions), custom-name collisions, reads that some
evaluation path cannot satisfy, and layout conflicts. The runtime checks
nothing. Debug assertions guard the generated code against itself at
wiring boundaries, following the existing precedent for TypeId checks.
evaluation path cannot satisfy, and layout conflicts. The runtime does
no name lookup. Layout construction and the safe record builders check
field type identity and panic on a mismatch, which is what guards the
generated code against itself at wiring boundaries.
## Soundness
@@ -599,10 +681,21 @@ of wires rather than of anything a kernel controls, safe kernel code can
make semantic mistakes (evaluating an input it did not need to) but
cannot misalign an offset. Kernels see contexts only as an opaque
`impl Ctx + ...` they cannot construct, and lifetimes keep them from
stashing handles in node state. The one remaining discipline lives
inside generated code (a result buffer must not be borrowed across a
sibling evaluation it could alias), and debug assertions guard it at
wiring boundaries, following the existing precedent for TypeId checks.
stashing handles in node state.
That is the property the design is for, and reaching it is work rather
than a consequence. Type identity is stamped on every layout field and
checked where layouts are built and where the safe builders write, which
is the enforcement this rests on. What is not yet closed, and is tracked
as such: a producer whose layout was never installed writes through a
path that assumes the inline width, an owned record replays against a
caller-supplied layout it does not check, and the record stack's own
bounds check is a debug assertion while its buffer can be reset under
live records. Each is reachable from safe code, so the claim above holds
by the discipline of the generated code and not yet by construction. The
direction is to replace the emitted raw operations with a small set of
checked surfaces, so that the remaining unsafe is the erased glue and
the wiring-computed byte plans, where it is irreducible.
# Drawbacks
@@ -617,9 +710,10 @@ wiring boundaries, following the existing precedent for TypeId checks.
would absorb attribute renames without recompiling.
- Until an elision pass exists, attributes that are written but never
read occupy slots and copies.
- Transient per-lane views pad small elements up to the record
alignment, and only the columnar storage is footprint-proportional.
- Two execution forms (per-lane views and columnar batches) are more
- Per-lane views pad small elements up to the record alignment, and so
does a run of records until the columnar storage arrives; only the
column form is footprint-proportional.
- Two execution forms (per-lane views and batches) are more
machinery than one representation. They share a single layout
descriptor, and the measured seam between them is about 1.5ns per
lane, but the machinery still has to exist.
@@ -628,11 +722,11 @@ wiring boundaries, following the existing precedent for TypeId checks.
- Keep runtime maps (the current implementation): roughly 500ns per item
on the reference chain and ~57ns marginal per attribute, an allocation
per value, and no compile-time name checking. Interning the keys
per column, and no compile-time name checking. Interning the keys
improves the constant (about 1.9ns per access vs. 0.43 for a resolved
offset) but keeps a per-access search and rules out the structural
optimizations that need static layouts: bypass, uniform columns, slot
coalescing.
optimizations that need static layouts: bypass, uniform columns, and
packing a level's fields to their resolved offsets.
- Attributes as separate graph edges, one channel per attribute: bypass
and per-channel caching become graph structure. We prototyped and
measured this. Without caching at fan-outs, every channel re-evaluates
@@ -660,9 +754,9 @@ Item and List wire types work, which remains the behavioral reference
for this design: name-specific defaults, merge with default fill, and
the Data panel's presentation of items all carry over, and the wire
rank display and Data panel belong to the editor and are unaffected
here. One behavior is refined rather than kept: flat merge previously
had to drop one input's top-level attributes, which the push-down rule
now preserves. The present representation (string-keyed storage inside
here. One behavior is refined rather than kept: flat merge
has to drop one input's top-level attributes, which the push-down rule
is meant to preserve. The present representation (string-keyed storage inside
`List<T>`, with per-carrier implementations rows) is what the Motivation
section measures. This RFC keeps its observable behavior while replacing
the storage and registration strategy underneath.
@@ -674,14 +768,29 @@ from shading languages for residency.
# Unresolved questions
- Leveled attributes, the largest open area. Attributes at more than one
nesting level are designed but not built: the layout key carries a
level, and nothing populates a level above 0. Open within it are the
binding rules as stated (does a read really bind to the top level of
the wire it is destructured from, and is pinning element-reading nodes
to level 0 the right rule), how a structure node's per-copy write
lands in the former top row, whether residency is worth its analysis
or whether the extent machinery already answers it (an attribute whose
index function ignores the index is `Free`), and what the storage for
a level above 0 looks like given that the record tier is flat. The
UX half of the same question is the map/enter construct: how "set on
the parent" and "map over the children" read differently in the graph.
Until this is settled, the Repeat-around-Opacity requirement is
unmet.
- Where and how the combine rule is declared on the attribute marker.
Merge push-down and flatten both consume it, and inner-wins is the
implemented fallback.
intended fallback.
- The spelling of the push-down marker on the merge node, the one part
of the additive shape the extent override cannot express.
- The graph UX of the map/enter construct.
- Naming: `Attribute` trait vs. `Attr` wrapper, and whether the
authoring `List` sharing the wire type's name helps or confuses.
- Naming: `Attribute` trait vs. `Attr` wrapper. The authoring list type
is spelled `IList` here, as in the implementation, to keep it clear of
the legacy wire type's `List`; a rename to `List` is planned once that
type retires.
- Generic-typed writes: where the default for a generically written
name comes from (a `Default` bound on the value vs. an input on the
name source), what `A` instantiates to at the Rust level, whether
@@ -690,7 +799,7 @@ from shading languages for residency.
node.
- Whether evaluating at a lane outside the input's extent is clamped,
wrapped, or a debug assertion.
- `List<List<W>>` outputs, i.e. one node pushing two levels.
- `IList<IList<W>>` outputs, i.e. one node pushing two levels.
- How chatty the editor boundary becomes per frame, given that tools
consume materialized views today.