Merge the wiring surface into the registry and express the memo nodes as macro kernels

This commit is contained in:
Dennis Kobert
2026-07-27 16:06:42 +00:00
parent c3fdc97f81
commit 88d9c637e7
10 changed files with 571 additions and 675 deletions

View File

@@ -4,7 +4,7 @@ use core_types::gpoll::GPoll;
use core_types::list::{AttributeDyn, AttributeValueDyn, List, ListDyn};
use core_types::transform::Footprint;
use core_types::uuid::NodeId;
use core_types::{Color, OwnedContextImpl};
use core_types::Color;
use glam::{DAffine2, DVec2};
use graphic_types::vector_types::GradientStops;
use graphic_types::{Artboard, Graphic, Vector};
@@ -49,72 +49,3 @@ fn context_modification<T>(
let scope = ctx.scope().nullified(features_to_keep);
value.eval(&ctx.nullified(features_to_keep, &scope))
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::graphene_hash::CacheHash;
use core_types::transform::Footprint;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
/// Verifies that nullified context fields don't affect the cache hash — only the kept features matter.
#[test]
fn test_nullified_context_hash_stability() {
use core_types::Context;
use std::sync::Arc;
let original_ctx: Context = Some(Arc::new(
OwnedContextImpl::empty()
.with_footprint(Footprint::default())
.with_index(1)
.with_real_time(10.5)
.with_vararg(Box::new("test"))
.with_animation_time(20.25),
));
// A second context with different values for the nullified fields
let changed_ctx: Context = Some(Arc::new(
OwnedContextImpl::empty()
.with_footprint(Footprint::default())
.with_index(2)
.with_real_time(999.9)
.with_vararg(Box::new("test"))
.with_animation_time(888.8),
));
// Nullify everything — both should hash the same regardless of their field values
let features_to_keep = ContextFeatures::empty();
let nullified1 = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), features_to_keep);
let nullified2 = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), features_to_keep);
let mut hasher1 = DefaultHasher::new();
nullified1.cache_hash(&mut hasher1);
let mut hasher2 = DefaultHasher::new();
nullified2.cache_hash(&mut hasher2);
assert_eq!(
hasher1.finish(),
hasher2.finish(),
"Hash of nullified context should remain stable regardless of input changes when features are nullified"
);
// Keep only footprint and varargs — both have the same footprint and vararg, so hash should still match
let partial_features = ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS;
let partial1 = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), partial_features);
let partial2 = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), partial_features);
let mut hasher3 = DefaultHasher::new();
partial1.cache_hash(&mut hasher3);
let mut hasher4 = DefaultHasher::new();
partial2.cache_hash(&mut hasher4);
assert_eq!(
hasher3.finish(),
hasher4.finish(),
"Hash should be stable when keeping only footprint and varargs and their values are the same"
);
}
}

View File

@@ -1,35 +1,105 @@
use core_types::gpoll::Interrupt;
use core_types::arena::{Arena, ArenaCell};
use core_types::context::Ctx;
use core_types::frame_table::{FrameTable, Lookup};
use core_types::gnode::GNode;
use core_types::gpoll::{Extent, Finality, GPoll, Interrupt};
use core_types::graphene_hash::CacheHash;
use core_types::memo::*;
use std::hash::DefaultHasher;
use std::hash::Hasher;
use core_types::registry::cache_key;
use std::sync::Arc;
use std::sync::Mutex;
/// Helps speed up repeated renders in a computationally-heavy part of the node graph.
///
/// Stores the last evaluated data that flowed through this node and immediately returns that data on subsequent renders if the context has not changed.
#[node_macro::node(category("General"), path(graphene_core::memo), skip_impl)]
fn memoize<I: CacheHash, T: Clone>(input: I, #[data] cache: Arc<Mutex<Option<(u64, T)>>>, content: impl Node<I, Output = T>) -> Result<T, Interrupt> {
// Caches the output of a given node called with a specific input.
//
// A cache miss occurs when the Option is None. In this case, the node evaluates the inner node and memoizes (stores) the result.
//
// A cache hit occurs when the Option is Some and has a stored hash matching the hash of the call argument. In this case, the node returns the cached value without re-evaluating the inner node.
//
// Currently, only one input-output pair is cached. Subsequent calls with different inputs will overwrite the previous cache.
let mut hasher = DefaultHasher::new();
input.cache_hash(&mut hasher);
let hash = hasher.finish();
if let Some(data) = cache.lock().as_ref().unwrap().as_ref().and_then(|data| (data.0 == hash).then_some(data.1.clone())) {
return Ok(data);
#[node_macro::node(category("General"), path(graphene_core::memo), skip_impl, extent(memoize_extent))]
fn memoize<I: CacheHash, T: Clone>(input: I, #[data] cache: Arc<Mutex<Option<(u64, T, Finality)>>>, content: impl Node<I, Output = T>) -> GPoll<T> {
let key = cache_key(&input);
if let Some((hash, value, finality)) = cache.lock().unwrap().as_ref() {
if *hash == key {
return match finality {
Finality::AllFinal => GPoll::Final(value.clone()),
Finality::Partial => GPoll::Partial(value.clone()),
};
}
}
let result = content.eval(&input);
match &result {
GPoll::Final(value) => *cache.lock().unwrap() = Some((key, value.clone(), Finality::AllFinal)),
GPoll::Partial(value) => *cache.lock().unwrap() = Some((key, value.clone(), Finality::Partial)),
GPoll::Pending | GPoll::Fallback(_) | GPoll::Error(_) => {}
}
result
}
let value = content.eval(input)?;
*cache.lock().unwrap() = Some((hash, value.clone()));
Ok(value)
fn memoize_extent<C, T, NodeContent>(node: &MemoizeNode<T, NodeContent>, ctx: &C) -> GPoll<Extent>
where
T: Clone,
NodeContent: GNode<C, Output = T>,
{
node.content.extent(ctx)
}
#[node_macro::node(category(""), path(graphene_core::memo), skip_impl, extent(frame_memo_extent))]
fn frame_memo<'e, T: Clone + 'static>(
ctx: impl Ctx + CacheHash + ExtractArena<'e>,
#[data] cell: ArenaCell<FrameTable<T, 32>>,
content: impl Node<Context<'_>, Output = T>,
) -> GPoll<&'e T> {
let arena = ctx.arena();
let table = match cell.load(arena) {
Some(table) => table,
None => match arena.alloc(FrameTable::new()) {
Some((table, weak)) => {
cell.store(weak);
table
}
None => return park(arena, content.eval(ctx)),
},
};
match table.lookup(cache_key(ctx)) {
Lookup::Hit(Finality::AllFinal, value) => GPoll::Final(value),
Lookup::Hit(Finality::Partial, value) => GPoll::Partial(value),
Lookup::Vacant(slot) => match content.eval(ctx) {
GPoll::Final(value) => GPoll::Final(slot.publish(value, Finality::AllFinal)),
GPoll::Partial(value) => GPoll::Partial(slot.publish(value, Finality::Partial)),
unpublishable => {
slot.release();
park(arena, unpublishable)
}
},
Lookup::Full => park(arena, content.eval(ctx)),
}
}
fn frame_memo_extent<C, T, NodeContent>(node: &FrameMemoNode<T, NodeContent>, ctx: &C) -> GPoll<Extent>
where
T: Clone + 'static,
NodeContent: GNode<C, Output = T>,
{
node.content.extent(ctx)
}
pub fn park<'e, T>(arena: &'e Arena, result: GPoll<T>) -> GPoll<&'e T> {
match result {
GPoll::Final(value) => match arena.alloc(value) {
Some((parked, _)) => GPoll::Final(parked),
None => GPoll::arena_exhausted(),
},
GPoll::Partial(value) => match arena.alloc(value) {
Some((parked, _)) => GPoll::Partial(parked),
None => GPoll::arena_exhausted(),
},
GPoll::Fallback(boxed) => {
let (value, error) = *boxed;
match arena.alloc(value) {
Some((parked, _)) => GPoll::Fallback(Box::new((parked, error))),
None => GPoll::arena_exhausted(),
}
}
GPoll::Pending => GPoll::Pending,
GPoll::Error(error) => GPoll::Error(error),
}
}
type MonitorValue<I, T> = Arc<Mutex<Option<Arc<IORecord<I, T>>>>>;
@@ -55,3 +125,127 @@ fn serialize_monitor<I: Clone + 'static + Send + Sync, T: Clone + 'static + Send
let io = io.lock().unwrap();
io.as_ref().map(|output| output.clone() as Arc<dyn std::any::Any + Send + Sync>)
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::SourceId;
use core_types::concrete;
use core_types::context::{ContextImpl, EvalScope};
use core_types::registry::{EdgeHandle, ErasedGNode, ErasedLendGNode};
use core_types::Type;
use std::sync::atomic::{AtomicU32, Ordering};
struct CountingNode(AtomicU32);
impl<Input> GNode<Input> for CountingNode {
type Output = u32;
fn eval(&self, _input: &Input) -> GPoll<u32> {
GPoll::Final(self.0.fetch_add(1, Ordering::Relaxed) + 1)
}
}
struct PartialCountingNode(AtomicU32);
impl<Input> GNode<Input> for PartialCountingNode {
type Output = u32;
fn eval(&self, _input: &Input) -> GPoll<u32> {
GPoll::Partial(self.0.fetch_add(1, Ordering::Relaxed) + 1)
}
}
struct ValueNode<T>(T);
impl<T: Clone, Input> GNode<Input> for ValueNode<T> {
type Output = T;
fn eval(&self, _input: &Input) -> GPoll<T> {
GPoll::Final(self.0.clone())
}
}
fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> {
EvalScope::new(Some(0.5), None, None, generations, arena)
}
#[test]
fn memoize_caches_across_evals() {
let arena = Arena::new(1024);
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let memoized = MemoizeNode::new(CountingNode(AtomicU32::new(0)));
assert_eq!(memoized.eval(&ctx), GPoll::Final(1));
assert_eq!(memoized.eval(&ctx), GPoll::Final(1));
}
#[test]
fn memo_invalidates_on_generation_bump() {
let arena = Arena::new(1024);
let source: SourceId = 7;
let before = [(source, 1)];
let after = [(source, 2)];
let scope_before = scope_fixture(&before, &arena);
let scope_after = scope_fixture(&after, &arena);
let memoized = MemoizeNode::new(CountingNode(AtomicU32::new(0)));
assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1));
assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1));
assert_eq!(memoized.eval(&ContextImpl::root(&scope_after)), GPoll::Final(2));
}
#[test]
fn memo_replays_partiality_on_hit() {
let arena = Arena::new(1024);
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let memoized = MemoizeNode::new(PartialCountingNode(AtomicU32::new(0)));
assert_eq!(memoized.eval(&ctx), GPoll::Partial(1));
assert_eq!(memoized.eval(&ctx), GPoll::Partial(1));
}
#[test]
fn memoized_edges_stack_and_rewire() {
let arena = Arena::new(1024);
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let edge = EdgeHandle::new(Box::new(CountingNode(AtomicU32::new(0))) as Box<ErasedGNode<u32>>);
let memoized = EdgeHandle::new(Box::new(MemoizeNode::new(edge.downcast::<u32>().unwrap())) as Box<ErasedGNode<u32>>);
let stacked = MemoizeNode::new(memoized.downcast::<u32>().unwrap());
assert_eq!(stacked.eval(&ctx), GPoll::Final(1));
assert_eq!(stacked.eval(&ctx), GPoll::Final(1));
}
#[test]
fn frame_memo_turns_an_owned_edge_into_a_lending_edge() {
let arena = Arena::new(4096);
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let edge = EdgeHandle::new(Box::new(ValueNode("lent out".to_string())) as Box<ErasedGNode<String>>);
let lending = EdgeHandle::new_ref(Box::new(FrameMemoNode::new(edge.downcast::<String>().unwrap())) as Box<ErasedLendGNode<String>>);
assert_eq!(*lending.ty(), Type::Ref(Box::new(concrete!(String))));
let node = lending.downcast_lend::<String>().unwrap();
let GPoll::Final(first) = node.eval(&ctx) else {
panic!("lend must fill the frame table and lend");
};
let GPoll::Final(second) = node.eval(&ctx) else {
panic!("second eval must lend the published value");
};
assert_eq!(first, "lent out");
assert!(std::ptr::eq(first, second));
}
}

View File

@@ -26,6 +26,6 @@ mod test {
#[test]
pub fn passthrough_node() {
assert_eq!(passthrough((), &4), &4);
assert_eq!(passthrough(&(), &4), &4);
}
}

View File

@@ -1068,7 +1068,7 @@ mod graphene_test {
use core_types::context::{ContextImpl, EvalScope, ExtractIndex};
use core_types::gnode::{BatchStatus, GNode};
use core_types::gpoll::{Finality, GPoll};
use core_types::wire::{EdgeHandle, ErasedGNode, resolve_and_wire};
use core_types::registry::{EdgeHandle, ErasedGNode, construct};
use std::mem::MaybeUninit;
struct SourceNode<T>(T);
@@ -1130,7 +1130,7 @@ mod graphene_test {
let entries = logical_or_entries();
let value = EdgeHandle::new(Box::new(SourceNode(true)) as Box<ErasedGNode<bool>>);
let other_value = EdgeHandle::new(Box::new(SourceNode(false)) as Box<ErasedGNode<bool>>);
let wired = resolve_and_wire(&entries[0], vec![value, other_value]).unwrap().downcast::<bool>().unwrap();
let wired = construct(&entries[0], vec![value, other_value]).unwrap().downcast::<bool>().unwrap();
assert_eq!(GNode::eval(&wired, &ctx), GPoll::Final(true));
}
@@ -1150,7 +1150,7 @@ mod graphene_test {
let augend = EdgeHandle::new(Box::new(SourceNode(1.5f64)) as Box<ErasedGNode<f64>>);
let addend = EdgeHandle::new(Box::new(SourceNode(2.5f64)) as Box<ErasedGNode<f64>>);
let wired = resolve_and_wire(&entries[0], vec![augend, addend]).unwrap().downcast::<f64>().unwrap();
let wired = construct(&entries[0], vec![augend, addend]).unwrap().downcast::<f64>().unwrap();
assert_eq!(GNode::eval(&wired, &ctx), GPoll::Final(4.0));
}