tess 1.0.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/core/config.h>
5#include <tess/core/fail_fast.h>
6#include <tess/ops/queued.h>
7
8#include <cstddef>
9#include <cstdint>
10#include <source_location>
11#include <type_traits>
12#include <vector>
13
14// A ResultChannel<T> is caller-owned queued-operation result scratch
15// delivering one typed payload plus an OpCompletion per queued
16// operation, keyed by OpHandle. Publication is synchronous and
17// executor-agnostic: each op's executing thread writes only its own dense
18// slot (the same per-operation-slot discipline the partitioned dirty scratch
19// uses), and every read -- state, completion, drain -- happens on the batch
20// owner's thread after the execute call returns, so the executor join
21// barrier supplies visibility and the channel holds no atomics.
22//
23// This synchronous channel is deliberately drain-only: results use
24// drain_results(visitor) in handle (== enqueue) order. Cooperative work that
25// remains pending across caller-visible boundaries uses the separate
26// ResumableWorkQueue<T>; keeping the two lifecycles separate avoids atomics and
27// generation checks on the synchronous planner/executor hot path.
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 OperationBatch hands out handles
79// 0..N-1 per operation batch -- so lookup is O(1) with no map. Externally
80// synchronized like every tess scratch: the batch 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 OperationBatch::clear(): handle assignment restarts at zero
84// there, and a channel kept across it would alias new-batch 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 batches stay allocation-free while the batch's
104 // operation count fits within it.
105 void reserve_operations(std::size_t count) { slots_.reserve(count); }
106
107 // Batch reset: drops all slots (keeping capacity). Pair with
108 // OperationBatch::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 constexpr auto no_throw_visitor =
168 std::is_nothrow_invocable_v<Visitor&, OpHandle, const OpCompletion&,
169 const T*>;
170 const auto visit_slot = [&]() noexcept(no_throw_visitor) {
171 visit(OpHandle{static_cast<std::uint64_t>(index)},
172 slots_[index].completion,
173 has_value ? &slots_[index].value : nullptr);
174 };
175 if constexpr (!has_exceptions || no_throw_visitor) {
176 visit_slot();
177 } else {
178#if TESS_HAS_EXCEPTIONS
179 try {
180 visit_slot();
181 } catch (...) {
182 if (generation_ == drain_generation && index < slots_.size()) {
183 slots_[index].drained = false;
184 }
185 throw;
186 }
187#endif
188 }
189 ++visited;
190 }
191 return visited;
192 }
193
194 private:
195 struct Slot {
196 T value{};
197 OpCompletion completion{};
198 OpResultState state = OpResultState::Unbound;
199 bool drained = false;
200 };
201
202 // Producer hooks, called only by the friended wrappers below. `ensure`
203 // and `prepare_operation` run on the batch owner's thread before any
204 // dispatch; `value_for` and `complete` run on whichever thread executes
205 // the op, touching only that op's slot.
206 void ensure_slot(OpHandle handle) {
207 if (handle.value >= slots_.size()) {
208 slots_.resize(static_cast<std::size_t>(handle.value) + 1);
209 }
210 }
211
212 void prepare_operation(OpHandle handle, std::source_location source) {
213 ensure_slot(handle);
214 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
215 slot.value = T{};
216 slot.completion = OpCompletion{};
217 slot.completion.source = source;
218 slot.state = OpResultState::Pending;
219 slot.drained = false;
220 }
221
222 // Unlike state() and completion(), this returns a reference and so has
223 // no value that can mean "absent" -- the caller is required to have
224 // established the slot exists, typically by checking state() first.
225 // Asserting and then indexing anyway made a violated precondition
226 // undefined behaviour in exactly the builds where asserts are off. The
227 // check is now unconditional: one predictable compare against a bound
228 // already in cache, in exchange for never reading past the slots.
229 [[nodiscard]] auto value_for(OpHandle handle) noexcept -> T& {
230 if (handle.value >= slots_.size()) {
231 detail::fail_fast(
232 "ResultChannel::value_for called with a handle that has no slot");
233 }
234 return slots_[static_cast<std::size_t>(handle.value)].value;
235 }
236
237 void complete(OpHandle handle, PlannedExecutionResult result,
238 std::source_location source) noexcept {
239 TESS_ASSERT(handle.value < slots_.size());
240 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
241 slot.completion.status = OperationStatus::Planned;
242 slot.completion.failure = OperationFailure::None;
243 slot.completion.execution = result.status;
244 slot.completion.chunk_count = result.chunk_count;
245 slot.completion.source = source;
246 slot.completion.completed = true;
247 slot.state = result.status == PlannedExecutionStatus::Executed
248 ? OpResultState::Ready
249 : OpResultState::Failed;
250 }
251
252 void fail_planned(OpHandle handle, const OperationReport& report) {
253 ensure_slot(handle);
254 auto& slot = slots_[static_cast<std::size_t>(handle.value)];
255 slot.value = T{};
256 slot.completion = OpCompletion{};
257 slot.completion.status = report.status;
258 slot.completion.failure = report.failure;
259 slot.completion.chunk_count = report.chunk_count;
260 slot.completion.source = report.source;
261 slot.completion.completed = true;
262 slot.state = OpResultState::Failed;
263 slot.drained = false;
264 }
265
266 template <typename U>
267 friend auto record_plan_completions(const ExecutionReport& report,
268 ResultChannel<U>& channel) -> std::size_t;
269
270 template <WritePolicy Policy, typename Executor, typename World, typename U,
271 typename Fn>
272 friend auto execute_phase_partitioned_dirty_with_results(
273 Executor&& executor, World& world, const ExecutionPlan& plan,
274 const ExecutionPhase& phase, PlannedPhaseExecutionScratch& scratch,
275 ResultChannel<U>& channel, Fn&& fn) -> PlannedExecutionResult;
276
277 template <WritePolicy Policy, typename World, typename U, typename Fn>
278 friend auto execute_plan_deferred_dirty_with_results(
279 World& world, const ExecutionPlan& plan, PlannedDirtyAccumulator& dirty,
280 ResultChannel<U>& channel, Fn&& fn) -> PlannedExecutionResult;
281
282 std::vector<Slot> slots_;
283 std::uint64_t generation_ = 0;
284};
285
286template <typename T>
287auto record_plan_completions(const ExecutionReport& report,
288 ResultChannel<T>& channel) -> std::size_t {
289 std::size_t stamped = 0;
290 for (const auto& operation : report.operations()) {
291 if (operation.status == OperationStatus::Planned) {
292 continue;
293 }
294 channel.fail_planned(operation.handle, operation);
295 ++stamped;
296 }
297 return stamped;
298}
299
300// Result-bearing variant of execute_phase_partitioned_dirty_with: the
301// caller's callback receives each chunk view PLUS a mutable reference to the
302// operation's channel value (`fn(view, T& value)`), accumulated across the
303// op's chunks on whichever thread executes it -- op-exclusive, so no
304// synchronization. Every operation in the phase is prepared upfront on the
305// caller's thread, so an execution that stops early (the serial executor
306// aborts at the first failure) leaves a Pending tail rather than Unbound
307// gaps, and each op's completion is stamped by its executing thread -- a
308// post-barrier sweep over the scratch results would misread never-run
309// operations as Executed, because PlannedExecutionResult default-constructs
310// to that status. Aggregate return and dirty partitioning are identical to
311// the resultless helper.
312template <WritePolicy Policy, typename Executor, typename World, typename T,
313 typename Fn>
316 Executor&& executor, World& world, const ExecutionPlan& plan,
317 const ExecutionPhase& phase, PlannedPhaseExecutionScratch& scratch,
318 ResultChannel<T>& channel, Fn&& fn) -> PlannedExecutionResult {
319 const auto operations = plan.operations();
320 // Capability, world, and policy checks happen before touching either the
321 // channel or scratch. A phase issued for another plan or world, or carrying
322 // any other policy, therefore cannot publish partial completion state.
323 const auto phase_validation =
324 detail::execution_phase_validation_status<Policy>(world, plan, phase);
325 if (phase_validation != PlannedExecutionStatus::Executed) {
326 detail::record_execution_phase_validation_failure(phase_validation);
328 phase_validation,
329 0,
330 };
331 }
332
333 TESS_DIAG_EVENT_VALUE(queued_phase_execute, phase.operation_count());
334 TESS_DIAG_EVENT_VALUE(queued_partitioned_phase, phase.operation_count());
335 for (const auto& operation :
336 operations.subspan(phase.first_operation(), phase.operation_count())) {
337 channel.prepare_operation(operation.handle, operation.source);
338 }
339 scratch.prepare(world, phase.operation_count());
340 auto&& callback = fn;
341 using View = detail::PlannedChunkView<Policy, World>;
342 constexpr auto no_throw_callback =
343 std::is_nothrow_invocable_v<decltype(callback)&, View&, T&>;
344 for (std::size_t offset = 0; offset < phase.operation_count(); ++offset) {
345 const auto index = phase.first_operation() + offset;
346 scratch.dirty_for_operation(offset).reserve(
347 !operations[index].field_access.dirty_mask
348 ? 0
349 : operations[index].chunks().size());
350 }
351 auto result = execute_operation_index_range(
352 std::forward<Executor>(executor), executor_phase_range(phase),
353 // Each partition reserved one record per possible chunk visit above;
354 // push_back therefore cannot allocate. Clang-tidy 22 does not carry
355 // that capacity proof through std::vector.
356 // NOLINTNEXTLINE(bugprone-exception-escape)
357 [&](std::size_t index) noexcept(no_throw_callback) {
358 const auto offset = index - phase.first_operation();
359 const auto& operation = operations[index];
360 auto& value = channel.value_for(operation.handle);
361 auto operation_result =
362 detail::execute_validated_phase_operation_deferred_dirty<Policy>(
363 world, operation, scratch.dirty_for_operation(offset),
364 [&](auto view) noexcept(no_throw_callback) {
365 callback(view, value);
366 });
367 channel.complete(operation.handle, operation_result, operation.source);
368 scratch.record_result(offset, operation_result);
369 return operation_result;
370 });
371
372 std::size_t chunk_count = 0;
373 for (const auto operation_result : scratch.results()) {
374 if (operation_result.status != PlannedExecutionStatus::Executed) {
375 TESS_DIAG_EVENT(queued_phase_failure);
377 operation_result.status,
378 chunk_count,
379 };
380 }
381 chunk_count += operation_result.chunk_count;
382 }
383
384 if (result.status != PlannedExecutionStatus::Executed) {
385 TESS_DIAG_EVENT(queued_phase_failure);
387 result.status,
388 chunk_count,
389 };
390 }
391
393 PlannedExecutionStatus::Executed,
394 chunk_count,
395 };
396}
397
398// Result-bearing variant of execute_plan_deferred_dirty: serial, whole-plan,
399// aborting at the first non-Executed result with the same partial-execution
400// contract (earlier writes kept, chunk counts reported). All operations are
401// prepared upfront, so the aborted tail reads Pending through the channel.
403template <WritePolicy Policy, typename World, typename T, typename Fn>
404[[nodiscard]] auto execute_plan_deferred_dirty_with_results(
405 World& world, const ExecutionPlan& plan, PlannedDirtyAccumulator& dirty,
406 ResultChannel<T>& channel, Fn&& fn) -> PlannedExecutionResult {
407 for (const auto& operation : plan.operations()) {
408 channel.prepare_operation(operation.handle, operation.source);
409 }
410 std::size_t chunk_count = 0;
411 auto&& callback = fn;
412 for (const auto& operation : plan.operations()) {
413 auto& value = channel.value_for(operation.handle);
414 auto result = execute_planned_operation_deferred_dirty<Policy>(
415 world, operation, dirty, [&](auto view) { callback(view, value); });
416 channel.complete(operation.handle, result, operation.source);
417 if (result.status != PlannedExecutionStatus::Executed) {
419 result.status,
420 chunk_count + result.chunk_count,
421 };
422 }
423 chunk_count += result.chunk_count;
424 }
426 PlannedExecutionStatus::Executed,
427 chunk_count,
428 };
429}
430
431} // namespace tess
Definition queued.h:709
Definition queued.h:647
Definition queued.h:1521
Definition queued.h:978
Definition queued.h:1415
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:315
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:76
Definition queued.h:883
Definition phase_executor.h:65