From 7d809bfcd97b6b857779125f8eb8b2deb56ac667 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 18 Oct 2026 03:14:22 -0500 Subject: [PATCH 1/2] state_exec: native calls past the first level are the evaluator's Inside an evaluator step, the native function the step applies may call a native function itself; a native call asked for from deeper is deferred to step_data, which makes it at its own level (data_step_scope::calls_here). (apply apply L), send through itself and a constructor that is its own init are now loops of steps that max_steps and max_time stop the same way on every platform, instead of hitting the stack-dependent nesting limit. A forwarding call job gives its place to its callee's work, so such chains run in constant space. Inherited init/finalize and the constructor look up the inherited chain 64 classes a call instead of calling through it. pending_data_work chains are freed a link at a time. native_nesting stays the backstop for generators and native calls from C++ with no evaluator. --- docs/STATE_EXEC_DEVELOPER_GUIDE.md | 10 +- docs/STATE_EXEC_GRAMMAR.md | 58 ++++- inc/cvc/state/state_exec/data_job.h | 52 +++-- .../state/state_exec/stackless_evaluator.h | 5 + src/cvc/state/state_exec/builtins.cpp | 6 +- src/cvc/state/state_exec/closure_natives.h | 9 + src/cvc/state/state_exec/data_job.cpp | 33 ++- .../state/state_exec/stackless_evaluator.cpp | 66 ++++-- src/cvc/tests/state_exec_bench_test.cpp | 33 +++ src/cvc/tests/state_exec_deep_data_test.cpp | 207 ++++++++++++++++-- 10 files changed, 402 insertions(+), 77 deletions(-) diff --git a/docs/STATE_EXEC_DEVELOPER_GUIDE.md b/docs/STATE_EXEC_DEVELOPER_GUIDE.md index ac57fd3da..6bd628d74 100644 --- a/docs/STATE_EXEC_DEVELOPER_GUIDE.md +++ b/docs/STATE_EXEC_DEVELOPER_GUIDE.md @@ -1479,10 +1479,12 @@ read. The document is held to the allocation limit as it grows. the work runs whole in the call's step. - **Calls.** `collections.map`, `filter` and `reduce` hand each call of their function to the evaluator running the program, and so do the builtins `apply` and `send`. A native function - is called inside the data step. A DSL function runs as the process's own frames, so its - body's steps are eval steps. The job goes on in the data step after it returns, even with - `data.step_nodes` = 0. Another native function can do the same with - `call_function(fn, args, who)`. + is called right away when the native function the step applies asks for it, and by the + evaluator, in a data step, when a native function deeper than that asks (so native calls nest + at most one level; `docs/STATE_EXEC_GRAMMAR.md`, "Native call nesting"). A DSL function runs + as the process's own frames, so its body's steps are eval steps. The job goes on in the data + step after it returns, even with `data.step_nodes` = 0. Another native function can do the + same with `call_function(fn, args, who)`. - **Cost.** `collections.sort` is a stable merge sort, n log n nodes. `collections.unique` costs a node per comparison, and compares lists and dicts with a sliced equality walk. `collections.dict-merge` keeps a key index, so it is linear. Every job polls the diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index 369f25b4b..a9022bedf 100644 --- a/docs/STATE_EXEC_GRAMMAR.md +++ b/docs/STATE_EXEC_GRAMMAR.md @@ -242,10 +242,12 @@ A node is an element visited: a list element or a dict entry walked, or an eleme - **Signals and watch events** wait while a walk is pending, because the walk's call frame is on top of the evaluation stack. - **Function calls.** A job can stop for a call of a function (`collections.map`, `filter`, - `reduce`, and `apply`/`send` through `call_function()`), and the evaluator makes it. A native function is called inside the data step. A - DSL function runs as frames above the waiting call, in eval steps; `return` and `break` in it - unwind no further than that call. The job goes on in the data step after the function - returns. Such a job waits on the evaluator even with `step_nodes` = 0. + `reduce`, and `apply`/`send` through `call_function()`), and the evaluator makes it. A native + function is called inside the step: by the native call the step applies, if the job is its own, + and otherwise by the evaluator in a data step (see "Native call nesting"). A DSL function runs + as frames above the waiting call, in eval steps; `return` and `break` in it unwind no further + than that call. The job goes on in the data step after the function returns. Such a job waits + on the evaluator even with `step_nodes` = 0. - **Snapshots** keep `step_nodes` but not a walk in flight: after a restore, the waiting call or macro expansion runs again. The frames of a DSL function it was calling are left out. - **Values changed between slices.** A walk holds the lists and dicts it walks by owning @@ -270,14 +272,46 @@ 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. 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. +running `init`, an inherited `init` running its superclass's, and the stdlib jobs +(`collections.map` and friends). Inside a running program they nest at most one level on the +native stack. The native function the evaluator applies may call a native function itself +(`apply` calling `+`), but a native call asked for by that function, or from anywhere deeper, is +the evaluator's: it makes the call at its own level, in a data step (`call_function`, +`run_data_job`). + +- **Runaways are loops.** `(apply apply L)` with `L` containing itself, `send` re-sending through + itself, or a class whose `init` is its own constructor is a sequence of steps. `max_steps` and + `max_time` stop it as they stop `(while t nil)`, at the same step on every platform and stack + size. +- **Long chains finish.** `apply` applied 10000 levels deep, or a chain of 10000 classes that + inherit `init`, runs on any stack. +- **Inherited `init` and `finalize`** look up the chain of classes that inherit them too, 64 + classes a call (`kInheritedHops`), to the first class that defines one, and call that. A + constructor does the same, and with no class defining `init` it calls nothing. A longer chain, + or one that loops, goes on as calls. +- **Space.** A call whose value is its caller's, unchanged (`apply`, `send`, an inherited + `init`), hands its place to the call it makes, so a chain of them runs in constant space. A + call with more to do after its callee (a constructor returns the instance once `init` is done) + waits, a link of pending work per level, like a function that calls itself before doing + anything else. Pending work is not yet part of a process's measured heap (`max_memory`). +- **Cost.** A call the evaluator makes costs about 300 ns more than one made directly (MSVC + Release, `state_exec_bench_test` `NativeCallThroughput`). The one direct level keeps `apply` + and `send` at their old cost, and only a native call two levels down pays it. Constructing a + class that inherits `init` costs less than it did, since the chain is looked up, not called. + +What still nests is a **level**: + +- the one call above, made by the step's own native call; +- a generator advancing, which steps an evaluator of its own (generators of generators nest a + level apiece); +- a native function calling another from C++ where no program is running: a host calling a + native function directly, or a job run to completion (`run_to_completion`). + +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 diff --git a/inc/cvc/state/state_exec/data_job.h b/inc/cvc/state/state_exec/data_job.h index e23e07912..287b37bd8 100644 --- a/inc/cvc/state/state_exec/data_job.h +++ b/inc/cvc/state/state_exec/data_job.h @@ -73,6 +73,11 @@ class data_job { /// from that call's arguments. virtual void own_inputs() {} + /// True for a job whose result is the value of the one call it makes, unchanged (a call + /// call_function() made a job). When that call leaves work of its own, the evaluator lets the + /// work take the job's place, so a chain of such calls runs in constant space. + virtual bool forwards_call() const { return false; } + protected: std::size_t nodes_ = 0; std::optional call_; @@ -132,15 +137,19 @@ data_options data_options_from_state(cvc::state &root, const std::string &schedu /// evaluator: it runs a slice per step, makes the job's calls (a DSL function as ordinary /// frames of the same process) and completes the call with then's value once the job is done; /// what this returns is then ignored, so the native function should return it at once. -/// Anywhere else the job runs to completion here (see run_to_completion). Without `then`, the -/// job's result is the call's. +/// A native function the job calls is called here only from the step's own native call +/// (data_step_scope::calls_here); deeper, the evaluator calls it. Anywhere else the job runs to +/// completion here (see run_to_completion). Without `then`, the job's result is the call's. value_t run_data_job(std::unique_ptr job, std::function then = {}); /// Apply `fn`, a native function or a DSL closure, to `args` from inside a native function. -/// A native function is called here. A closure runs through the evaluator, as a one-call job -/// (run_data_job): inside a stackless evaluator step its body becomes the process's own frames, -/// and the native function should return what this returns at once; anywhere else a closure -/// throws. `who` names the caller in errors. +/// Inside a stackless evaluator step the call is the evaluator's, as a one-call job +/// (run_data_job): a closure's body becomes the process's own frames, and a native function is +/// called by the evaluator at its own level, in a data step, unless this is the step's own native +/// call, which calls it here (data_step_scope::calls_here). The native function should return +/// what this returns at once. Anywhere else a native function is called here, one level of +/// native nesting (detail::native_nesting), and a closure throws. `who` names the caller in +/// errors. value_t call_function(const value_t &fn, std::span args, const char *who); /// As call_function, with `then` applied to the call's value: for a native function that has @@ -150,16 +159,18 @@ value_t call_function(const value_t &fn, std::span args, const ch 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 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. +/// How native calls may nest on one thread's native stack. Inside an evaluator step they barely +/// do: when a native function (apply, send, a class constructor running init, an inherited init +/// running its superclass's, a job) calls a native function, the call is made here only from the +/// native function the step applies, and any call deeper than that is the evaluator's, made in a +/// data step (data_step_scope::calls_here). So a self-containing argument list, a class whose init +/// is its own constructor, or a long chain of inits is a sequence of steps, stopped by max_steps +/// and max_time like a loop. What still nests is a level: that one call, a generator stepping its +/// own evaluator, and a native function calling another from C++ with no evaluator step running +/// (or one that cannot wait for it, see run_to_completion). 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 @@ -232,6 +243,12 @@ class data_step_scope { /// The job a native call deferred, if any. std::unique_ptr take_deferred() { return std::move(deferred_); } + /// Whether a native function that the native call being applied wants called (call_function, + /// a job's call) is called right here, on the native stack: only from that native call itself, + /// one level down, and while the slice has room. A call from deeper goes to the evaluator, + /// which makes it at its own level, so native calls nest at most one level in a step. + bool calls_here() const; + /// Free backlog owners with what is left of the slice (all of them when not slicing). void drain_backlog(); @@ -241,11 +258,14 @@ class data_step_scope { private: friend value_t cvc::state_exec::run_data_job(std::unique_ptr, std::function); + friend value_t cvc::state_exec::call_function(const value_t &, std::span, + const char *); friend void cvc::state_exec::count_data_nodes(std::size_t); std::size_t step_nodes_; evaluation_stats &stats_; release_backlog *backlog_; + std::size_t native_depth_; // native_nesting::depth() when the step began std::size_t nodes_ = 0; bool may_defer_ = false; std::unique_ptr deferred_; diff --git a/inc/cvc/state/state_exec/stackless_evaluator.h b/inc/cvc/state/state_exec/stackless_evaluator.h index 48f5e0bcc..37eb355bb 100644 --- a/inc/cvc/state/state_exec/stackless_evaluator.h +++ b/inc/cvc/state/state_exec/stackless_evaluator.h @@ -90,6 +90,11 @@ struct pending_data_work { std::shared_ptr outer; /// The result is the value of a call outer's job made, not the waiting frame's. bool feeds_outer = false; + + /// Frees the chain of outer work a link at a time, not one native frame per link: a native + /// call that waits on one like it, with more to do after it (a class constructor whose init + /// constructs again), makes a chain as long as the process's steps allow. + ~pending_data_work(); }; /// Serializable evaluator state — the entire execution snapshot. diff --git a/src/cvc/state/state_exec/builtins.cpp b/src/cvc/state/state_exec/builtins.cpp index 01f2ab786..54c00d9dd 100644 --- a/src/cvc/state/state_exec/builtins.cpp +++ b/src/cvc/state/state_exec/builtins.cpp @@ -129,7 +129,8 @@ value_t dispatch_method(const value_t &obj, const std::string &method, if (value_t *m = dict_find(*cls_dict, method)) { if (!std::holds_alternative(m->v) && !std::holds_alternative(m->v)) throw std::runtime_error("send: method is not callable"); - // Invoke: first arg is self. A closure runs as the program's own frames. + // Invoke: first arg is self. A closure runs as the program's own frames, and a native + // method nests no deeper than one level (call_function). std::vector call_args; call_args.reserve(1 + args.size()); call_args.push_back(obj); @@ -628,7 +629,8 @@ value_t builtin_send(std::span args) { value_t builtin_apply(std::span args) { expect_exact(args, 2, "apply"); auto &arg_list = as_list(args[1], "apply"); - // A native function is called here; a closure runs as the program's own frames. + // A closure runs as the program's own frames; a native function is called here, unless this + // apply was itself called by a native function, when the evaluator calls it (call_function). return call_function(args[0], arg_list, "apply"); } diff --git a/src/cvc/state/state_exec/closure_natives.h b/src/cvc/state/state_exec/closure_natives.h index 18f785f0f..842469d33 100644 --- a/src/cvc/state/state_exec/closure_natives.h +++ b/src/cvc/state/state_exec/closure_natives.h @@ -30,9 +30,18 @@ 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; + /// The method a call of this one comes to: up the chain of classes that inherit it too, the + /// first class's own `name` method, or nullptr when no class defines one (the default). A + /// chain longer than kInheritedHops classes (or one that loops) stops at the inherited method + /// it reached, whose call goes on from there. + const value_t *resolve() const; ~class_inherited_method() { drop_owner(super_cls); } }; +/// How many classes up one call of an inherited method looks itself (class_inherited_method:: +/// resolve) before it leaves the rest of the chain to a call. +constexpr int kInheritedHops = 64; + } // namespace cvc::state_exec::detail #endif // CVC_STATE_EXEC_CLOSURE_NATIVES_H diff --git a/src/cvc/state/state_exec/data_job.cpp b/src/cvc/state/state_exec/data_job.cpp index 0e79c2557..561b3c819 100644 --- a/src/cvc/state/state_exec/data_job.cpp +++ b/src/cvc/state/state_exec/data_job.cpp @@ -503,6 +503,8 @@ class call_walk final : public data_job { value_t result() override { return std::move(result_); } + bool forwards_call() const override { return true; } + private: value_t fn_; std::vector args_; @@ -733,8 +735,9 @@ value_t run_data_job(std::unique_ptr job, std::functionstep_nodes_ > 0; std::unique_ptr waits_on; for (;;) { @@ -747,7 +750,7 @@ value_t run_data_job(std::unique_ptr job, std::functionpending_call(); const native_fn *fn = pending ? std::get_if(&pending->fn->v) : nullptr; - if (!fn || (slicing && scope->nodes_ >= scope->step_nodes_)) + if (!fn || !scope->calls_here()) break; const data_job::call_request call = *job->take_call(); value_t value; @@ -769,13 +772,17 @@ value_t run_data_job(std::unique_ptr job, std::function args, const char *who) { if (const auto *native = std::get_if(&fn.v)) { - detail::native_nesting level(who); - return (*native)(args); + // Here, unless an evaluator step can make the call at its own level instead. + const detail::data_step_scope *scope = t_scope; + if (!scope || !scope->may_defer_ || scope->deferred_ || scope->calls_here()) { + detail::native_nesting level(who); + return (*native)(args); + } + } else if (const auto *cls = std::get_if(&fn.v); !cls || !*cls) { + throw std::runtime_error(std::string(who) + ": not a function: " + fn.type_name()); } - 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)); - 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)); } value_t call_function(const value_t &fn, std::span args, const char *who, @@ -816,7 +823,7 @@ std::size_t detail::native_nesting::depth() { return t_native_depth; } detail::data_step_scope::data_step_scope(std::size_t step_nodes, evaluation_stats &stats, release_backlog *backlog) : step_nodes_(step_nodes), stats_(stats), backlog_(step_nodes > 0 ? backlog : nullptr), - outer_(t_scope), routing_(backlog_) { + native_depth_(t_native_depth), outer_(t_scope), routing_(backlog_) { t_scope = this; } @@ -826,6 +833,12 @@ std::size_t detail::data_step_scope::remaining() const { return step_nodes_ > nodes_ ? step_nodes_ - nodes_ : 1; } +bool detail::data_step_scope::calls_here() const { + // The evaluator applies a native function without a level of native nesting, so the depth is + // the step's own only inside that function. + return t_native_depth == native_depth_ && (step_nodes_ == 0 || nodes_ < step_nodes_); +} + void detail::data_step_scope::drain_backlog() { if (!backlog_ || backlog_->empty() || nodes_ >= step_nodes_) return; diff --git a/src/cvc/state/state_exec/stackless_evaluator.cpp b/src/cvc/state/state_exec/stackless_evaluator.cpp index 64bf2784d..d34fab72c 100644 --- a/src/cvc/state/state_exec/stackless_evaluator.cpp +++ b/src/cvc/state/state_exec/stackless_evaluator.cpp @@ -11,6 +11,13 @@ namespace cvc::state_exec { +pending_data_work::~pending_data_work() { + // Each link is unlinked from the next before it goes, so its own destructor has nothing to free. + std::shared_ptr next = std::move(outer); + while (next && next.use_count() == 1) + next = std::move(next->outer); +} + // --------------------------------------------------------------------------- // ctor // --------------------------------------------------------------------------- @@ -169,8 +176,10 @@ environment_ptr bind_closure_args(const closure_ptr &cls, std::spanargs); } if (auto deferred = data ? data->take_deferred() : nullptr) { - // The function left deep-data work of its own: it runs first, and its result is the call's. - await_deferred(state, std::move(*deferred), work->base, work->frame_args, work, true); + // The function left deep-data work of its own (a call of its own, say): it runs first, and + // its result is the call's. A job that only forwards the call's value gives it its place. + if (job.forwards_call() && !work->then) { + state.data_work = work->outer; + await_deferred(state, std::move(*deferred), work->base, work->frame_args, work->outer, + work->feeds_outer); + } else { + await_deferred(state, std::move(*deferred), work->base, work->frame_args, work, true); + } return false; } job.call_returned(std::move(value)); @@ -1311,10 +1327,19 @@ value_t detail::class_constructor::operator()(std::span init_args if (k == "init") { if (!is_callable(v)) break; + // An inherited init is followed up the chain here, and with no class defining one there + // is nothing to run. + const value_t *method = &v; + if (const auto *fn = std::get_if(&v.v)) + if (const auto *inherited = fn->target(); + inherited && inherited->name == "init") + method = inherited->resolve(); + if (!method) + break; std::vector a; a.push_back(inst_val); a.insert(a.end(), init_args.begin(), init_args.end()); - const value_t init = v; // init may change the class dict under `v` + const value_t init = *method; // init may change the class dict under `method` // 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; }); } @@ -1322,16 +1347,31 @@ value_t detail::class_constructor::operator()(std::span init_args return inst_val; } -value_t detail::class_inherited_method::operator()(std::span args) const { - if (super_cls) { - for (auto &[k, v] : *super_cls) { +const value_t *detail::class_inherited_method::resolve() const { + const class_inherited_method *at = this; + for (int hop = 1;; ++hop) { + if (!at->super_cls) + return nullptr; + const value_t *found = nullptr; + for (auto &[k, v] : *at->super_cls) if (k == name) { - 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"); + found = &v; + break; } - } + if (!found || !is_callable(*found)) + return nullptr; + const auto *fn = std::get_if(&found->v); + const auto *next = fn ? fn->target() : nullptr; + if (!next || next->name != name || hop == kInheritedHops) + return found; + at = next; + } +} + +value_t detail::class_inherited_method::operator()(std::span args) const { + if (const value_t *method = resolve()) { + const value_t inherited = *method; // it may change the class dict under `method` + return call_function(inherited, args, name == "init" ? "init" : "finalize"); } if (name == "init") return args.empty() ? nil_value : args[0]; diff --git a/src/cvc/tests/state_exec_bench_test.cpp b/src/cvc/tests/state_exec_bench_test.cpp index f973adeb3..713ff62e1 100644 --- a/src/cvc/tests/state_exec_bench_test.cpp +++ b/src/cvc/tests/state_exec_bench_test.cpp @@ -290,6 +290,39 @@ TEST_F(ExecBenchTest, StdlibJobThroughput) { report("stdlib_small_calls_10000", M, std::chrono::duration_cast(t1 - t0)); } +TEST_F(ExecBenchTest, NativeCallThroughput) { + if (!bench_enabled()) { + SUCCEED(); + return; + } + + // Native functions calling native functions (apply, send to a native method, a constructor + // running inherited inits), 10000 calls a loop; native_call_plain is the loop with a direct + // call, for comparison. + constexpr int64_t kCalls = 10000; + constexpr int M = 20; + auto env = builtins::make_default_environment(); + const auto loop = [&](const char *name, const std::string &setup, const std::string &call) { + const std::string src = "(begin " + setup + " (set i 0) (while (< i " + std::to_string(kCalls) + + ") (begin " + call + " (set i (+ i 1)))) i)"; + auto t0 = hrclock::now(); + for (int r = 0; r < M; ++r) { + stackless_evaluator ev(env); + ev.evaluate_script(src); + } + auto t1 = hrclock::now(); + report(name, M * kCalls, std::chrono::duration_cast(t1 - t0)); + }; + loop("native_call_plain", "", "(list i 1)"); + loop("native_call_apply", "", "(apply list (list i 1))"); + loop("native_call_apply_apply", "", "(apply apply (list list (list i 1)))"); + loop("native_call_send_native", "(defclass C) (set-attr C.__cls_dict__ \"m\" list) (set o (C))", + "(send o \"m\" i)"); + loop("native_call_construct_inherited", "(defclass P) (defclass K P)", "(K)"); + loop("native_call_construct_chain4", "(defclass A) (defclass B A) (defclass C B) (defclass D C)", + "(D)"); +} + TEST_F(ExecBenchTest, SchedulerThroughput) { if (!bench_enabled()) { SUCCEED(); diff --git a/src/cvc/tests/state_exec_deep_data_test.cpp b/src/cvc/tests/state_exec_deep_data_test.cpp index b75e5b70f..38d37119c 100644 --- a/src/cvc/tests/state_exec_deep_data_test.cpp +++ b/src/cvc/tests/state_exec_deep_data_test.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -436,16 +437,19 @@ TEST_F(StateExecDeepDataCodec, BadSpillReferenceThrows) { // ---- 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 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. +// A native function that calls a native function (apply, send, a class constructor running init, +// an inherited init running its superclass's) calls it itself only when it is the native function +// an evaluator step applies; a call from any deeper is the evaluator's, made in a data step. So a +// value or class that refers to itself is a sequence of steps that max_steps stops, the same on +// any stack, and a long chain of such calls finishes on a small one. What still nests natively is +// a generator stepping its own evaluator, and a native function calling another from C++ with no +// evaluator step running: detail::native_nesting refuses a level past kMaxNativeNesting levels or +// with less than kNativeStackReserve bytes of stack left, so those throw instead of overflowing. +// The small stack is only 64 KiB bigger than the reserve, where the stack runs out first. constexpr std::size_t kSmallStackKiB = kNativeStackReserve / 1024 + 64; constexpr std::size_t kThreadStackKiB = 1024; +constexpr std::size_t kBigStackKiB = 4096; constexpr std::size_t kWasmStackKiB = 64; // wasm's default stack // Run `body` on a thread with a `kib` stack. @@ -490,26 +494,140 @@ std::size_t expect_nesting_limit(const std::string &script, std::size_t kib = kS return levels; } +// What a scheduler made of a process running `script` with `max_steps` (0: no limit). +struct budgeted_run { + process_status status = process_status::ready; + uint64_t steps = 0; + value_t result; +}; + +// Run `script` as a process of a scheduler on a `kib` stack. An error thrown out of the scheduler +// (the sync scheduler rethrows a process's) fails the test. +budgeted_run run_budgeted(const std::string &script, uint64_t max_steps, + std::size_t kib = kSmallStackKiB) { + budgeted_run out; + on_stack(kib, [&] { + scheduler sched; + execute_options opts; + opts.max_steps = max_steps; + opts.env = builtins::make_default_environment(); + try { + const int pid = sched.execute(script, opts); + sched.run(); + const auto info = sched.get_process_info(pid); + ASSERT_TRUE(info); + out.status = info->status; + out.steps = info->step_count; + out.result = sched.get_result(pid).value_or(nil_value); + } catch (const std::exception &e) { + ADD_FAILURE() << e.what() << ": " << script.substr(0, 200); + } + EXPECT_EQ(detail::native_nesting::depth(), 0u); + }); + return out; +} + +// The innermost pending data work's chain length (pending_data_work::outer). +std::size_t work_chain(const evaluator_state &st) { + std::size_t n = 0; + for (const pending_data_work *w = st.data_work.get(); w; w = w->outer.get()) + ++n; + return n; +} + +const std::string kApplyCycle = "(begin (set L (list apply nil)) (set-nth L 1 L) (apply apply L))"; + 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); + // (apply apply L) with L = (apply L): apply calls itself once per trip around the cycle. It + // loops like (while t), and max_steps stops it at the same step on a small stack and a big one. + for (const std::size_t kib : {kSmallStackKiB, kBigStackKiB}) { + SCOPED_TRACE(kib); + const budgeted_run r = run_budgeted(kApplyCycle, 20000, kib); + EXPECT_EQ(r.status, process_status::killed); + EXPECT_EQ(r.steps, 20000u); + } +} + +TEST(StateExecNativeNesting, ARunawayCallChainRunsInConstantSpace) { + // Each apply the evaluator calls leaves the next as work of its own, which takes the place of + // the call that made it: the stack and the pending work stay the size they were. + on_stack(kSmallStackKiB, [] { + stackless_evaluator ev(builtins::make_default_environment()); + evaluator_state st = ev.create_state(parse(kApplyCycle)); + while (!st.data_work) + ASSERT_FALSE(ev.step(st)); + const std::size_t frames = st.stack.size(); + for (int i = 0; i < 100000; ++i) { + ASSERT_FALSE(ev.step(st)); + ASSERT_EQ(st.stack.size(), frames); + ASSERT_LE(work_chain(st), 1u); + } + EXPECT_EQ(detail::native_nesting::depth(), 0u); + // L holds itself: break the cycle so the test leaks nothing. + std::get(st.global_env->lookup("L")->v)->at(1) = nil_value; + }); +} + +TEST(StateExecNativeNesting, AChainOfAppliesFinishesOnASmallStack) { + // x is (apply (apply ... (+ (1 2)))) 10000 deep, so (apply apply x) applies apply 10002 levels + // deep before + gives 3, sliced or not. + const std::string script = "(begin (set x (list + (list 1 2))) (set i 0) (while (< i 10000)" + " (begin (set x (list apply x)) (set i (+ i 1)))) (apply apply x))"; + for (const std::size_t step_nodes : {std::size_t{0}, std::size_t{4}}) { + SCOPED_TRACE(step_nodes); + on_stack(kSmallStackKiB, [&] { + stackless_evaluator ev(builtins::make_default_environment()); + evaluator_state st = ev.create_state(parse(script)); + st.data_step_nodes = step_nodes; + while (!ev.step(st)) + ASSERT_LE(work_chain(st), 1u); + EXPECT_EQ(std::get(st.result.v), 3); + EXPECT_EQ(detail::native_nesting::depth(), 0u); + }); + } } TEST(StateExecNativeNesting, SendThroughItself) { - // A class whose method "m" is `send` itself: (send o "m" "m" ...) re-sends once per "m". + // A class whose method "m" is `send` itself: (send o "m" "m" ... "k") re-sends once per "m", + // 5000 times, down to the method k. That works on a small stack; a budget too small for its + // steps stops it. 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 + "))"); + const std::string script = "(begin (defclass C (k (self) 7)) (set-attr C.__cls_dict__ \"m\" send)" + " (set o (C)) (send o" + + ms + " \"k\"))"; + const budgeted_run done = run_budgeted(script, 0); + ASSERT_EQ(done.status, process_status::terminated); + EXPECT_EQ(std::get(done.result.v), 7); + EXPECT_GT(done.steps, 2500u); // a data step for every other send, at least + const budgeted_run cut = run_budgeted(script, done.steps / 2); + EXPECT_EQ(cut.status, process_status::killed); + EXPECT_EQ(cut.steps, done.steps / 2); } TEST(StateExecNativeNesting, ConstructorThatIsItsOwnInit) { - expect_nesting_limit("(begin (defclass P) (set-attr P.__cls_dict__ \"init\" P) (P))"); + // Each construction's init constructs again and waits for it, like a function that calls + // itself before anything else: never done, and max_steps stops it. Its waiting work, 2 levels + // a step, is freed with the process. (Each level passes init one argument more, so the budget + // is kept small.) + const budgeted_run r = + run_budgeted("(begin (defclass P) (set-attr P.__cls_dict__ \"init\" P) (P))", 500); + EXPECT_EQ(r.status, process_status::killed); + EXPECT_EQ(r.steps, 500u); +} + +TEST(StateExecNativeNesting, ALongChainOfWaitingWorkIsFreedWithoutRecursion) { + // A closure init that constructs again waits in a chain of pending work a link per level; the + // chain is freed a link at a time when the process goes, here on wasm's stack. + on_stack(kWasmStackKiB, [] { + stackless_evaluator ev(builtins::make_default_environment()); + evaluator_state st = ev.create_state(parse("(begin (defclass P (init (self) (P))) (P))")); + while (work_chain(st) < 20000) + for (int i = 0; i < 1000; ++i) + ASSERT_FALSE(ev.step(st)); + st = evaluator_state{}; + }); } // A chain of `n` classes, each the superclass of the next, defining no init of their own, so @@ -522,11 +640,31 @@ std::string class_chain(std::size_t n) { } TEST(StateExecNativeNesting, InheritedInitUpALongChain) { - on_stack(kThreadStackKiB, [] { - value_t obj = run_script(class_chain(100) + " (K))"); + // An inherited init is its superclass's. A call of one looks up the chain itself, 64 classes + // at a time (kInheritedHops); further on, the evaluator calls the next in the place of the + // one before, so 10000 classes construct on a small stack. + on_stack(kSmallStackKiB, [] { + value_t obj = run_script(class_chain(10000) + " (K))"); EXPECT_TRUE(std::holds_alternative(obj.v)); + // An init the top class defines runs once a construction, with the instance. + value_t counted = run_script( + "(begin (set runs (list 0)) (defclass P (init (self) (begin (set-nth runs 0" + " (+ (nth runs 0) 1)) (set-attr self \"top\" 1))))" + " (set i 0) (while (< i 1000) (begin (defclass K P) (set P.__cls_dict__ K.__cls_dict__)" + " (set i (+ i 1)))) (list (get-attr (K) \"top\") (get-attr (K) \"top\") (nth runs 0)))"); + EXPECT_EQ(to_string(counted), "(1 1 2)"); + EXPECT_EQ(detail::native_nesting::depth(), 0u); }); - expect_nesting_limit(class_chain(10000) + " (K))"); +} + +TEST(StateExecNativeNesting, AnInheritedInitThatInheritsItself) { + // P's init is set to K's inherited one, which is P's: following the chain never ends, so it + // goes on as calls, kInheritedHops classes a call, until max_steps stops it. + const budgeted_run r = run_budgeted("(begin (defclass P) (defclass K P) (set-attr P.__cls_dict__" + " \"init\" (get-attr K.__cls_dict__ \"init\")) (K))", + 2000); + EXPECT_EQ(r.status, process_status::killed); + EXPECT_EQ(r.steps, 2000u); } TEST(StateExecNativeNesting, MethodsCallingMethodsAreFrames) { @@ -552,6 +690,35 @@ TEST(StateExecNativeNesting, GeneratorsOfGenerators) { expect_nesting_limit(mk + "(next (mk 10000)))"); } +TEST(StateExecNativeNesting, NativeCallsFromCxxWithoutAnEvaluatorNest) { + // With no evaluator step running, call_function makes a native call right where it is asked + // for, so apply applying itself through a cycle nests until the limit refuses a level. A job + // run to completion makes its calls the same way. + auto env = builtins::make_default_environment(); + const value_t apply = *env->lookup("apply"); + const value_t reduce = *env->lookup("collections.reduce"); + value_t cycle = make_list({apply, nil_value}); + std::get(cycle.v)->at(1) = cycle; // (apply ) + for (const bool through_a_job : {false, true}) { + SCOPED_TRACE(through_a_job); + on_stack(kSmallStackKiB, [&] { + try { + if (through_a_job) // (collections.reduce (list (list apply )) apply apply) + call_function(reduce, + std::vector{make_list({make_list({apply, cycle})}), apply, apply}, + "test"); + else + call_function(apply, std::vector{apply, cycle}, "test"); + ADD_FAILURE() << "expected the native nesting limit"; + } catch (const std::runtime_error &e) { + EXPECT_NE(std::string(e.what()).find("nested too deeply ("), std::string::npos) << e.what(); + } + EXPECT_EQ(detail::native_nesting::depth(), 0u); + }); + } + std::get(cycle.v)->at(1) = nil_value; // break the cycle so the test leaks nothing +} + 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. From 26e0bf3ff481c60b974928c99004752550a4421c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 18 Oct 2026 15:59:04 -0500 Subject: [PATCH 2/2] state_exec: max_memory counts the deep-data work a process waits on A heap measurement could not start while a process waited on deep-data work, and it never looked at what that work held, so a process that never stops waiting (a class whose init is its own constructor, or a closure init that constructs again) was held only by max_steps/max_time. Now a measurement may start on top of work in flight and runs first; it counts each pending work and what its job holds (data_job::held_values/held_bytes, implemented by the call and generator jobs). A job left to run after its call reports its bytes with eval_alloc_note so a re-measure comes due. fork refuses while any work in the chain waits on a frame (waits_on_data_work), not only the innermost. --- docs/PYCVC_STATE_EXEC_PARITY.md | 2 +- docs/STATE_EXEC_DEVELOPER_GUIDE.md | 8 +- docs/STATE_EXEC_GRAMMAR.md | 3 +- inc/cvc/state/state_exec/data_job.h | 11 +++ inc/cvc/state/state_exec/heap.h | 5 +- .../state/state_exec/stackless_evaluator.h | 4 + src/cvc/state/state_exec/async_scheduler.cpp | 5 +- src/cvc/state/state_exec/data_job.cpp | 22 +++++ src/cvc/state/state_exec/heap.cpp | 23 ++++- src/cvc/state/state_exec/scheduler.cpp | 5 +- .../state/state_exec/stackless_evaluator.cpp | 7 ++ src/cvc/tests/state_exec_heap_test.cpp | 89 +++++++++++++++++++ 12 files changed, 170 insertions(+), 14 deletions(-) diff --git a/docs/PYCVC_STATE_EXEC_PARITY.md b/docs/PYCVC_STATE_EXEC_PARITY.md index ca3ea3ef5..2ae3b144a 100644 --- a/docs/PYCVC_STATE_EXEC_PARITY.md +++ b/docs/PYCVC_STATE_EXEC_PARITY.md @@ -110,7 +110,7 @@ are listed too. Status after PR 3: | | 38 `builtin_*` functions | none | exception: DSL builtins, called from DSL source | | | `percent_encode`, `percent_decode` | `pycvc.percent_encode`, `pycvc.percent_decode` | wrapped (PR 2) | | stackless_evaluator.h, evaluator.h | `stackless_evaluator`, `evaluator`, `evaluator_state`, `eval_frame`, `eval_phase`, `evaluation_stats`, `step_kind` (#658), `evaluation_interrupted`, `evaluation_timeout` | the same names under `pycvc.` | wrapped (PR 3). `eval_phase` and `step_kind` are `IntEnum`s, and `evaluation_stats` has #658's counts by kind (`steps`, `step_time`, `data_nodes`, ...); the two exceptions are `RuntimeError` subclasses. `on_complete` is a Python callable. `evaluator.user_macros()` returns a copy, a dict of name -> (params, body). `restrict_special_forms` takes any iterable of str, or None | -| | `evaluator_state.data_work`, `evaluator_state.backlog`, `pending_data_work` (#658) | none | exception: deep-data work in flight between steps, internal to the evaluator; `data_step_nodes` is wrapped | +| | `evaluator_state.data_work`, `evaluator_state.backlog`, `pending_data_work` (#658), `waits_on_data_work` | none | exception: deep-data work in flight between steps, internal to the evaluator; `data_step_nodes` is wrapped | | | `return_exception` | none | exception: the internal unwinding of `(return ...)`, never seen outside an evaluation | | async_evaluator.h, async_stackless_evaluator.h | the async evaluators | none | exception: coroutine API | | resource_policy.h | `resource_policy`, `process_limits`, `validate_limits` | `pycvc.resource_policy` (its `mode` is `pycvc.resource_policy.mode`, an `IntEnum`), `pycvc.process_limits`, `pycvc.validate_limits` | wrapped (PR 3) | diff --git a/docs/STATE_EXEC_DEVELOPER_GUIDE.md b/docs/STATE_EXEC_DEVELOPER_GUIDE.md index 6bd628d74..506c84714 100644 --- a/docs/STATE_EXEC_DEVELOPER_GUIDE.md +++ b/docs/STATE_EXEC_DEVELOPER_GUIDE.md @@ -867,10 +867,16 @@ these holds: - natives have reported allocating 64 KiB, or half the last measurement if that is more, since the last one (the functions with an allocation check, §11, plus copies like `cons` and - `slice`); + `slice`, and the jobs of calls left waiting on the evaluator); - what they reported could cross the remaining budget; - `limits.heap_check_steps` steps have passed (default 4096; `0` = only the two above). +A measurement can start while the process waits on other deep-data work, such as a call the +evaluator makes for a native function: it runs first, and the work goes on after it. It counts +that work too, with what the work's jobs hold (a waiting call's function and arguments, the +values `collect` has gathered). So a process that never stops waiting, like a class whose `init` +constructs again, is still held to `max_memory`. + The kill decision always uses an actual measurement, never the reported bytes, so temporaries a program drops don't count. Only a process with `max_memory > 0` is measured. `process_info::heap_memory` and `heap_measurements` report the last measurement. The per-step diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index a9022bedf..0e2ed55b5 100644 --- a/docs/STATE_EXEC_GRAMMAR.md +++ b/docs/STATE_EXEC_GRAMMAR.md @@ -293,7 +293,8 @@ the evaluator's: it makes the call at its own level, in a data step (`call_funct `init`), hands its place to the call it makes, so a chain of them runs in constant space. A call with more to do after its callee (a constructor returns the instance once `init` is done) waits, a link of pending work per level, like a function that calls itself before doing - anything else. Pending work is not yet part of a process's measured heap (`max_memory`). + anything else. Pending work and what its jobs hold count toward the process's measured heap, + so `max_memory` stops such a chain too. - **Cost.** A call the evaluator makes costs about 300 ns more than one made directly (MSVC Release, `state_exec_bench_test` `NativeCallThroughput`). The one direct level keeps `apply` and `send` at their old cost, and only a native call two levels down pays it. Constructing a diff --git a/inc/cvc/state/state_exec/data_job.h b/inc/cvc/state/state_exec/data_job.h index 287b37bd8..7b3d23926 100644 --- a/inc/cvc/state/state_exec/data_job.h +++ b/inc/cvc/state/state_exec/data_job.h @@ -78,6 +78,17 @@ class data_job { /// work take the job's place, so a chain of such calls runs in constant space. virtual bool forwards_call() const { return false; } + /// For measuring the heap of a process whose work waits on the job (heap.h), which happens + /// between its slices: `visit` is called with each value the job owns a copy of (what it was + /// given, what it has built so far). A job that only borrows its caller's arguments, or whose + /// results are held to the allocation limit as they grow, need not report them. + virtual void held_values(const std::function &visit) const { (void)visit; } + + /// The heap bytes the job takes itself, its object and its own buffers, besides what the + /// values it reports hold. A job left to run after its call reports these as allocated + /// (eval_alloc_note), so a heap measurement comes due as such jobs pile up. + virtual std::size_t held_bytes() const { return 0; } + protected: std::size_t nodes_ = 0; std::optional call_; diff --git a/inc/cvc/state/state_exec/heap.h b/inc/cvc/state/state_exec/heap.h index 93eeca91a..da6eddc27 100644 --- a/inc/cvc/state/state_exec/heap.h +++ b/inc/cvc/state/state_exec/heap.h @@ -64,8 +64,9 @@ std::size_t heap_remeasure_bytes(std::size_t last_measured); /// The heap accounting a scheduler does after each step of `proc`: take a measurement that just /// finished (proc.heap_bytes), add `allocated` (the step guard's eval_alloc_guard::allocated()), /// and start the next measurement as the process's data work when one is due -/// (process::heap_measure_due). `state_bytes` is the memory tracker's count for the process. -/// Nothing is measured for a process without a memory budget. +/// (process::heap_measure_due), on top of any deep-data work in flight. The measurement counts +/// that work too, with what its jobs hold (data_job::held_values). `state_bytes` is the memory +/// tracker's count for the process. Nothing is measured for a process without a memory budget. void heap_after_step(process &proc, std::size_t allocated, uint64_t state_bytes); // --------------------------------------------------------------------------- diff --git a/inc/cvc/state/state_exec/stackless_evaluator.h b/inc/cvc/state/state_exec/stackless_evaluator.h index 37eb355bb..e80d691e7 100644 --- a/inc/cvc/state/state_exec/stackless_evaluator.h +++ b/inc/cvc/state/state_exec/stackless_evaluator.h @@ -127,6 +127,10 @@ struct evaluator_state { std::optional heap_measured; }; +/// Whether a frame of `st` waits on deep-data work: any work but a heap measurement, which waits +/// on no frame. A process cannot fork while one does (the child could not finish the call). +bool waits_on_data_work(const evaluator_state &st); + /// Synchronous stackless evaluator (Tier 2 — local contexts only). /// /// Uses an explicit stack of eval_frame objects instead of the C++ call stack. diff --git a/src/cvc/state/state_exec/async_scheduler.cpp b/src/cvc/state/state_exec/async_scheduler.cpp index 6a3d91d29..e6254a5a6 100644 --- a/src/cvc/state/state_exec/async_scheduler.cpp +++ b/src/cvc/state/state_exec/async_scheduler.cpp @@ -735,9 +735,8 @@ int async_scheduler::fork(int pid) { return -1; // Deep-data work in flight (a function collections.map calls, say) is not copied: a child // could not finish the call its stack waits on. A heap measurement waits on no frame, so a - // child without it is whole. - if (parent.state.data_work && - parent.state.data_work->what != pending_data_work::kind::heap_measure) + // child without it is whole, unless the measurement runs on top of such work. + if (waits_on_data_work(parent.state)) return -1; int child_pid = next_pid_++; diff --git a/src/cvc/state/state_exec/data_job.cpp b/src/cvc/state/state_exec/data_job.cpp index 561b3c819..f113a895c 100644 --- a/src/cvc/state/state_exec/data_job.cpp +++ b/src/cvc/state/state_exec/data_job.cpp @@ -505,6 +505,17 @@ class call_walk final : public data_job { bool forwards_call() const override { return true; } + void held_values(const std::function &visit) const override { + visit(fn_); + for (const value_t &a : args_) + visit(a); + visit(result_); + } + + std::size_t held_bytes() const override { + return sizeof(*this) + args_.capacity() * sizeof(value_t); + } + private: value_t fn_; std::vector args_; @@ -522,6 +533,16 @@ class generator_walk final : public data_job { value_t result() override { return make_list(std::move(values_)); } + // The generator itself is the waiting call's argument, which its frame holds. + void held_values(const std::function &visit) const override { + for (const value_t &v : values_) + visit(v); + } + + std::size_t held_bytes() const override { + return sizeof(*this) + values_.capacity() * sizeof(value_t); + } + private: // Keep a value it yielded. True when the walk goes on (collect). bool keep(value_t v) { @@ -765,6 +786,7 @@ value_t run_data_job(std::unique_ptr job, std::functioncall_returned(std::move(value)); } job->own_inputs(); + eval_alloc_note(job->held_bytes()); // it outlives the call (heap.h) scope->deferred_ = std::make_unique( detail::deferred_data_job{std::move(job), std::move(then), std::move(waits_on)}); return nil_value; diff --git a/src/cvc/state/state_exec/heap.cpp b/src/cvc/state/state_exec/heap.cpp index 13ff922b6..9abb62fac 100644 --- a/src/cvc/state/state_exec/heap.cpp +++ b/src/cvc/state/state_exec/heap.cpp @@ -266,6 +266,18 @@ class heap_share_job final : public data_job { // The roots, whole: the references the process holds directly. auto root = [this](node_kind k, const auto &sp) { add(k, sp, /*from_root=*/true); }; process_edges(p, root); + // And the deep-data work it waits on, with what that work's jobs hold: a call that waits on + // a call like it (a constructor whose init constructs again) piles them up. + for (const pending_data_work *w = p.state.data_work.get(); w; w = w->outer.get()) { + own_ += static_cast(sizeof(pending_data_work) + kControlBlock); + if (!w->job) + continue; + own_ += static_cast(w->job->held_bytes()); + w->job->held_values([&](const value_t &v) { + own_ += static_cast(inline_heap(v)); + value_edges(v, root); + }); + } } bool step(std::size_t budget) override { @@ -517,21 +529,26 @@ void heap_after_step(process &proc, std::size_t allocated, uint64_t state_bytes_ proc.heap_alloc_bytes += allocated; ++proc.heap_steps; if (proc.heap_measuring) { - if (!st.data_work) - proc.heap_measuring = false; // dropped with the state it ran in (a signal, a restore) + // Until it finishes, a measurement is the innermost work: if not, it was dropped with the + // state it ran in (a signal, a restore). + if (!st.data_work || st.data_work->what != pending_data_work::kind::heap_measure) + proc.heap_measuring = false; return; } - if (st.done || st.reading || st.data_work || st.stack.empty()) + if (st.done || st.reading || st.stack.empty()) return; if (!proc.heap_measure_due(state_bytes_used)) return; // It waits on no frame: evaluation resumes at the top of the stack, as it was, so `base` and // `frame_args` are the whole stack and the top frame's values (a snapshot keeps all of them). + // Started in the middle of other deep-data work (a call in flight, which a process can wait on + // for as long as its budget lasts), it runs first, and that work goes on after it. auto work = std::make_shared(); work->job = make_heap_share_job(proc); work->what = pending_data_work::kind::heap_measure; work->base = st.stack.size(); work->frame_args = st.stack.back().results.size(); + work->outer = std::move(st.data_work); st.data_work = std::move(work); proc.heap_measuring = true; } diff --git a/src/cvc/state/state_exec/scheduler.cpp b/src/cvc/state/state_exec/scheduler.cpp index 18321ec98..7300aa745 100644 --- a/src/cvc/state/state_exec/scheduler.cpp +++ b/src/cvc/state/state_exec/scheduler.cpp @@ -739,9 +739,8 @@ int scheduler::fork(int pid) { return -1; // Deep-data work in flight (a function collections.map calls, say) is not copied: a child // could not finish the call its stack waits on. A heap measurement waits on no frame, so a - // child without it is whole. - if (parent.state.data_work && - parent.state.data_work->what != pending_data_work::kind::heap_measure) + // child without it is whole, unless the measurement runs on top of such work. + if (waits_on_data_work(parent.state)) return -1; int child_pid = next_pid_++; diff --git a/src/cvc/state/state_exec/stackless_evaluator.cpp b/src/cvc/state/state_exec/stackless_evaluator.cpp index d34fab72c..0853966ef 100644 --- a/src/cvc/state/state_exec/stackless_evaluator.cpp +++ b/src/cvc/state/state_exec/stackless_evaluator.cpp @@ -18,6 +18,13 @@ pending_data_work::~pending_data_work() { next = std::move(next->outer); } +bool waits_on_data_work(const evaluator_state &st) { + for (const pending_data_work *w = st.data_work.get(); w; w = w->outer.get()) + if (w->what != pending_data_work::kind::heap_measure) + return true; + return false; +} + // --------------------------------------------------------------------------- // ctor // --------------------------------------------------------------------------- diff --git a/src/cvc/tests/state_exec_heap_test.cpp b/src/cvc/tests/state_exec_heap_test.cpp index fff4613fc..a153717c1 100644 --- a/src/cvc/tests/state_exec_heap_test.cpp +++ b/src/cvc/tests/state_exec_heap_test.cpp @@ -454,3 +454,92 @@ TEST(HeapBudgetTest, AProcessForksDuringAMeasurement) { EXPECT_GT(sched.get_process_info(pid)->heap_measurements, 0u); EXPECT_EQ(std::get(sched.get_result(pid)->v), 400); } + +// A measurement due in the middle of a call in flight (here collections.map calling a DSL +// function) runs on top of it, waiting on no frame, and the call goes on after it. The process +// cannot fork meanwhile: a frame still waits on the call. +TEST(HeapBudgetTest, AMeasurementRunsOnTopOfACallInFlight) { + cvc::app app; + auto env = builtins::make_default_environment(); + env->set("keep", value_t{int_list(200)}); + stackless_evaluator ev(env); + process p; + p.max_memory = std::size_t{64} << 20; + p.state = ev.create_state(parse("(length (collections.map keep (lambda (x) (+ x 1))))")); + p.state.global_env = env; + p.state.data_step_nodes = 8; + p.state.stats.start(); + while (!(p.state.data_work && p.state.data_work->calling)) + ASSERT_FALSE(ev.step(p.state)); + const std::shared_ptr call = p.state.data_work; + const std::size_t frames = p.state.stack.size(); + heap_after_step(p, std::size_t{1} << 20, 0); // allocations call for a measurement + ASSERT_TRUE(p.state.data_work); + ASSERT_EQ(p.state.data_work->what, pending_data_work::kind::heap_measure); + EXPECT_EQ(p.state.data_work->outer, call); + EXPECT_EQ(p.state.data_work->base, frames); + EXPECT_TRUE(waits_on_data_work(p.state)); + while (!p.state.heap_measured) { + ASSERT_FALSE(ev.step(p.state)); + ASSERT_EQ(p.state.stack.size(), frames); // nothing else runs meanwhile + } + EXPECT_EQ(p.state.data_work, call); + heap_after_step(p, 0, 0); + EXPECT_GT(p.heap_bytes, 0u); + EXPECT_FALSE(p.heap_measuring); + while (!ev.step(p.state)) { + } + EXPECT_EQ(std::get(p.state.result.v), 200); +} + +// --- work that never finishes is still held to max_memory ------------------------------------ +// +// A call the evaluator makes can wait on another like it for as long as the budget lasts: a class +// whose init is its own constructor, or a closure init that constructs again. A measurement counts +// that work, with what its jobs hold (each waiting call's function and arguments), so max_memory +// stops the process; max_steps is only a backstop here. + +const char *const kSelfInit = "(begin (defclass P) (set-attr P.__cls_dict__ \"init\" P) (P))"; +const char *const kInitConstructs = "(begin (defclass P (init (self) (P))) (P))"; + +TEST(HeapBudgetTest, TheWorkAProcessWaitsOnCounts) { + // After a hundred steps of kSelfInit, ~180 constructions wait, the k-th on a call with k + // arguments: some 16000 values held by jobs alone. + stackless_evaluator ev(builtins::make_default_environment()); + process p; + p.state = ev.create_state(parse(kSelfInit)); + for (int i = 0; i < 100; ++i) + ASSERT_FALSE(ev.step(p.state)); + EXPECT_GT(measure_heap_share(p), 10000 * sizeof(value_t)); +} + +TEST(HeapBudgetTest, ACallChainThatNeverEndsRunsOutOfMemory) { + // kSelfInit's arguments grow quadratically: 2 MiB within a few hundred steps. + for (const char *script : {kSelfInit, kInitConstructs}) { + SCOPED_TRACE(script); + const uint64_t backstop = script == kSelfInit ? 1000 : 1000000; + { + async_scheduler sched; + execute_options opts; + opts.max_memory = std::size_t{2} << 20; + opts.max_steps = backstop; + const int pid = sched.execute(std::string(script), opts); + sched.sync_run(); + EXPECT_EQ(sched.get_exit_error(pid).value_or(""), "memory_limit_exceeded"); + EXPECT_GT(sched.get_process_info(pid)->heap_measurements, 0u); + } + { + scheduler sched; + execute_options opts; + opts.max_memory = std::size_t{2} << 20; + opts.max_steps = backstop; + const int pid = sched.execute(std::string(script), opts); + sched.run(); + const auto info = sched.get_process_info(pid); + ASSERT_TRUE(info); + EXPECT_EQ(info->status, process_status::killed); + EXPECT_LT(info->step_count, backstop); // memory, not the step budget + EXPECT_GE(info->heap_memory, opts.max_memory); + } + } +}