Make eval/FullBinaryNode tree traversal iterative (stack-safe for deep trees)#575
Conversation
…tive FullBinaryNode owns its children by unique_ptr, so the implicitly-generated destructor recursed to the tree's depth; deep_copy() and the free function transform_node() likewise recursed. On a deep tree (a long contraction chain or a nested Sum) these overflowed the C++ call stack at depth ~thousands, which is why building/copying/destroying such a tree crashed before evaluation even ran. Replace all three with explicit-stack iterative implementations: - ~FullBinaryNode() dismantles the subtree via a work list, detaching each node's children before it is destroyed so every node destruction is O(1). - deep_copy() and transform_node() do an iterative post-order build with an explicit frame stack (std::deque, so a top-frame reference survives push_back). transform_node applies its data map post-order; as a pure map its application order is immaterial, and its only callers (typed binarize<T> and the TA to_ta_node helper) are order-insensitive. The tree fold (fold_left_to_node / the range-accumulate ctor) and the tree visitors (detail::visit, parent-pointer based) were already iterative. size(), operator==, and digraph/tikz remain recursive but are not on the deep-tree build/eval/destroy path. Adds a [FullBinaryNode][stack-safety] test that builds, copies, transform_nodes, and destroys a depth-200000 tree (all O(N)); pre-refactor this overflowed the stack. Full FullBinaryNode + eval suites remain green.
Replace the code-recursive descent in evaluate(node, le, cache) -- the only evaluate overload with unbounded recursion (the layout/multi-root overloads just wrap or loop over it) -- with an explicit std::deque work stack. Depth is now bounded by the heap, not the C++ call stack, so a deep tree (a Sum or product chain with many operands) no longer risks a stack overflow in the traversal. The per-node cache key is EvalExpr's stored O(1) hash, so nothing in the walk recurses. Behavior is preserved: - Checked cache wrapper: a hit returns the phase-applied cached pointer; a miss on a mapped node schedules a store once computed (the store_after flag replaces the recursive evaluate<..., Unchecked> re-entry). - Custom-evaluator interception is consulted when a frame is first visited and a non-null result short-circuits the subtree (children never pushed) -- the subtree pruning custom evaluators rely on is unchanged. - leaf / Adjoint / Sum / Product dispatch, the shaped-product hook, de-nesting, canonicalization-phase multiplication, and every trace log site (MultByPhase, custom-eval, leaf, binary, cache access/store) carry over unchanged. Adds an [eval_tapp][custom-evaluator] test that pins the interception contract on the plain (non-batched) path: a non-null custom result short-circuits the subtree (leaves never evaluated), a null result declines to the standard scheme. Note: this makes the traversal stack-safe; FullBinaryNode's deep_copy, transform_node, and destructor are made iterative in the companion commit.
There was a problem hiding this comment.
Pull request overview
This PR refactors evaluation-tree traversal and FullBinaryNode subtree operations to be iterative, making deep-tree evaluation/copy/transform/destruction stack-safe (bounded by heap rather than C++ call stack). This targets previously observed stack overflows on trees with depth in the thousands+ during evaluation and node lifecycle operations.
Changes:
- Reworked
eval::evaluate(node, le, cache)to use an explicit frame stack (std::deque) instead of recursion, preserving cache behavior and custom-evaluator interception semantics. - Made
FullBinaryNodedeep copy,transform_node, and destruction iterative to avoid recursive depth during copy/transform/destroy. - Added unit tests to cover deep-tree stack-safety and to pin the custom-evaluator short-circuit contract on the non-batched evaluation path.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
tests/unit/test_eval_tapp.cpp |
Adds a unit test ensuring custom-evaluator interception is consulted pre-scheme and can short-circuit subtree evaluation. |
tests/unit/test_binary_node.cpp |
Adds a deep-tree (depth 200k) stack-safety regression test for copy/transform/destruction. |
SeQuant/core/eval/eval.hpp |
Refactors the core evaluate() traversal to be iterative via an explicit stack of frames. |
SeQuant/core/binary_node.hpp |
Implements iterative deep_copy, transform_node, and a non-recursive subtree destructor for FullBinaryNode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Finalize a freshly computed Phase-B result: if this Checked node needs | ||
| // storing, cache it (phase-applied) and hand back the phase-applied cached | ||
| // pointer -- exactly the recursive Checked wrapper's store path. Otherwise | ||
| // pass the raw result through unchanged. | ||
| auto finish_phase_b = [&cache, &apply_phase](Frame const& f, | ||
| ResultPtr rb) -> ResultPtr { | ||
| if (!f.store_after) return rb; | ||
| auto ptr = cache.store(f.node, apply_phase(f.node, std::move(rb))); | ||
| if constexpr (detail::trace(EvalTrace)) | ||
| log::cache(f.node, cache, log::label(f.node)); | ||
| return apply_phase(f.node, ptr); | ||
| }; |
There was a problem hiding this comment.
This store-then-return-phase pattern is a faithful copy of the pre-existing recursive code on master, not a change introduced by this PR. Master's Checked wrapper does the same in its cache.exists(node) branch:
auto ptr = cache.store(
node, mult_by_phase(evaluate<..., Unchecked>(node, le, cache)));
return mult_by_phase(ptr);finish_phase_b mirrors that exactly (store apply_phase(...), return apply_phase(ptr)), and the already-cached cache.access branch applies the phase once in both master and this PR. This PR is a mechanical recursion->iteration refactor that preserves evaluation semantics; the real-tree [dryrun-eval] results are unchanged. The "aliasing" comment concerns the operand (child) buffers on the binary-eval path (a child with canon_phase()!=1 keeps its pre-phase buffer in the cache, so it cannot be consumed by move) -- a separate node from the one being stored here. If there is a latent double-phase concern it is pre-existing on master and out of scope for a stack-safety refactor; happy to file it separately.
There was a problem hiding this comment.
Followed up with a full trace of the cache/phase semantics -- the double application is intentional and correct, not a latent bug (superseding my "happy to file separately" note above):
apply_phasealways fresh-allocates (res->mult_by_phase(phase)constructs a neweval_result; never mutates in place), so the two calls make two distinct buffers -- the cached buffer is never re-mutated.cache.store(key, ptr)returns the same buffer it was handed (entry::storethenentry::access-> samedata_p), so the return does re-apply phase: cache holdsphase*rb, caller getsphase*(phase*rb).canon_phaseis a strict involution:std::int8_tset only to +/-1. Sophase^2 == 1, and the store path returnsrbunchanged while the cache retains the occurrence-independentphase*rb.- Both roles are load-bearing for CSE: the cache is keyed canonically, so occurrences differing only by sign share one entry. The store-side
apply_phasenormalizes the value into the canonical (pre-phase) bufferphase*rb-- exactly the "cache holds pre-phase data" invariant used by the aliasing checks. The return-sideapply_phasede-normalizes back to this occurrence's valuerb; a later hit for a different occurrence applies its own node phase at the Enter path. Dropping either call corrupts signs (store-side -> caches an occurrence-specific signed buffer that poisons other occurrences; return-side -> hands the storing occurrence the wrong-sign canonical buffer). - Reachable (interior contraction nodes can carry
canon_phase == -1and are exactly the cached/repeated ones) but correct -- which is why master produces correct antisymmetric CCSD/CCSDT energies. Behavior is byte-equivalent to master; this PR only translated the recursivemult_by_phasewrapper intofinish_phase_b. No change needed.
|
Did you also find a case where you run into stack overflows due to very deep trees? Did you also run into #418? with these expressions? |
yes, even conventional high-order CC (before @ABesharat's changes) could not be handled as a single tree, due to running out of stack space. |
|
Alright but from skimming through the changes #418 is unaffected by the changes here, right? |
|
It should fix #418 . One of the tests checks an expression w/ 200000 terms |
Reconcile the predicted-peak / batching / dry-run work with master's merged #576 (outer-product pruning + build_context skip + fast_flops), #575/#574 (iterative evaluate()/binary_node), and TNv3 high-order aux work. Conflict resolutions: - options.hpp: CostParams and OptimizeOptions carry BOTH the branch's peak_threshold / term_batch_axes and master's prune_outer_products. - single_term.hpp: keep the perf-first objective dispatch (perf_first, DenseTimeSpace/DenseTimeSpaceBatched, peak_threshold, out_axes) and thread master's prune_outer_products into every model. - cost_model.hpp: PeakModel / PeakBatchedModel keep the branch's perf-first members (perf_first, peak_threshold, numeric_size, charge_batch_recompute) and gain master's prune_outer_products; build_context bodies auto-merged so the pruning skip + fast_flops precompute coexist with the perf-first tables. - optimize.cpp: CostParams init passes both peak_threshold and prune_outer_products. - eval.hpp: keep the branch's malloc_trim release_after_op() hook at each op. - test_optimize.cpp: keep both the branch's threshold/perf-first tests and master's pruning + fast-flops-parity tests. The branch's own (superseded) outer-product-pruning commits were dropped before this merge since #576 supersedes them; the predict-hook add/remove experiment nets to no hook, matching master.
Squash the code-recursive tree traversals in the eval layer so evaluation of a deep tree (a long contraction chain, or a nested Sum) is bounded by the heap rather than the C++ call stack. Previously such a tree overflowed the stack at depth ~thousands -- in the
evaluate()walk, and (before evaluation even ran) inFullBinaryNode's copy/transform/destroy.binary_node:deep_copy,transform_node, and the destructor iterativeFullBinaryNodeowns its children byunique_ptr, so the implicit destructor recursed to the tree's depth;deep_copy()and the free functiontransform_node()likewise recursed.~FullBinaryNode()dismantles the subtree via a work list, detaching each node's children before it is destroyed, so every node destruction is O(1).deep_copy()andtransform_node()do an iterative post-order build with an explicit frame stack (std::deque, so a top-frame reference survivespush_back).transform_nodeapplies its data map post-order; as a pure map its order is immaterial, and its only callers (typedbinarize<T>and the TAto_ta_nodehelper) are order-insensitive.The tree fold (
fold_left_to_node/ the range-accumulate ctor) and the tree visitors (detail::visit, parent-pointer based) were already iterative.size(),operator==, anddigraph/tikzremain recursive but are not on the build/eval/destroy path.eval: the coreevaluate()traversal iterativeThe core
evaluate(node, le, cache)-- the only overload with unbounded recursion (the layout/multi-root overloads just wrap or loop over it) -- now walks with an explicitstd::dequework stack. The per-node cache key isEvalExpr's stored O(1) hash, so nothing in the walk recurses.Behavior is preserved:
store_afterflag replaces the recursiveevaluate<..., Unchecked>re-entry).Tests
[FullBinaryNode][stack-safety]: build, copy,transform_node, and destroy a depth-200000 tree (all O(N)); pre-refactor this overflowed the stack.[eval_tapp][custom-evaluator]: pins the interception contract on the plain (non-batched) path -- a non-null custom result short-circuits the subtree (leaves never evaluated), a null result declines to the standard scheme.