Skip to content

Make eval/FullBinaryNode tree traversal iterative (stack-safe for deep trees)#575

Merged
evaleev merged 2 commits into
masterfrom
evaleev/feature/stack-safe-tree-traversal
Jul 8, 2026
Merged

Make eval/FullBinaryNode tree traversal iterative (stack-safe for deep trees)#575
evaleev merged 2 commits into
masterfrom
evaleev/feature/stack-safe-tree-traversal

Conversation

@evaleev

@evaleev evaleev commented Jul 7, 2026

Copy link
Copy Markdown
Member

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) in FullBinaryNode's copy/transform/destroy.

binary_node: deep_copy, transform_node, and the destructor iterative

FullBinaryNode owns its children by unique_ptr, so the implicit destructor recursed to the tree's depth; deep_copy() and the free function transform_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() 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 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 build/eval/destroy path.

eval: the core evaluate() traversal iterative

The 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 explicit std::deque work stack. 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 (a 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 carry over unchanged.

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.

evaleev added 2 commits July 7, 2026 18:56
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 FullBinaryNode deep 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.

Comment on lines +563 to +574
// 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);
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_phase always fresh-allocates (res->mult_by_phase(phase) constructs a new eval_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::store then entry::access -> same data_p), so the return does re-apply phase: cache holds phase*rb, caller gets phase*(phase*rb).
  • canon_phase is a strict involution: std::int8_t set only to +/-1. So phase^2 == 1, and the store path returns rb unchanged while the cache retains the occurrence-independent phase*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_phase normalizes the value into the canonical (pre-phase) buffer phase*rb -- exactly the "cache holds pre-phase data" invariant used by the aliasing checks. The return-side apply_phase de-normalizes back to this occurrence's value rb; 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 == -1 and 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 recursive mult_by_phase wrapper into finish_phase_b. No change needed.

@evaleev
evaleev merged commit eefdfc6 into master Jul 8, 2026
22 of 23 checks passed
@evaleev
evaleev deleted the evaleev/feature/stack-safe-tree-traversal branch July 8, 2026 00:35
@Krzmbrzl

Krzmbrzl commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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?

@evaleev

evaleev commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

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.

@Krzmbrzl

Krzmbrzl commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Alright but from skimming through the changes #418 is unaffected by the changes here, right?

@evaleev

evaleev commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

It should fix #418 . One of the tests checks an expression w/ 200000 terms

evaleev added a commit that referenced this pull request Jul 10, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants