From 5812c0363ae9cf21e1d7f6a6229fb637790f728e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 18 Oct 2026 00:02:39 -0500 Subject: [PATCH 1/3] state_exec: a limit on native call nesting; class natives free their chain iteratively A native function that calls another function itself, or runs a nested evaluator, nests on the native stack. Several of these could ask for unbounded nesting and crash the process: - (apply apply L) with L containing itself; - send re-sending through itself; - a class whose init is its own constructor; - a long chain of inherited inits; - methods calling methods, and generators of generators. detail::native_nesting now counts these levels: - where: call_function, call_here, run_data_job's job calls, the class constructor's init call, inherited init/finalize, class methods' nested evaluator and generator_next; - the limit: a level throws past kMaxNativeNesting (256) levels, or when less than kNativeStackReserve of the thread's native stack is left; - the reserve is 128 KiB on Windows, where MSVC runs a catch block before unwinding the frames above it, and 32 KiB elsewhere; - native_stack.cpp asks the platform where the stack ends: VirtualQuery, pthread_getattr_np, pthread_get_stackaddr_np or emscripten_stack_get_end. Measured per level in a Release build: about 0.5 KiB for apply, 2-4 KiB for a nested generator, and over 10 KiB for a class method's evaluator. That spread is why a level count alone could not fit a 64 KiB stack. class_method, class_constructor and class_inherited_method drop what they hold through drop_owner, as value_t does. Freeing a long superclass chain used to take a native frame per class. The constructor and inherited methods also call a copy of the function, not a reference into a class dict the callee may change. Tests (StateExecNativeNesting): - each runaway case throws on a 64 KiB stack instead of crashing, and leaves no level counted; - legitimate nesting still works on 1 MiB; - a 4 MiB stack stops at the level count; - freeing a 10000-class chain on 64 KiB crashed before this change. --- inc/cvc/state/state_exec/data_job.h | 34 +++++ inc/cvc/state/state_exec/generator.h | 1 + src/cvc/CMakeLists.txt | 2 + src/cvc/state/state_exec/closure_natives.h | 7 + src/cvc/state/state_exec/data_job.cpp | 30 ++++- src/cvc/state/state_exec/native_stack.cpp | 73 ++++++++++ src/cvc/state/state_exec/native_stack.h | 15 +++ .../state/state_exec/stackless_evaluator.cpp | 15 ++- src/cvc/tests/state_exec_deep_data_test.cpp | 127 ++++++++++++++++++ 9 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/cvc/state/state_exec/native_stack.cpp create mode 100644 src/cvc/state/state_exec/native_stack.h diff --git a/inc/cvc/state/state_exec/data_job.h b/inc/cvc/state/state_exec/data_job.h index 9bdce9132..2ee0314f4 100644 --- a/inc/cvc/state/state_exec/data_job.h +++ b/inc/cvc/state/state_exec/data_job.h @@ -143,12 +143,46 @@ value_t run_data_job(std::unique_ptr job, std::function args, const char *who); +/// How native calls may nest on one thread's native stack. A level is a native function calling +/// another function itself (apply, send, a class constructor running init, an inherited init +/// running its superclass's, a job's call made where the job runs) or running a nested evaluator +/// (a class method, a generator advancing). The evaluator applying a native function is not a +/// level. A self-containing argument list, a class whose init is its own constructor, or a long +/// enough chain of such calls asks for unbounded nesting, so a level is refused (it throws) past +/// kMaxNativeNesting levels, or when less than kNativeStackReserve bytes of the thread's native +/// stack are left (where the platform says where its stack ends). Levels cost from about 0.5 KiB +/// (apply) to over 10 KiB (a class method's nested evaluator) in a release build. +constexpr std::size_t kMaxNativeNesting = 256; + +/// The native stack a level leaves for the error it may throw. MSVC runs a catch block before +/// unwinding the frames above it, so on Windows whatever catches the error (a scheduler killing +/// the process, say) runs at the depth it was thrown from and needs this room; elsewhere a throw +/// unwinds first and needs little. +#ifdef _WIN32 +constexpr std::size_t kNativeStackReserve = 128 * 1024; +#else +constexpr std::size_t kNativeStackReserve = 32 * 1024; +#endif + /// Count `nodes` units of deep-data work done in the current evaluator step (a walk run to /// completion counts itself; the release backlog counts what it frees). void count_data_nodes(std::size_t nodes); namespace detail { +/// RAII: one level of native nesting on this thread (see kNativeStackReserve). Throws, naming +/// `who`, when the stack is too close to its end for another. +class native_nesting { +public: + explicit native_nesting(const char *who); + ~native_nesting(); + native_nesting(const native_nesting &) = delete; + native_nesting &operator=(const native_nesting &) = delete; + + /// Levels this thread is nested now. + static std::size_t depth(); +}; + /// A native call's deep-data job left to run after the call returned (see run_data_job). struct deferred_data_job { std::unique_ptr job; diff --git a/inc/cvc/state/state_exec/generator.h b/inc/cvc/state/state_exec/generator.h index c7fe4b176..fe98204ae 100644 --- a/inc/cvc/state/state_exec/generator.h +++ b/inc/cvc/state/state_exec/generator.h @@ -65,6 +65,7 @@ inline std::optional generator_next(generator &gen) { // (generator (lambda () (while t 1)))) would spin here inside one native step, so honour // the evaluation deadline (a no-op when none is armed) rather than looping forever. gen.state.yielded = false; + detail::native_nesting level("generator"); // a nested evaluator while (!gen.state.done && !gen.state.yielded) { if (eval_deadline_expired()) throw std::runtime_error("generator: evaluation exceeded time budget"); diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 32a091091..51978d1cc 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -212,6 +212,8 @@ set(SOURCE_FILES state/state_exec/async_stackless_evaluator.cpp state/state_exec/state_value_codec.cpp state/state_exec/data_job.cpp + state/state_exec/native_stack.cpp + state/state_exec/native_stack.h state/state_exec/heap.cpp state/state_exec/closure_natives.h state/state_exec/process.cpp diff --git a/src/cvc/state/state_exec/closure_natives.h b/src/cvc/state/state_exec/closure_natives.h index a92955f5b..3773c04a8 100644 --- a/src/cvc/state/state_exec/closure_natives.h +++ b/src/cvc/state/state_exec/closure_natives.h @@ -11,12 +11,17 @@ namespace cvc::state_exec::detail { +// Each drops what it holds through drop_owner, as value_t does: a class dict holds these natives, +// and they hold the next class dict up the chain, so plain shared_ptr destructors would free a +// long superclass chain with a native stack frame per class. + /// A defclass method: runs `method` (self first, then the call's arguments) on a fresh evaluator /// over `global_env`. struct class_method { closure_ptr method; environment_ptr global_env; value_t operator()(std::span args) const; + ~class_method() { drop_owner(method); } }; /// A defclass constructor: a new instance dict whose __class__ is `cls_dict`, passed to the @@ -24,6 +29,7 @@ struct class_method { struct class_constructor { dict_ptr cls_dict; value_t operator()(std::span init_args) const; + ~class_constructor() { drop_owner(cls_dict); } }; /// The init or finalize a class gets when it defines none: the superclass's `name` method, if @@ -32,6 +38,7 @@ struct class_inherited_method { dict_ptr super_cls; // may be null: no superclass std::string name; // "init" or "finalize" value_t operator()(std::span args) const; + ~class_inherited_method() { drop_owner(super_cls); } }; } // namespace cvc::state_exec::detail diff --git a/src/cvc/state/state_exec/data_job.cpp b/src/cvc/state/state_exec/data_job.cpp index 14a7565cc..653b7ebf4 100644 --- a/src/cvc/state/state_exec/data_job.cpp +++ b/src/cvc/state/state_exec/data_job.cpp @@ -5,6 +5,8 @@ work on the heap and run a slice at a time. */ +#include "native_stack.h" + #include #include #include @@ -512,6 +514,9 @@ class call_walk final : public data_job { // The data side of the evaluator step running on this thread, if any. thread_local detail::data_step_scope *t_scope = nullptr; +// Levels of native nesting on this thread (detail::native_nesting). +thread_local std::size_t t_native_depth = 0; + // Run `walk` to completion and count its nodes into the current step. template Walk &finish_walk(Walk &walk) { walk.step(0); @@ -594,6 +599,7 @@ value_t call_here(const data_job::call_request &call) { scope->allow_defer(true); } } guard; + detail::native_nesting level(call.who); return (*fn)(call.args); } if (std::holds_alternative(call.fn->v)) @@ -661,7 +667,11 @@ value_t run_data_job(std::unique_ptr job, std::functionnodes_ >= scope->step_nodes_)) break; const data_job::call_request call = *job->take_call(); - value_t value = (*fn)(call.args); + value_t value; + { + detail::native_nesting level(call.who); + value = (*fn)(call.args); + } if (scope->deferred_) { waits_on = std::move(scope->deferred_); break; @@ -675,8 +685,10 @@ value_t run_data_job(std::unique_ptr job, std::function args, const char *who) { - if (const auto *native = std::get_if(&fn.v)) + if (const auto *native = std::get_if(&fn.v)) { + detail::native_nesting level(who); return (*native)(args); + } if (const auto *cls = std::get_if(&fn.v); cls && *cls) return run_data_job( std::make_unique(fn, std::vector(args.begin(), args.end()), who)); @@ -692,6 +704,20 @@ void count_data_nodes(std::size_t nodes) { } } +// -- native_nesting -------------------------------------------------------------- + +detail::native_nesting::native_nesting(const char *who) { + if (t_native_depth >= kMaxNativeNesting || native_stack_left() < kNativeStackReserve) + throw std::runtime_error(std::string(who) + ": native calls nested too deeply (" + + std::to_string(t_native_depth) + + " levels; a value or class that refers to itself?)"); + ++t_native_depth; +} + +detail::native_nesting::~native_nesting() { --t_native_depth; } + +std::size_t detail::native_nesting::depth() { return t_native_depth; } + // -- data_step_scope ------------------------------------------------------------- detail::data_step_scope::data_step_scope(std::size_t step_nodes, evaluation_stats &stats, diff --git a/src/cvc/state/state_exec/native_stack.cpp b/src/cvc/state/state_exec/native_stack.cpp new file mode 100644 index 000000000..c65fee17e --- /dev/null +++ b/src/cvc/state/state_exec/native_stack.cpp @@ -0,0 +1,73 @@ +/* + Copyright 2026 The University of Texas at Austin + + native_stack — where the calling thread's native stack ends, so nesting native calls can stop + short of it (detail::native_nesting). +*/ + +#include "native_stack.h" + +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#elif defined(__EMSCRIPTEN__) +#include +#elif defined(__APPLE__) || defined(__linux__) +#include +#endif + +namespace cvc::state_exec::detail { + +namespace { + +// The lowest address of this thread's stack, or 0 when the platform does not say. +std::uintptr_t stack_low() { +#if defined(_WIN32) + // The reservation a stack address belongs to starts at the stack's lowest address (the guard + // pages are inside it). VirtualQuery, unlike GetCurrentThreadStackLimits, is in every Windows + // the build targets (_WIN32_WINNT is 0x0601). + MEMORY_BASIC_INFORMATION info{}; + const char here = 0; + if (VirtualQuery(&here, &info, sizeof info) == 0) + return 0; + return reinterpret_cast(info.AllocationBase); +#elif defined(__EMSCRIPTEN__) + return static_cast(emscripten_stack_get_end()); +#elif defined(__APPLE__) + const pthread_t self = pthread_self(); + const auto high = reinterpret_cast(pthread_get_stackaddr_np(self)); + return high - pthread_get_stacksize_np(self); +#elif defined(__linux__) + pthread_attr_t attr; + if (pthread_getattr_np(pthread_self(), &attr) != 0) + return 0; + void *addr = nullptr; + std::size_t size = 0; + const int rc = pthread_attr_getstack(&attr, &addr, &size); + pthread_attr_destroy(&attr); + return rc == 0 ? reinterpret_cast(addr) : 0; +#else + return 0; +#endif +} + +} // namespace + +std::size_t native_stack_left() { + thread_local const std::uintptr_t low = stack_low(); + if (low == 0) + return std::numeric_limits::max(); + const char here = 0; + const auto sp = reinterpret_cast(&here); + return sp > low ? static_cast(sp - low) : 0; +} + +} // namespace cvc::state_exec::detail diff --git a/src/cvc/state/state_exec/native_stack.h b/src/cvc/state/state_exec/native_stack.h new file mode 100644 index 000000000..dffd8ff6c --- /dev/null +++ b/src/cvc/state/state_exec/native_stack.h @@ -0,0 +1,15 @@ +// Private: how much native stack the calling thread has left, for detail::native_nesting. +#ifndef CVC_STATE_EXEC_NATIVE_STACK_H +#define CVC_STATE_EXEC_NATIVE_STACK_H + +#include + +namespace cvc::state_exec::detail { + +/// Bytes of native stack left below the caller on this thread (stacks grow down on every +/// platform libcvc builds for), or SIZE_MAX when the platform does not say where its stack ends. +std::size_t native_stack_left(); + +} // namespace cvc::state_exec::detail + +#endif // CVC_STATE_EXEC_NATIVE_STACK_H diff --git a/src/cvc/state/state_exec/stackless_evaluator.cpp b/src/cvc/state/state_exec/stackless_evaluator.cpp index 4a0898183..f357255f6 100644 --- a/src/cvc/state/state_exec/stackless_evaluator.cpp +++ b/src/cvc/state/state_exec/stackless_evaluator.cpp @@ -1262,6 +1262,7 @@ value_t detail::class_method::operator()(std::span call_args) con for (size_t j = 0; j < method->params.size(); ++j) local->set(method->params[j].name, all_args[j]); } + detail::native_nesting level("method"); // a nested evaluator stackless_evaluator temp(global_env); if (method->body.size() == 1) return temp.evaluate(method->body[0], local); @@ -1281,8 +1282,11 @@ value_t detail::class_constructor::operator()(std::span init_args std::vector a; a.push_back(inst_val); a.insert(a.end(), init_args.begin(), init_args.end()); - if (auto *fn = std::get_if(&v.v)) - (*fn)(a); + if (auto *fn = std::get_if(&v.v)) { + const native_fn init = *fn; // init may change the class dict under `v` + detail::native_nesting level("constructor"); + init(a); + } break; } } @@ -1293,8 +1297,11 @@ value_t detail::class_inherited_method::operator()(std::span args if (super_cls) { for (auto &[k, v] : *super_cls) { if (k == name) { - if (auto *fn = std::get_if(&v.v)) - return (*fn)(args); + if (auto *fn = std::get_if(&v.v)) { + const native_fn inherited = *fn; // it may change the class dict under `v` + detail::native_nesting level(name.c_str()); + return inherited(args); + } break; } } diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index 98a46d6af..731d9b57d 100644 --- a/src/cvc/tests/state_exec_deep_data_test.cpp +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -387,4 +389,129 @@ TEST_F(StateExecDeepDataCodec, BadSpillReferenceThrows) { EXPECT_TRUE(values_equal(decode_value(node), nested_list(200))); } +// ---- native nesting ---------------------------------------------------------- +// +// A native function that calls another function (apply, send, a class constructor running init, +// an inherited init running its superclass's) or runs a nested evaluator (a class method, a +// generator) nests on the native stack. detail::native_nesting refuses a level past +// kMaxNativeNesting levels or with less than kNativeStackReserve bytes of stack left, so a value or +// class that refers to itself throws instead of overflowing. The runaway cases run on a 64 KiB +// stack (wasm's default) and must throw, not crash; the legitimate ones run on 1 MiB, where the +// heaviest levels (a class method's evaluator, over 10 KiB each) still fit. + +constexpr std::size_t kWasmStackKiB = 64; +constexpr std::size_t kThreadStackKiB = 1024; + +// Run `body` on a thread with a `kib` stack. +void on_stack(std::size_t kib, const std::function &body) { + std::exception_ptr error; + boost::thread::attributes attrs; + attrs.set_stack_size(kib * 1024); + boost::thread t(attrs, [&] { + try { + body(); + } catch (...) { + error = std::current_exception(); + } + }); + t.join(); + if (error) + std::rethrow_exception(error); +} + +value_t run_script(const std::string &script) { + stackless_evaluator ev(builtins::make_default_environment()); + return ev.evaluate(parse(script)); +} + +// Run `script` on a `kib` stack, expecting the nesting limit's error; returns the levels it +// reports. Afterwards no level is left counted. +std::size_t expect_nesting_limit(const std::string &script, std::size_t kib = kWasmStackKiB) { + std::size_t levels = 0; + on_stack(kib, [&] { + try { + run_script(script); + ADD_FAILURE() << "expected the native nesting limit: " << script; + } catch (const std::runtime_error &e) { + const std::string what = e.what(); + const std::size_t at = what.find("nested too deeply ("); + EXPECT_NE(at, std::string::npos) << what; + if (at != std::string::npos) + levels = std::strtoull(what.c_str() + at + 19, nullptr, 10); + } + EXPECT_EQ(detail::native_nesting::depth(), 0u); + }); + return levels; +} + +TEST(StateExecNativeNesting, ApplyOnASelfContainingList) { + // (apply apply L) with L = (apply L): apply calls itself once per trip around the cycle. + const std::string loop = "(begin (set L (list apply nil)) (set-nth L 1 L) (apply apply L))"; + // On a small stack the stack left decides; on a big one, the level count. + EXPECT_LT(expect_nesting_limit(loop), kMaxNativeNesting); + EXPECT_EQ(expect_nesting_limit(loop, 4096), kMaxNativeNesting); +} + +TEST(StateExecNativeNesting, SendThroughItself) { + // A class whose method "m" is `send` itself: (send o "m" "m" ...) re-sends once per "m". + std::string ms; + for (int i = 0; i < 5000; ++i) + ms += " \"m\""; + expect_nesting_limit("(begin (defclass C) (set-attr C.__cls_dict__ \"m\" send) (set o (C))" + " (send o" + + ms + "))"); +} + +TEST(StateExecNativeNesting, ConstructorThatIsItsOwnInit) { + expect_nesting_limit("(begin (defclass P) (set-attr P.__cls_dict__ \"init\" P) (P))"); +} + +// A chain of `n` classes, each the superclass of the next, defining no init of their own, so +// constructing the last runs every inherited init up the chain. Leaves `begin` open. +std::string class_chain(std::size_t n) { + return "(begin (defclass P) (set i 0)" + " (while (< i " + + std::to_string(n) + + ") (begin (defclass K P) (set P.__cls_dict__ K.__cls_dict__) (set i (+ i 1))))"; +} + +TEST(StateExecNativeNesting, InheritedInitUpALongChain) { + on_stack(kThreadStackKiB, [] { + value_t obj = run_script(class_chain(100) + " (K))"); + EXPECT_TRUE(std::holds_alternative(obj.v)); + }); + expect_nesting_limit(class_chain(10000) + " (K))"); +} + +TEST(StateExecNativeNesting, MethodsCallingMethods) { + // Each call of `down` runs on a nested evaluator and sends `down` again: the heaviest level. + const std::string cls = "(begin (defclass C (down (self n) (if (= n 0) 0 (+ 1 (send self " + "\"down\" (- n 1)))))) (set o (C)) "; + on_stack(kThreadStackKiB, [&] { + value_t r = run_script(cls + "(send o \"down\" 20))"); + EXPECT_EQ(std::get(r.v), 20); + }); + expect_nesting_limit(cls + "(send o \"down\" 100000))"); +} + +TEST(StateExecNativeNesting, GeneratorsOfGenerators) { + // (mk n) yields from (mk (- n 1)): advancing it advances n nested generators. + const std::string mk = "(begin (set mk nil) (set mk (lambda (n) (generator (lambda ()" + " (if (= n 0) (yield 7) (for x (mk (- n 1)) (yield x))))))) "; + on_stack(kThreadStackKiB, [&] { + value_t r = run_script(mk + "(next (mk 50)))"); + EXPECT_EQ(std::get(r.v), 7); + }); + expect_nesting_limit(mk + "(next (mk 10000)))"); +} + +TEST(StateExecNativeNesting, FreeingALongClassChain) { + // Each class holds its superclass's dict inside its inherited init and finalize (native + // functions, not values): freeing the chain must not take a native frame per class. + on_stack(kWasmStackKiB, [] { + stackless_evaluator ev(builtins::make_default_environment()); + ev.evaluate(parse(class_chain(depth() / 10) + ")")); + }); +} + } // namespace From 72c97f935056bc70aa4843eb7a6f4ed557cc3b2c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 18 Oct 2026 00:21:57 -0500 Subject: [PATCH 2/3] state_exec codec: closures refer to a table of scopes, each written once The codec wrote each closure's scope chain inside the closure, and each binding in it with a fresh encode_value. That cost one native level per closure nesting. A function whose scope binds the function itself made encode_environment recurse forever. Any defun'd function is one, so a snapshot of a paused process that had defined one overflowed the stack. Values, closure bodies and scopes are now one walk with one explicit stack (value_writer): - each scope the walk reaches is an entry of an __envs__ table under the node it started at, holding the scope's bindings (sorted by name) and an __outer__ reference; - a closure's __env__ is a reference to an entry (__type__ "env_ref") or nil; - encode_environment writes a reference at its node, with the table under it. value_reader decodes it the same way: - a referenced entry becomes one environment, filled once, so closures that shared a scope share it again; - a bad reference, or an __outer__ chain that loops back on itself, throws (the loop is unlinked first, so nothing leaks). Older snapshots still decode. A closure whose __env__ is missing or an inline env_chain is read the old way: its body expressions and scope bindings each with their own __spill__ table. Tests: - AFunctionItsOwnScopeBinds: a defun'd factorial round-trips, keeps its scope, and runs; - ClosuresThatShareAScopeStillShareIt; - OlderSnapshotsStillDecode: the old layout, built by hand, spill table included; - BadScopeReferencesThrow; - StateExecDeepDataCodec.ClosureChainRoundTrip: 10000 closures, each bound in the next one's scope, on the small stack. --- docs/STATE_EXEC_GRAMMAR.md | 19 + inc/cvc/state/state_exec/state_value_codec.h | 14 +- .../state/state_exec/state_value_codec.cpp | 692 +++++++++++------- src/cvc/tests/state_exec_codec_test.cpp | 133 ++++ src/cvc/tests/state_exec_deep_data_test.cpp | 25 + 5 files changed, 630 insertions(+), 253 deletions(-) diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index 2485bcc02..32810090a 100644 --- a/docs/STATE_EXEC_GRAMMAR.md +++ b/docs/STATE_EXEC_GRAMMAR.md @@ -261,6 +261,25 @@ Measured per node (MSVC Release, `state_exec_bench_test` `DataWalkThroughput`): The stdlib jobs (`StdlibJobThroughput`) cost 5-20 ns a node for elements and 200-370 ns for a 256-byte copy. +**Snapshots of closures.** The codec writes each scope a value's closures capture once, in an +`__envs__` table under the snapshot node, and a closure refers to its scope's entry. A function +that `defun` bound in the scope it captures is no cycle, and closures that share a scope share +it again when decoded. Older snapshots, with each closure's scope chain written inside it, still +decode. + +### Native call nesting + +Some native functions call other functions themselves: `apply`, `send`, a class constructor +running `init`, an inherited `init` running its superclass's. Some run a nested evaluator: a class +method, a generator advancing. Each of these nests on the native stack, so a value or class that +refers to itself could ask for unbounded nesting: `(apply apply L)` with `L` containing itself, or +a class whose `init` is its own constructor. A level is refused with an error ("native calls nested +too deeply") past 256 levels (`kMaxNativeNesting`), or when less of the thread's native stack is +left than `kNativeStackReserve`: 128 KiB on Windows, where a catch block runs before the frames +above it unwind, and 32 KiB elsewhere. Levels cost from about 0.5 KiB (`apply`) to over 10 KiB (a +class method) in a release build. DSL functions calling DSL functions are not levels: they are +evaluator frames on the heap. + ### Budget by kind Each process's steps are split by what they were spent on (`step_kind`): diff --git a/inc/cvc/state/state_exec/state_value_codec.h b/inc/cvc/state/state_exec/state_value_codec.h index d7db7f7e6..e341aa726 100644 --- a/inc/cvc/state/state_exec/state_value_codec.h +++ b/inc/cvc/state/state_exec/state_value_codec.h @@ -25,7 +25,9 @@ namespace cvc::state_exec { /// Encode a value_t into a state subtree. /// native_fn values are stored as a marker ("__native__") since functions -/// cannot be serialized. +/// cannot be serialized. The scopes the value's closures capture are written once each, +/// in an `__envs__` table under `node` that the closures refer to, so closures that share a +/// scope share it again when decoded, and a closure its own scope binds is no cycle. void encode_value(cvc::state &node, const value_t &val); /// Decode a value_t from a state subtree previously written by encode_value. @@ -36,12 +38,14 @@ value_t decode_value(cvc::state &node); // Environment codec // --------------------------------------------------------------------------- -/// Encode an environment chain into a state subtree. -/// Flattens the scope chain into an indexed list of scope nodes, each -/// containing its bindings. native_fn bindings are skipped. +/// Encode an environment chain into a state subtree: `node` refers to an entry of an +/// `__envs__` table under it, and each scope in the chain (and each scope a closure bound +/// in one captures) is an entry with its bindings and a reference to its enclosing scope. +/// native_fn bindings are skipped. void encode_environment(cvc::state &node, const environment_ptr &env); -/// Decode an environment chain from a state subtree. +/// Decode an environment chain from a state subtree, written by encode_environment (or, in +/// an older snapshot, as an inline list of scopes). environment_ptr decode_environment(cvc::state &node); // --------------------------------------------------------------------------- diff --git a/src/cvc/state/state_exec/state_value_codec.cpp b/src/cvc/state/state_exec/state_value_codec.cpp index 97c737fe5..6251c6464 100644 --- a/src/cvc/state/state_exec/state_value_codec.cpp +++ b/src/cvc/state/state_exec/state_value_codec.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -112,8 +113,23 @@ std::string b64_decode(const std::string &in) { // 8 levels make at most ~30 tree levels per stretch (a dict level is 3: __entries__, the // index, __val__). A write that deep, under a snapshot root ~5 levels down, fits a 64 KiB // stack (wasm's); with 16, the dicts' ~55 did not. +// +// Closures and the scopes they capture are part of the same walk. A closure's scopes are not +// written inside it: each scope the walk reaches is written once, as an entry of the __envs__ +// table under the node the walk started at (its bindings, and an __outer__ reference to the +// enclosing scope), and a closure's __env__ holds a reference: __type__ "env_ref", value the +// entry's index. A closure whose scope binds the closure itself (any function made by defun) +// refers back to an entry already written instead of writing itself forever, and closures that +// share a scope share its entry, and the environment it decodes to. +// +// Snapshots from before wrote each closure's scope chain inside it (__type__ "env_chain", the +// scopes listed innermost first), and every closure body expression and scope binding value as a +// walk of its own, with its own __spill__ table. Those still decode: a closure whose __env__ is +// missing or an env_chain is one of them. (A closure written now always has an __env__: an +// env_ref, or nil.) constexpr std::size_t kSpillDepth = 8; const char *SPILL_KEY = "__spill__"; +const char *ENVS_KEY = "__envs__"; // The non-null list or dict a value holds, or null. const void *container_of(const value_t &v) { @@ -124,84 +140,459 @@ const void *container_of(const value_t &v) { return nullptr; } -// Write a value that is not a non-null list or dict. +// Write a value that is not a non-null list, dict or closure. void encode_leaf(cvc::state &node, const value_t &val); -// Read a value whose __type__ is `type`, which is not "list", "dict" or "spill_ref". +// Read a value whose __type__ is `type`, which is not "list", "dict", "closure" or "spill_ref". value_t decode_leaf(cvc::state &node, const std::string &type); -} // namespace +// Start the element list `items` of something with `size` elements. The length is known up +// front, so it is written once, not once per element as state_list::push_back would (every +// write signals each ancestor). An empty one gets the "0" that state_list's constructor writes. +void open_items(cvc::state &items, std::size_t size) { + if (size > 0) + items("__len__").value(size); + else + (void)state_list(items); +} -// --------------------------------------------------------------------------- -// encode_value -// --------------------------------------------------------------------------- +// The index an env_ref or spill_ref holds, if it is a whole number below `size`. +std::optional ref_index(const std::string &ref, std::size_t size) { + std::size_t i = 0; + const auto [end, ec] = std::from_chars(ref.data(), ref.data() + ref.size(), i); + if (ec != std::errc{} || end != ref.data() + ref.size() || i >= size) + return std::nullopt; + return i; +} + +// One encode walk, from the node encode_value or encode_environment was called on. +class value_writer { +public: + explicit value_writer(cvc::state &root) : root_(root) {} + + // Write `v` at `node`, and everything it reaches. + void write(cvc::state &node, const value_t &v) { + begin(node, v, 0); + drain(); + } -void encode_value(cvc::state &root, const value_t &val) { - // A list or dict whose elements are being written. + // Write a reference to `env` (nil for none) at `node`, and every scope it reaches. + void write_env(cvc::state &node, const environment_ptr &env) { + env_ref(node, env); + drain(); + } + +private: + enum class kind { list, dict, body, scope }; + + // Something whose elements are being written under `items`. struct frame { - const value_t *val; - cvc::state *items; // its __elems__ or __entries__ node - std::size_t next; // the next element to write - std::size_t size; // its length, as written to __len__ - std::size_t nest; // how many levels below its stretch's start it is + kind what; + const value_t *val = nullptr; // list, dict + const std::vector *body = nullptr; // body: a closure's expressions + std::vector> bindings; // scope + cvc::state *items = nullptr; + std::size_t next = 0; + std::size_t size = 0; + std::size_t nest = 0; // how many levels below its stretch's start it is }; - std::vector stack; - std::unordered_set open; // the lists and dicts on `stack` - std::optional spill; - // Write `v` into `node`: a leaf now, a list or dict by its type and a frame for its elements. - auto begin = [&](cvc::state &node, const value_t &v, std::size_t nest) { + // Write `v` into `node`: a leaf now, anything with elements by its type and a frame for them. + void begin(cvc::state &node, const value_t &v, std::size_t nest) { + if (const auto *cls = std::get_if(&v.v); cls && *cls) { + begin_closure(node, **cls, nest); + return; + } const void *c = container_of(v); if (!c) { encode_leaf(node, v); return; } - if (!open.insert(c).second) + if (!open_.insert(c).second) throw std::runtime_error("encode_value: the value contains itself"); cvc::state *target = &node; if (nest >= kSpillDepth) { - if (!spill) - spill.emplace(root(SPILL_KEY)); + if (!spill_) + spill_.emplace(root_(SPILL_KEY)); set_type(node, "spill_ref"); - node.value(std::to_string(spill->size())); - target = &spill->push_back(); + node.value(std::to_string(spill_->size())); + target = &spill_->push_back(); nest = 0; } const bool is_list = std::holds_alternative(v.v); set_type(*target, is_list ? "list" : "dict"); cvc::state &items = (*target)(is_list ? "__elems__" : "__entries__"); - // The length is known up front, so it is written once, not once per element as - // state_list::push_back would (every write signals each ancestor). An empty list or dict - // gets the "0" that state_list's constructor writes, as before. const std::size_t size = is_list ? std::get(v.v)->size() : std::get(v.v)->size(); - if (size > 0) - items("__len__").value(size); - else - (void)state_list(items); - stack.push_back({&v, &items, 0, size, nest}); + open_items(items, size); + frame f{is_list ? kind::list : kind::dict}; + f.val = &v; + f.items = &items; + f.size = size; + f.nest = nest; + stack_.push_back(std::move(f)); + } + + void begin_closure(cvc::state &node, const closure &cls, std::size_t nest) { + set_type(node, "closure"); + state_list params(node("__params__")); + for (const symbol &p : cls.params) + params.push_back().value(p.name); + node("__variadic__").value(std::string(cls.variadic ? "1" : "0")); + env_ref(node("__env__"), cls.env_snapshot); + cvc::state &items = node("__body__"); + open_items(items, cls.body.size()); + if (cls.body.empty()) + return; + frame f{kind::body}; + f.body = &cls.body; + f.items = &items; + f.size = cls.body.size(); + f.nest = nest; + stack_.push_back(std::move(f)); + } + + // Write a reference to `env`'s entry in the __envs__ table at `node`, giving it an entry + // (written by drain()) the first time. + void env_ref(cvc::state &node, const environment_ptr &env) { + if (!env) { + set_type(node, "nil"); + return; + } + const auto [it, fresh] = env_index_.try_emplace(env.get(), scopes_.size()); + if (fresh) { + if (!envs_) + envs_.emplace(root_(ENVS_KEY)); + envs_->push_back(); + scopes_.push_back(env); + } + set_type(node, "env_ref"); + node.value(std::to_string(it->second)); + } + + // Start writing the next scope that has an entry but no contents yet. + void begin_scope() { + const std::size_t i = scopes_written_++; + const environment_ptr env = scopes_[i]; // a copy: env_ref() may grow scopes_ + cvc::state &entry = envs_->at(i); + if (env->outer) + env_ref(entry("__outer__"), env->outer); + frame f{kind::scope}; + for (const auto &[name, val] : env->bindings) + // Native functions can't be serialized; a restore registers the builtins again. + if (!std::holds_alternative(val.v)) + f.bindings.emplace_back(name, &val); + std::sort(f.bindings.begin(), f.bindings.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + cvc::state &items = entry("__bindings__"); + open_items(items, f.bindings.size()); + f.items = &items; + f.size = f.bindings.size(); + if (f.size > 0) + stack_.push_back(std::move(f)); + } + + void drain() { + for (;;) { + while (!stack_.empty()) { + frame &f = stack_.back(); + if (f.next == f.size) { + if (f.what == kind::list || f.what == kind::dict) + open_.erase(container_of(*f.val)); + stack_.pop_back(); + continue; + } + const std::size_t i = f.next++; + const std::size_t nest = f.nest + 1; + cvc::state &slot = state_list(*f.items).at(i); + // begin() may push, invalidating f: everything it needs is read from f first. + switch (f.what) { + case kind::list: + begin(slot, (*std::get(f.val->v))[i], nest); + break; + case kind::dict: { + const auto &[key, val] = (*std::get(f.val->v))[i]; + slot("__key__").value(key); + begin(slot("__val__"), val, nest); + break; + } + case kind::body: + begin(slot, (*f.body)[i], nest); + break; + case kind::scope: { + const value_t *val = f.bindings[i].second; + slot("__key__").value(f.bindings[i].first); + begin(slot("__val__"), *val, 0); + break; + } + } + } + if (scopes_written_ == scopes_.size()) + return; + begin_scope(); + } + } + + cvc::state &root_; + std::vector stack_; + std::unordered_set open_; // the lists and dicts on stack_ + std::optional spill_; + std::optional envs_; + std::unordered_map env_index_; + std::vector scopes_; // by entry index; kept alive while the walk runs + std::size_t scopes_written_ = 0; +}; + +// One decode walk, from the node decode_value or decode_environment was called on. +class value_reader { +public: + explicit value_reader(cvc::state &root) : root_(root) {} + + value_t read(cvc::state &node) { + value_t out; + begin(node, out, &root_); + drain(); + return out; + } + + environment_ptr read_env(cvc::state &node) { + environment_ptr env = env_at(node, &root_); + drain(); + return env; + } + +private: + enum class kind { list, dict, body, scope }; + + // Something whose elements are being read from `items`. `root` is where the walk that wrote + // them started (its __spill__ and __envs__ tables); `own_roots` marks an older snapshot's body + // expressions and binding values, each written as a walk of its own. + struct frame { + kind what; + cvc::state *items; + std::size_t next; + std::size_t size; + std::vector *list; // list, body + ordered_map *dict; // dict + environment *env; // scope + cvc::state *root; + bool own_roots; }; - begin(root, val, 0); - while (!stack.empty()) { - frame &f = stack.back(); - if (f.next == f.size) { - open.erase(container_of(*f.val)); - stack.pop_back(); - continue; + struct spill_table { + std::optional entries; + std::vector used; // each entry is referenced once; a second reference is corrupt + }; + + // An __envs__ entry whose environment exists (something refers to it) but is not filled yet. + struct pending_scope { + environment_ptr env; + cvc::state *entry; + cvc::state *root; + }; + + void push(kind what, cvc::state &items, std::vector *list, ordered_map *dict, + environment *env, cvc::state *root, bool own_roots) { + const std::size_t n = state_list(items).size(); + if (list) + list->reserve(n); + else if (dict) + dict->reserve(n); + stack_.push_back({what, &items, 0, n, list, dict, env, root, own_roots}); + } + + // Read the value at `node` into `out`: a leaf now, anything with elements as an empty value + // that a frame fills. + void begin(cvc::state &node, value_t &out, cvc::state *root) { + cvc::state *src = &node; + std::string type = get_type(node); + if (type == "spill_ref") { + spill_table &t = spills_[root]; + if (!t.entries) { + if (!has_child(*root, SPILL_KEY)) + throw std::runtime_error("decode_value: spill_ref without a spill table"); + t.entries.emplace((*root)(SPILL_KEY)); + t.used.assign(t.entries->size(), false); + } + const std::string ref = node.value(); + const std::optional i = ref_index(ref, t.used.size()); + if (!i || t.used[*i]) + throw std::runtime_error("decode_value: bad spill_ref: " + ref); + t.used[*i] = true; + src = &t.entries->at(*i); + type = get_type(*src); + if (type != "list" && type != "dict") + throw std::runtime_error("decode_value: spill entry " + ref + " is not a list or dict"); } - const std::size_t i = f.next++; - const std::size_t nest = f.nest + 1; - cvc::state &slot = state_list(*f.items).at(i); - if (auto *l = std::get_if(&f.val->v)) { - begin(slot, (**l)[i], nest); // may push, invalidating f + if (type == "list") { + auto elems = std::make_shared>(); + out = value_t(elems); + if (has_child(*src, "__elems__")) + push(kind::list, (*src)("__elems__"), elems.get(), nullptr, nullptr, root, false); + } else if (type == "dict") { + auto entries = std::make_shared(); + out = value_t(entries); + if (has_child(*src, "__entries__")) + push(kind::dict, (*src)("__entries__"), nullptr, entries.get(), nullptr, root, false); + } else if (type == "closure") { + begin_closure(*src, out, root); } else { - const auto &[k, v] = (*std::get(f.val->v))[i]; - slot("__key__").value(k); - begin(slot("__val__"), v, nest); // may push, invalidating f + out = decode_leaf(*src, type); } } -} + + void begin_closure(cvc::state &node, value_t &out, cvc::state *root) { + auto cls = std::make_shared(); + out = value_t(cls); + if (has_child(node, "__params__")) { + state_list params(node("__params__")); + for (std::size_t i = 0; i < params.size(); ++i) + cls->params.push_back(symbol{params.at(i).value()}); + } + if (has_child(node, "__variadic__")) + cls->variadic = node("__variadic__").value() == "1"; + // Written now: an __env__ that is an env_ref or nil. Anything else is an older snapshot. + bool current = false; + if (has_child(node, "__env__")) { + cvc::state &env = node("__env__"); + const std::string type = get_type(env); + current = type != "env_chain"; + cls->env_snapshot = env_at(env, root); + } + if (has_child(node, "__body__")) + push(kind::body, node("__body__"), &cls->body, nullptr, nullptr, root, !current); + } + + // The environment the reference at `node` names: an env_ref into `root`'s __envs__ table + // (created on first reference, filled by drain()), an older snapshot's inline env_chain, or + // null. + environment_ptr env_at(cvc::state &node, cvc::state *root) { + const std::string type = get_type(node); + if (type.empty() || type == "nil") + return nullptr; + if (type == "env_ref") { + std::vector &table = envs_[root]; + cvc::state *envs = root->findDescendant(ENVS_KEY); + if (!envs) + throw std::runtime_error("decode_value: env_ref without an __envs__ table"); + if (table.empty()) + table.resize(state_list(*envs).size()); + const std::string ref = node.value(); + const std::optional i = ref_index(ref, table.size()); + if (!i) + throw std::runtime_error("decode_value: bad env_ref: " + ref); + if (!table[*i]) { + table[*i] = std::make_shared(); + pending_.push_back({table[*i], &state_list(*envs).at(*i), root}); + } + return table[*i]; + } + // An older snapshot's chain, innermost scope first. Each binding was a walk of its own. + if (!has_child(node, "__scopes__")) + return nullptr; + state_list scopes(node("__scopes__")); + std::vector chain; + chain.reserve(scopes.size()); + for (std::size_t i = 0; i < scopes.size(); ++i) { + auto env = std::make_shared(); + cvc::state &scope = scopes.at(i); + if (has_child(scope, "__bindings__")) + push(kind::scope, scope("__bindings__"), nullptr, nullptr, env.get(), nullptr, true); + if (!chain.empty()) + chain.back()->outer = env; + chain.push_back(std::move(env)); + } + return chain.empty() ? nullptr : chain.front(); + } + + void drain() { + for (;;) { + while (!stack_.empty()) { + frame &f = stack_.back(); + if (f.next == f.size) { + stack_.pop_back(); + continue; + } + cvc::state &item = state_list(*f.items).at(f.next++); + // begin() may push, invalidating f: everything it needs is read from f first. + switch (f.what) { + case kind::list: + case kind::body: { + cvc::state *root = f.own_roots ? &item : f.root; + value_t &slot = f.list->emplace_back(); + begin(item, slot, root); + break; + } + case kind::dict: { + cvc::state *root = f.root; + std::string key = item("__key__").value(); + value_t &slot = f.dict->emplace_back(std::move(key), value_t{}).second; + begin(item("__val__"), slot, root); + break; + } + case kind::scope: { + const bool own_roots = f.own_roots; + cvc::state *root = f.root; + value_t &slot = f.env->bindings[item("__key__").value()]; // a stable reference + cvc::state &val = item("__val__"); + begin(val, slot, own_roots ? &val : root); + break; + } + } + } + if (filled_ == pending_.size()) + break; + const pending_scope p = pending_[filled_++]; // a copy: env_at() may grow pending_ + if (has_child(*p.entry, "__outer__")) + p.env->outer = env_at((*p.entry)("__outer__"), p.root); + if (has_child(*p.entry, "__bindings__")) + push(kind::scope, (*p.entry)("__bindings__"), nullptr, nullptr, p.env.get(), p.root, false); + } + check_chains(); + } + + // An __outer__ reference may only lead outward: a scope chain that comes back to itself would + // make every lookup through it loop forever. + void check_chains() { + std::unordered_map done; // false: on the chain being walked + for (const auto &[root, table] : envs_) + for (const environment_ptr &start : table) { + std::vector path; + for (const environment *e = start.get(); e; e = e->outer.get()) { + const auto [it, fresh] = done.try_emplace(e, false); + if (!fresh) { + if (it->second) + break; + for (auto &[r, t] : envs_) // unlink them all, or the loop would never be freed + for (const environment_ptr &env : t) + if (env) + env->outer.reset(); + throw std::runtime_error("decode_value: an __outer__ chain loops back on itself"); + } + path.push_back(e); + } + for (const environment *e : path) + done[e] = true; + } + } + + cvc::state &root_; + std::vector stack_; + std::unordered_map spills_; // by walk root + std::unordered_map> envs_; // by walk root + std::vector pending_; + std::size_t filled_ = 0; // pending_ scopes filled +}; + +} // namespace + +// --------------------------------------------------------------------------- +// encode_value / decode_value +// --------------------------------------------------------------------------- + +void encode_value(cvc::state &root, const value_t &val) { value_writer(root).write(root, val); } + +value_t decode_value(cvc::state &root) { return value_reader(root).read(root); } namespace { @@ -231,31 +622,11 @@ void encode_leaf(cvc::state &node, const value_t &val) { set_type(node, "symbol"); node.value(arg.name); } else if constexpr (std::is_same_v) { - set_type(node, "list"); // null: a non-null list is encode_value's + set_type(node, "list"); // null: a non-null list is the walk's } else if constexpr (std::is_same_v) { - set_type(node, "dict"); // null: a non-null dict is encode_value's + set_type(node, "dict"); // null: a non-null dict is the walk's } else if constexpr (std::is_same_v) { - set_type(node, "closure"); - if (arg) { - // Params - auto ¶ms_node = node("__params__"); - state_list pl(params_node); - for (auto &p : arg->params) { - pl.push_back().value(p.name); - } - // Variadic flag - node("__variadic__").value(std::string(arg->variadic ? "1" : "0")); - // Body expressions - auto &body_node = node("__body__"); - state_list bl(body_node); - for (auto &b : arg->body) { - encode_value(bl.push_back(), b); - } - // Environment snapshot - if (arg->env_snapshot) { - encode_environment(node("__env__"), arg->env_snapshot); - } - } + set_type(node, "closure"); // null: a non-null closure is the walk's } else if constexpr (std::is_same_v) { set_type(node, "native_fn"); // Can't serialize functions — store a marker @@ -275,95 +646,6 @@ void encode_leaf(cvc::state &node, const value_t &val) { val.v); } -} // namespace - -// --------------------------------------------------------------------------- -// decode_value -// --------------------------------------------------------------------------- - -value_t decode_value(cvc::state &root) { - // A list or dict whose elements are being read. - struct frame { - cvc::state *items; // its __elems__ or __entries__ node - std::size_t next; // the next element to read - std::size_t size; - std::vector *list; - ordered_map *dict; - }; - std::vector stack; - std::optional spill; - std::vector spill_used; // each entry is referenced once; a second reference is corrupt - - // Read the value at `node` into `out`: a leaf now, a list or dict as an empty container that - // a frame fills. - auto begin = [&](cvc::state &node, value_t &out) { - cvc::state *src = &node; - std::string type = get_type(node); - if (type == "spill_ref") { - if (!spill) { - if (!has_child(root, SPILL_KEY)) - throw std::runtime_error("decode_value: spill_ref without a spill table"); - spill.emplace(root(SPILL_KEY)); - spill_used.assign(spill->size(), false); - } - const std::string ref = node.value(); - std::size_t i = 0; - const auto [end, ec] = std::from_chars(ref.data(), ref.data() + ref.size(), i); - if (ec != std::errc{} || end != ref.data() + ref.size() || i >= spill_used.size() || - spill_used[i]) - throw std::runtime_error("decode_value: bad spill_ref: " + ref); - spill_used[i] = true; - src = &spill->at(i); - type = get_type(*src); - if (type != "list" && type != "dict") - throw std::runtime_error("decode_value: spill entry " + ref + " is not a list or dict"); - } - if (type == "list") { - auto elems = std::make_shared>(); - out = value_t(elems); - if (has_child(*src, "__elems__")) { - cvc::state &items = (*src)("__elems__"); - const std::size_t n = state_list(items).size(); - elems->reserve(n); - stack.push_back({&items, 0, n, elems.get(), nullptr}); - } - } else if (type == "dict") { - auto entries = std::make_shared(); - out = value_t(entries); - if (has_child(*src, "__entries__")) { - cvc::state &items = (*src)("__entries__"); - const std::size_t n = state_list(items).size(); - entries->reserve(n); - stack.push_back({&items, 0, n, nullptr, entries.get()}); - } - } else { - out = decode_leaf(*src, type); - } - }; - - value_t result; - begin(root, result); - while (!stack.empty()) { - frame &f = stack.back(); - if (f.next == f.size) { - stack.pop_back(); - continue; - } - cvc::state &item = state_list(*f.items).at(f.next++); - if (f.list) { - value_t &slot = f.list->emplace_back(); - begin(item, slot); // may push, invalidating f - } else { - std::string key = item("__key__").value(); - value_t &slot = f.dict->emplace_back(std::move(key), value_t{}).second; - begin(item("__val__"), slot); // may push, invalidating f - } - } - return result; -} - -namespace { - value_t decode_leaf(cvc::state &node, const std::string &type) { if (type.empty() || type == "nil") return nil_value; @@ -386,29 +668,6 @@ value_t decode_leaf(cvc::state &node, const std::string &type) { if (type == "symbol") return value_t(symbol{node.value()}); - if (type == "closure") { - auto cls = std::make_shared(); - // Params - if (has_child(node, "__params__")) { - state_list pl(node("__params__")); - for (size_t i = 0; i < pl.size(); ++i) - cls->params.push_back(symbol{pl.at(i).value()}); - } - // Variadic - if (has_child(node, "__variadic__")) - cls->variadic = node("__variadic__").value() == "1"; - // Body - if (has_child(node, "__body__")) { - state_list bl(node("__body__")); - for (size_t i = 0; i < bl.size(); ++i) - cls->body.push_back(decode_value(bl.at(i))); - } - // Environment - if (has_child(node, "__env__")) - cls->env_snapshot = decode_environment(node("__env__")); - return value_t(std::move(cls)); - } - if (type == "native_fn") { // Can't restore native functions — return nil return nil_value; @@ -428,77 +687,14 @@ value_t decode_leaf(cvc::state &node, const std::string &type) { } // namespace // --------------------------------------------------------------------------- -// encode_environment +// encode_environment / decode_environment // --------------------------------------------------------------------------- void encode_environment(cvc::state &node, const environment_ptr &env) { - if (!env) { - set_type(node, "nil"); - return; - } - - set_type(node, "env_chain"); - - // Flatten the scope chain into an ordered list (innermost first) - state_list sl(node("__scopes__")); - auto current = env; - while (current) { - auto &scope_node = sl.push_back(); - // Store bindings as key/value entries in a state_list so we - // don't rely on iterating children() (which returns full paths). - state_list bindings(scope_node("__bindings__")); - for (auto &[name, val] : current->bindings) { - // Skip native_fn bindings — they can't be serialized and will - // be re-registered from builtins on decode - if (std::holds_alternative(val.v)) - continue; - auto &entry = bindings.push_back(); - entry("__key__").value(name); - encode_value(entry("__val__"), val); - } - current = current->outer; - } + value_writer(node).write_env(node, env); } -// --------------------------------------------------------------------------- -// decode_environment -// --------------------------------------------------------------------------- - -environment_ptr decode_environment(cvc::state &node) { - std::string type = get_type(node); - if (type == "nil" || type.empty()) - return nullptr; - - if (!has_child(node, "__scopes__")) - return nullptr; - - state_list sl(node("__scopes__")); - if (sl.empty()) - return nullptr; - - // Reconstruct chain from outermost to innermost - // Scopes are stored innermost-first, so we reverse - std::vector scopes; - for (size_t i = 0; i < sl.size(); ++i) { - auto env = std::make_shared(); - auto &scope_node = sl.at(i); - if (has_child(scope_node, "__bindings__")) { - state_list bindings(scope_node("__bindings__")); - for (size_t j = 0; j < bindings.size(); ++j) { - auto &entry = bindings.at(j); - std::string key = entry("__key__").value(); - env->bindings[key] = decode_value(entry("__val__")); - } - } - scopes.push_back(std::move(env)); - } - - // Link chain: scopes[0] is innermost, scopes[N-1] is outermost - for (size_t i = 0; i + 1 < scopes.size(); ++i) - scopes[i]->outer = scopes[i + 1]; - - return scopes[0]; -} +environment_ptr decode_environment(cvc::state &node) { return value_reader(node).read_env(node); } // --------------------------------------------------------------------------- // encode_eval_frame diff --git a/src/cvc/tests/state_exec_codec_test.cpp b/src/cvc/tests/state_exec_codec_test.cpp index 20fea8d41..26c75867c 100644 --- a/src/cvc/tests/state_exec_codec_test.cpp +++ b/src/cvc/tests/state_exec_codec_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include using namespace cvc::state_exec; @@ -262,6 +263,138 @@ TEST_F(EnvironmentCodecTest, NativeFnBindingsSkipped) { EXPECT_EQ(result->lookup("my-builtin"), nullptr); } +// The builtins a decoded scope chain lost (native functions are not written), put back in its +// outermost scope. +static void restore_builtins(const environment_ptr &env) { + environment_ptr outermost = env; + while (outermost && outermost->outer) + outermost = outermost->outer; + const environment_ptr defaults = builtins::make_default_environment(); + for (auto &[name, val] : defaults->bindings) + if (!outermost->bindings.count(name)) + outermost->bindings[name] = val; +} + +TEST_F(EnvironmentCodecTest, AFunctionItsOwnScopeBinds) { + // `defun` binds the function in the scope it captures: encoding used to recurse through the + // two forever. Each scope is now written once and referred to. + stackless_evaluator ev(builtins::make_default_environment()); + value_t fact = + ev.evaluate(parse("(begin (defun fact (n) (if (< n 2) 1 (* n (fact (- n 1))))) fact)")); + auto &node = ctx.root()("__env_test_self__"); + encode_value(node, fact); + value_t back = decode_value(node); + + ASSERT_TRUE(std::holds_alternative(back.v)); + const closure_ptr &f = std::get(back.v); + ASSERT_TRUE(f->env_snapshot); + value_t *bound = f->env_snapshot->lookup("fact"); + ASSERT_TRUE(bound); + ASSERT_TRUE(std::holds_alternative(bound->v)); + EXPECT_EQ(std::get(bound->v)->env_snapshot, f->env_snapshot); // the same scope + + restore_builtins(f->env_snapshot); + auto global = builtins::make_default_environment(); + stackless_evaluator run(global); + auto env = environment::extend(global); + env->set("g", back); + EXPECT_EQ(std::get(run.evaluate(parse("(g 5)"), env).v), 120); + f->env_snapshot->bindings.clear(); // break the scope -> closure -> scope cycle +} + +TEST_F(EnvironmentCodecTest, ClosuresThatShareAScopeStillShareIt) { + auto outer = std::make_shared(); + outer->set("base", value_t{100}); + auto scope = environment::extend(outer); + scope->set("n", value_t{1}); + auto make = [&](const char *body) { + auto c = std::make_shared(); + c->body = {parse(body)}; + c->env_snapshot = scope; + return value_t{c}; + }; + value_t pair = make_list({make("(+ n base)"), make("(set n (+ n 1))")}); + + auto &node = ctx.root()("__env_test_shared__"); + encode_value(node, pair); + EXPECT_EQ(cvc::state_list(node("__envs__")).size(), 2u); // scope and outer, once each + + value_t back = decode_value(node); + const auto &l = *std::get(back.v); + ASSERT_EQ(l.size(), 2u); + const environment_ptr &a = std::get(l[0].v)->env_snapshot; + const environment_ptr &b = std::get(l[1].v)->env_snapshot; + ASSERT_TRUE(a); + EXPECT_EQ(a, b); + ASSERT_TRUE(a->outer); + EXPECT_EQ(std::get(a->lookup("base")->v), 100); +} + +TEST_F(EnvironmentCodecTest, OlderSnapshotsStillDecode) { + // The layout written before scopes got their own table: the chain inside the closure (an + // env_chain, innermost scope first), and each body expression a walk of its own, with its + // own __spill__ table. + auto &node = ctx.root()("__env_test_older__"); + auto set_type = [](cvc::state &n, const char *t) { n("__type__").value(std::string(t)); }; + set_type(node, "closure"); + cvc::state_list(node("__params__")).push_back().value(std::string("x")); + node("__variadic__").value(std::string("0")); + cvc::state_list body(node("__body__")); + cvc::state &expr0 = body.push_back(); + set_type(expr0, "symbol"); + expr0.value(std::string("x")); + cvc::state &expr1 = body.push_back(); // spilled into its own table + set_type(expr1, "spill_ref"); + expr1.value(std::string("0")); + cvc::state &spilled = cvc::state_list(expr1("__spill__")).push_back(); + set_type(spilled, "list"); + (void)cvc::state_list(spilled("__elems__")); + cvc::state &env = node("__env__"); + set_type(env, "env_chain"); + cvc::state_list scopes(env("__scopes__")); + for (auto [key, val] : {std::pair{"y", 5}, {"z", 6}}) { + cvc::state &entry = cvc::state_list(scopes.push_back()("__bindings__")).push_back(); + entry("__key__").value(std::string(key)); + set_type(entry("__val__"), "int"); + entry("__val__").value(std::to_string(val)); + } + + value_t back = decode_value(node); + ASSERT_TRUE(std::holds_alternative(back.v)); + const closure &c = *std::get(back.v); + ASSERT_EQ(c.params.size(), 1u); + EXPECT_EQ(c.params[0].name, "x"); + ASSERT_EQ(c.body.size(), 2u); + EXPECT_EQ(std::get(c.body[0].v).name, "x"); + EXPECT_TRUE(std::get(c.body[1].v)->empty()); + ASSERT_TRUE(c.env_snapshot); + EXPECT_EQ(std::get(c.env_snapshot->lookup("y")->v), 5); + EXPECT_EQ(std::get(c.env_snapshot->lookup("z")->v), 6); // from the outer scope +} + +TEST_F(EnvironmentCodecTest, BadScopeReferencesThrow) { + auto outer = std::make_shared(); + outer->set("a", value_t{1}); + auto scope = environment::extend(outer); + auto c = std::make_shared(); + c->env_snapshot = scope; + + auto &node = ctx.root()("__env_test_bad__"); + encode_value(node, value_t{c}); + cvc::state &ref = node("__env__"); + ASSERT_EQ(ref("__type__").value(), "env_ref"); + ref.value(std::string("7")); // past the table + EXPECT_THROW(decode_value(node), std::runtime_error); + ref.value(std::string("0")); + EXPECT_NO_THROW(decode_value(node)); + + // Entry 1 (the outer scope) made to enclose entry 0 again: lookups would loop forever. + cvc::state &outer_ref = cvc::state_list(node("__envs__")).at(1)("__outer__"); + outer_ref("__type__").value(std::string("env_ref")); + outer_ref.value(std::string("0")); + EXPECT_THROW(decode_value(node), std::runtime_error); +} + // =========================================================================== // Evaluator-state round-trip tests // =========================================================================== diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index 731d9b57d..75478956a 100644 --- a/src/cvc/tests/state_exec_deep_data_test.cpp +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -348,6 +348,31 @@ TEST_F(StateExecDeepDataCodec, CyclicValueThrows) { l->clear(); // break the cycle so the list is freed } +TEST_F(StateExecDeepDataCodec, ClosureChainRoundTrip) { + // Each closure's scope binds the closure made before it: the codec used to recurse twice per + // closure (into its scope, and from the binding into the next closure). + const std::size_t n = codec_depth(); + value_t chain = nil_value; + for (std::size_t i = 0; i < n; ++i) { + auto scope = std::make_shared(); + scope->set("prev", chain); + auto c = std::make_shared(); + c->body = {value_t{symbol{"prev"}}}; + c->env_snapshot = scope; + chain = value_t{c}; + } + cvc::state &node = snapshot_node("closures"); + on_small_stack([&] { + encode_value(node, chain); + value_t back = decode_value(node); + std::size_t depth = 0; + for (const value_t *v = &back; std::holds_alternative(v->v); ++depth) + v = std::get(v->v)->env_snapshot->lookup("prev"); + EXPECT_EQ(depth, n); + }); + EXPECT_LE(state_depth(node), 32u); +} + TEST_F(StateExecDeepDataCodec, SharedValueEncodesTwice) { // A DAG (the same list reached twice) is not a cycle: it encodes, and decodes as two copies. value_t inner = make_list({value_t{int64_t{3}}}); From c4ca22c58cec805ea7383c83b1fe39770f4b5b6d Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 18 Oct 2026 00:45:51 -0500 Subject: [PATCH 3/3] state_exec: class methods and generator bodies run inside the calling process A class method used to run to completion on a temporary evaluator inside one native call. A DSL generator's body ran to its next yield the same way. Neither counted against the process's max_steps; only the time deadline bounded them. A method also cost over 10 KiB of native stack per nested call. Methods: - A method is now a closure in its class's dict, taking self first (detail::class_method is gone). send already calls through call_function, so the body runs as frames of the process. - The constructor runs init through a new call_function overload with a continuation. Its value is the instance once init has run, even when init's frames or deep-data work take several steps. - Inherited init/finalize tail-call through call_function. - super's native path shares step_apply's handling (apply_native), so a native super method that defers works. - Methods calling methods no longer nest natively: 10000 nested sends run on a stack that could not hold two before. Generators: - make_generator_job advances a generator as a data job. Each step of a DSL generator's evaluator, or each value of a native one, is a node. - next and collect run that job. collect checks the allocation limit per value, and the deadline per value when nothing slices. - A for loop over a DSL generator gets each value from a child native call, (%generator-next %generator) in a scope of the loop frame's own, so the job slices and waits like any other. - With data.step_nodes = N a generator body runs N steps per process step, in data steps, against max_steps and max_time. The generator's own deep-data work slices the same way. - Native generators (range) in for and next still advance inline. Measured: a loop over 300000 values of DSL generators costs about 15% more (3.1 s -> 3.6 s, cvc exec), for the child frame and job per value. Evaluation benches were within this busy machine's run-to-run noise. Tests: - DataStepsScheduler.MethodStepsCountAgainstMaxSteps; - GeneratorBodiesRunASlicePerStep (and killed at max_steps); - ForOverAGeneratorGivesTheSameSlicedOrNot; - ConstructorReturnsTheInstanceAfterInitDefers; - StateExecNativeNesting.MethodsCallingMethodsAreFrames; - the nesting tests' small stack is sized from the platform's reserve; - SchedulerTest.TimeBudgetStopsALoopingInitMethod: a looping init is now killed by max_time like any loop, instead of throwing out of run(). --- docs/STATE_EXEC_GRAMMAR.md | 28 +++- inc/cvc/state/state_exec/data_job.h | 35 +++- .../state/state_exec/stackless_evaluator.h | 7 + src/cvc/state/state_exec/builtins.cpp | 26 +-- src/cvc/state/state_exec/closure_natives.h | 12 +- src/cvc/state/state_exec/data_job.cpp | 93 +++++++++++ src/cvc/state/state_exec/heap.cpp | 6 +- .../state/state_exec/stackless_evaluator.cpp | 155 ++++++++++-------- src/cvc/tests/state_exec_data_steps_test.cpp | 94 +++++++++++ src/cvc/tests/state_exec_deep_data_test.cpp | 24 +-- src/cvc/tests/state_exec_scheduler_test.cpp | 17 +- 11 files changed, 369 insertions(+), 128 deletions(-) diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index 32810090a..7f6dfe560 100644 --- a/docs/STATE_EXEC_GRAMMAR.md +++ b/docs/STATE_EXEC_GRAMMAR.md @@ -270,15 +270,25 @@ decode. ### Native call nesting Some native functions call other functions themselves: `apply`, `send`, a class constructor -running `init`, an inherited `init` running its superclass's. Some run a nested evaluator: a class -method, a generator advancing. Each of these nests on the native stack, so a value or class that -refers to itself could ask for unbounded nesting: `(apply apply L)` with `L` containing itself, or -a class whose `init` is its own constructor. A level is refused with an error ("native calls nested -too deeply") past 256 levels (`kMaxNativeNesting`), or when less of the thread's native stack is -left than `kNativeStackReserve`: 128 KiB on Windows, where a catch block runs before the frames -above it unwind, and 32 KiB elsewhere. Levels cost from about 0.5 KiB (`apply`) to over 10 KiB (a -class method) in a release build. DSL functions calling DSL functions are not levels: they are -evaluator frames on the heap. +running `init`, an inherited `init` running its superclass's. A generator advancing steps an +evaluator of its own. Each of these nests on the native stack, so a value or class that refers to +itself could ask for unbounded nesting: `(apply apply L)` with `L` containing itself, or a class +whose `init` is its own constructor. A level is refused with an error ("native calls nested too +deeply") past 256 levels (`kMaxNativeNesting`), or when less of the thread's native stack is left +than `kNativeStackReserve`: 128 KiB on Windows, where a catch block runs before the frames above +it unwind, and 32 KiB elsewhere. Levels cost from about 0.5 KiB (`apply`) to a few KiB (a +generator) in a release build. + +DSL functions are not levels, and neither are class methods. A method is a closure in its class's +dict, taking `self` first. `send`, `super` and a constructor's `init` call it through the +evaluator (`call_function`), so its body runs as frames of the calling process: on the heap, a +step at a time, against the process's budget. + +**Generators.** A DSL generator's body runs on an evaluator of its own, but `next`, `collect` and +`for` advance it as a job (`make_generator_job`). Each step of its body, or each value of a native +generator like `range`, is a deep-data node. So with `data.step_nodes` = N, the body runs N +steps per process step, in data steps, against `max_steps` and `max_time`. A generator's own +deep-data work slices the same way. ### Budget by kind diff --git a/inc/cvc/state/state_exec/data_job.h b/inc/cvc/state/state_exec/data_job.h index 2ee0314f4..e23e07912 100644 --- a/inc/cvc/state/state_exec/data_job.h +++ b/inc/cvc/state/state_exec/data_job.h @@ -143,27 +143,44 @@ value_t run_data_job(std::unique_ptr job, std::function args, const char *who); +/// As call_function, with `then` applied to the call's value: for a native function that has +/// more to do after the call, such as a class constructor returning the new instance once init +/// has run. The call is a one-call job either way (run_data_job), so `then` runs after the +/// function finishes, however the evaluator ran it. `who` must outlive the call (a literal). +value_t call_function(const value_t &fn, std::span args, const char *who, + std::function then); + /// How native calls may nest on one thread's native stack. A level is a native function calling /// another function itself (apply, send, a class constructor running init, an inherited init -/// running its superclass's, a job's call made where the job runs) or running a nested evaluator -/// (a class method, a generator advancing). The evaluator applying a native function is not a -/// level. A self-containing argument list, a class whose init is its own constructor, or a long -/// enough chain of such calls asks for unbounded nesting, so a level is refused (it throws) past -/// kMaxNativeNesting levels, or when less than kNativeStackReserve bytes of the thread's native -/// stack are left (where the platform says where its stack ends). Levels cost from about 0.5 KiB -/// (apply) to over 10 KiB (a class method's nested evaluator) in a release build. +/// running its superclass's, a job's call made where the job runs) or stepping a generator's own +/// evaluator. The evaluator applying a native function is not a level, and neither is a DSL +/// function or class method: those run as evaluator frames, on the heap. A self-containing +/// argument list, a class whose init is its own constructor, or a long enough chain of such calls +/// asks for unbounded nesting, so a level is refused (it throws) past kMaxNativeNesting levels, or +/// when less than kNativeStackReserve bytes of the thread's native stack are left (where the +/// platform says where its stack ends). Levels cost from about 0.5 KiB (apply) to a few KiB (a +/// generator's evaluator) in a release build. constexpr std::size_t kMaxNativeNesting = 256; /// The native stack a level leaves for the error it may throw. MSVC runs a catch block before /// unwinding the frames above it, so on Windows whatever catches the error (a scheduler killing -/// the process, say) runs at the depth it was thrown from and needs this room; elsewhere a throw -/// unwinds first and needs little. +/// the process, say) runs at the depth it was thrown from and needs this room: a throw with 32 KiB +/// left crashed in a test, 64 KiB did not, and this doubles that for heavier catch blocks. +/// Elsewhere a throw unwinds first and needs little. #ifdef _WIN32 constexpr std::size_t kNativeStackReserve = 128 * 1024; #else constexpr std::size_t kNativeStackReserve = 32 * 1024; #endif +/// Advance `gen` as a job: each step of a DSL generator's own evaluator (or each value of a +/// native one) is a node, so a generator body runs a slice per step of the process advancing +/// it, counted against its budget, instead of to its next yield inside one step. The result is +/// a one-element list holding the value yielded, or an empty list once the generator is +/// exhausted. With `collect`, the job runs the generator to exhaustion and its result is the +/// list of everything it yielded. +std::unique_ptr make_generator_job(generator_ptr gen, bool collect = false); + /// Count `nodes` units of deep-data work done in the current evaluator step (a walk run to /// completion counts itself; the release backlog counts what it frees). void count_data_nodes(std::size_t nodes); diff --git a/inc/cvc/state/state_exec/stackless_evaluator.h b/inc/cvc/state/state_exec/stackless_evaluator.h index 1173ea52c..48f5e0bcc 100644 --- a/inc/cvc/state/state_exec/stackless_evaluator.h +++ b/inc/cvc/state/state_exec/stackless_evaluator.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -223,6 +224,12 @@ class stackless_evaluator { void step_break_unwind(evaluator_state &state, eval_frame &frame); // Helpers + // A for loop over a DSL generator: push the call that advances it as a job. + void advance_generator(evaluator_state &state, eval_frame &frame); + // Apply native `fn` to `args` as the top frame's call: its value pops the frame, or, if it + // left deep-data work (a job, or a DSL function to call), the frame waits for that. + void apply_native(evaluator_state &state, eval_frame &frame, const native_fn &fn, + std::span args); void call_closure(evaluator_state &state, eval_frame &frame, const closure_ptr &cls, const std::vector &args); // Push the frame that evaluates `cls`'s body in `local` (its parameters already bound). diff --git a/src/cvc/state/state_exec/builtins.cpp b/src/cvc/state/state_exec/builtins.cpp index a28f50a3f..01f2ab786 100644 --- a/src/cvc/state/state_exec/builtins.cpp +++ b/src/cvc/state/state_exec/builtins.cpp @@ -715,8 +715,15 @@ value_t builtin_next(std::span args) { auto *gp = std::get_if(&args[0].v); if (!gp || !*gp) throw std::runtime_error("next: expected generator"); - auto result = generator_next(**gp); - return result ? *result : nil_value; + if ((*gp)->native_step) { // range and friends: one cheap value + auto result = generator_next(**gp); + return result ? *result : nil_value; + } + // A DSL generator's body runs as a job, a slice per step when the process slices. + return run_data_job(make_generator_job(*gp), [](value_t got) { + const auto &l = *std::get(got.v); + return l.empty() ? nil_value : l.front(); + }); } value_t builtin_is_generator(std::span args) { @@ -778,17 +785,10 @@ value_t builtin_collect(std::span args) { auto *gp = std::get_if(&args[0].v); if (!gp || !*gp) throw std::runtime_error("collect: expected generator"); - std::vector result; - while (auto next = generator_next(**gp)) { - // Draining an unbounded/huge generator (e.g. (collect (range 0 1e11))) runs entirely - // inside this one native step; honour the evaluation deadline so it aborts at the time - // budget instead of hanging/OOMing (a no-op when no deadline is armed). - if (eval_deadline_expired()) - throw std::runtime_error("collect: evaluation exceeded time budget"); - eval_alloc_check(alloc_bytes(result.size() + 1, sizeof(value_t)), "collect"); - result.push_back(std::move(*next)); - } - return make_list(std::move(result)); + // Draining a generator is a job: a value (or a step of a DSL generator's body) is a node, so + // a huge or unbounded one (e.g. (collect (range 0 1e11))) spreads over the process's steps, + // within its budget, and the allocation limit checks every value kept. + return run_data_job(make_generator_job(*gp, true)); } // --------------------------------------------------------------------------- diff --git a/src/cvc/state/state_exec/closure_natives.h b/src/cvc/state/state_exec/closure_natives.h index 3773c04a8..18f785f0f 100644 --- a/src/cvc/state/state_exec/closure_natives.h +++ b/src/cvc/state/state_exec/closure_natives.h @@ -13,16 +13,8 @@ namespace cvc::state_exec::detail { // Each drops what it holds through drop_owner, as value_t does: a class dict holds these natives, // and they hold the next class dict up the chain, so plain shared_ptr destructors would free a -// long superclass chain with a native stack frame per class. - -/// A defclass method: runs `method` (self first, then the call's arguments) on a fresh evaluator -/// over `global_env`. -struct class_method { - closure_ptr method; - environment_ptr global_env; - value_t operator()(std::span args) const; - ~class_method() { drop_owner(method); } -}; +// long superclass chain with a native stack frame per class. (A class's own methods are plain +// closures in its dict.) /// A defclass constructor: a new instance dict whose __class__ is `cls_dict`, passed to the /// class's init method if it has one. diff --git a/src/cvc/state/state_exec/data_job.cpp b/src/cvc/state/state_exec/data_job.cpp index 653b7ebf4..0e79c2557 100644 --- a/src/cvc/state/state_exec/data_job.cpp +++ b/src/cvc/state/state_exec/data_job.cpp @@ -511,9 +511,86 @@ class call_walk final : public data_job { value_t result_; }; +// A generator advanced a node at a time (make_generator_job). +class generator_walk final : public data_job { +public: + generator_walk(generator_ptr gen, bool collect) : gen_(std::move(gen)), collect_(collect) {} + + bool step(std::size_t nodes) override; + + value_t result() override { return make_list(std::move(values_)); } + +private: + // Keep a value it yielded. True when the walk goes on (collect). + bool keep(value_t v) { + if (collect_) + eval_alloc_check(alloc_bytes(values_.size() + 1, sizeof(value_t)), "collect"); + values_.push_back(std::move(v)); + return collect_; + } + + generator_ptr gen_; + bool collect_; + bool advancing_ = false; // a DSL generator is part way to its next yield + std::vector values_; +}; + // The data side of the evaluator step running on this thread, if any. thread_local detail::data_step_scope *t_scope = nullptr; +bool generator_walk::step(std::size_t nodes) { + generator &g = *gen_; + const std::size_t start = nodes_; + auto spent = [&] { return nodes > 0 && nodes_ - start >= nodes; }; + for (;;) { + if (g.exhausted) + return true; + if (g.native_step) { // range and friends: a value is a node + if (spent()) + return false; + if (eval_deadline_expired()) // (collect (range 0 1e11)) when nothing slices + throw std::runtime_error(std::string(collect_ ? "collect" : "next") + + ": evaluation exceeded time budget"); + std::optional v = g.native_step(); + ++nodes_; + if (!v) { + g.exhausted = true; + return true; + } + if (!keep(std::move(*v))) + return true; + continue; + } + if (!advancing_) { + g.state.yielded = false; + advancing_ = true; + // Deep-data work in the body slices like the process's own. + if (detail::data_step_scope *scope = t_scope) + g.state.data_step_nodes = scope->step_nodes(); + } + while (!g.state.done && !g.state.yielded) { + if (spent()) + return false; + if (eval_deadline_expired()) + throw std::runtime_error("generator: evaluation exceeded time budget"); + { + detail::native_nesting level("generator"); // its own evaluator, on this stack + g.evaluator.step(g.state); + } + ++nodes_; + } + advancing_ = false; + if (g.state.yielded) { + // A yield that was also the body's last expression counts; the next advance ends it. + if (!keep(g.state.result)) + return true; + continue; + } + g.exhausted = true; + return true; + } +} + // Levels of native nesting on this thread (detail::native_nesting). thread_local std::size_t t_native_depth = 0; @@ -582,6 +659,12 @@ std::unique_ptr make_substitute_job(value_t tmpl, return std::make_unique(std::move(tmpl), std::move(subst)); } +std::unique_ptr make_generator_job(generator_ptr gen, bool collect) { + if (!gen) + throw std::runtime_error(collect ? "collect: expected generator" : "next: expected generator"); + return std::make_unique(std::move(gen), collect); +} + namespace { // Make a call a job running here (not on an evaluator) waits for. A native function's own // deep-data work must run here too, or its result would be lost: deferral is off for the call. @@ -695,6 +778,16 @@ value_t call_function(const value_t &fn, std::span args, const ch throw std::runtime_error(std::string(who) + ": not a function: " + fn.type_name()); } +value_t call_function(const value_t &fn, std::span args, const char *who, + std::function then) { + const auto *cls = std::get_if(&fn.v); + if (!std::holds_alternative(fn.v) && !(cls && *cls)) + throw std::runtime_error(std::string(who) + ": not a function: " + fn.type_name()); + return run_data_job( + std::make_unique(fn, std::vector(args.begin(), args.end()), who), + std::move(then)); +} + void count_data_nodes(std::size_t nodes) { if (nodes == 0) return; diff --git a/src/cvc/state/state_exec/heap.cpp b/src/cvc/state/state_exec/heap.cpp index 05167745e..13ff922b6 100644 --- a/src/cvc/state/state_exec/heap.cpp +++ b/src/cvc/state/state_exec/heap.cpp @@ -58,11 +58,7 @@ template void value_edges(const value_t &v, F &f) { if (x) f(node_kind::data, x); } else if constexpr (std::is_same_v) { - if (const auto *m = x.template target()) { - if (m->method) - f(node_kind::closure, m->method); - env_edge(m->global_env, f); - } else if (const auto *c = x.template target()) { + if (const auto *c = x.template target()) { if (c->cls_dict) f(node_kind::dict, c->cls_dict); } else if (const auto *i = x.template target()) { diff --git a/src/cvc/state/state_exec/stackless_evaluator.cpp b/src/cvc/state/state_exec/stackless_evaluator.cpp index f357255f6..64bf2784d 100644 --- a/src/cvc/state/state_exec/stackless_evaluator.cpp +++ b/src/cvc/state/state_exec/stackless_evaluator.cpp @@ -785,19 +785,7 @@ void stackless_evaluator::step_apply(evaluator_state &state, eval_frame &frame) throw std::runtime_error("apply: undefined function: " + head_sym->name); if (auto *fn = std::get_if(&func_val->v)) { - detail::data_step_scope *data = detail::data_step_scope::current(); - value_t result; - { - native_call_scope call(data); - result = (*fn)(evaled); - } - if (auto deferred = data ? data->take_deferred() : nullptr) { - // The call left deep-data work: this frame waits while step_data() runs it. - await_deferred(state, std::move(*deferred), state.stack.size(), frame.results.size(), - state.data_work, false); - return; - } - pop_frame(state, std::move(result)); + apply_native(state, frame, *fn, evaled); return; } if (auto *cp = std::get_if(&func_val->v)) { @@ -807,6 +795,23 @@ void stackless_evaluator::step_apply(evaluator_state &state, eval_frame &frame) throw std::runtime_error("apply: not callable: " + head_sym->name); } +void stackless_evaluator::apply_native(evaluator_state &state, eval_frame &frame, + const native_fn &fn, std::span args) { + detail::data_step_scope *data = detail::data_step_scope::current(); + value_t result; + { + native_call_scope call(data); + result = fn(args); + } + if (auto deferred = data ? data->take_deferred() : nullptr) { + // The call left deep-data work: this frame waits while step_data() runs it. + await_deferred(state, std::move(*deferred), state.stack.size(), frame.results.size(), + state.data_work, false); + return; + } + pop_frame(state, std::move(result)); +} + // --------------------------------------------------------------------------- // step: if // --------------------------------------------------------------------------- @@ -866,31 +871,45 @@ void stackless_evaluator::step_while_body(evaluator_state &state, eval_frame &fr // step: for // --------------------------------------------------------------------------- +// The names a for frame's generator call (advance_generator) binds in its extra_env. A user's +// script cannot reach that scope: it is the call's own. +const char *const kForGenerator = "%generator"; +const char *const kForGeneratorNext = "%generator-next"; + void stackless_evaluator::step_for_body(evaluator_state &state, eval_frame &frame) { + // A DSL generator's next value came back from the call advance_generator() pushed: a list of + // the value, or empty once the generator is exhausted. extra_strs[1] marks the wait. + if (frame.extra_strs.size() > 1) { + frame.extra_strs.pop_back(); + const value_t got = std::move(frame.results.back()); + frame.results.clear(); + const auto &l = *std::get(got.v); + if (l.empty()) { + pop_frame(state, nil_value); + return; + } + frame.env->set(frame.extra_strs[0], l.front()); + push_frame(state, frame.extra_vals[0], frame.env); + return; + } + if (frame.index == -1) { // Collection was just evaluated auto collection = frame.results.back(); - if (auto *gp = std::get_if(&collection.v)) { + if (std::holds_alternative(collection.v)) { // Generator: store in extra_vals[1] for lazy iteration frame.extra_vals.push_back(collection); frame.extra_lists = {{}}; // placeholder - auto next = generator_next(**gp); - if (!next) { - pop_frame(state, nil_value); - return; - } frame.index = 0; frame.results.clear(); - frame.env->set(frame.extra_strs[0], *next); - push_frame(state, frame.extra_vals[0], frame.env); - return; + } else { + auto *lp = std::get_if(&collection.v); + if (!lp) + throw std::runtime_error("for: collection must be a list or generator"); + frame.extra_lists = {std::vector((*lp)->begin(), (*lp)->end())}; + frame.index = 0; + frame.results.clear(); } - auto *lp = std::get_if(&collection.v); - if (!lp) - throw std::runtime_error("for: collection must be a list or generator"); - frame.extra_lists = {std::vector((*lp)->begin(), (*lp)->end())}; - frame.index = 0; - frame.results.clear(); } else { frame.index++; frame.results.clear(); @@ -899,8 +918,12 @@ void stackless_evaluator::step_for_body(evaluator_state &state, eval_frame &fram // Generator path: extra_vals has size > 1 when a generator is stored if (frame.extra_vals.size() > 1) { auto *gp = std::get_if(&frame.extra_vals[1].v); - if (gp) { - auto next = generator_next(**gp); + if (gp && *gp && !(*gp)->native_step) { + advance_generator(state, frame); + return; + } + if (gp) { // range and friends: one cheap value, here + auto next = *gp ? generator_next(**gp) : std::nullopt; if (!next) { pop_frame(state, nil_value); return; @@ -921,6 +944,24 @@ void stackless_evaluator::step_for_body(evaluator_state &state, eval_frame &fram } } +void stackless_evaluator::advance_generator(evaluator_state &state, eval_frame &frame) { + // The call (%generator-next %generator), in a scope of the frame's own: an ordinary native + // call, so the job it runs (make_generator_job) slices and waits like any other, and its + // value comes back to this frame. + if (!frame.extra_env) { + frame.extra_env = environment::extend(frame.env); + frame.extra_env->set(kForGenerator, frame.extra_vals[1]); + frame.extra_env->set( + kForGeneratorNext, value_t{native_fn{[](std::span args) -> value_t { + return run_data_job(make_generator_job(std::get(args[0].v))); + }}}); + } + frame.extra_strs.push_back(kForGeneratorNext); // waiting for the value + environment_ptr scope = frame.extra_env; + push_frame(state, make_list({value_t{symbol{kForGeneratorNext}}, value_t{symbol{kForGenerator}}}), + std::move(scope)); // last: it may move `frame` +} + // --------------------------------------------------------------------------- // step: set // --------------------------------------------------------------------------- @@ -1088,7 +1129,8 @@ void stackless_evaluator::step_super_args(evaluator_state &state, eval_frame &fr call_args.push_back(self_val); for (size_t i = 1; i < frame.results.size(); ++i) call_args.push_back(frame.results[i]); - pop_frame(state, (*fn)(call_args)); + const native_fn method = *fn; // it may change the class dict under `v` + apply_native(state, frame, method, call_args); return; } if (auto *cp = std::get_if(&v.v)) { @@ -1242,8 +1284,10 @@ void stackless_evaluator::handle_defclass(evaluator_state &state, eval_frame &fr mcls->env_snapshot = env; track_captured_env(env); // the class is bound in env: a cycle candidate - cls_dict->emplace_back(msym->name, - value_t{native_fn{detail::class_method{std::move(mcls), global_env_}}}); + // A method is a closure taking self first: send and super call it like any function, its + // body running as frames of the calling process (call_function), not on an evaluator of + // its own. + cls_dict->emplace_back(msym->name, value_t{std::move(mcls)}); } // Constructor @@ -1252,26 +1296,12 @@ void stackless_evaluator::handle_defclass(evaluator_state &state, eval_frame &fr pop_frame(state, *env->lookup(cls_name)); } -value_t detail::class_method::operator()(std::span call_args) const { - // Run the method to completion on a temporary evaluator. - std::vector all_args(call_args.begin(), call_args.end()); - auto local = environment::extend(method->env_snapshot); - if (method->variadic) { - local->set(method->params.back().name, make_list(all_args)); - } else { - for (size_t j = 0; j < method->params.size(); ++j) - local->set(method->params[j].name, all_args[j]); - } - detail::native_nesting level("method"); // a nested evaluator - stackless_evaluator temp(global_env); - if (method->body.size() == 1) - return temp.evaluate(method->body[0], local); - std::vector be; - be.reserve(1 + method->body.size()); - be.push_back(value_t{symbol{"begin"}}); - be.insert(be.end(), method->body.begin(), method->body.end()); - return temp.evaluate(make_list(std::move(be)), local); +namespace { +bool is_callable(const value_t &v) { + const auto *cls = std::get_if(&v.v); + return std::holds_alternative(v.v) || (cls && *cls); } +} // namespace value_t detail::class_constructor::operator()(std::span init_args) const { auto inst = std::make_shared>>(); @@ -1279,15 +1309,14 @@ value_t detail::class_constructor::operator()(std::span init_args value_t inst_val{inst}; for (auto &[k, v] : *cls_dict) { if (k == "init") { + if (!is_callable(v)) + break; std::vector a; a.push_back(inst_val); a.insert(a.end(), init_args.begin(), init_args.end()); - if (auto *fn = std::get_if(&v.v)) { - const native_fn init = *fn; // init may change the class dict under `v` - detail::native_nesting level("constructor"); - init(a); - } - break; + const value_t init = v; // init may change the class dict under `v` + // Whatever init returns, the call's value is the instance, once init has run. + return call_function(init, a, "constructor", [inst_val](value_t) { return inst_val; }); } } return inst_val; @@ -1297,12 +1326,10 @@ value_t detail::class_inherited_method::operator()(std::span args if (super_cls) { for (auto &[k, v] : *super_cls) { if (k == name) { - if (auto *fn = std::get_if(&v.v)) { - const native_fn inherited = *fn; // it may change the class dict under `v` - detail::native_nesting level(name.c_str()); - return inherited(args); - } - break; + if (!is_callable(v)) + break; + const value_t inherited = v; // it may change the class dict under `v` + return call_function(inherited, args, name == "init" ? "init" : "finalize"); } } } diff --git a/src/cvc/tests/state_exec_data_steps_test.cpp b/src/cvc/tests/state_exec_data_steps_test.cpp index db5dfc6d2..70bad284b 100644 --- a/src/cvc/tests/state_exec_data_steps_test.cpp +++ b/src/cvc/tests/state_exec_data_steps_test.cpp @@ -572,3 +572,97 @@ TEST_F(DataStepsScheduler, BudgetUsageAndInspectReportTheKinds) { ASSERT_TRUE(info); EXPECT_GT(info->data_time, 0.0); } + +// ---- methods and generators inside the process ---------------------------------- +// +// A class method is a closure run as frames of the process that sends to it, and a DSL +// generator's body is advanced as a job (make_generator_job): both are the process's own steps, +// counted against its budget, where each used to run to completion on an evaluator of its own +// inside one step. + +TEST_F(DataStepsScheduler, MethodStepsCountAgainstMaxSteps) { + scheduler sched; + sched.set_watch_root(&root); + sched.load_settings(); + const std::string script = "(begin (defclass C (spin (self n) (begin (set i 0)" + " (while (< i n) (set i (+ i 1))) i))) (set o (C))" + " (send o \"spin\" 2000))"; + const int done = sched.execute(script); + sched.run(); + auto info = sched.get_process_info(done); + ASSERT_TRUE(info); + EXPECT_EQ(info->status, process_status::terminated); + EXPECT_EQ(std::get(sched.get_result(done)->v), 2000); + EXPECT_GT(info->eval_steps, 2000u); // the method's loop, step by step + + execute_options opts; + opts.max_steps = 500; + const int killed = sched.execute(script, opts); + sched.run(); + EXPECT_EQ(sched.get_process_info(killed)->status, process_status::killed); +} + +TEST_F(DataStepsScheduler, GeneratorBodiesRunASlicePerStep) { + root("state_exec.defaults.data.step_nodes").value("16"); + scheduler sched; + sched.set_watch_root(&root); + sched.load_settings(); + const std::string script = "(begin (set g (generator (lambda () (begin (set k 0)" + " (while (< k 500) (set k (+ k 1))) (yield k) (yield 7)))))" + " (list (next g) (next g) (next g) (length (collect (range 0 100)))))"; + const int done = sched.execute(script); + sched.run(); + auto info = sched.get_process_info(done); + ASSERT_TRUE(info); + ASSERT_EQ(info->status, process_status::terminated); + const auto &r = *std::get(sched.get_result(done)->v); + ASSERT_EQ(r.size(), 4u); + EXPECT_EQ(std::get(r[0].v), 500); + EXPECT_EQ(std::get(r[1].v), 7); + EXPECT_TRUE(r[2].is_nil()); // exhausted + EXPECT_EQ(std::get(r[3].v), 100); + EXPECT_GT(info->data_nodes, 2000u); // the body's steps, and range's values + EXPECT_GT(info->data_steps, 100u); + + execute_options opts; + opts.max_steps = info->eval_steps + 20; // room for the program, not the generator's body + const int killed = sched.execute(script, opts); + sched.run(); + EXPECT_EQ(sched.get_process_info(killed)->status, process_status::killed); +} + +TEST_F(DataStepsScheduler, ForOverAGeneratorGivesTheSameSlicedOrNot) { + const std::string script = "(begin (set g (generator (lambda () (begin (set j 0)" + " (while (< j 50) (begin (yield j) (set j (+ j 1))))))))" + " (set s 0) (for x g (set s (+ s x))) s)"; + for (const char *slice : {"0", "4"}) { + root("state_exec.defaults.data.step_nodes").value(slice); + scheduler sched; + sched.set_watch_root(&root); + sched.load_settings(); + const int pid = sched.execute(script); + sched.run(); + ASSERT_EQ(sched.get_process_info(pid)->status, process_status::terminated) << slice; + EXPECT_EQ(std::get(sched.get_result(pid)->v), 49 * 50 / 2) << slice; + } +} + +TEST_F(DataStepsScheduler, ConstructorReturnsTheInstanceAfterInitDefers) { + // K inherits P's init, a closure that compares two deep lists: the comparison runs as data + // steps inside init's frames, and the constructor's value is still the instance, not init's. + root("state_exec.defaults.data.step_nodes").value("16"); + scheduler sched; + sched.set_watch_root(&root); + sched.load_settings(); + const int pid = sched.execute( + std::string("(begin (set a 1) (set b 1) (set i 0)" + " (while (< i 300) (begin (set a (list a)) (set b (list b)) (set i (+ i 1))))" + " (defclass P (init (self x) (begin (set-attr self \"same\" (= a b)) 42)))" + " (defclass K P) (set o (K 1)) (get-attr o \"same\"))")); + sched.run(); + auto info = sched.get_process_info(pid); + ASSERT_TRUE(info); + ASSERT_EQ(info->status, process_status::terminated); + EXPECT_TRUE(std::get(sched.get_result(pid)->v)); + EXPECT_GT(info->data_steps, 0u); +} diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index 75478956a..6b5d95df8 100644 --- a/src/cvc/tests/state_exec_deep_data_test.cpp +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -420,12 +420,13 @@ TEST_F(StateExecDeepDataCodec, BadSpillReferenceThrows) { // an inherited init running its superclass's) or runs a nested evaluator (a class method, a // generator) nests on the native stack. detail::native_nesting refuses a level past // kMaxNativeNesting levels or with less than kNativeStackReserve bytes of stack left, so a value or -// class that refers to itself throws instead of overflowing. The runaway cases run on a 64 KiB -// stack (wasm's default) and must throw, not crash; the legitimate ones run on 1 MiB, where the -// heaviest levels (a class method's evaluator, over 10 KiB each) still fit. +// class that refers to itself throws instead of overflowing. The runaway cases run on a stack only +// 64 KiB bigger than the reserve, where the stack runs out first, and must throw, not crash; the +// legitimate ones run on 1 MiB. -constexpr std::size_t kWasmStackKiB = 64; +constexpr std::size_t kSmallStackKiB = kNativeStackReserve / 1024 + 64; constexpr std::size_t kThreadStackKiB = 1024; +constexpr std::size_t kWasmStackKiB = 64; // wasm's default stack // Run `body` on a thread with a `kib` stack. void on_stack(std::size_t kib, const std::function &body) { @@ -451,7 +452,7 @@ value_t run_script(const std::string &script) { // Run `script` on a `kib` stack, expecting the nesting limit's error; returns the levels it // reports. Afterwards no level is left counted. -std::size_t expect_nesting_limit(const std::string &script, std::size_t kib = kWasmStackKiB) { +std::size_t expect_nesting_limit(const std::string &script, std::size_t kib = kSmallStackKiB) { std::size_t levels = 0; on_stack(kib, [&] { try { @@ -508,15 +509,16 @@ TEST(StateExecNativeNesting, InheritedInitUpALongChain) { expect_nesting_limit(class_chain(10000) + " (K))"); } -TEST(StateExecNativeNesting, MethodsCallingMethods) { - // Each call of `down` runs on a nested evaluator and sends `down` again: the heaviest level. +TEST(StateExecNativeNesting, MethodsCallingMethodsAreFrames) { + // A method is a closure: send calls it through call_function, so its body runs as frames of + // the calling evaluator, on the heap, and methods calling methods do not nest natively. const std::string cls = "(begin (defclass C (down (self n) (if (= n 0) 0 (+ 1 (send self " "\"down\" (- n 1)))))) (set o (C)) "; - on_stack(kThreadStackKiB, [&] { - value_t r = run_script(cls + "(send o \"down\" 20))"); - EXPECT_EQ(std::get(r.v), 20); + on_stack(kSmallStackKiB, [&] { + value_t r = run_script(cls + "(send o \"down\" 10000))"); + EXPECT_EQ(std::get(r.v), 10000); + EXPECT_EQ(detail::native_nesting::depth(), 0u); }); - expect_nesting_limit(cls + "(send o \"down\" 100000))"); } TEST(StateExecNativeNesting, GeneratorsOfGenerators) { diff --git a/src/cvc/tests/state_exec_scheduler_test.cpp b/src/cvc/tests/state_exec_scheduler_test.cpp index 4c597bfae..ba6c8fbad 100644 --- a/src/cvc/tests/state_exec_scheduler_test.cpp +++ b/src/cvc/tests/state_exec_scheduler_test.cpp @@ -153,15 +153,18 @@ TEST_F(SchedulerTest, SingleProcessSimple) { EXPECT_EQ(std::get(val.v), 42); } -TEST_F(SchedulerTest, DeadlineBoundsUncappedNestedMethodStep) { - // A defclass constructor runs its init method body on a FRESH nested evaluator; a looping - // body lives inside ONE scheduler step, which the between-steps max_time check cannot see. - // The scheduler arms the cooperative per-thread deadline for each step (from the process's - // remaining budget), so the nested loop aborts at the budget instead of hanging the run. +TEST_F(SchedulerTest, TimeBudgetStopsALoopingInitMethod) { + // A defclass constructor's init is a closure run as frames of the calling process, so a + // looping body is the process's own steps: the scheduler's max_time check kills it, as any + // loop, instead of the body spinning inside one step on an evaluator of its own. execute_options opts; opts.max_time = 0.05; // 50 ms process budget - sched.execute(std::string("(begin (defclass B (init (self) (while 1 1))) (B))"), opts); - EXPECT_THROW(sched.run(), std::exception); // bounded abort (~50ms), not a hang + const int pid = + sched.execute(std::string("(begin (defclass B (init (self) (while 1 1))) (B))"), opts); + sched.run(); // bounded (~50 ms), not a hang + auto info = sched.get_process_info(pid); + ASSERT_TRUE(info); + EXPECT_EQ(info->status, process_status::killed); } TEST_F(SchedulerTest, SingleProcessArithmetic) {