tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
auto_exec.h
1#pragma once
2
3#include <tess/ops/phase_executor.h>
4#include <tess/ops/queued.h>
5#include <tess/ops/result_channel.h>
6#include <tess/sim/schedule.h>
7
8#include <cstddef>
9#include <cstdint>
10
11// The M5 auto-exec task: one schedule task running the whole queued-ops
12// pipeline -- plan -> parallel phase planning -> execute (serial or pool,
13// chosen per phase) -> per-phase dirty apply -> ack drain -- over a
14// caller-owned FrameOps queue. Enqueue whenever; the pipeline runs on the
15// task's cadence and both the queue and its result channel are cleared
16// together at the end of every run (the paired-clear discipline handles
17// restart at zero).
18namespace tess {
19
21enum class AutoExecStatus : std::uint8_t {
22 // No operations were queued; the run was a no-op.
23 Idle,
24 // Every planned operation executed; rejected operations (if any) were
25 // delivered through the drain with reasons.
26 Executed,
27 // At least one queued operation's write policy differs from the task's
28 // Policy parameter: NOTHING executed (asserted in debug), and the queue
29 // is DROPPED -- keeping it would wedge the task forever in release,
30 // rescanning and refusing the same poisoned frame while new enqueues pile
31 // on. Pre-validating keeps runtime aborts unreachable, so serial and pool
32 // execution can never diverge on partially-applied plans.
33 PolicyMismatch,
34};
35
36// Statistics of the most recent run, readable between ticks.
39 AutoExecStatus status = AutoExecStatus::Idle;
40 std::size_t planned_ops = 0;
41 std::size_t rejected_ops = 0;
42 std::size_t executed_chunks = 0;
43 std::size_t merged_dirty_chunks = 0;
44 std::size_t drained = 0;
45 std::size_t phases = 0;
46 std::size_t pool_phases = 0;
47};
48
49// Auto-exec over a dense (AlwaysResident) world. `Policy` must be ReadOnly
50// or UniquePerChunk (the write policies the parallel phase planner
51// supports), and every enqueued operation must carry exactly that policy.
52// `ChunkFn` is the per-chunk kernel `fn(view, Ack&)` from the
53// result-bearing execute wrappers; `Ack` accumulates op-exclusively on the
54// executing thread, but the kernel object itself is SHARED across workers
55// when a pool is attached -- it must be safe for concurrent invocation
56// (stateless, or synchronizing any mutable state itself). When a worker pool is
57// attached, phases with at least `parallel_threshold` operations run on it;
58// smaller phases and everything else stay serial, and results are
59// byte-identical either way because pre-validation makes runtime aborts
60// unreachable.
61//
62// Planning reuses a task-owned ExecutionReport (its rows, planned ops,
63// and chunk lists are recycled between runs), so steady-state ticks plan
64// allocation-free once capacities warm up (audit 2026-07-11 M4).
66template <typename World, WritePolicy Policy, typename Ack, typename ChunkFn>
67class AutoExecTask {
68 static_assert(Policy == WritePolicy::ReadOnly ||
69 Policy == WritePolicy::UniquePerChunk,
70 "auto-exec supports the parallel-phase write policies only");
71
72 public:
73 using ResultHook = void (*)(void* ctx, OpHandle handle,
74 const OpCompletion& completion,
75 const Ack* ack) noexcept;
76
77 AutoExecTask(World& world, FrameOps& ops, ChunkFn fn)
78 : world_(&world), ops_(&ops), fn_(static_cast<ChunkFn&&>(fn)) {}
79
80 void reserve_operations(std::size_t count) {
81 channel_.reserve_operations(count);
82 scratch_.reserve_operations(count);
83 }
84
85 // Attaches the production pool: phases with at least `threshold`
86 // operations run on it. The pool must outlive the task.
87 void use_pool(WorkerPoolPhaseExecutor& pool,
88 std::size_t threshold = 2) noexcept {
89 pool_ = &pool;
90 parallel_threshold_ = threshold == 0 ? 1 : threshold;
91 }
92
93 void set_result_hook(void* ctx, ResultHook hook) noexcept {
94 hook_ctx_ = ctx;
95 hook_ = hook;
96 }
97
98 [[nodiscard]] auto last_run() const noexcept -> const AutoExecRunStats& {
99 return last_run_;
100 }
101
102 auto operator()(const ScheduleTaskContext&) -> ScheduleTaskResult {
103 last_run_ = AutoExecRunStats{};
104 if (ops_->empty()) {
105 return ScheduleTaskResult{};
106 }
107 try {
108 return run_nonempty();
109 } catch (...) {
110 // Planning/execution exceptions preserve the caller-owned queue for
111 // inspection or replacement, but transient completion slots must never
112 // leak into a later run. Partial world writes make blind retry unsafe.
113 channel_.clear();
114 throw;
115 }
116 }
117
118 private:
119 auto run_nonempty() -> ScheduleTaskResult {
120 // Pre-validate policy uniformity BEFORE planning so a mismatch executes
121 // nothing at all (deterministic under any executor).
122 for (const auto& operation : ops_->operations()) {
123 if (operation.write_policy != Policy) {
124 TESS_ASSERT_MSG(false,
125 "auto-exec queue contains a mismatched write policy");
126 last_run_.status = AutoExecStatus::PolicyMismatch;
127 ops_->clear();
128 channel_.clear();
129 return ScheduleTaskResult{};
130 }
131 }
132
133 const auto& report = plan_operations(*world_, *ops_, plan_report_);
134 (void)record_plan_completions(report, channel_);
135 last_run_.planned_ops = report.planned_count();
136 last_run_.rejected_ops = report.failed_count();
137
138 auto produced_dirty = std::uint32_t{0};
139 if (!report.plan().empty()) {
140 const auto phases = plan_parallel_execution_phases(report.plan());
141 // Policy uniformity was pre-validated against the planner-supported
142 // set, so phase planning cannot fail.
143 TESS_ASSERT(phases.ok());
144 last_run_.phases = phases.phases().size();
145 for (const auto& phase : phases.phases()) {
146 const auto use_pool =
147 pool_ != nullptr && phase.operation_count() >= parallel_threshold_;
148 auto result = PlannedExecutionResult{};
149 try {
150 if (use_pool) {
151 ++last_run_.pool_phases;
152 result = execute_phase_partitioned_dirty_with_results<Policy>(
153 *pool_, *world_, report.plan(), phase, scratch_, channel_,
154 [this](auto view, Ack& ack) { fn_(view, ack); });
155 } else {
156 const SerialPhaseExecutor serial;
157 result = execute_phase_partitioned_dirty_with_results<Policy>(
158 serial, *world_, report.plan(), phase, scratch_, channel_,
159 [this](auto view, Ack& ack) { fn_(view, ack); });
160 }
161 } catch (...) {
162 // Dirty records are written before each callback. Both concurrent
163 // executors join before rethrowing, and this allocation-free merge
164 // is noexcept, so every started callback is conservatively visible
165 // without replacing the original kernel exception.
166 const auto merged =
167 detail::merge_planned_dirty_after_exception(*world_, scratch_);
168 TESS_ASSERT(merged.status == PlannedDirtyMergeStatus::Merged);
169 last_run_.merged_dirty_chunks += merged.merged_chunk_count;
170 throw;
171 }
172 TESS_ASSERT(result.status == PlannedExecutionStatus::Executed);
173 last_run_.executed_chunks += result.chunk_count;
174 // Merge after EACH phase: the partitioned scratch is re-prepared
175 // per phase, so a single post-loop merge would drop every phase's
176 // dirty records but the last.
177 auto merged = PlannedDirtyMergeResult{};
178 try {
179 merged = merge_planned_dirty(*world_, scratch_);
180 } catch (...) {
181 // Normal coalescing reserves before consuming partitions. If that
182 // reserve fails, the no-allocation cold path can still publish every
183 // started callback's dirty metadata before preserving the exception.
184 const auto fallback =
185 detail::merge_planned_dirty_after_exception(*world_, scratch_);
186 TESS_ASSERT(fallback.status == PlannedDirtyMergeStatus::Merged);
187 last_run_.merged_dirty_chunks += fallback.merged_chunk_count;
188 throw;
189 }
190 TESS_ASSERT(merged.status == PlannedDirtyMergeStatus::Merged);
191 last_run_.merged_dirty_chunks += merged.merged_chunk_count;
192 }
193 for (const auto& operation : report.plan().operations()) {
194 produced_dirty |= operation.field_access.dirty_mask;
195 }
196 last_run_.status = AutoExecStatus::Executed;
197 } else {
198 last_run_.status = report.operations().empty() ? AutoExecStatus::Idle
199 : AutoExecStatus::Executed;
200 }
201
202 // Clear the queue BEFORE draining: the plan already copied everything
203 // execution needed, and a result hook may enqueue follow-up work -- it
204 // lands in the fresh queue for the next run instead of being discarded.
205 ops_->clear();
206 if (hook_ != nullptr) {
207 last_run_.drained += channel_.drain_results(
208 [this](OpHandle handle, const OpCompletion& completion,
209 const Ack* ack) noexcept {
210 hook_(hook_ctx_, handle, completion, ack);
211 });
212 }
213 channel_.clear();
214 return ScheduleTaskResult{produced_dirty, 0, false};
215 }
216
217 World* world_;
218 FrameOps* ops_;
219 ChunkFn fn_;
220 WorkerPoolPhaseExecutor* pool_ = nullptr;
221 std::size_t parallel_threshold_ = 2;
222 ResultChannel<Ack> channel_;
224 ResultHook hook_ = nullptr;
225 void* hook_ctx_ = nullptr;
226 AutoExecRunStats last_run_{};
227 // Reused across runs; see the planning note above.
228 ExecutionReport plan_report_;
229};
230
231} // namespace tess
Definition queued.h:1047
Definition queued.h:1157
Definition queued.h:941
Dense per-operation completion and payload channel.
Definition result_channel.h:92
Definition phase_executor.h:256
Definition world.h:22
Counts planning, execution, dirty merging, draining, and phase dispatch.
Definition auto_exec.h:38
Completion metadata spanning planning and execution failure domains.
Definition result_channel.h:50
Definition queued.h:74
Definition queued.h:650
Definition phase_executor.h:63
Supplies a task with the current tick, trigger bits, and work allowance.
Definition schedule.h:107
Returns produced dirty bits and bounded background progress to the schedule.
Definition schedule.h:118
Definition phase_executor.h:95