Skip to content

state: walk the tree and its links without recursing once per level - #664

Merged
transfix merged 1 commit into
masterfrom
fix/state-iterative-names-signals
Oct 17, 2026
Merged

transfix merged 1 commit into
masterfrom
fix/state-iterative-names-signals

Conversation

@transfix

Copy link
Copy Markdown
Owner

Why

cvc::state recursed once per tree level on every write, and in several other places:

  • fullName() rebuilt the path parent by parent, which is O(depth²) characters.
  • childChanged reached the root through each node's first slot (notifyParent) re-firing its parent's signal: one boost::signals2 invocation frame per level.
  • Destroying a subtree, children(), sweepExpired() and resolving a long path in operator() recursed the same way.
  • state_distributed_admin::link_cycles() recursed once per link in a chain.

On a 64 KiB stack (wasm's), a write 41 levels deep overflowed. That is why #657's snapshot codec has to spill containers to keep snapshot trees about 30 levels deep. This was asked as "can fullName() and the change signals be scheduled on the state_exec scheduler?". cvc::state sits below state_exec, and its signals are a synchronous contract for every observer, so this removes the recursion in place instead and keeps what observers see.

What

  • fullName()/parentName(): one pass up the _parent chain, building the path once. A write now builds its full name only for the error it throws, not on every write.
  • emitChildChanged() (new, public): fires childChanged on the parent and every ancestor in a loop.
    • The root's slots fire first, each ancestor gets the path relative to it, and the parent's slots fire last. That is the order the relay produced, and SignalsReachAncestorsRootFirst passes on both master and this branch.
    • Only nodes with slots get a path, cut from one string.
    • notifyParent is gone. Emitting childChanged directly now fires only that node's slots; nothing in the repo did that.
  • ~state: destroys its subtree with a heap stack, leaf-first, so every node still has its ancestors when it dies. A child someone else owns (a pin) keeps its subtree.
    • destroyed() fires on the node being destroyed first, then on its descendants leaf-first (before, all in pre-order). Only tests connect to destroyed, and none depends on the order.
  • operator(): a loop over the path's keys. Before, it was a call per key that re-joined the rest of the path, which was also quadratic in the path's length.
  • children(): pre-order with a heap stack. Each node's children are snapshotted under its own lock; before, it held every ancestor's lock. Full names are carried down instead of rebuilt per node. The output is the same, including with a regex.
  • sweepExpired(): post-order with a heap stack.
  • Links.
    • link_cycles() runs Tarjan's strongconnect on an explicit stack.
    • Link resolution (resolveLink, resolveRemote, resolvedValue/resolvedData, sendMessage) and transparent_link_aliases were already loops with hop budgets.
    • A write through a writable transparent link still signals the target's ancestors, not the link's. The test pins that.

Measurements (MSVC 14.38 Release, Windows)

Deepest operation that passes, before → after. A 64 KiB or 256 KiB stack is a thread with that stack; 1 MiB is the main thread.

Operation 64 KiB 256 KiB 1 MiB
Write deep in the tree, then tear it down 40 → 100000 ✔ 220 → 100000 ✔ 936 → 100000 ✔
Destroy a deep subtree 236 → 100000 ✔ 936 → 100000 ✔ 936 → 100000 ✔
children() of a chain (test capped at 3000) 23 → 3000 ✔ 119 → 3000 ✔ 502 → 3000 ✔
Resolve and cycle-check a link chain (capped at 20000) 475 → 20000 ✔ 2516 → 20000 ✔ 10644 → 20000 ✔
A link cycle (capped at 20000) 496 → 20000 ✔ 2516 → 20000 ✔ 10644 → 20000 ✔
Write through a link to a deep target (capped at 20000) 41 → 20000 ✔ 220 → 20000 ✔ 936 → 20000 ✔

A write 30 levels deep with a listener on the root (as the sync adapter and pycvc's watch have) took 11.3 µs before and 2.2 µs after. That is the median of 8 interleaved rounds per side, StateDeepTree.WriteBench.

Tests

  • New state_deep_tree_test (10), run on a 64 KiB thread:
    • a write 100000 levels deep and its teardown;
    • destroying a 100000-deep subtree;
    • children() 3000 deep;
    • a 20000-link chain resolved and cycle-checked, and a 20000-link cycle;
    • a write through a transparent link to a target 20000 deep.
  • It also checks that these are unchanged:
    • signal order and relative paths across ancestors;
    • fullName()/parentName();
    • children() order with and without a regex;
    • pinned subtrees surviving their parent's destruction.
  • CVC_STATE_DEEP_DEPTH / CVC_STATE_DEEP_STACK_KIB override depth and stack, and CVC_STATE_BENCH=1 runs the benchmark.
  • All 164 test executables were run locally. Everything passes except image and volume I/O tests that fail the same way on master on this box:
    • image_test ReadsCmykInputAsRgb and cvc_cli_test Vol2Img/Img2Vol: ImageMagick has no decode delegate here.
    • volume_io_test: an intermittent access violation in CvcRawRoundTrip/RawivToMrcConversion. Five runs of a master-based build: 3 failed (2 crashes). This branch: 4 of 5 failed, and 2 of 2 earlier runs passed.
    • hdf5_volume_test timed out once under load. It passed in 527 s in the other run, close to the runner's 600 s cap.
  • clang-format 18 is clean, and check_test_targets.py is OK.

Not in this PR (still recursive)

  • ptree()/json() and their restore. The boost property tree they build is itself recursive, both to build and to destroy.
  • traverse(). It also visits a node once per ancestor, because children() lists all descendants and traverse() recurses into each.
  • reset(true).
  • The sync adapter calls transparent_link_aliases() on every replicated mutation, and that lists the whole tree each time. It's a separate performance item.

cvc::state recursed once per tree level on every write: fullName()
rebuilt the path parent by parent, and childChanged reached the root
through each node's first slot (notifyParent) re-firing its parent's
signal. Destroying a subtree, listing descendants (children()),
sweeping expiries and resolving a long path in operator() recursed the
same way, and link_cycles() recursed once per link in a chain. On a
64 KiB stack (wasm's), a write 41 levels deep overflowed it.

- fullName()/parentName(): one pass up the _parent chain, building the
  path once (it was O(depth^2) characters). A write builds its full
  name only for the error it throws.
- emitChildChanged(): the parent and every ancestor in a loop, the
  root's slots first, each with the path relative to it -- the order
  the relay produced. Only nodes with slots get a path, cut from one
  string. notifyParent is gone; emitting childChanged directly now
  fires only that node's slots.
- ~state: destroys its subtree with a heap stack, leaf-first, so every
  node still has its ancestors when it dies. A child someone else owns
  keeps its subtree.
- operator(): a loop over the path's keys (a call per key, re-joining
  the rest of the path each time, was also quadratic in its length).
- children(): pre-order with a heap stack, each node's children
  snapshotted under its own lock (it held every ancestor's lock), full
  names carried down instead of rebuilt per node.
- sweepExpired(): post-order with a heap stack.
- link_cycles(): Tarjan's strongconnect on an explicit stack. Link
  resolution (resolveLink, resolveRemote, resolvedValue) and the
  transparent-link alias walk were already loops with hop budgets.

New state_deep_tree_test runs on a 64 KiB thread: a write 100000
levels deep, its teardown, children() 3000 deep, resolving and
cycle-checking a 20000-link chain, a write through a transparent link
to a target 20000 deep, and checks that signal order, names, lists and
pinned subtrees are unchanged.
@transfix
transfix merged commit e00bc46 into master Oct 17, 2026
15 checks passed
transfix added a commit that referenced this pull request Oct 18, 2026
The walks #664 left recursive: ptree()/json()/save() and their loader,
traverse(), and reset().

- ptree()/json()/save() write one level: an entry per node keyed by its
  full path, in pre-order, holding its value ("" for a childless empty
  node below the start). Those are the leaves of the nested document
  they used to write, in the same order. boost's ptree, write_json and
  read_json recurse once per nesting level, so the nested form could not
  be made deep-safe without a codec of our own. The loader walks every
  nesting level with a heap stack, so nested documents still load.
- traverse() visits each node once. It traverse()d every descendant
  children() listed, so a node d levels down a chain was visited 2^(d-1)
  times (state_cluster_shard::snapshot() listed entries that many
  times). Pre-order with a heap stack, traverseEnter/Exit nested, the
  regex as before; it walks held nodes instead of re-finding them by
  name from the root, which recreated nodes removed meanwhile.
- reset(true) clears pre-order and touches post-order as before, with a
  heap stack and no lock held while signaling. The recursion held every
  ancestor's lock during each touch, so a slot that looked its node up
  from the root (as the sync adapter's do) deadlocked. reset(false)
  drops the child map after releasing the lock, so destroyed() slots
  don't run under it.

New state_deep_tree_test cases run on a 64 KiB stack: json/save/restore
round trip 1000 deep, traverse 3000 deep, reset 100000 deep (3000 with
signals), plus the visit order, reset order, flat format, legacy nested
load and the lock-free signaling. state_snapshot_protocol_test checks a
snapshot lists each node once.
@transfix
transfix deleted the fix/state-iterative-names-signals branch October 29, 2026 20:54
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