diff --git a/benches/run-program.rs b/benches/run-program.rs index 5d78de343..25cf2dbe0 100644 --- a/benches/run-program.rs +++ b/benches/run-program.rs @@ -192,7 +192,7 @@ type EnvFn = fn(&mut Allocator) -> NodePtr; fn run_program_benchmark(c: &mut Criterion) { let mut a = Allocator::new(); - let dialect = ChiaDialect::new(ClvmFlags::empty()); + let dialect = ChiaDialect::new(ClvmFlags::ENABLE_GC); let test_case_checkpoint = a.checkpoint(); diff --git a/clvm-fuzzing/src/node_eq.rs b/clvm-fuzzing/src/node_eq.rs index ce7a45860..b76042581 100644 --- a/clvm-fuzzing/src/node_eq.rs +++ b/clvm-fuzzing/src/node_eq.rs @@ -27,3 +27,32 @@ pub fn node_eq(allocator: &Allocator, lhs: NodePtr, rhs: NodePtr) -> bool { } true } + +/// Compare two CLVM trees that may belong to different allocators. +/// Returns true if they are structurally identical, false otherwise. +pub fn node_eq_two( + lhs_allocator: &Allocator, + lhs: NodePtr, + rhs_allocator: &Allocator, + rhs: NodePtr, +) -> bool { + let mut stack = vec![(lhs, rhs)]; + + while let Some((l, r)) = stack.pop() { + match (lhs_allocator.sexp(l), rhs_allocator.sexp(r)) { + (SExp::Pair(ll, lr), SExp::Pair(rl, rr)) => { + stack.push((lr, rr)); + stack.push((ll, rl)); + } + (SExp::Atom, SExp::Atom) => { + if lhs_allocator.atom(l).as_ref() != rhs_allocator.atom(r).as_ref() { + return false; + } + } + _ => { + return false; + } + } + } + true +} diff --git a/docs/new-operator-checklist.md b/docs/new-operator-checklist.md index acabf086a..b36b36a2c 100644 --- a/docs/new-operator-checklist.md +++ b/docs/new-operator-checklist.md @@ -27,6 +27,12 @@ Follow this checklist when adding operators: - Extend the benchmark-clvm-cost.rs to include benchmarks for the new operator, to establish its cost. - The opcode decoding and dispatching happens in `src/chia_dialect.rs` +- The ChiaDialect trait also has a function called gc_candidate(). If the new + operator is likely to return a small atom (say 48 bytes or less), this + function should return `true` for the new opcode. This allows the interpreter to + free all memory allocated by the opcode and any arguments computed for its + invocation. As long as the return value is a small atom and can easily be put + back in the allocator. - Add support for the new operators in `src/test_ops.rs` `parse_atom()`, to compile the name of the operator to its corresponding opcode. - If the operator(s) are part of an extension to `softfork`, add another value diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index f19873274..bfc4d5da3 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -23,6 +23,12 @@ path = "fuzz_targets/run_program.rs" test = false doc = false +[[bin]] +name = "garbage-collection" +path = "fuzz_targets/garbage_collection.rs" +test = false +doc = false + [[bin]] name = "serialized-length" path = "fuzz_targets/serialized_length.rs" diff --git a/fuzz/fuzz_targets/garbage_collection.rs b/fuzz/fuzz_targets/garbage_collection.rs new file mode 100644 index 000000000..8baf2c151 --- /dev/null +++ b/fuzz/fuzz_targets/garbage_collection.rs @@ -0,0 +1,68 @@ +#![no_main] + +use clvm_fuzzing::{make_clvm_program, make_tree_limits, node_eq_two}; +use libfuzzer_sys::{Corpus, fuzz_target}; + +use clvmr::allocator::Allocator; +use clvmr::chia_dialect::{ChiaDialect, ClvmFlags}; +use clvmr::cost::Cost; +use clvmr::reduction::{Reduction, Response}; +use clvmr::run_program::run_program; + +const MAX_COST: Cost = 11_000_000_000; + +fuzz_target!(|data: &[u8]| -> Corpus { + let mut results: Vec<(Response, Allocator)> = Vec::new(); + + for flags in [ClvmFlags::empty(), ClvmFlags::ENABLE_GC] { + let mut unstructured = arbitrary::Unstructured::new(data); + let mut a = Allocator::new(); + let (args, _) = + make_tree_limits(&mut a, &mut unstructured, 100, true).expect("out of memory"); + let Ok(program) = make_clvm_program(&mut a, &mut unstructured, args, 100_000) else { + return Corpus::Reject; + }; + let dialect = ChiaDialect::new(flags); + let result = run_program(&mut a, &dialect, program, args, MAX_COST); + results.push((result, a)); + } + + assert_eq!( + results[0].1.atom_count(), + results[1].1.atom_count(), + "atom count differs empty vs ENABLE_GC" + ); + assert_eq!( + results[0].1.pair_count(), + results[1].1.pair_count(), + "pair count differs empty vs ENABLE_GC" + ); + assert_eq!( + results[0].1.heap_size(), + results[1].1.heap_size(), + "heap size differs empty vs ENABLE_GC" + ); + + match (&results[0].0, &results[1].0) { + (Ok(Reduction(cost_empty, node_empty)), Ok(Reduction(cost_gc, node_gc))) => { + assert_eq!(cost_empty, cost_gc, "cost differs empty vs ENABLE_GC"); + assert!( + node_eq_two(&results[0].1, *node_empty, &results[1].1, *node_gc), + "result value differs empty vs ENABLE_GC" + ); + } + (Err(e_empty), Err(e_gc)) => { + assert_eq!( + e_empty.to_string(), + e_gc.to_string(), + "error differs empty vs ENABLE_GC" + ); + } + _ => panic!( + "outcome mismatch: empty={} ENABLE_GC={}", + results[0].0.is_ok(), + results[1].0.is_ok() + ), + } + Corpus::Keep +}); diff --git a/fuzz/fuzz_targets/run_program.rs b/fuzz/fuzz_targets/run_program.rs index 114d43fdb..57ea6a6a0 100644 --- a/fuzz/fuzz_targets/run_program.rs +++ b/fuzz/fuzz_targets/run_program.rs @@ -21,7 +21,12 @@ fuzz_target!(|data: &[u8]| -> Corpus { let allocator_checkpoint = allocator.checkpoint(); - for flags in [ClvmFlags::empty(), ClvmFlags::NO_UNKNOWN_OPS, MEMPOOL_MODE] { + for flags in [ + ClvmFlags::ENABLE_GC, + ClvmFlags::empty(), + ClvmFlags::NO_UNKNOWN_OPS, + MEMPOOL_MODE, + ] { let dialect = ChiaDialect::new(flags.union(ClvmFlags::DISABLE_OP)); allocator.restore_checkpoint(&allocator_checkpoint); diff --git a/src/allocator.rs b/src/allocator.rs index cc653d707..b79ca71f1 100644 --- a/src/allocator.rs +++ b/src/allocator.rs @@ -170,14 +170,47 @@ pub struct IntPair { // to restore an allocator to a previous state. It cannot be used to re-create // the state from some other allocator. pub struct Checkpoint { - u8s: usize, - pairs: usize, - atoms: usize, + inner: TransparentCheckpoint, ghost_atoms: usize, ghost_pairs: usize, ghost_heap: usize, } +pub struct TransparentCheckpoint { + u8s: u32, + pairs: u32, + atoms: u32, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum NodeStatus { + /// The node was created before we took the checkpoint, it will still be + /// valid after restoring the allocator state to the checkpoint. + Before, + /// This node was created after we took the checkpoint. It also refers to + /// data that was created after the checkpoint. Every aspect of this node will + /// become invalid after restoring the allocator to the checkpoint. + AfterNewBytes, + /// This node was created after the checkpoint, but it's referencing data + /// from the heap that was allocated before the checkpoint. This is a case + /// that can happen with substr() on a value from the environment. The node + /// will become invalid, but the underlying data won't, so we could create a + /// new node referencing the same data, after restoring the allocator to the + /// checkpoint. + AfterOldBytes { start: u32, end: u32 }, +} + +/// Result of restoring to a transparent checkpoint while considering a return value. +#[derive(Debug)] +pub enum MaybeRestore { + /// Restore performed; return value unchanged (still valid). + NoReplace, + /// Restore performed; caller must replace stack top with this node. + Replace(NodePtr), + /// Restore not performed (return value not materializable); checkpoint still valid. + Aborted, +} + pub enum NodeVisitor<'a> { Buffer(&'a [u8]), U32(u32), @@ -417,14 +450,12 @@ impl Allocator { ) } - // create a checkpoint for the current state of the allocator. This can be - // used to go back to an earlier allocator state by passing the Checkpoint - // to restore_checkpoint(). + /// create a checkpoint for the current state of the allocator. This can be + /// used to go back to an earlier allocator state by passing the Checkpoint + /// to restore_checkpoint(). pub fn checkpoint(&self) -> Checkpoint { Checkpoint { - u8s: self.u8_vec.len(), - pairs: self.pair_vec.len(), - atoms: self.atom_vec.len(), + inner: self.transparent_checkpoint(), ghost_atoms: self.ghost_atoms, ghost_pairs: self.ghost_pairs, ghost_heap: self.ghost_heap, @@ -432,19 +463,39 @@ impl Allocator { } pub fn restore_checkpoint(&mut self, cp: &Checkpoint) { + self.restore_transparent_checkpoint(&cp.inner); + self.ghost_atoms = cp.ghost_atoms; + self.ghost_pairs = cp.ghost_pairs; + self.ghost_heap = cp.ghost_heap; + } + + /// create a checkpoint for the current state of the allocator. This is used + /// to free all atoms and pairs allocated after this point, without + /// affecting counters. i.e. as if they are still allocated + pub fn transparent_checkpoint(&self) -> TransparentCheckpoint { + TransparentCheckpoint { + u8s: self.u8_vec.len() as u32, + pairs: self.pair_vec.len() as u32, + atoms: self.atom_vec.len() as u32, + } + } + + /// A transparent checkpoint works the same as a regular one but it doesn't + /// restore the counters. The atoms and pair being removed are still counted. + pub fn restore_transparent_checkpoint(&mut self, cp: &TransparentCheckpoint) { // if any of these asserts fire, it means we're trying to restore to // a state that has already been "long-jumped" passed (via another // restore to an earlier state). You can only restore backwards in time, // not forwards. - assert!(self.u8_vec.len() >= cp.u8s); - assert!(self.pair_vec.len() >= cp.pairs); - assert!(self.atom_vec.len() >= cp.atoms); - self.u8_vec.truncate(cp.u8s); - self.pair_vec.truncate(cp.pairs); - self.atom_vec.truncate(cp.atoms); - self.ghost_atoms = cp.ghost_atoms; - self.ghost_pairs = cp.ghost_pairs; - self.ghost_heap = cp.ghost_heap; + assert!(self.u8_vec.len() >= cp.u8s as usize); + assert!(self.pair_vec.len() >= cp.pairs as usize); + assert!(self.atom_vec.len() >= cp.atoms as usize); + self.ghost_heap += self.u8_vec.len() - cp.u8s as usize; + self.ghost_pairs += self.pair_vec.len() - cp.pairs as usize; + self.ghost_atoms += self.atom_vec.len() - cp.atoms as usize; + self.u8_vec.truncate(cp.u8s as usize); + self.pair_vec.truncate(cp.pairs as usize); + self.atom_vec.truncate(cp.atoms as usize); // This invalidates all NodePtrs with higher index than this, with a // lower version than self.versions.len() @@ -453,6 +504,118 @@ impl Allocator { .push((self.atom_vec.len() as u32, self.pair_vec.len() as u32)); } + /// classify whether a node survives a restore to the checkpoint, and + /// whether it references bytes allocated before or after that checkpoint. + pub fn checkpoint_node_status( + &self, + checkpoint: &TransparentCheckpoint, + node: NodePtr, + ) -> NodeStatus { + match node.object_type() { + ObjectType::Pair => { + if node.index() < checkpoint.pairs { + NodeStatus::Before + } else { + NodeStatus::AfterNewBytes + } + } + ObjectType::Bytes => { + if node.index() < checkpoint.atoms { + NodeStatus::Before + } else { + let atom = self.atom_vec[node.index() as usize]; + if atom.start < checkpoint.u8s { + NodeStatus::AfterOldBytes { + start: atom.start, + end: atom.end, + } + } else { + NodeStatus::AfterNewBytes + } + } + } + ObjectType::SmallAtom => NodeStatus::Before, + } + } + + /// Attempt to restore the checkpoint, and preserve the value of the / + /// specified node. If the node was allocated after the checkpoint, it will + /// be invalidated. Fix up accounting and optionally produce a replacement + /// node. Caller must replace the value stack top when `Replace(node)` is + /// returned, If the node is a tree or too large to be restored, the + /// allocator will not be restored to the checkpoint and Aborted will be + /// returned. + pub fn maybe_restore_with_node( + &mut self, + checkpoint: &TransparentCheckpoint, + ret: NodePtr, + ) -> Result { + const CLONE_ATOM_LIMIT: usize = 48; + const MIN_SAVINGS: usize = 1024; + + let saved_bytes = (self.u8_vec.len() - checkpoint.u8s as usize) + + (self.atom_vec.len() - checkpoint.atoms as usize) * 8 + + (self.pair_vec.len() - checkpoint.pairs as usize) * 8; + if saved_bytes < MIN_SAVINGS { + return Ok(MaybeRestore::Aborted); + } + + match self.checkpoint_node_status(checkpoint, ret) { + NodeStatus::Before => { + self.restore_transparent_checkpoint(checkpoint); + Ok(MaybeRestore::NoReplace) + } + NodeStatus::AfterOldBytes { start, end } => { + self.restore_transparent_checkpoint(checkpoint); + if self.ghost_atoms == 0 { + return Err(EvalErr::InternalError( + NodePtr::NIL, + "ghost atom accounting error".to_string(), + )); + } + self.ghost_atoms -= 1; + if end < start || end as usize > self.u8_vec.len() { + return Err(EvalErr::InternalError( + self.nil(), + "invalid atom byte range".to_string(), + )); + } + let idx = self.atom_vec.len(); + self.atom_vec.push(AtomBuf { start, end }); + let new_ret = self.mk_node(ObjectType::Bytes, idx); + Ok(MaybeRestore::Replace(new_ret)) + } + NodeStatus::AfterNewBytes => { + let NodeVisitor::Buffer(buf) = self.node(ret) else { + return Ok(MaybeRestore::Aborted); + }; + + if buf.len() > CLONE_ATOM_LIMIT { + return Ok(MaybeRestore::Aborted); + } + let mut saved_bytes = [0u8; CLONE_ATOM_LIMIT]; + let len = buf.len(); + saved_bytes[..len].copy_from_slice(buf); + self.restore_transparent_checkpoint(checkpoint); + if self.ghost_atoms == 0 { + return Err(EvalErr::InternalError( + NodePtr::NIL, + "ghost atom accounting error".to_string(), + )); + } + self.ghost_atoms -= 1; + if self.ghost_heap < len { + return Err(EvalErr::InternalError( + NodePtr::NIL, + "ghost heap accounting error".to_string(), + )); + } + self.ghost_heap -= len; + Ok(MaybeRestore::Replace(self.new_atom(&saved_bytes[..len])?)) + } + } + } + pub fn new_atom(&mut self, v: &[u8]) -> Result { let start = self.u8_vec.len() as u32; if start as usize + self.ghost_heap + v.len() > self.heap_limit { @@ -628,6 +791,7 @@ impl Allocator { self.ghost_atoms += amount; Ok(()) } + pub fn new_substr(&mut self, node: NodePtr, start: u32, end: u32) -> Result { #[cfg(feature = "allocator-debug")] self.validate_node(node); @@ -1590,6 +1754,149 @@ mod tests { assert_eq!(a.add_ghost_pair(1).unwrap_err(), EvalErr::TooManyPairs); } + #[test] + fn test_transparent_checkpoint() { + let mut a = Allocator::new(); + + let atom1 = a.new_atom(&[4, 3, 2, 1]).unwrap(); + assert!(a.atom(atom1).as_ref() == [4, 3, 2, 1]); + + let checkpoint = a.transparent_checkpoint(); + + let atom2 = a.new_atom(&[6, 5, 4, 3]).unwrap(); + let _pair1 = a.new_pair(atom1, atom2).unwrap(); + assert!(a.atom(atom1).as_ref() == [4, 3, 2, 1]); + assert!(a.atom(atom2).as_ref() == [6, 5, 4, 3]); + + let atom_count_before = a.atom_count(); + let pair_count_before = a.pair_count(); + + // at this point we have two atoms and a checkpoint from before the second + // atom was created + + // now, restoring the checkpoint state will make atom2 disappear + + a.restore_transparent_checkpoint(&checkpoint); + + assert_eq!(a.atom_count(), atom_count_before); + assert_eq!(a.pair_count(), pair_count_before); + + assert!(a.atom(atom1).as_ref() == [4, 3, 2, 1]); + let atom3 = a.new_atom(&[6, 5, 4, 3]).unwrap(); + assert!(a.atom(atom3).as_ref() == [6, 5, 4, 3]); + + // since atom2 was removed, atom3 should actually be using that slot + assert_eq!(atom2, atom3); + } + + #[test] + fn test_transparent_checkpoint_contains() { + let mut a = Allocator::new(); + + let atom_before = a.new_atom(b"hello").unwrap(); + let pair_before = a.new_pair(atom_before, atom_before).unwrap(); + let small_before = a.new_small_number(1).unwrap(); + + let checkpoint = a.transparent_checkpoint(); + + let atom_after_new = a.new_atom(b"world").unwrap(); + let pair_after = a.new_pair(atom_after_new, atom_before).unwrap(); + let small_after = a.new_small_number(2).unwrap(); + let atom_after_old = a.new_substr(atom_before, 0, 5).unwrap(); + + assert_eq!( + a.checkpoint_node_status(&checkpoint, atom_before), + NodeStatus::Before + ); + assert_eq!( + a.checkpoint_node_status(&checkpoint, pair_before), + NodeStatus::Before + ); + assert_eq!( + a.checkpoint_node_status(&checkpoint, small_before), + NodeStatus::Before + ); + assert_eq!( + a.checkpoint_node_status(&checkpoint, small_after), + NodeStatus::Before + ); + assert_eq!( + a.checkpoint_node_status(&checkpoint, atom_after_new), + NodeStatus::AfterNewBytes + ); + assert_eq!( + a.checkpoint_node_status(&checkpoint, pair_after), + NodeStatus::AfterNewBytes + ); + assert!(matches!( + a.checkpoint_node_status(&checkpoint, atom_after_old), + NodeStatus::AfterOldBytes { .. } + )); + } + + fn alloc_filler(a: &mut Allocator) { + a.new_atom(&[0u8; 1024]).unwrap(); + } + + #[test] + fn test_restore_node_before_checkpoint() { + let mut a = Allocator::new(); + let atom1 = a.new_atom(&[4, 3, 2, 1]).unwrap(); + let cp = a.transparent_checkpoint(); + alloc_filler(&mut a); + let out = a.maybe_restore_with_node(&cp, atom1).unwrap(); + assert!(matches!(out, MaybeRestore::NoReplace)); + assert_eq!(a.atom(atom1).as_ref(), [4, 3, 2, 1]); + } + + #[test] + fn test_restore_node_after_old_bytes() { + let mut a = Allocator::new(); + let atom_hello = a.new_atom(b"hello").unwrap(); + let cp = a.transparent_checkpoint(); + alloc_filler(&mut a); + let substr = a.new_substr(atom_hello, 0, 5).unwrap(); + assert_eq!(a.atom(substr).as_ref(), b"hello"); + let out = a.maybe_restore_with_node(&cp, substr).unwrap(); + let MaybeRestore::Replace(new_node) = out else { + panic!("expected Replace"); + }; + assert_eq!(a.atom(new_node).as_ref(), b"hello"); + } + + #[test] + fn test_restore_node_after_new_bytes() { + let mut a = Allocator::new(); + let cp = a.transparent_checkpoint(); + alloc_filler(&mut a); + let atom_x = a.new_atom(b"foobar").unwrap(); + let out = a.maybe_restore_with_node(&cp, atom_x).unwrap(); + let MaybeRestore::Replace(new_node) = out else { + panic!("expected Replace"); + }; + assert_eq!(a.atom(new_node).as_ref(), b"foobar"); + } + + #[test] + fn test_restore_aborted_atom_too_large() { + let mut a = Allocator::new(); + let cp = a.transparent_checkpoint(); + alloc_filler(&mut a); + let big: Vec = (0..49).collect(); + let atom_big = a.new_atom(&big).unwrap(); + let out = a.maybe_restore_with_node(&cp, atom_big).unwrap(); + assert!(matches!(out, MaybeRestore::Aborted)); + } + + #[test] + fn test_restore_aborted_savings_too_small() { + let mut a = Allocator::new(); + let cp = a.transparent_checkpoint(); + let tiny = a.new_atom(b"x").unwrap(); + let out = a.maybe_restore_with_node(&cp, tiny).unwrap(); + assert!(matches!(out, MaybeRestore::Aborted)); + } + #[test] fn test_substr() { let mut a = Allocator::new(); diff --git a/src/chia_dialect.rs b/src/chia_dialect.rs index b8acfa69a..7dd46e414 100644 --- a/src/chia_dialect.rs +++ b/src/chia_dialect.rs @@ -1,4 +1,4 @@ -use crate::allocator::{Allocator, NodePtr}; +use crate::allocator::{Allocator, NodePtr, NodeVisitor}; use crate::bls_ops::{ op_bls_g1_multiply, op_bls_g1_negate, op_bls_g1_subtract, op_bls_g2_add, op_bls_g2_multiply, op_bls_g2_negate, op_bls_g2_subtract, op_bls_map_to_g1, op_bls_map_to_g2, @@ -41,6 +41,11 @@ bitflags! { /// Hard-fork; enable only when it activates. const RELAXED_BLS = 0x0008; + /// When set, operators that return nil/one may be treated as GC + /// candidates (allocator checkpoint/restore). When not set, + /// gc_candidate() always returns false. + const ENABLE_GC = 0x0010; + /// Enables the keccak256 op *outside* the softfork guard. Hard-fork; /// enable only when it activates. const ENABLE_KECCAK_OPS_OUTSIDE_GUARD = 0x0100; @@ -56,7 +61,6 @@ bitflags! { /// Use malachite-bigint instead of num-bigint for div, divmod, mod, and modpow. const MALACHITE = 0x1000; - } } @@ -100,6 +104,31 @@ impl Default for ChiaDialect { } impl Dialect for ChiaDialect { + // determine whether the specified operator is a candidate for garbage + // collection, meaning we save the state of the Allocator and potentially + // restore it once the operator returns + fn gc_candidate(&self, allocator: &Allocator, op: NodePtr) -> bool { + if !self.flags.contains(ClvmFlags::ENABLE_GC) { + return false; + } + // apply listp eq gr_bytes sha256 strlen add subtract multiply + // div divmod gr ash lsh logand logior logxor lognot point_add + // pubkey_for_exp not any all coinid bls_g1_subtract + // bls_g1_multiply bls_g1_negate bls_g2_add bls_g2_subtract + // bls_g2_multiply bls_g2_negate bls_map_to_g1 + // bls_pairing_identity bls_verify modpow mod keccak256 + // sha256_tree + #[allow(clippy::match_like_matches_macro)] + match allocator.node(op) { + NodeVisitor::U32( + 2 | 7 | 9 | 10 | 11 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 + | 27 | 29 | 30 | 32 | 33 | 34 | 48 | 49 | 50 | 51 | 56 | 58 | 59 | 60 | 61 | 62 + | 63, + ) => true, + _ => false, + } + } + fn op( &self, allocator: &mut Allocator, diff --git a/src/dialect.rs b/src/dialect.rs index 7b622cebc..bf8cbec9c 100644 --- a/src/dialect.rs +++ b/src/dialect.rs @@ -25,6 +25,7 @@ pub trait Dialect { fn softfork_kw(&self) -> u32; fn softfork_extension(&self, ext: u32) -> OperatorSet; fn flags(&self) -> ClvmFlags; + fn gc_candidate(&self, allocator: &Allocator, op: NodePtr) -> bool; fn op( &self, allocator: &mut Allocator, diff --git a/src/run_program.rs b/src/run_program.rs index 94ca57661..adb985ed0 100644 --- a/src/run_program.rs +++ b/src/run_program.rs @@ -1,5 +1,7 @@ use super::traverse_path::{traverse_path, traverse_path_fast}; -use crate::allocator::{Allocator, Checkpoint, NodePtr, NodeVisitor, SExp}; +use crate::allocator::{ + Allocator, Checkpoint, MaybeRestore, NodePtr, NodeVisitor, SExp, TransparentCheckpoint, +}; use crate::cost::Cost; use crate::dialect::{Dialect, OperatorSet}; use crate::error::{EvalErr, Result}; @@ -31,6 +33,7 @@ enum Operation { Cons, ExitGuard, SwapEval, + RestoreAllocator, #[cfg(feature = "pre-eval")] PostEval, @@ -104,6 +107,7 @@ struct RunProgramContext<'a, D> { env_stack: Vec, op_stack: Vec, softfork_stack: Vec, + allocator_stack: Vec, #[cfg(feature = "counters")] pub counters: Counters, @@ -188,6 +192,7 @@ impl<'a, D: Dialect> RunProgramContext<'a, D> { env_stack: Vec::new(), op_stack: Vec::new(), softfork_stack: Vec::new(), + allocator_stack: Vec::new(), #[cfg(feature = "counters")] counters: Counters::new(), pre_eval, @@ -203,6 +208,7 @@ impl<'a, D: Dialect> RunProgramContext<'a, D> { env_stack: Vec::new(), op_stack: Vec::new(), softfork_stack: Vec::new(), + allocator_stack: Vec::new(), #[cfg(feature = "counters")] counters: Counters::new(), #[cfg(feature = "pre-eval")] @@ -232,6 +238,13 @@ impl<'a, D: Dialect> RunProgramContext<'a, D> { self.push(operand_list)?; Ok(QUOTE_COST) } else { + if self.dialect.gc_candidate(self.allocator, operator_node) { + self.allocator_stack + .push(self.allocator.transparent_checkpoint()); + self.op_stack.push(Operation::RestoreAllocator); + self.account_op_push(); + } + self.push_env(env)?; self.op_stack.push(Operation::Apply); self.account_op_push(); @@ -245,7 +258,7 @@ impl<'a, D: Dialect> RunProgramContext<'a, D> { // evaluated. // // each evaluation pops both, pushes the result list - // back, evaluates and the executes the Cons operation + // back, evaluates and then executes the Cons operation // to add the most recent result to the list. Leaving // the new list at the top of the stack for the next // pair to be evaluated. @@ -497,6 +510,29 @@ impl<'a, D: Dialect> RunProgramContext<'a, D> { Operation::ExitGuard => self.exit_guard(cost)?, Operation::Cons => self.cons_op()?, Operation::SwapEval => self.swap_eval_op()?, + Operation::RestoreAllocator => { + let Some(checkpoint) = self.allocator_stack.pop() else { + return Err(EvalErr::InternalError( + NodePtr::NIL, + "allocator checkpoint stack empty".to_string(), + )); + }; + let Some(&top) = self.val_stack.last() else { + return Err(EvalErr::InternalError( + NodePtr::NIL, + "value stack empty".to_string(), + )); + }; + match self.allocator.maybe_restore_with_node(&checkpoint, top)? { + MaybeRestore::NoReplace => {} + MaybeRestore::Replace(new_node) => { + self.val_stack.pop().unwrap(); + self.val_stack.push(new_node); + } + MaybeRestore::Aborted => {} + } + 0 + } #[cfg(feature = "pre-eval")] Operation::PostEval => { let f = self.posteval_stack.pop().unwrap(); @@ -1364,7 +1400,7 @@ mod tests { let args = check(parse_exp(&mut allocator, t.args)); let expected_result = &t.result.map(|v| check(parse_exp(&mut allocator, v))); - let dialect = ChiaDialect::new(t.flags); + let dialect = ChiaDialect::new(t.flags.union(ClvmFlags::ENABLE_GC)); println!("prg: {}", t.prg); match run_program(&mut allocator, &dialect, program, args, t.cost) { Ok(Reduction(cost, prg_result)) => { @@ -1619,7 +1655,7 @@ mod tests { let (counters, result) = run_program_with_counters( &mut a, - &ChiaDialect::new(ClvmFlags::empty()), + &ChiaDialect::new(ClvmFlags::ENABLE_GC), program, args, cost, @@ -1627,10 +1663,10 @@ mod tests { assert_eq!(counters.val_stack_usage, 3015); assert_eq!(counters.env_stack_usage, 1005); - assert_eq!(counters.op_stack_usage, 3014); - assert_eq!(counters.allocated_atom_count, 998); + assert_eq!(counters.op_stack_usage, 6017); + assert_eq!(counters.allocated_atom_count, 972); assert_eq!(counters.atom_count, 2040); - assert_eq!(counters.allocated_pair_count, 22077); + assert_eq!(counters.allocated_pair_count, 21419); assert_eq!(counters.pair_count, 22077); assert_eq!(counters.heap_size, 771880); diff --git a/src/runtime_dialect.rs b/src/runtime_dialect.rs index e60875a72..691ceec1b 100644 --- a/src/runtime_dialect.rs +++ b/src/runtime_dialect.rs @@ -34,6 +34,9 @@ impl RuntimeDialect { } impl Dialect for RuntimeDialect { + fn gc_candidate(&self, _allocator: &Allocator, _op: NodePtr) -> bool { + false + } fn op( &self, allocator: &mut Allocator, diff --git a/src/test_ops.rs b/src/test_ops.rs index 43b9615ea..6d7302abe 100644 --- a/src/test_ops.rs +++ b/src/test_ops.rs @@ -430,15 +430,17 @@ mod tests { #[cfg(feature = "pre-eval")] use crate::error::Result; + #[cfg(feature = "pre-eval")] + use crate::serde::node_to_bytes; #[cfg(feature = "pre-eval")] const COST_LIMIT: u64 = 1000000000; #[cfg(feature = "pre-eval")] struct EvalFTracker { - pub prog: NodePtr, - pub args: NodePtr, - pub outcome: Option, + pub prog: Vec, + pub args: Vec, + pub outcome: Option>, } #[cfg(feature = "pre-eval")] @@ -480,8 +482,12 @@ mod tests { let tracking = Rc::new(RefCell::new(HashMap::new())); let pre_eval_tracking = tracking.clone(); - let pre_eval_f: PreEvalF = Box::new(move |_allocator, prog, args| { + let pre_eval_f: PreEvalF = Box::new(move |a, prog, args| { let tracking_key = pre_eval_tracking.borrow().len(); + let prog = node_to_bytes(a, prog).expect("node_to_bytes prog"); + let args = node_to_bytes(a, args).expect("node_to_bytes args"); + let post_prog = prog.clone(); + let post_args = args.clone(); // Ensure lifetime of mutable borrow is contained. // It must end before the lifetime of the following closure. { @@ -496,14 +502,16 @@ mod tests { ); } let post_eval_tracking = pre_eval_tracking.clone(); - let post_eval_f: Callback = Box::new(move |_a, outcome| { + let post_eval_f: Callback = Box::new(move |a, outcome| { + let outcome_bytes = + outcome.map(|node| node_to_bytes(a, node).expect("node_to_bytes outcome")); let mut tracking_mutable = post_eval_tracking.borrow_mut(); tracking_mutable.insert( tracking_key, EvalFTracker { - prog, - args, - outcome, + prog: post_prog.clone(), + args: post_args.clone(), + outcome: outcome_bytes, }, ); }); @@ -512,7 +520,7 @@ mod tests { let result = run_program_with_pre_eval( &mut allocator, - &ChiaDialect::new(ClvmFlags::NO_UNKNOWN_OPS), + &ChiaDialect::new(ClvmFlags::NO_UNKNOWN_OPS.union(ClvmFlags::ENABLE_GC)), program, NodePtr::NIL, COST_LIMIT, @@ -534,23 +542,30 @@ mod tests { // args consed let args_consed = allocator.new_pair(a99, a101).unwrap(); + let serialize = |node| node_to_bytes(&allocator, node).expect("serialize expected"); let desired_outcomes = [ - (args, NodePtr::NIL, arg_mid), - (f_quoted, NodePtr::NIL, f_expr), - (a2, arg_mid, a99), - (a5, arg_mid, a101), - (cons_expr, arg_mid, args_consed), - (f_expr, arg_mid, a99), - (program, NodePtr::NIL, a99), + (serialize(args), serialize(NodePtr::NIL), serialize(arg_mid)), + ( + serialize(f_quoted), + serialize(NodePtr::NIL), + serialize(f_expr), + ), + (serialize(a2), serialize(arg_mid), serialize(a99)), + (serialize(a5), serialize(arg_mid), serialize(a101)), + ( + serialize(cons_expr), + serialize(arg_mid), + serialize(args_consed), + ), + (serialize(f_expr), serialize(arg_mid), serialize(a99)), + (serialize(program), serialize(NodePtr::NIL), serialize(a99)), ]; let mut found_outcomes = HashSet::new(); let tracking_examine = tracking.borrow(); for (_, v) in tracking_examine.iter() { let found = desired_outcomes.iter().position(|(p, a, o)| { - node_eq(&allocator, *p, v.prog) - && node_eq(&allocator, *a, v.args) - && node_eq(&allocator, v.outcome.unwrap(), *o) + *p == v.prog && *a == v.args && v.outcome.as_ref() == Some(o) }); found_outcomes.insert(found); assert!(found.is_some()); diff --git a/tools/src/bin/generate-stress-tests.rs b/tools/src/bin/generate-stress-tests.rs index 73da6a517..3322ce262 100644 --- a/tools/src/bin/generate-stress-tests.rs +++ b/tools/src/bin/generate-stress-tests.rs @@ -148,7 +148,7 @@ pub fn main() { let max_cost = std::cmp::max(1, 11_000_000_000 - bytes.len() as u64 * 12_000); - let dialect = ChiaDialect::new(ClvmFlags::empty()); + let dialect = ChiaDialect::new(ClvmFlags::ENABLE_GC); let start = Instant::now(); let Reduction(cost, _) = clvmr::run_program(&mut a, &dialect, program, NodePtr::NIL, 20_000_000_000) diff --git a/tools/src/bin/sha256tree-benching.rs b/tools/src/bin/sha256tree-benching.rs index e29808301..812b13cc6 100644 --- a/tools/src/bin/sha256tree-benching.rs +++ b/tools/src/bin/sha256tree-benching.rs @@ -20,7 +20,7 @@ CPU. // this function calculates the cost per node theoretically // for a perfectly balanced binary tree fn time_complete_tree(a: &mut Allocator, sha_prog: NodePtr, leaf_size: usize, output_file: &str) { - let dialect = ChiaDialect::new(ClvmFlags::ENABLE_SHA256_TREE); + let dialect = ChiaDialect::new(ClvmFlags::ENABLE_SHA256_TREE.union(ClvmFlags::ENABLE_GC)); let op_code = a.new_small_number(63).unwrap(); let quote = a.one();