diff --git a/node-graph/rfcs/attribute-model.md b/node-graph/rfcs/attribute-model.md index 4409249152..b8695c076d 100644 --- a/node-graph/rfcs/attribute-model.md +++ b/node-graph/rfcs/attribute-model.md @@ -316,7 +316,10 @@ forwarding a lazy input's attributes is routing. 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. +async sources) all compose with the forms above. An async source writes +its attributes through its claim like any other node; a reference-valued +attribute crosses the future boundary as an owned deep copy, covered in +the reference section. # Reference-level explanation @@ -495,6 +498,64 @@ level with resolved offsets is the base case both are built on. 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. +## Arenas, promotion, and memos + +Droppable payloads and every borrow a record lends live in two arena +regions per executor. The transient arena is reset at the end of every +evaluation: allocation is a bump, a parked payload registers a drop +obligation, and the reset is a cursor rewind plus the registered drops. +The persistent region holds what must outlive an evaluation (published +memo spans, moved payloads) and flushes as a whole epoch, only between +evaluations and only after a refused reservation. Every crossing from +outside storage into a region is generation-checked, so a stale handle +is a cache miss, never a dangle. + +Two rules the split rests on, both bought with measurements: + +- The retention law: arena occupancy is not a proxy for retained heap. + A park costs one pointer in the region while owning arbitrary heap + outside it (a 52x gap was measured), so a single region cannot + decouple transient droppable heap from span lifetime; the one-arena + epoch design this replaced ran a real document to a 12.3 GiB OOM. + The split, plus a per-park retained-heap hint kept as a lower bound, + is the answer. +- The sharing law: sharing a payload between persistent entries is + sound because persistent invalidation is epochal, every entry dying + at one flush; per-entry eviction would need the refcounts this design + avoids. One level down, a reference-valued attribute field's header + is never provenance-shared: it clones, or moves where the payload + owns all of its content and the transient arena confirms the + reference is its own park keyed by the payload's static type. The + move is a header memcpy with the drop obligation and its heap hint + transferred, the source entry tombstoned in place, and a + per-generation forwarding map so a payload two records share moves + once. + +Promotion into the persistent region is copy-on-write over provenance: +a reference already persistent or `'static` is shared pointer for +pointer, a transient leaf payload moves its header, and region bytes +memcpy. Types with arena-resident interiors register promote glue in +element- and field-keyed registries, so nested content takes the same +dispatch instead of a deep copy through an owned intermediate. + +Two memo nodes ride this, with different contracts. The frame memo is +the cheap span-only memo the compiler inserts at nullification +boundaries: it publishes a promoted level into the persistent region +keyed on the lane-normalized context, a hit before the next flush +serves straight out of the region, and a flush makes an entry +unreachable, never wrong. The explicit memoize node is two-tier: the +same span fast path over deep copies that survive flushes, so +author-marked expensive content never pays recomputation for a flush. + +An async source's future outlives the evaluation that spawns it and may +outlive a persistent epoch, so a reference-valued attribute cannot +cross into it as a region handle: a flush would invalidate the handle +mid-flight, and pinning the park would need the refcounts the sharing +law avoids. It crosses as an owned deep copy instead (`OwnedAttr`), +taken through the field glue before the first await while the spawning +evaluation's storage is live, and parked into the serving arena at +every lift, one copy per invocation. + ## Kernel io lowering | Signature form | Meaning | Lowering | @@ -507,6 +568,7 @@ level with resolved offsets is the base case both are built on. | `(_, a): ((), Attr)` | attribute-only input | wired record edge with unit element; the attribute is the payload | | `Attr` in the return tuple | attribute write | offset write into the output record | | `RemoveAttr` in the return tuple | attribute delete | the name leaves the output layout; functionally a write of the default | +| `OwnedAttr` in an async source's return tuple | owned attribute write | the value crosses the future boundary as a deep copy taken before the first await, and parks into the serving arena at every lift; exhaustion surfaces as a poll | | `keys: IList` | 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, Output = Concrete>` | lazy value input | the value flows, attributes do not |