Skip to content

state_exec: native calls past the first level are the evaluator's - #689

Merged
transfix merged 2 commits into
masterfrom
feat/state-exec-native-calls
Oct 18, 2026
Merged

transfix merged 2 commits into
masterfrom
feat/state-exec-native-calls

Conversation

@transfix

Copy link
Copy Markdown
Owner

Follow-up to the deep-data work (#678, #682, #683; #687 fixed a macOS failure in the nesting test this rewrites).

Why

A native function that called a native function (apply, send, a class constructor running init, an inherited init running its superclass's, a job's call) called it on the C++ stack. detail::native_nesting bounded that with a level count (256) and a stack reserve (128 KiB on Windows, 32 KiB elsewhere). So where a runaway like (apply apply L), with L containing itself, stopped depended on how big each platform's frames are; the macOS failure #687 fixed came from that.

Native calls past the first level are the evaluator's

  • The rule. Inside an evaluator step, the native function the step applies may still call a native function itself (apply calling +). A native call asked for from any deeper is deferred to the evaluator, which makes it at its own level in a data step, the way it already ran closures. data_step_scope remembers the native depth when the step began, and calls_here() answers "is this the step's own native call, with room left in the slice". call_function and run_data_job both use it.
  • Runaways are loops. (apply apply L), send re-sending through itself, or a class whose init is its own constructor is now a sequence of steps. max_steps and max_time stop it as they stop (while t nil), at the same step on every platform and stack size.
  • Constant space. A call job that only forwards its callee's value (call_walk, new data_job::forwards_call()) gives its place to the work its callee leaves (step_data). So apply applying apply, or send re-sending, runs in constant stack and pending work.
  • Inherited init/finalize look up the chain of classes that inherit them too, 64 classes a call (kInheritedHops), and call the first class's own method. The constructor does the same, and when no class up the chain defines init it calls nothing. A longer chain, or one that loops, goes on as calls.
  • What still nests (native_nesting stays as the backstop): the one direct call, a generator stepping its own evaluator (generator_walk), and native calls from C++ with no evaluator step that can take them (call_function outside a program, call_here/run_to_completion).

A crash fixed on the way

pending_data_work::outer chains were freed recursively, one native frame per link. A closure init that constructs again ((defclass P (init (self) (P)))) builds such a chain one link per level on master too, so freeing a killed process could overflow the stack. The chain is now freed a link at a time. With the old destructor, ALongChainOfWaitingWorkIsFreedWithoutRecursion crashes on a 64 KiB stack.

Behavior changes

  • (apply apply L), send through itself and a constructor that is its own init no longer throw "native calls nested too deeply". They run until max_steps/max_time. Chains that used to hit the limit (10000 nested applys, 10000 inherited inits, 5000 nested sends) now finish on any stack.
  • A native call made two native levels down now runs in a data step of its own. Signals and watch events wait until it's done, and fork called that way returns -1, as for any call in flight.
  • A constructor that is its own init passes init one argument more per level. It waits a link of pending work per level, and pending work isn't part of the measured heap (max_memory), so only max_steps/max_time stop it. The same gap already existed for a closure init that constructs again.

Cost

New ExecBenchTest.NativeCallThroughput (in state_exec_bench_test, CVC_STATE_EXEC_BENCH=1). Each case runs a 10000-iteration loop 20 times; the numbers are ns per iteration, the minimum of 6 interleaved rounds (MSVC 14.38 Release, Windows). "Defer every call" is this PR with calls_here() always false: it shows what the one direct level is for.

Loop body master this PR defer every call
(list i 1) (no nested call) 1920 1943 1969
(apply list (list i 1)) 2375 2402 2768
(apply apply (list list (list i 1))) 3055 3445 3641
(send o "m" i), m native 2200 2279 2515
(K), init inherited from P 2194 1712 1782
(D), init inherited up 4 classes 2344 1691 1749
  • A deferred call costs about 300 ns more than a direct one, so a call two native levels down (apply of apply) is about 13% slower.
  • apply and send cost what they did, within noise.
  • Constructing a class that inherits init is 22-28% faster, since the chain is looked up instead of called.
  • EvalFibonacci (3.57 → 3.54 ms), StdlibJobThroughput (map with a native function 45 → 45 ns a node, with a lambda 331 → 310) and the small-calls loop (34.5 → 34.0 ms) are within noise.

Tests

StateExecNativeNesting in state_exec_deep_data_test:

  • ApplyOnASelfContainingList: killed by max_steps at exactly 20000 steps, on a stack 64 KiB above the reserve and on 4 MiB.
  • ARunawayCallChainRunsInConstantSpace: 100000 steps of the cycle, with the stack and the pending work the same size throughout.
  • AChainOfAppliesFinishesOnASmallStack: apply 10002 levels deep gives 3, sliced (data.step_nodes 4) or not.
  • SendThroughItself: 5000 nested sends reach the real method; half the steps it needs kills it at that budget.
  • ConstructorThatIsItsOwnInit: killed by max_steps.
  • ALongChainOfWaitingWorkIsFreedWithoutRecursion: a 20000-link chain freed on a 64 KiB stack.
  • InheritedInitUpALongChain: 10000 classes construct on a small stack, and a defined init 1000 classes up runs once per construction.
  • AnInheritedInitThatInheritsItself: a looping chain is killed by max_steps, not hung inside a step.
  • NativeCallsFromCxxWithoutAnEvaluatorNest: the backstop, for call_function and for a job run to completion.
  • GeneratorsOfGenerators still expects the nesting limit.

Docs: "Native call nesting" in docs/STATE_EXEC_GRAMMAR.md, and "Function calls" there and in the developer guide.

Local results (MSVC 14.38 Release, Windows, cvcpkg prefix)

Inside an evaluator step, the native function the step applies may call a
native function itself; a native call asked for from deeper is deferred to
step_data, which makes it at its own level (data_step_scope::calls_here).
(apply apply L), send through itself and a constructor that is its own init
are now loops of steps that max_steps and max_time stop the same way on every
platform, instead of hitting the stack-dependent nesting limit. A forwarding
call job gives its place to its callee's work, so such chains run in constant
space. Inherited init/finalize and the constructor look up the inherited chain
64 classes a call instead of calling through it. pending_data_work chains are
freed a link at a time. native_nesting stays the backstop for generators and
native calls from C++ with no evaluator.
…ive-calls

# Conflicts:
#	src/cvc/tests/state_exec_deep_data_test.cpp
@transfix
transfix merged commit 18ac448 into master Oct 18, 2026
19 of 20 checks passed
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