diff --git a/node-graph/libraries/core-types/src/node.rs b/node-graph/libraries/core-types/src/node.rs index 577ea89560..5a6f0be357 100644 --- a/node-graph/libraries/core-types/src/node.rs +++ b/node-graph/libraries/core-types/src/node.rs @@ -1,6 +1,7 @@ use crate::context::InjectIndex; use crate::gpoll::{Extent, Finality, GPoll, GraphError, Interrupt, Level}; use std::cell::Cell; +use std::marker::PhantomData; use std::mem::MaybeUninit; use std::ops::Range; @@ -172,6 +173,66 @@ impl<'e> RecordLane<'_, crate::record::RecordValue<'e>> { } } +/// A materialized nesting level handed to a folding kernel: a thin element-typed +/// view over the [`RecordBatch`] the level was collected into. `'a` is the batch +/// view, `'e` the record payloads. The eventual `List` once `IList` is renamed. +#[derive(Debug)] +pub struct List<'a, 'e, T> { + batch: RecordBatch<'a, crate::record::RecordValue<'e>>, + _element: PhantomData, +} + +impl<'a, 'e, T: Copy> List<'a, 'e, T> { + /// # Safety + /// `T` must be the batch's record element type, proven at the consumer's wiring. + pub unsafe fn new(batch: RecordBatch<'a, crate::record::RecordValue<'e>>) -> Self { + Self { batch, _element: PhantomData } + } + + pub fn len(&self) -> usize { + self.batch.len() + } + + pub fn is_empty(&self) -> bool { + self.batch.is_empty() + } + + pub fn get(&self, index: usize) -> T { + // SAFETY: `List::new` established that `T` is the batch's element type. + self.batch.get(index, |lane| unsafe { lane.element::() }) + } + + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.len()).map(move |index| self.get(index)) + } +} + +impl<'a, 'e, T: Copy> IntoIterator for List<'a, 'e, T> { + type Item = T; + type IntoIter = ListIter<'a, 'e, T>; + + fn into_iter(self) -> ListIter<'a, 'e, T> { + ListIter { list: self, position: 0 } + } +} + +pub struct ListIter<'a, 'e, T> { + list: List<'a, 'e, T>, + position: usize, +} + +impl Iterator for ListIter<'_, '_, T> { + type Item = T; + + fn next(&mut self) -> Option { + (self.position < self.list.len()).then(|| { + let value = self.list.get(self.position); + self.position += 1; + value + }) + } +} + pub trait Node { type Output; diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 253f622222..9607b02c56 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -353,6 +353,13 @@ impl std::fmt::Debug for RecordValue<'_> { } } +// SAFETY: `element_write` requires the element `Send + Sync` and attribute payloads +// are `Copy` or arena-backed, so the record bytes behind the pointer are thread-safe; +// `'e` ties the pointer's validity to the shared arena and record-stack discipline. +unsafe impl Send for RecordValue<'_> {} +// SAFETY: as `Send`. +unsafe impl Sync for RecordValue<'_> {} + impl<'e> RecordValue<'e> { #[doc(hidden)] pub fn zeroed() -> Self { diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 710f71adbb..591e7d5816 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -54,7 +54,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let model = analyze(parsed); let node = crate::codegen::ir::build(parsed); let kind = model.as_ref().map(|_| crate::codegen::ir::node_kind(&node)); - let carrier_present = node.inputs.first().is_some_and(|input| input.subject); + let carrier_present = matches!(node.inputs.first(), Some(input) if input.subject && crate::codegen::ir::materialized_levels(&node, 0) == 0); let record_io = matches!(kind, Some(crate::codegen::ir::NodeKind::RecordIo)); let flip = matches!(kind, Some(crate::codegen::ir::NodeKind::Flip)); let carrier_flip = flip && carrier_present; @@ -722,7 +722,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let async_source = async_fn || future_kernel; let node = crate::codegen::ir::build(parsed); let kind = crate::codegen::ir::node_kind(&node); - let carrier_present = node.inputs.first().is_some_and(|input| input.subject); + let carrier_present = matches!(node.inputs.first(), Some(input) if input.subject && crate::codegen::ir::materialized_levels(&node, 0) == 0); let flip = matches!(kind, crate::codegen::ir::NodeKind::Flip); let carrier_flip = flip && carrier_present; let opaque = matches!(kind, crate::codegen::ir::NodeKind::Opaque); @@ -951,6 +951,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .map(|(index, field)| { let pat = &field.pat_ident; match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, .. }) if ir::materialized_levels(&node, index) > 0 => { + quote!(#pat: #core_types::node::List<'_, '_, #ty>) + } ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(#pat: &#ty), ParsedFieldType::Regular(RegularParsedField { ty, .. }) if !field.attribute_reads.is_empty() => read_tuple_param(field, quote!(#pat), quote!(#ty)), ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(#pat: #ty), @@ -1088,6 +1091,28 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // A carrier primary evaluates beyond the node's own frame (in the // record/flip tail), so it does not bind here. ValueBinding::Carrier => quote!(), + ValueBinding::Materialized => { + let levels = ir::materialized_levels(&node, index); + quote! { + let __arena = #core_types::context::ExtractArena::arena(__input); + let __count = match #core_types::node::Node::extent(&self.#name, __input, #core_types::gpoll::Level::Below(#levels)) { + #core_types::gpoll::GPoll::Final(#core_types::gpoll::Extent::Exactly(__count)) => __count, + #core_types::gpoll::GPoll::Pending => return #core_types::gpoll::GPoll::Pending, + _ => return #core_types::gpoll::GPoll::Error(::std::boxed::Box::new(#core_types::gpoll::GraphError::new("reduce over a non-exact extent"))), + }; + let __scratch = match __arena.alloc_scratch::<#core_types::record::RecordValue<'__record>>(__count) { + Some(__scratch) => __scratch, + None => return #core_types::gpoll::GPoll::Error(::std::boxed::Box::new(#core_types::gpoll::GraphError::new("reduce scratch allocation failed"))), + }; + let __batch = match #core_types::node::Node::eval_batch(&self.#name, __input, 0..__count as u64, Some(__scratch)) { + #core_types::node::BatchStatus::Lent(__batch, _) | #core_types::node::BatchStatus::Filled(__batch, _) => __batch, + #core_types::node::BatchStatus::Pending => return #core_types::gpoll::GPoll::Pending, + #core_types::node::BatchStatus::Error(__error) => return #core_types::gpoll::GPoll::Error(::std::boxed::Box::new(__error)), + _ => return #core_types::gpoll::GPoll::Error(::std::boxed::Box::new(#core_types::gpoll::GraphError::new("reduce batch failed"))), + }; + let #name = unsafe { #core_types::node::List::<#ty>::new(__batch) }; + } + } // A reading secondary input claims a record edge: the element and // the declared reads copy out right after its eval, before any // later sibling eval can reuse the record stack. @@ -1668,7 +1693,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn true => { let mut bounds: Vec = regular_fields .iter() - .filter_map(|field| match &field.ty { + .enumerate() + .filter(|(index, _)| ir::materialized_levels(&node, *index) == 0) + .filter_map(|(_, field)| match &field.ty { // The conditional arena-park moves a lend element once. ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => Some(quote!(#ty: ::core::marker::Send + ::core::marker::Sync + 'static)), ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: ::core::clone::Clone)), diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index eff9ac62b6..4ff58d3171 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -63,7 +63,7 @@ pub(crate) fn analyze(parsed: &ParsedNodeFn) -> Option { } else if has_record_io(parsed) { return None; } else { - routing_io(parsed).is_some() || record_flip(parsed) || record_opaque(parsed) + routing_io(parsed).is_some() || record_flip(parsed) || record_opaque(parsed) || has_materialized_input(parsed) }; supported.then(|| dialect(parsed)) } @@ -367,6 +367,13 @@ pub(crate) fn flip_carrier(parsed: &ParsedNodeFn) -> bool { /// fully-concrete value-input nodes in this cut; batch, shader, async, lend, /// lazy, and generic nodes keep the plain lowering until their record forms /// land. +pub(crate) fn has_materialized_input(parsed: &ParsedNodeFn) -> bool { + parsed + .fields + .iter() + .any(|field| matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { list_levels, .. }) if *list_levels > 0)) +} + pub(crate) fn record_flip(parsed: &ParsedNodeFn) -> bool { if record_shape(parsed).is_some() || has_record_io(parsed) || routing_io(parsed).is_some() { return false; diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 44507c704b..d93a71b58e 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -59,10 +59,14 @@ fn inputs(parsed: &ParsedNodeFn, fields: &[&ParsedField], generics: &[Ident]) -> ParsedFieldType::Node(_) => Evaluation::Lazy, ParsedFieldType::Regular(_) => Evaluation::Eager, }; + let (element, depth) = match &field.ty { + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => strip_ilist(output_type), + ParsedFieldType::Regular(RegularParsedField { ty, list_levels, .. }) => (ty.clone(), *list_levels), + }; Input { ident: field.pat_ident.ident.clone(), evaluation, - shape: item_shape(field_element_type(field), &field.attribute_reads, generics), + shape: item_shape(&element, depth, &field.attribute_reads, generics), subject: subject(index, field, carrier_subject, routing.as_ref()), lend: matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. })), } @@ -135,10 +139,9 @@ fn field_element_type(field: &ParsedField) -> &Type { } } -fn item_shape(element: &Type, reads: &[AttributeRead], generics: &[Ident]) -> ItemShape { - let (element, depth) = strip_ilist(element); +fn item_shape(element: &Type, depth: u8, reads: &[AttributeRead], generics: &[Ident]) -> ItemShape { ItemShape { - element: element_of(&element, generics), + element: element_of(element, generics), depth, attrs: reads.iter().map(|read| LevelAttr { marker: read.marker.clone(), level: 0 }).collect(), } @@ -226,6 +229,7 @@ fn level_delta(node: &Node) -> i8 { /// How an eager value input binds in eval. pub(crate) enum ValueBinding { Carrier, + Materialized, Lend, ReadingSecondary, RecordElement, @@ -282,10 +286,18 @@ fn has_attr_io(node: &Node) -> bool { node.inputs.iter().any(|input| matches!(input.evaluation, Evaluation::Eager) && !input.shape.attrs.is_empty()) || !node.output.shape.attrs.is_empty() || !node.output.removes.is_empty() } +/// Levels of `input[index]` the output does not carry; `> 0` folds the input +/// into a `List` before the kernel. +pub(crate) fn materialized_levels(node: &Node, index: usize) -> u8 { + node.inputs[index].shape.depth.saturating_sub(node.output.shape.depth) +} + pub(crate) fn value_binding(node: &Node, index: usize) -> ValueBinding { let input = &node.inputs[index]; let kind = node_kind(node); - if matches!(kind, NodeKind::RecordIo | NodeKind::Flip) && index == 0 && input.subject { + if materialized_levels(node, index) > 0 { + ValueBinding::Materialized + } else if matches!(kind, NodeKind::RecordIo | NodeKind::Flip) && index == 0 && input.subject { ValueBinding::Carrier } else if matches!(kind, NodeKind::Flip) && input.lend { ValueBinding::Lend @@ -620,6 +632,7 @@ mod tests { match &field.ty { ParsedFieldType::Regular(_) => match value_binding(node, index) { ValueBinding::Carrier => "carrier", + ValueBinding::Materialized => "materialized", ValueBinding::Lend => "lend", ValueBinding::ReadingSecondary => "reading", ValueBinding::RecordElement => "record", diff --git a/node-graph/node-macro/src/codegen/metadata.rs b/node-graph/node-macro/src/codegen/metadata.rs index 5e8444ed1a..67d04834f5 100644 --- a/node-graph/node-macro/src/codegen/metadata.rs +++ b/node-graph/node-macro/src/codegen/metadata.rs @@ -17,10 +17,9 @@ pub(crate) fn generate_node_input_references( for (input_index, (parsed_input, input_ident)) in parsed.fields.iter().zip(field_idents).enumerate() { let mut ty = match &parsed_input.ty { - ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty, - ParsedFieldType::Node(NodeParsedField { output_type, .. }) => output_type, - } - .clone(); + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty.clone(), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => output_type.clone(), + }; // We only want the necessary generics. let used = generic_collector.filter_unnecessary_generics(&mut modified, &mut ty); diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index fb663d5817..06e33034c2 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -313,6 +313,8 @@ impl Parse for NumberRange { #[derive(Clone, Debug)] pub struct RegularParsedField { pub ty: Type, + /// `IList` nesting stripped from `ty` at parse; `ty` holds the element row. + pub list_levels: u8, /// The original reference tokens when the parameter was written `&T`; `ty` holds the peeled inner type. pub lend: Option, pub exposed: bool, @@ -911,6 +913,7 @@ fn parse_node_implementations(attr: &Attribute, name: &Ident) -> syn:: } fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Result { + let (ty, list_levels) = crate::codegen::ir::strip_ilist(&ty); let ident = &pat_ident.ident; // Checks for the #[data] attribute, indicating that this is a data field rather than an input parameter to the node. @@ -1122,6 +1125,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul number_hard_max, number_mode_range, ty, + list_levels, lend, value_source, implementations, @@ -1236,6 +1240,7 @@ impl ParsedNodeFn { widget_override: ParsedWidgetOverride::Hidden, ty: ParsedFieldType::Regular(RegularParsedField { ty, + list_levels: 0, lend: None, exposed: false, value_source, @@ -1410,6 +1415,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, @@ -1504,6 +1510,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(DVec2), exposed: false, value_source: ParsedValueSource::None, @@ -1579,6 +1586,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::Default(quote!(50.)), @@ -1652,6 +1660,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, @@ -1737,6 +1746,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(f64), exposed: false, value_source: ParsedValueSource::None, @@ -1825,6 +1835,7 @@ mod tests { widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { lend: None, + list_levels: 0, ty: parse_quote!(String), exposed: true, value_source: ParsedValueSource::None, diff --git a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs index 23a4b78a61..1e0907e880 100644 --- a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs +++ b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs @@ -232,6 +232,7 @@ impl PerPixelAdjustCodegen<'_> { widget_override: Default::default(), ty: ParsedFieldType::Regular(RegularParsedField { ty: parse_quote!(#wgpu_executor::WgpuExecutorHandle), + list_levels: 0, lend: None, exposed: true, value_source: ParsedValueSource::Scope(Box::new(parse_quote!("graphene_std::platform_application_io::WgpuExecutorNode"))), diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index feabd3b5cf..51adb8dd0c 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -6,7 +6,7 @@ //! wiring is by hand until the compiler pass constructs layouts. use core_types::attribute::{Attr, Opacity, RemoveAttr}; -use core_types::context::{ExtractArena, ExtractIndex}; +use core_types::context::{ExtractArena, ExtractIndex, InjectIndex}; use core_types::gpoll::{ErrorKind, GraphError, Interrupt}; use core_types::{Context, Ctx}; @@ -57,6 +57,11 @@ fn repeat_opacity(ctx: impl Ctx + ExtractIndex, element: f64, count: u32) -> ILi emit(element, Attr(ctx.innermost_index() as f64)) } +#[node_macro::node(category("Test"))] +fn sum(_: impl Ctx + InjectIndex + Copy, items: IList) -> f64 { + items.into_iter().sum() +} + /// The pushed level's extent is the copy count; other levels forward to the carrier. fn repeat_opacity_extent(node: &RepeatOpacityNode, ctx: &C, level: u8) -> core_types::gpoll::GPoll where @@ -288,6 +293,29 @@ mod tests { assert_eq!(node.extent_at(&ctx, 0), core_types::gpoll::GPoll::Final(core_types::gpoll::Extent::Exactly(3))); } + #[test] + fn reducer_folds_a_repeated_level() { + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let base = f64_layout(&[]); + let leveled = repeat_opacity_layout(&base); + let out = f64_layout(&[]); + reserve_for(&[&base, &leveled, &out]); + + let repeat = RepeatOpacityNode::new(bare_source(&base, 7.), ValueNode(3u32), &base); + let node = SumNode::new(repeat, &leveled); + assert_eq!(node.layout().depth, 0, "the reducer collapsed the rank level"); + + let GPoll::Final(value) = node.eval(&ctx) else { + panic!("expected a final record"); + }; + // sum(repeat(3, 7)) folds three copies of the element back to a scalar. + assert_eq!(unsafe { out.rec(&value).element::() }, 21.); + } + #[test] fn layout_meta_folds_to_construction() { let base = f64_layout(&[]);