diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 59cd9b938b..3747f1e0d0 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -750,6 +750,53 @@ impl<'a, 'e, N> RecordLazyInput<'a, 'e, N> { } } +/// The derive-routing carrier beside its declared attribute reads: evaluating +/// at a derived context yields the opaque row token and the read values in one +/// step, so the kernel drives the per-copy eval while reads stay resolved +/// against the source's wired layout. +#[derive(Clone, Copy)] +pub struct DerivedLazyInput<'a, 'e, Out, N> { + node: &'a N, + cell: &'a crate::node::StatusCell, + input_index: usize, + reads: &'a [Option], + read: unsafe fn(Rec, &[Option]) -> Out, + _lifetime: std::marker::PhantomData RecordValue<'e>>, +} + +impl<'a, 'e, Out, N> DerivedLazyInput<'a, 'e, Out, N> { + /// `read` must be sound against the layout the offsets in `reads` were + /// resolved from; the macro proves both at wiring. + pub fn new(node: &'a N, cell: &'a crate::node::StatusCell, input_index: usize, reads: &'a [Option], read: unsafe fn(Rec, &[Option]) -> Out) -> Self { + Self { + node, + cell, + input_index, + reads, + read, + _lifetime: std::marker::PhantomData, + } + } + + pub fn eval<'d, C>(&self, ctx: &C) -> Result + where + N: DerivedRecordEdge<'d, C>, + { + let value: RecordValue<'e> = self.node.eval_derived(self.cell, self.input_index, ctx)?.rebind(); + // SAFETY: declared reads imply a non-empty layout, so the record is + // spilled and its pointer is the frame the offsets index into. + Ok(unsafe { (self.read)(Rec::new(value.ptr), self.reads) }) + } +} + +/// The read-less [`DerivedLazyInput`] glue: the token alone. +/// +/// # Safety +/// `rec` must be a spilled record's frame. +pub unsafe fn token_only<'e>(rec: Rec, _reads: &[Option]) -> RecordValue<'e> { + RecordValue::spilled(rec) +} + /// The per-thread record stack: every record evaluation claims its activation /// frame at the stack pointer and evaluates its carrier beyond it, so slot /// addresses are a property of the evaluating thread and no global assignment diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index beeb547700..f254be457f 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -826,7 +826,8 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn if routing_generic.is_some() || record_io || flip { impl_generics.insert(0, quote!('__record)); } - if derive_routing { + let lazy_carrier = record_io && carrier_present && matches!(parsed.fields.iter().find(|field| !field.is_data_field).map(|field| &field.ty), Some(ParsedFieldType::Node(_))); + if derive_routing || (lazy_carrier && derives) { generics.insert(0, quote!('__record)); } @@ -865,6 +866,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } } } + if lazy_carrier && derives { + let source_generic = format_ident!("__Source0"); + generics.push(quote! { + #source_generic: for<'__derived> #core_types::record::DerivedRecordEdge<'__derived, #core_types::context::Derived<'__derived, #ctx_ident>> + }); + } if flip { let mut kernel_lazy = false; for (index, field) in regular_fields.iter().enumerate() { @@ -943,6 +950,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let source_generic = format_ident!("__Source{index}"); match (ir::lazy_binding(&node, index), raw_lazy) { (LazyBinding::DeriveRouting, _) => quote!(#pat: #core_types::record::RecordLazyInput<'_, '__record, #source_generic>), + (LazyBinding::DeriveCarrier, _) => { + let out = lazy_read_out(field, output_type); + quote!(#pat: #core_types::record::DerivedLazyInput<'_, '__record, #out, #source_generic>) + } (LazyBinding::OpaqueRecord, _) => quote!(#pat: &#core_types::record::RecordEdgeInput<'_, #source_generic>), (LazyBinding::Element, true) => { let out = lazy_read_out(field, output_type); @@ -975,6 +986,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }, false => quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>), }, + ParsedFieldType::Node(_) if record_io && !skips_carrier && index == 0 => match derives { + true => quote!(#node_generic: for<'__derived> #core_types::record::DerivedRecordEdge<'__derived, #core_types::context::Derived<'__derived, #ctx_ident>>), + false => quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>), + }, ParsedFieldType::Regular(_) if record_io && !skips_carrier && index == 0 => { quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>) } @@ -1148,6 +1163,22 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn (LazyBinding::DeriveRouting, _) => quote! { let #name = #core_types::record::RecordLazyInput::new(&self.#name, &__cell, #index); }, + (LazyBinding::DeriveCarrier, _) => { + let reads = reads_of(index); + let read_fn = format_ident!("__{}_read_{}", fn_name, index); + match reads.is_empty() { + true => quote! { + let #name = #core_types::record::DerivedLazyInput::new(&self.#name, &__cell, #index, &[], #core_types::record::token_only); + }, + false => { + let slot_idents: Vec = reads.iter().map(|(slot, _)| format_ident!("__read_{slot}")).collect(); + quote! { + let __carrier_reads = [#(self.#slot_idents),*]; + let #name = #core_types::record::DerivedLazyInput::new(&self.#name, &__cell, #index, &__carrier_reads, self::#read_fn); + } + } + } + } (LazyBinding::Element, true) => { let slot = format_ident!("__in_{index}"); match field.attribute_reads.is_empty() { @@ -1242,7 +1273,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let decl = match &field.ty { ParsedFieldType::Node(_) => match ir::lazy_binding(&node, index) { - ir::LazyBinding::DeriveRouting => quote! { + ir::LazyBinding::DeriveRouting | ir::LazyBinding::DeriveCarrier => quote! { let #query = |__copy: u64, __lvl: u8| { let __head = #core_types::context::DeriveCtx::index_head(__input); #core_types::record::DerivedRecordEdge::extent_at_derived(&self.#name, &#core_types::context::DeriveCtx::promoted(__input, &__head, __copy), __lvl) @@ -1505,6 +1536,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let carrier_arg = if skips_carrier { None + } else if lazy_carrier { + // The kernel drives the derived carrier itself through its handle. + let name = ®ular_fields[0].pat_ident.ident; + Some(quote!(#name)) } else if let Some(ty) = carrier_read_ty { Some(tuple_arg(regular_fields[0], quote!(unsafe { #core_types::record::read_element::<#ty>(__src_rec) }))) } else { @@ -1521,7 +1556,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } }); let record_kernel_call = quote!(self::#fn_name(__input #(, &self.#data_names)* #(, #carrier_arg)* #(, #value_args)*)); - let carrier_eval = (!skips_carrier).then(|| { + let carrier_eval = (!skips_carrier && !lazy_carrier).then(|| { let name = ®ular_fields[0].pat_ident.ident; quote! { let __src = match __cell.eval_input(0, &self.#name, __input) { @@ -1531,8 +1566,18 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let __src_rec = self.__carrier.rec(&__src); } }); - let carry = (!skips_carrier).then(|| quote!(unsafe { #core_types::record::apply_plan(__src_rec, __dst, &self.__plan) };)); - let carrier_read_bindings: Vec = match skips_carrier { + let carry = (!skips_carrier && !lazy_carrier).then(|| quote!(unsafe { #core_types::record::apply_plan(__src_rec, __dst, &self.__plan) };)); + // A lazy carrier's source record is the token the kernel returned; its + // content frames sit above `__dst` and stay readable until the truncate. + let lazy_carry = lazy_carrier + .then(|| { + quote! { + let __src_rec = self.__carrier.rec(&__element); + unsafe { #core_types::record::apply_plan(__src_rec, __dst, &self.__plan) }; + } + }) + .unwrap_or_default(); + let carrier_read_bindings: Vec = match skips_carrier || lazy_carrier { true => Vec::new(), false => reads_of(0).into_iter().map(|(slot, read)| read_binding(slot, read, quote!(__src_rec))).collect(), }; @@ -1546,9 +1591,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn _ => quote!(#record_kernel_call), }; let attr_binders: Vec = (0..write_markers.len()).map(|index| format_ident!("__attr_{index}")).collect(); - let element_binder = match element_write { - Some(_) => quote!(__element), - None => quote!(_), + let element_binder = match (element_write, lazy_carrier) { + (Some(_), _) | (None, true) => quote!(__element), + (None, false) => quote!(_), }; // Slot binders in the return tuple's own order: an `Attr` binds the // next write binder, a `RemoveAttr` binds nothing. @@ -1590,6 +1635,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #(#carrier_read_bindings)* let __kernel_value = #kernel_value; #destructure + #lazy_carry #element_store #(#attr_stores)* if self.__frame_bytes != 0 { @@ -1714,7 +1760,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let record_bounds: Vec = { - let arena_bound = (record_io && skips_carrier) || (!record_io && (derive_routing || flip)); + let arena_bound = (record_io && skips_carrier) || (record_io && lazy_carrier) || (!record_io && (derive_routing || flip)); let mut bounds = if arena_bound { vec![quote!(#ctx_ident: #core_types::context::ExtractArena)] } else { @@ -2037,6 +2083,16 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let marker = &read.marker; quote!(#core_types::attribute::Attr<'__read, #marker>) }); + if matches!(ir::lazy_binding(&node, index), LazyBinding::DeriveCarrier) { + return quote! { + /// # Safety + /// `__rec` must be a spilled record's frame, of the layout + /// `__reads` was resolved against; the token rebinds it. + unsafe fn #read_fn<'__read>(__rec: #core_types::record::Rec, __reads: &[Option]) -> (#core_types::record::RecordValue<'__read> #(, #attr_tys)*) { + (#core_types::record::RecordValue::spilled(__rec) #(, #attr_slots)*) + } + }; + } quote! { /// # Safety /// `__rec` must be a record whose element is the declared output diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index 4ff58d3171..885d8982df 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -2,12 +2,15 @@ use super::*; /// How a record node's primary input lowers: `None` writes a fresh record, /// `Token` carries the element bytes through as `ElToken`, `Read` reads a -/// concrete element at offset 0. The element and write set fold from the IR. +/// concrete element at offset 0, and `LazyToken` is a derive-routing carrier +/// the kernel evaluates itself, returning the row token it received. The +/// element and write set fold from the IR. #[derive(Clone)] pub(crate) enum RecordCarrier { None, Token, Read, + LazyToken, } /// A well-formed record-io node: only the carrier form is retained, so @@ -269,19 +272,41 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { if !has_reads && writes.is_none() { return None; } - if parsed.is_async || parsed.fields.iter().any(|field| matches!(field.ty, ParsedFieldType::Node(_))) { - return None; - } - let reads_well_placed = parsed.fields.iter().all(|field| { - field.attribute_reads.is_empty() || (!field.is_data_field && matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: None, .. }))) - }); - if !reads_well_placed { + if parsed.is_async { return None; } let carrier_field = parsed.fields.first()?; if carrier_field.is_data_field { return None; } + // A first-field lazy carrier: the kernel evaluates the derived content + // itself and returns its opaque row token beside the write set. + let lazy_carrier = matches!(&carrier_field.ty, ParsedFieldType::Node(_)); + if parsed.fields.iter().skip(lazy_carrier as usize).any(|field| matches!(field.ty, ParsedFieldType::Node(_))) { + return None; + } + let reads_well_placed = parsed.fields.iter().enumerate().all(|(index, field)| { + field.attribute_reads.is_empty() + || (lazy_carrier && index == 0) + || (!field.is_data_field && matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: None, .. }))) + }); + if !reads_well_placed { + return None; + } + if lazy_carrier { + let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &carrier_field.ty else { + unreachable!("guarded by the lazy_carrier match"); + }; + let token = unbounded_generic(parsed, output_type)?; + let element = match writes { + Some(RecordWrites { element, .. }) => element, + None => value, + }; + if !matches!(bare_ident(&element), Some(ident) if ident == &token) { + return None; + } + return Some(RecordShape { carrier: RecordCarrier::LazyToken }); + } let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier_field.ty else { return None; }; diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 013eee094a..69da8ab77f 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -76,7 +76,9 @@ fn inputs(parsed: &ParsedNodeFn, fields: &[&ParsedField], generics: &[Ident]) -> fn subject(index: usize, field: &ParsedField, carrier_subject: bool, routing: Option<&RoutingIo>) -> bool { match &field.ty { - ParsedFieldType::Node(NodeParsedField { output_type, .. }) => is_record_value(output_type) || routing.is_some_and(|routing| bare_ident(output_type) == Some(&routing.generic)), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => { + is_record_value(output_type) || routing.is_some_and(|routing| bare_ident(output_type) == Some(&routing.generic)) || (index == 0 && carrier_subject) + } ParsedFieldType::Regular(RegularParsedField { ty, .. }) => routing.is_some_and(|routing| bare_ident(ty) == Some(&routing.generic)) || (index == 0 && carrier_subject), } } @@ -272,6 +274,7 @@ pub(crate) enum LazyBinding { Element, Plain, DeriveRouting, + DeriveCarrier, OpaqueRecord, } @@ -345,6 +348,8 @@ pub(crate) fn lazy_binding(node: &Node, index: usize) -> LazyBinding { let kind = node_kind(node); if node.derives && matches!(kind, NodeKind::Routing) && input.subject { LazyBinding::DeriveRouting + } else if node.derives && matches!(kind, NodeKind::RecordIo) && input.subject { + LazyBinding::DeriveCarrier } else if matches!(kind, NodeKind::Flip) { LazyBinding::Element } else if matches!(input.shape.element, Element::Opaque) { @@ -670,6 +675,7 @@ mod tests { }, ParsedFieldType::Node(_) => match (lazy_binding(node, index), raw) { (LazyBinding::DeriveRouting, _) => "derive-routing", + (LazyBinding::DeriveCarrier, _) => "derive-carrier", (LazyBinding::OpaqueRecord, _) => "opaque-record", (LazyBinding::Element, true) => "flip-raw", (LazyBinding::Element, false) => "flip-lazy", diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 9e4cc32748..ca472d36ed 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -97,21 +97,31 @@ fn validate_record_io(parsed: &ParsedNodeFn) { ); return; }; + let lazy_carrier = matches!(&crate::codegen::record_shape(parsed), Some(shape) if matches!(shape.carrier, crate::codegen::RecordCarrier::LazyToken)); let carrier_ty = match &carrier.ty { ParsedFieldType::Regular(RegularParsedField { ty, lend: None, .. }) if !carrier.is_data_field => Some(ty), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) if lazy_carrier => Some(output_type), _ => None, }; let Some(carrier_ty) = carrier_ty else { emit_error!( carrier.pat_ident.span(), - "a record node's primary input is an owned element, an unbounded passthrough generic, or `_: ()`; not `#[data]`, `&T`, or `impl Node`" + "a record node's primary input is an owned element, an unbounded passthrough generic, a lazy passthrough source, or `_: ()`; not `#[data]` or `&T`" ); return; }; + if lazy_carrier && !crate::codegen::ir::build(parsed).derives { + emit_error!( + parsed.input.pat_ident.span(), + "a lazy record carrier evaluates at derived contexts; spell `impl Ctx + DeriveCtx`" + ); + return; + } let no_carrier = matches!(carrier_ty, Type::Tuple(tuple) if tuple.elems.is_empty()); let token = match (no_carrier, &carrier.ty) { (false, ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. })) if implementations.is_empty() => crate::codegen::unbounded_generic(parsed, ty), + (false, ParsedFieldType::Node(NodeParsedField { output_type, .. })) if lazy_carrier => crate::codegen::unbounded_generic(parsed, output_type), _ => None, }; let element = writes.as_ref().map(|writes| &writes.element).unwrap_or(&value); @@ -179,20 +189,23 @@ fn validate_lazy_reads(parsed: &ParsedNodeFn) { if !crate::codegen::has_lazy_reads(parsed) { return; } - if !crate::codegen::record_flip(parsed) { + let lazy_carrier = matches!(&crate::codegen::record_shape(parsed), Some(shape) if matches!(shape.carrier, crate::codegen::RecordCarrier::LazyToken)); + if !crate::codegen::record_flip(parsed) && !lazy_carrier { emit_error!( parsed.fn_name.span(), "attribute reads on a lazy input need the record lowering; routing, `plain`, shader, batch, and non-row-assignable generic nodes keep the plain one" ); } - for field in &parsed.fields { + for (index, field) in parsed.fields.iter().enumerate() { let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &field.ty else { continue; }; if field.attribute_reads.is_empty() { continue; } - if crate::codegen::unbounded_generic(parsed, output_type).is_some() { + // The lazy carrier forwards its token AND reads: the reads resolve + // against its wired layout, not the row type. + if crate::codegen::unbounded_generic(parsed, output_type).is_some() && !(lazy_carrier && index == 0) { emit_error!( field.pat_ident.span(), "an unbounded generic source forwards its whole record; attribute reads need a concrete output type" diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 1adea3333c..9101fe2265 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -100,6 +100,29 @@ fn repeat_extent(content: ExtentIn<'_>, count: ValueIn<'_, u32>, _reverse: Value } } +/// Test-only lazy-carrier creator: each copy evaluates the content at its own +/// index and re-scales the row's opacity by the copy number. +#[node_macro::node(category("Test"), extent(repeat_faded_extent))] +fn repeat_faded( + ctx: impl Ctx + DeriveCtx + ExtractIndex, + content: impl Node, Output = (T, Attr)>, + count: u32, +) -> Result)>, Interrupt> { + let spilled = ctx.index_head(); + let copy = ctx.innermost_index() % count as u64; + let (element, opacity) = content.eval(&ctx.promoted(&spilled, copy))?; + Ok(emit(element, Attr(*opacity * (copy + 1) as f64))) +} + +/// The pushed level's extent is the copy count; inner levels forward to the +/// content, whose extent is taken uniform across copies (queried at copy 0). +fn repeat_faded_extent(content: ExtentIn<'_>, count: ValueIn<'_, u32>, level: LevelIn) -> GPoll { + match level.pushed() { + true => count.get().map(|count| Extent::Exactly(count as usize)), + false => content.at(level), + } +} + #[node_macro::node(category("Test"))] fn source_opacity(_: impl Ctx, _: (), element: f64, opacity: f64) -> (f64, Attr) { (element, Attr(opacity)) @@ -472,6 +495,48 @@ mod tests { } } + #[test] + fn lazy_carrier_reads_and_rewrites_the_attr_per_copy() { + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let base = f64_layout(&[Opacity::NAME]); + let opacity_offset = base.offset_of(Opacity::NAME, 0).unwrap(); + let content = RecordSourceNode { + layout: base.clone(), + element: 7., + fields: vec![(opacity_offset, 0.5)], + partial: false, + }; + reserve_for(&[&base]); + + let node = install( + RepeatFadedNode::new(RecordSource::new(content, &base, &base), ValueNode(4u32), &base), + repeat_faded_layout_meta(), + &[Some(&base)], + ); + let leveled = Node::::layout(&node).clone(); + assert_eq!(leveled.depth, 1, "the IList return pushed one rank level above the content"); + assert_eq!(node.extent_at(&ctx, 0), GPoll::Final(Extent::Exactly(4))); + + let head = ctx.index_head(); + for copy in 0..4u64 { + let mark = stack::sp(); + let lane = ctx.promoted(&head, copy); + let GPoll::Final(value) = node.eval(&lane) else { + panic!("expected a final record"); + }; + let rec = leveled.rec(&value); + // The content row's element forwards; its opacity re-scales per copy. + assert_eq!(unsafe { rec.element::() }, 7.); + assert_eq!(unsafe { rec.read::(leveled.offset_of(Opacity::NAME, 0).unwrap()) }, 0.5 * (copy + 1) as f64); + // SAFETY: the element and attr were read out above, so no borrow into this lane's frames remains. + unsafe { stack::rewind(mark) }; + } + } + #[test] fn reducer_folds_a_repeated_level() { let arena = Arena::new(1024).unwrap();