tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
result_channel.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/ops/queued.h>
5
6#include <cstddef>
7#include <cstdint>
8#include <source_location>
9#include <type_traits>
10#include <vector>
11
12// Queued-operation result channels (M4). A ResultChannel<T> is caller-owned
13// scratch delivering one typed payload plus an OpCompletion per queued
14// operation, keyed by OpHandle. Publication is synchronous and
15// executor-agnostic: each op's executing thread writes only its own dense
16// slot (the same per-operation-slot discipline the partitioned dirty scratch
17// uses), and every read -- state, completion, drain -- happens on the frame
18// owner's thread after the execute call returns, so the S1 executor join
19// barrier supplies visibility and the channel holds no atomics.
20//
21// The current synchronous scope is deliberately drain-only: results use
22// drain_results(visitor) in handle (== enqueue) order. There is no future
23// type -- the pipeline has no asynchronous execution path yet, so a future
24// could never be pending across a caller-visible boundary; one returns with
25// budget-deferred execution. This is a recorded divergence from the TDD's
26// full handle vocabulary, exactly like the deferred cancelled/superseded
27// states.
28namespace tess {
29
31template <typename T>
32class ResultChannel;
33
34// Copies every plan-time rejection out of `report` into failed channel
35// slots, so validation failures deliver their reasons through the same
36// drain as executed results (never values). Returns the number of slots
37// stamped. Call once per plan, before executing.
39template <typename T>
40auto record_plan_completions(const ExecutionReport& report,
41 ResultChannel<T>& channel) -> std::size_t;
42
43// Completion record for one queued operation, carrying both failure
44// domains: plan-time verdicts (status/failure, from the OperationReport)
45// and run-time verdicts (execution). `completed` distinguishes a stamped
46// record from a default-constructed one, so a never-completed lookup can
47// never read as success. `execution` is meaningful only for operations
48// that reached execution; plan-time rejections keep its default.
51 OperationStatus status = OperationStatus::Planned;
52 OperationFailure failure = OperationFailure::None;
53 PlannedExecutionStatus execution = PlannedExecutionStatus::Executed;
54 std::size_t chunk_count = 0;
55 std::source_location source = std::source_location::current();
56 bool completed = false;
57
58 [[nodiscard]] constexpr bool ok() const noexcept {
59 return completed && status == OperationStatus::Planned &&
60 failure == OperationFailure::None &&
61 execution == PlannedExecutionStatus::Executed;
62 }
63};
64
65// Lifecycle of one channel slot. `Pending` covers both "prepared, execution
66// has not reached it" and "plan aborted before it ran" -- a drain after a
67// partial execution visits only completed slots, and the pending tail is
68// the caller's signal that the plan stopped early.
70enum class OpResultState : std::uint8_t {
71 Unbound, // no slot recorded for this handle
72 Pending, // prepared for execution; not yet completed
73 Ready, // completed and ok(); the value is readable
74 Failed, // completed with reasons; there is no value
75};
76
77// Caller-owned, fixed-capacity result channel keyed by OpHandle. Slots are
78// dense -- slot index == handle.value, because FrameOps hands out handles
79// 0..N-1 per frame -- so lookup is O(1) with no map. Externally
80// synchronized like every tess scratch: the frame owner calls everything
81// except the producer hooks, which the result-bearing execute wrappers
82// invoke from worker threads on disjoint per-op slots. clear() must be
83// called alongside FrameOps::clear(): handle assignment restarts at zero
84// there, and a channel kept across it would alias new-frame handles onto
85// stale slots.
86//
87// T must be default-constructible; the warm allocation-free contract
88// additionally requires T's default construction and assignment to be
89// allocation-free (as with any POD ack payload).
91template <typename T>
92class ResultChannel {
93 static_assert(std::is_default_constructible_v<T>,
94 "result payloads are default-constructed into slots");
95
96 public:
97 ResultChannel() = default;
98 ResultChannel(const ResultChannel&) = delete;
99 auto operator=(const ResultChannel&) -> ResultChannel& = delete;
100 ResultChannel(ResultChannel&&) = delete;
101 auto operator=(ResultChannel&&) -> ResultChannel& = delete;
102
103 // Cold-path capacity; warm frames stay allocation-free while the frame's
104 // operation count fits within it.
105 void reserve_operations(std::size_t count) { slots_.reserve(count); }
106
107 // Frame reset: drops all slots (keeping capacity). Pair with
108 // FrameOps::clear() -- see the class comment.
109 void clear() noexcept {
110 slots_.clear();
111 ++generation_;
112 }
113
114 [[nodiscard]] auto state(OpHandle handle) const noexcept -> OpResultState {
115 if (handle.value >= slots_.size()) {
116 return OpResultState::Unbound;
117 }
118 return slots_[static_cast<std::size_t>(handle.value)].state;
119 }
120
121 // Completion lookup by value: a handle without a completed slot returns a
122 // default OpCompletion, whose ok() is false by construction.
123 [[nodiscard]] auto completion(OpHandle handle) const noexcept
124 -> OpCompletion {
125 if (handle.value >= slots_.size()) {
126 return OpCompletion{};
127 }
128 return slots_[static_cast<std::size_t>(handle.value)].completion;
129 }
130
131 [[nodiscard]] auto size() const noexcept -> std::size_t {
132 return slots_.size();
133 }
134
135 // Bumped by clear(); lets tests and long-lived callers assert the
136 // paired-clear discipline.
137 [[nodiscard]] auto generation() const noexcept -> std::uint64_t {
138 return generation_;
139 }
140
141 // Visits every completed, not-yet-drained slot in handle (== enqueue)
142 // order as visit(OpHandle, const OpCompletion&, const T* value); `value`
143 // is null for Failed slots -- failures deliver reasons, not values. The
144 // references are valid only until the visitor mutates this channel's
145 // storage or lifecycle. Visited slots are marked drained (drain-once);
146 // state()/completion() stay readable until clear(). A reentrant clear ends
147 // the current drain. Returns the number of slots visited. Pending slots are
148 // skipped, not consumed: after a partial plan execution they remain for the
149 // caller to inspect.
150 template <typename Visitor>
151 auto drain_results(Visitor&& visit) -> std::size_t {
152 std::size_t visited = 0;
153 const auto drain_generation = generation_;
154 for (std::size_t index = 0;
155 generation_ == drain_generation && index < slots_.size(); ++index) {
156 if (slots_[index].drained ||
157 (slots_[index].state != OpResultState::Ready &&
158 slots_[index].state != OpResultState::Failed)) {
159 continue;
160 }
161 const auto has_value = slots_[index].state == OpResultState::Ready;
162 // Preserve the hot-path store ordering while making delivery
163 // transactional: an exceptional visitor restores the current-generation
164 // slot so the caller can retry it. Do not retain a slot reference across
165 // the visitor because it may reallocate or clear the channel.
166 slots_[index].drained = true;
167 try {
168 visit(OpHandle{static_cast<std::uint64_t>(index)},
169 slots_[index].completion,
170 has_value ? &slots_[index].value : nullptr);
171 } catch (...) {
172 if (generation_ == drain_generation && index < slots_.size()) {
173 slots_[index].drained = false;
174 }
175 throw;
176 }
177 ++visited;
178 }
179 return visited;
180 }
181
182 private:
183 struct Slot {
184 T value{};
185 OpCompletion completion{};
186 OpResultState state = OpResultState::Unbound;
187 bool drained = false;
188 };
189
190 // Producer hooks, called only by the friended wrappers below. `ensure`
191 // and `prepare_operation` run on the frame owner's thread before any
192 // dispatch; `value_for` and `complete` run on whichever thread executes
193 // the op, touching only that op's slot.
194 void ensure_slot(OpHandle handle) {
195 if (handle.value >= slots_.size()) {
196 slots_.resize(static_cast<std::size_t>(handle.value) + 1);
197 }
198 }
199
200 void prepare_operation(OpHandle handle, std::source_location source) {
201 ensure_slot(handle);
202 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
203 slot.value = T{};
204 slot.completion = OpCompletion{};
205 slot.completion.source = source;
206 slot.state = OpResultState::Pending;
207 slot.drained = false;
208 }
209
210 [[nodiscard]] auto value_for(OpHandle handle) noexcept -> T& {
211 TESS_ASSERT(handle.value < slots_.size());
212 return slots_[static_cast<std::size_t>(handle.value)].value;
213 }
214
215 void complete(OpHandle handle, PlannedExecutionResult result,
216 std::source_location source) noexcept {
217 TESS_ASSERT(handle.value < slots_.size());
218 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
219 slot.completion.status = OperationStatus::Planned;
220 slot.completion.failure = OperationFailure::None;
221 slot.completion.execution = result.status;
222 slot.completion.chunk_count = result.chunk_count;
223 slot.completion.source = source;
224 slot.completion.completed = true;
225 slot.state = result.status == PlannedExecutionStatus::Executed
226 ? OpResultState::Ready
227 : OpResultState::Failed;
228 }
229
230 void fail_planned(OpHandle handle, const OperationReport& report) {
231 ensure_slot(handle);
232 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
233 slot.value = T{};
234 slot.completion = OpCompletion{};
235 slot.completion.status = report.status;
236 slot.completion.failure = report.failure;
237 slot.completion.chunk_count = report.chunk_count;
238 slot.completion.source = report.source;
239 slot.completion.completed = true;
240 slot.state = OpResultState::Failed;
241 slot.drained = false;
242 }
243
244 template <typename U>
245 friend auto record_plan_completions(const ExecutionReport& report,
246 ResultChannel<U>& channel) -> std::size_t;
247
248 template <WritePolicy Policy, typename Executor, typename World, typename U,
249 typename Fn>
250 friend auto execute_phase_partitioned_dirty_with_results(
251 Executor&& executor, World& world, const ExecutionPlan& plan,
252 const ExecutionPhase& phase, PlannedPhaseExecutionScratch& scratch,
253 ResultChannel<U>& channel, Fn&& fn) -> PlannedExecutionResult;
254
255 template <WritePolicy Policy, typename World, typename U, typename Fn>
256 friend auto execute_plan_deferred_dirty_with_results(
257 World& world, const ExecutionPlan& plan, PlannedDirtyAccumulator& dirty,
258 ResultChannel<U>& channel, Fn&& fn) -> PlannedExecutionResult;
259
260 std::vector<Slot> slots_;
261 std::uint64_t generation_ = 0;
262};
263
264template <typename T>
265auto record_plan_completions(const ExecutionReport& report,
266 ResultChannel<T>& channel) -> std::size_t {
267 std::size_t stamped = 0;
268 for (const auto& operation : report.operations()) {
269 if (operation.status == OperationStatus::Planned) {
270 continue;
271 }
272 channel.fail_planned(operation.handle, operation);
273 ++stamped;
274 }
275 return stamped;
276}
277
278// Result-bearing variant of execute_phase_partitioned_dirty_with: the
279// caller's callback receives each chunk view PLUS a mutable reference to the
280// operation's channel value (`fn(view, T& value)`), accumulated across the
281// op's chunks on whichever thread executes it -- op-exclusive, so no
282// synchronization. Every operation in the phase is prepared upfront on the
283// caller's thread, so an execution that stops early (the serial executor
284// aborts at the first failure) leaves a Pending tail rather than Unbound
285// gaps, and each op's completion is stamped by its executing thread -- a
286// post-barrier sweep over the scratch results would misread never-run
287// operations as Executed, because PlannedExecutionResult default-constructs
288// to that status. Aggregate return and dirty partitioning are identical to
289// the resultless helper.
290template <WritePolicy Policy, typename Executor, typename World, typename T,
291 typename Fn>
294 Executor&& executor, World& world, const ExecutionPlan& plan,
295 const ExecutionPhase& phase, PlannedPhaseExecutionScratch& scratch,
296 ResultChannel<T>& channel, Fn&& fn) -> PlannedExecutionResult {
297 const auto operations = plan.operations();
298 // Capability, world, and policy checks happen before touching either the
299 // channel or scratch. A phase issued for another plan or world, or carrying
300 // any other policy, therefore cannot publish partial completion state.
301 const auto phase_validation =
302 detail::execution_phase_validation_status<Policy>(world, plan, phase);
303 if (phase_validation != PlannedExecutionStatus::Executed) {
304 detail::record_execution_phase_validation_failure(phase_validation);
306 phase_validation,
307 0,
308 };
309 }
310
311 TESS_DIAG_EVENT_VALUE(queued_phase_execute, phase.operation_count());
312 TESS_DIAG_EVENT_VALUE(queued_partitioned_phase, phase.operation_count());
313 for (const auto& operation :
314 operations.subspan(phase.first_operation(), phase.operation_count())) {
315 channel.prepare_operation(operation.handle, operation.source);
316 }
317 scratch.prepare(world, phase.operation_count());
318 auto&& callback = fn;
319 auto result = execute_operation_index_range(
320 std::forward<Executor>(executor), executor_phase_range(phase),
321 [&](std::size_t index) {
322 const auto offset = index - phase.first_operation();
323 const auto& operation = operations[index];
324 auto& value = channel.value_for(operation.handle);
325 auto operation_result =
326 detail::execute_validated_phase_operation_deferred_dirty<Policy>(
327 world, operation, scratch.dirty_for_operation(offset),
328 [&](auto view) { callback(view, value); });
329 channel.complete(operation.handle, operation_result, operation.source);
330 scratch.record_result(offset, operation_result);
331 return operation_result;
332 });
333
334 std::size_t chunk_count = 0;
335 for (const auto operation_result : scratch.results()) {
336 if (operation_result.status != PlannedExecutionStatus::Executed) {
337 TESS_DIAG_EVENT(queued_phase_failure);
339 operation_result.status,
340 chunk_count,
341 };
342 }
343 chunk_count += operation_result.chunk_count;
344 }
345
346 if (result.status != PlannedExecutionStatus::Executed) {
347 TESS_DIAG_EVENT(queued_phase_failure);
349 result.status,
350 chunk_count,
351 };
352 }
353
355 PlannedExecutionStatus::Executed,
356 chunk_count,
357 };
358}
359
360// Result-bearing variant of execute_plan_deferred_dirty: serial, whole-plan,
361// aborting at the first non-Executed result with the same partial-execution
362// contract (earlier writes kept, chunk counts reported). All operations are
363// prepared upfront, so the aborted tail reads Pending through the channel.
365template <WritePolicy Policy, typename World, typename T, typename Fn>
366auto execute_plan_deferred_dirty_with_results(World& world,
367 const ExecutionPlan& plan,
369 ResultChannel<T>& channel,
370 Fn&& fn)
372 for (const auto& operation : plan.operations()) {
373 channel.prepare_operation(operation.handle, operation.source);
374 }
375 std::size_t chunk_count = 0;
376 auto&& callback = fn;
377 for (const auto& operation : plan.operations()) {
378 auto& value = channel.value_for(operation.handle);
379 auto result = execute_planned_operation_deferred_dirty<Policy>(
380 world, operation, dirty, [&](auto view) { callback(view, value); });
381 channel.complete(operation.handle, result, operation.source);
382 if (result.status != PlannedExecutionStatus::Executed) {
384 result.status,
385 chunk_count + result.chunk_count,
386 };
387 }
388 chunk_count += result.chunk_count;
389 }
391 PlannedExecutionStatus::Executed,
392 chunk_count,
393 };
394}
395
396} // namespace tess
Definition queued.h:416
Definition queued.h:358
Definition queued.h:1047
Definition queued.h:679
Definition queued.h:941
friend auto execute_phase_partitioned_dirty_with_results(Executor &&executor, World &world, const ExecutionPlan &plan, const ExecutionPhase &phase, PlannedPhaseExecutionScratch &scratch, ResultChannel< T > &channel, Fn &&fn) -> PlannedExecutionResult
Executes one phase while publishing per-operation payloads and completions.
Definition result_channel.h:293
Dense per-operation completion and payload channel.
Definition result_channel.h:92
Definition world.h:22
Completion metadata spanning planning and execution failure domains.
Definition result_channel.h:50
Definition queued.h:74
Definition queued.h:590
Definition phase_executor.h:63