diff --git a/docs/STATE_EXEC_GRAMMAR.md b/docs/STATE_EXEC_GRAMMAR.md index b197ae710..a483bb245 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 2b02c40d1..7498c80fe 100644 --- a/src/cvc/tests/state_exec_data_steps_test.cpp +++ b/src/cvc/tests/state_exec_data_steps_test.cpp @@ -715,3 +715,97 @@ TEST_F(DataStepsStateJson, NodesRemovedBetweenSlicesAreStillWritten) { EXPECT_EQ(std::get(sched.get_result(pid)->v), before); EXPECT_EQ(root("wide").numChildren(), 0u); } + +// ---- 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) {