Skip to content

state_exec: free, print, compare, copy and snapshot deep values without native recursion - #657

Merged
transfix merged 1 commit into
masterfrom
fix/state-exec-deep-data
Oct 17, 2026
Merged

transfix merged 1 commit into
masterfrom
fix/state-exec-deep-data

Conversation

@transfix

Copy link
Copy Markdown
Owner

Why

parser.max_depth (default 256, on the state tree) exists because the reader recursed. It can only rise once nothing else recurses once per level of a value. Freeing, printing, comparing, copying, macro-expanding and snapshotting a value all did. This PR makes them iterative.

What

  • Release. value_tag and environment destructors drop the last owner of a list, dict, closure, generator or enclosing scope through detail::release_nested.
    • The first release on a thread runs a loop over a local queue: 8 uninitialized inline slots, then the heap.
    • Owners dropped while it frees something are queued, not freed inline.
    • Dropping an owner that isn't the last is still a plain decrement.
    • Copy and move operations are explicitly defaulted, so value_tag keeps its moves.
  • Scope chains. environment::lookup and set_existing are loops.
  • Walkers. to_string, values_equal, deep_copy (behind state-data-get) and stackless_evaluator::substitute (macros) keep their work on heap stacks.
    • The 1000-level caps on to_string, values_equal and deep_copy are gone. They didn't protect small stacks: to_string overflowed a 256 KiB stack at 800 levels. They would also have become functional limits once max_depth rises.
    • to_string's 64 KiB output cap stays. values_equal and deep_copy still poll the evaluation deadline.
  • Snapshot codec. encode_value and decode_value are iterative. cvc::state itself recurses once per tree level on every write (fullName(), and childChanged climbing to the root), so the codec also keeps the tree it writes shallow.
    • A list or dict more than 8 levels below where its stretch of nesting began goes into a __spill__ table under the encode root. Its place holds __type__ spill_ref with the entry's index.
    • Values at most 8 levels deep encode exactly as before, so ordinary snapshots and old snapshots are unaffected.
    • A value that contains itself now throws instead of overflowing the stack. Decode rejects references that are out of range, not a number, or used twice.
    • __len__ is written once per container, not once per element.
    • Why 8 and not 16: with 16, a dict stretch (3 tree levels per dict) reached ~55 levels and overflowed a 64 KiB stack inside cvc::state's signal climb. With 8 it stays under ~30.

Tests

  • New state_exec_deep_data_test builds values 100000 levels deep at runtime (not through the parser). It runs each body on a 256 KiB thread, so per-level recursion fails on every platform. It covers:
    • drops: lists, dicts, mixed, a bare list_ptr, shared tails, closure chains, environment chains;
    • to_string, values_equal and macro expansion;
    • codec round trips at 10000 levels under a snapshot-depth root. The test checks that the tree written stays ≤ 32 levels deep and the old layout for shallow values. It also covers cycles, DAGs and corrupt spill references.
  • state_exec_intrinsics_test: StateDataGetOfTooDeepValueThrows becomes StateDataGetOfDeepValueCopies, which copies 100000 levels on a 256 KiB thread.
  • CVC_DEEP_DATA_DEPTH and CVC_DEEP_DATA_STACK_KIB override the depth and stack, for probing.
  • Local results (MSVC 14.38 Release, Windows):
    • all 20 state_exec, Ariadne and state_list suites pass (1299 tests);
    • check_test_targets.py is OK;
    • clang-format 18 is clean.

Measurements

Largest depth each operation survives, MSVC Release on this box. Before was measured with a scratch probe that leaks every other value, so each crash belongs to one operation. 1 MiB is the main thread; 256 and 64 KiB are threads with that stack (64 KiB is the wasm default).

Operation Before, 1 MiB Before, 256 KiB Before, 64 KiB After (all three)
Drop nested list 10644 2516 493 100000 ✔
Drop nested dict 7080 1686 330 100000 ✔
Drop closure→env chain 7080 1686 330 100000 ✔
Drop environment chain 21289 5078 990 100000 ✔
to_string capped at 1000 800 (crash under its cap) 154 100000 ✔ (64 KiB output cap)
values_equal throws at 1000 throws at 1000 242 100000 ✔
Codec encode 441 104 19 100000 ✔ (~10 s)
Codec encode+decode 441 104 19 100000 ✔

environment::lookup already survived 100000 (MSVC turned its tail recursion into a loop). It's a loop now on every compiler.

Benchmark: state_exec_bench_test with CVC_STATE_EXEC_BENCH=1, adding ValueDropThroughput. Old and new binaries were interleaved over 10 rounds; values are medians on a busy machine.

Bench Before ns/op After ns/op Ratio
parse_fib20_expr 3187 3279 1.03
eval_sum_0_999 1854251 1889439 1.02
value_drop_small 317 308 0.97
value_drop_shared_copy 16 16 1.00
value_drop_deep1000 (per level) 170 136 0.80
eval_nest_list_2000 4452206 4451328 1.00

An earlier 8-round run of the remaining benches gave eval_fib15 0.96, flood_1000_procs 0.92 and deep_recursion_100 0.98.

Still recursive (not in this PR)

  • pycvc value_to_py / py_to_value (bindings/pycvc/pycvc_exec.cpp), no cap.
  • build_audio_source (src/cvcGL/ariadne/media_verbs.cpp), no cap.
  • collect_channel_refs (src/cvc/ariadne/loader.cpp) stops at depth 256, so it has to follow parser.max_depth if that rises.
  • The codec through closures and environments: one native level per closure nesting.
  • cvc::state::fullName() / notifyParent, once per tree level, with O(depth²) path strings.
  • The tree-walking evaluator and async_evaluator, and their substitute. Only tests construct them.

Next: run these walkers on the executor a slice per step, like the sliced reader (#653), so deep-data work counts against max_steps and max_time, and report steps and time by category (read / eval / data).

…ut native recursion

The parser's nesting limit (parser.max_depth, default 256) exists because
the reader recursed; it can rise only once nothing else recurses once per
level of a value. Freeing, printing, comparing, copying, macro-expanding
and snapshotting a value all did.

- Release: value_tag and environment destructors drop the last owner of a
  list, dict, closure, generator or enclosing scope through
  detail::release_nested. The first release on a thread runs a loop over a
  local queue (8 owners inline, the rest on the heap); owners dropped while
  it frees something are queued, not freed inline. Dropping an owner that
  is not the last is still a plain decrement.
- environment::lookup and set_existing are loops.
- to_string, values_equal, deep_copy (state-data-get) and the stackless
  evaluator's macro substitute keep their work on heap stacks. The
  1000-level caps of to_string, values_equal and deep_copy are gone: they
  did not protect small stacks (to_string overflowed a 256 KiB stack at
  800 levels) and would have become functional limits once max_depth
  rises. to_string's 64 KiB length cap stays.
- Snapshot codec: encode_value and decode_value are iterative. cvc::state
  itself recurses once per tree level on every write (fullName() and the
  childChanged climb to the root), so a list or dict more than 8 levels
  below where its stretch of nesting began goes into a __spill__ table
  under the encode root, referenced by __type__ "spill_ref" and the entry's
  index. Values at most 8 levels deep encode exactly as before. A value
  that contains itself throws instead of overflowing; decode rejects bad
  or reused references. __len__ is written once per container, not once
  per element.

New state_exec_deep_data_test builds values 100000 levels deep at runtime
and drops, prints, compares, macro-expands and snapshots them (10000
levels for the codec) on a 256 KiB thread. state_exec_bench_test gains
ValueDropThroughput.
@transfix
transfix merged commit 3003734 into master Oct 17, 2026
15 checks passed
transfix added a commit that referenced this pull request Oct 18, 2026
…xec-data-steps

#653's branch merged master (bc20f8e), which brings #657 as 3003734. That
is the content this branch already carried as the cherry-pick 4d3bc6b, so
in every file where #658 builds on #657 (types.h/.cpp, intrinsics.cpp,
stackless_evaluator.cpp, state_exec_bench_test.cpp) #653's side was
byte-identical to 4d3bc6b, and #658's version stands. In
src/cvc/tests/CMakeLists.txt, master's new test targets join #658's
state_exec_data_steps_test lines.
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.

1 participant