tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
phase_executor.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/diagnostics/diagnostics.h>
7
8#include <algorithm>
9#include <atomic>
10#include <concepts>
11#include <condition_variable>
12#include <cstddef>
13#include <cstdint>
14#include <exception>
15#include <mutex>
16#include <thread>
17#include <type_traits>
18#include <utility>
19#include <vector>
20
21// Phase executor contract.
22//
23// Queued-operation planning groups already-validated operations into phases
24// whose members may execute together. A phase executor receives one
25// contiguous planned-operation index range and invokes the per-operation
26// callback for every index in it on normal return, completing or joining all
27// callbacks (and making their writes visible) before returning. A callback
28// exception may suppress work that has not started, but the executor still
29// joins callbacks already in flight before rethrowing. Executors do not plan,
30// do not reorder result reduction, and do not own dirty metadata: callers
31// reduce operation results in plan order and merge caller-owned dirty
32// partitions after the executor returns.
33//
34// Thread contract: `World` fields and `ChunkMeta` are not atomic. Concurrent
35// callbacks are safe only because planning proves disjoint mutable chunk
36// ownership per phase; callbacks write dirty records into per-operation
37// partitions instead of shared metadata. Executors that invoke callbacks
38// concurrently must not declare `serial_execution_tag` (see the
39// SerialExecutor concept below).
40
41namespace tess {
42
44enum class PlannedExecutionStatus : std::uint8_t {
45 Executed,
46 PolicyMismatch,
47 InvalidShape,
48 InvalidChunk,
49 InvalidPhase,
50};
51static_assert(sizeof(PlannedExecutionStatus) == sizeof(std::uint8_t));
52
55 std::size_t first_operation = 0;
56 std::size_t operation_count = 0;
57};
58
66 PlannedExecutionStatus status = PlannedExecutionStatus::Executed;
67 std::size_t chunk_count = 0;
68};
69
70namespace detail {
71
72// Probe callback used only to state the PhaseExecutor concept without
73// evaluating a lambda in an unevaluated context.
74struct PhaseExecutorProbeCallback {
75 [[nodiscard]] auto operator()(std::size_t /*operation_index*/) const
78 }
79};
80
81} // namespace detail
82
87template <typename Executor>
89 requires(const std::remove_cvref_t<Executor>& executor, std::size_t first,
90 std::size_t count, detail::PhaseExecutorProbeCallback callback) {
91 {
92 executor.for_each_operation(first, count, callback)
93 } -> std::same_as<PlannedExecutionResult>;
94 };
95
98 // Serialized-callback promise; see the SerialExecutor concept below.
99 using serial_execution_tag = void;
100
101 template <typename Fn>
102 [[nodiscard]] auto for_each_operation(ExecutorPhaseRange range, Fn&& fn) const
104 return for_each_operation(range.first_operation, range.operation_count,
105 std::forward<Fn>(fn));
106 }
107
108 template <typename Fn>
109 [[nodiscard]] auto for_each_operation(std::size_t first, std::size_t count,
110 Fn&& fn) const
112 auto&& callback = fn;
113 const auto end = first + count;
114 for (std::size_t i = first; i < end; ++i) {
115 auto result = callback(i);
116 if (result.status != PlannedExecutionStatus::Executed) {
117 return result;
118 }
119 }
120 return PlannedExecutionResult{};
121 }
122};
123
128template <typename Executor>
130 requires { typename std::remove_cvref_t<Executor>::serial_execution_tag; };
131
138template <bool CaptureExceptions>
139class ScopedThreadPhaseExecutorImpl {
140 public:
141 static_assert(!CaptureExceptions || has_exceptions,
142 "exception capture requires compiler exception support");
143 static constexpr bool captures_callback_exceptions = CaptureExceptions;
144
145 explicit ScopedThreadPhaseExecutorImpl(std::size_t worker_count) noexcept
146 : worker_count_(worker_count == 0 ? 1 : worker_count) {}
147
148 ScopedThreadPhaseExecutorImpl() noexcept
149 : ScopedThreadPhaseExecutorImpl(std::thread::hardware_concurrency()) {}
150
151 [[nodiscard]] auto worker_count() const noexcept -> std::size_t {
152 return worker_count_;
153 }
154
155 template <typename Fn>
156 [[nodiscard]] auto for_each_operation(std::size_t first, std::size_t count,
157 Fn&& fn) const
159 if (count == 0) {
160 return PlannedExecutionResult{};
161 }
162
163 const auto thread_count = std::min(worker_count_, count);
164 TESS_DIAG_EVENT_VALUE(queued_scoped_thread_dispatch, thread_count);
165 std::atomic<std::size_t> next_offset = 0;
166 std::vector<PlannedExecutionResult> results(count);
167 std::vector<std::thread> threads;
168 threads.reserve(thread_count);
169 auto&& callback = fn;
170 static_assert(
171 !has_exceptions || CaptureExceptions ||
172 std::is_nothrow_invocable_r_v<PlannedExecutionResult,
173 decltype(callback)&, std::size_t>,
174 "NoThrow executors require noexcept callbacks when exceptions are "
175 "enabled");
176
177 constexpr auto no_throw_callback =
178 !CaptureExceptions ||
179 std::is_nothrow_invocable_r_v<PlannedExecutionResult,
180 decltype(callback)&, std::size_t>;
181
182 if constexpr (no_throw_callback) {
183 const auto start_worker = [&] {
184 threads.emplace_back([&] {
185 while (true) {
186 const auto offset = next_offset.fetch_add(1);
187 if (offset >= count) {
188 return;
189 }
190 results[offset] = callback(first + offset);
191 }
192 });
193 };
194#if TESS_HAS_EXCEPTIONS
195 try {
196 for (std::size_t worker = 0; worker < thread_count; ++worker) {
197 start_worker();
198 }
199 } catch (...) {
200 for (auto& thread : threads) {
201 thread.join();
202 }
203 throw;
204 }
205#else
206 for (std::size_t worker = 0; worker < thread_count; ++worker) {
207 start_worker();
208 }
209#endif
210 } else {
211#if TESS_HAS_EXCEPTIONS
212 std::atomic<bool> cancelled = false;
213 std::exception_ptr exception;
214 std::mutex exception_mutex;
215
216 for (std::size_t worker = 0; worker < thread_count; ++worker) {
217 try {
218 threads.emplace_back([&] {
219 while (true) {
220 const auto offset = next_offset.fetch_add(1);
221 if (offset >= count ||
222 cancelled.load(std::memory_order_acquire)) {
223 return;
224 }
225 try {
226 results[offset] = callback(first + offset);
227 } catch (...) {
228 {
229 const std::scoped_lock lock{exception_mutex};
230 if (!exception) {
231 exception = std::current_exception();
232 }
233 }
234 cancelled.store(true, std::memory_order_release);
235 return;
236 }
237 }
238 });
239 } catch (...) {
240 for (auto& thread : threads) {
241 thread.join();
242 }
243 throw;
244 }
245 }
246
247 for (auto& thread : threads) {
248 thread.join();
249 }
250
251 if (exception) {
252 std::rethrow_exception(exception);
253 }
254#endif
255 }
256
257 if constexpr (no_throw_callback) {
258 for (auto& thread : threads) {
259 thread.join();
260 }
261 }
262
263 for (const auto result : results) {
264 if (result.status != PlannedExecutionStatus::Executed) {
265 return result;
266 }
267 }
268 return PlannedExecutionResult{};
269 }
270
271 private:
272 std::size_t worker_count_ = 1;
273};
274
276using ScopedThreadPhaseExecutor = ScopedThreadPhaseExecutorImpl<has_exceptions>;
277
279using NoThrowScopedThreadPhaseExecutor = ScopedThreadPhaseExecutorImpl<false>;
280
281// Stable persistent worker-pool backend behind the PhaseExecutor contract:
282// workers are created once and reused across phases, so phase
283// dispatch does not create threads. It invokes callbacks concurrently, so
284// like ScopedThreadPhaseExecutor it does not declare serial_execution_tag
285// and pairs only with execute_phase_partitioned_dirty_with. AutoExec uses it
286// as its synchronous parallel backend when a pool is attached; it is not a
287// general asynchronous scheduler. Callback exceptions cancel unclaimed work
288// and are rethrown on the dispatching thread after already-running callbacks
289// finish.
290// After reserve_operations, successful warm for_each_operation calls perform
291// no dynamic allocation.
292//
293// Dispatch contract: at most one for_each_operation may be in flight per
294// executor, and callbacks must not re-enter for_each_operation or call
295// reserve_operations on the same executor. All dispatch state
296// (job_context_ through results_) is shared per executor, so a nested or
297// concurrent dispatch clobbers the active job and deadlocks the outer
298// caller, which waits on done_cv_ while its own worker is parked inside
299// the nested call. Both violations fail fast under the pool mutex in every
300// build before dispatch state can be changed. Distinct executors are
301// independent and may dispatch in parallel.
302// The analyzer's padding complaint is the point: the alignas(128) members
303// below buy false-sharing isolation with those bytes.
304#if defined(_MSC_VER)
305#pragma warning(push)
306// C4324 reports padding introduced by alignment. The padding in this class is
307// intentional: it isolates contended worker-pool state from false sharing.
308#pragma warning(disable : 4324)
309#endif
310// NOLINTBEGIN(clang-analyzer-optin.performance.Padding)
311namespace detail {
312
313using PhaseJobInvoke = auto (*)(void*, std::size_t) -> PlannedExecutionResult;
314using NoThrowPhaseJobInvoke = auto (*)(void*, std::size_t) noexcept
316
317template <bool CaptureExceptions>
318struct WorkerPoolExceptionState {};
319
320template <>
321struct alignas(128) WorkerPoolExceptionState<true> {
322 mutable std::atomic<bool> cancelled_ = false;
323 mutable std::exception_ptr exception_;
324 mutable PhaseJobInvoke invoke_ = nullptr;
325 mutable bool no_throw_job_ = false;
326};
327
328} // namespace detail
329
336template <bool CaptureExceptions>
337class WorkerPoolPhaseExecutorImpl
338 : private detail::WorkerPoolExceptionState<CaptureExceptions> {
339 public:
340 static_assert(!CaptureExceptions || has_exceptions,
341 "exception capture requires compiler exception support");
342 static constexpr bool captures_callback_exceptions = CaptureExceptions;
343
344 explicit WorkerPoolPhaseExecutorImpl(std::size_t worker_count) {
345 const auto count = worker_count == 0 ? std::size_t{1} : worker_count;
346 workers_.reserve(count);
347#if TESS_HAS_EXCEPTIONS
348 try {
349 for (std::size_t worker = 0; worker < count; ++worker) {
350 workers_.emplace_back([this] { run_worker(); });
351 }
352 } catch (...) {
353 // A std::thread constructor threw mid-pool-construction: stop and
354 // join the workers that did start, then rethrow instead of letting
355 // workers_ unwind over joinable threads, which would terminate.
356 {
357 const std::scoped_lock lock{mutex_};
358 stop_ = true;
359 }
360 work_cv_.notify_all();
361 for (auto& worker : workers_) {
362 worker.join();
363 }
364 throw;
365 }
366#else
367 for (std::size_t worker = 0; worker < count; ++worker) {
368 workers_.emplace_back([this] { run_worker(); });
369 }
370#endif
371 }
372
373 WorkerPoolPhaseExecutorImpl()
374 : WorkerPoolPhaseExecutorImpl(std::thread::hardware_concurrency()) {}
375
376 WorkerPoolPhaseExecutorImpl(const WorkerPoolPhaseExecutorImpl&) = delete;
377 auto operator=(const WorkerPoolPhaseExecutorImpl&)
378 -> WorkerPoolPhaseExecutorImpl& = delete;
379 WorkerPoolPhaseExecutorImpl(WorkerPoolPhaseExecutorImpl&&) = delete;
380 auto operator=(WorkerPoolPhaseExecutorImpl&&)
381 -> WorkerPoolPhaseExecutorImpl& = delete;
382
383 ~WorkerPoolPhaseExecutorImpl() {
384 {
385 const std::scoped_lock lock{mutex_};
386 stop_ = true;
387 }
388 work_cv_.notify_all();
389 for (auto& worker : workers_) {
390 worker.join();
391 }
392 }
393
394 [[nodiscard]] auto worker_count() const noexcept -> std::size_t {
395 return workers_.size();
396 }
397
398 // Pre-sizes the per-operation result buffer so warm phases of up to
399 // `count` operations do not allocate. A larger phase grows the buffer on
400 // that dispatch. Only legal between dispatches: resizing results_ while
401 // workers write into it would relocate their slots (use-after-realloc).
402 void reserve_operations(std::size_t count) const {
403 const std::scoped_lock lock{mutex_};
404 if (dispatch_active_) {
405 detail::fail_fast(
406 "WorkerPoolPhaseExecutor::reserve_operations called during an "
407 "active dispatch");
408 }
409 if (results_.size() < count) {
410 results_.resize(count);
411 }
412 }
413
414 template <typename Fn>
415 [[nodiscard]] auto for_each_operation(std::size_t first, std::size_t count,
416 Fn&& fn) const
418 if (count == 0) {
419 const std::scoped_lock lock{mutex_};
420 if (dispatch_active_) {
421 detail::fail_fast(
422 "WorkerPoolPhaseExecutor::for_each_operation re-entered during "
423 "an active dispatch");
424 }
425 return PlannedExecutionResult{};
426 }
427 TESS_DIAG_EVENT_VALUE(queued_worker_pool_dispatch,
428 std::min(workers_.size(), count));
429
430 auto&& callback = fn;
431 using Callback = std::remove_reference_t<decltype(callback)>;
432 static_assert(
433 !has_exceptions || CaptureExceptions ||
434 std::is_nothrow_invocable_r_v<PlannedExecutionResult, Callback&,
435 std::size_t>,
436 "NoThrow executors require noexcept callbacks when exceptions are "
437 "enabled");
438 constexpr auto no_throw_callback =
439 !CaptureExceptions ||
440 std::is_nothrow_invocable_r_v<PlannedExecutionResult, Callback&,
441 std::size_t>;
442 std::size_t runs = 0;
443 PlannedExecutionResult result{};
444#if TESS_HAS_EXCEPTIONS
445 std::exception_ptr exception;
446#endif
447 {
448 const std::scoped_lock lock{mutex_};
449 // Single-dispatch guard under the mutex already acquired for setup.
450 if (dispatch_active_) {
451 detail::fail_fast(
452 "WorkerPoolPhaseExecutor::for_each_operation re-entered during "
453 "an active dispatch");
454 }
455 if (results_.size() < count) {
456 results_.resize(count);
457 }
458 // Set only after the potentially throwing resize so a bad_alloc
459 // cannot leave the flag wedged; the whole block holds mutex_, so
460 // a competing dispatch still observes the flag before touching
461 // any job state.
462 dispatch_active_ = true;
463 job_context_ = &callback;
464 if constexpr (no_throw_callback) {
465 job_invoke_nothrow_ =
466 [](void* context,
467 std::size_t index) noexcept -> PlannedExecutionResult {
468 return (*static_cast<Callback*>(context))(index);
469 };
470 }
471#if TESS_HAS_EXCEPTIONS
472 if constexpr (CaptureExceptions) {
473 this->no_throw_job_ = no_throw_callback;
474 if constexpr (!no_throw_callback) {
475 this->invoke_ = [](void* context,
476 std::size_t index) -> PlannedExecutionResult {
477 return (*static_cast<Callback*>(context))(index);
478 };
479 }
480 }
481#endif
482 job_first_ = first;
483 job_count_ = count;
484 // Claim short runs instead of single operations: one contended RMW
485 // per run instead of per op, while ~4 runs per worker keep the tail
486 // balanced.
487 job_stride_ = std::max<std::size_t>(
488 1, count / (std::max<std::size_t>(1, workers_.size()) * 4));
489 next_offset_.store(0, std::memory_order_relaxed);
490 finished_operations_.store(0, std::memory_order_relaxed);
491#if TESS_HAS_EXCEPTIONS
492 if constexpr (CaptureExceptions) {
493 this->cancelled_.store(false, std::memory_order_relaxed);
494 this->exception_ = nullptr;
495 }
496#endif
497 ++job_epoch_;
498 job_active_ = true;
499 // Derived under the lock so the notify count below never reads
500 // job_stride_ across the unlock. There is one dispatcher, but keeping
501 // the dependency local makes that constraint explicit.
502 runs = (count + job_stride_ - 1) / job_stride_;
503 }
504 // Wake only as many workers as there are runs to claim; a small phase
505 // on a wide pool otherwise storms every thread awake to find nothing. A
506 // worker that reaches wait() on its own sees
507 // the live job through the predicate, so under-notification cannot
508 // strand work.
509 if (runs >= workers_.size()) {
510 work_cv_.notify_all();
511 } else {
512 for (std::size_t i = 0; i < runs; ++i) {
513 work_cv_.notify_one();
514 }
515 }
516
517 {
518 std::unique_lock lock{mutex_};
519 done_cv_.wait(lock, [&] {
520 if constexpr (CaptureExceptions) {
521#if TESS_HAS_EXCEPTIONS
522 return active_workers_ == 0 &&
523 (this->exception_ || finished_operations_.load(
524 std::memory_order_acquire) == count);
525#else
526 return false;
527#endif
528 } else {
529 return active_workers_ == 0 &&
530 finished_operations_.load(std::memory_order_acquire) == count;
531 }
532 });
533#if TESS_HAS_EXCEPTIONS
534 if constexpr (CaptureExceptions) {
535 exception = this->exception_;
536 }
537#endif
538 // Select and copy the result before releasing dispatch ownership. A
539 // competing caller may resize or overwrite results_ as soon as the
540 // flag clears.
541 for (std::size_t offset = 0; offset < count; ++offset) {
542 if (results_[offset].status != PlannedExecutionStatus::Executed) {
543 result = results_[offset];
544 break;
545 }
546 }
547 job_active_ = false;
548 dispatch_active_ = false;
549 }
550
551#if TESS_HAS_EXCEPTIONS
552 if constexpr (CaptureExceptions) {
553 if (exception) {
554 std::rethrow_exception(exception);
555 }
556 }
557#endif
558
559 return result;
560 }
561
562 private:
563 void run_worker() {
564 std::uint64_t seen_epoch = 0;
565 while (true) {
566 std::unique_lock lock{mutex_};
567 work_cv_.wait(lock, [&] {
568 return stop_ || (job_active_ && job_epoch_ != seen_epoch);
569 });
570 if (stop_) {
571 return;
572 }
573 seen_epoch = job_epoch_;
574 ++active_workers_;
575 auto* const context = job_context_;
576 const auto invoke_nothrow = job_invoke_nothrow_;
577#if TESS_HAS_EXCEPTIONS
578 detail::PhaseJobInvoke invoke = nullptr;
579 auto no_throw_job = true;
580 if constexpr (CaptureExceptions) {
581 invoke = this->invoke_;
582 no_throw_job = this->no_throw_job_;
583 }
584#endif
585 const auto first = job_first_;
586 const auto count = job_count_;
587 const auto stride = job_stride_;
588 lock.unlock();
589
590 if constexpr (!CaptureExceptions) {
591 run_no_throw_job(context, invoke_nothrow, first, count, stride);
592 }
593#if TESS_HAS_EXCEPTIONS
594 if constexpr (CaptureExceptions) {
595 if (no_throw_job) {
596 run_no_throw_job(context, invoke_nothrow, first, count, stride);
597 } else {
598 run_catching_job(context, invoke, first, count, stride);
599 }
600 }
601#endif
602
603 lock.lock();
604 --active_workers_;
605 if (active_workers_ == 0) {
606 done_cv_.notify_one();
607 }
608 }
609 }
610
611 void run_no_throw_job(void* context, detail::NoThrowPhaseJobInvoke invoke,
612 std::size_t first, std::size_t count,
613 std::size_t stride) const noexcept {
614 while (true) {
615 const auto begin =
616 next_offset_.fetch_add(stride, std::memory_order_relaxed);
617 if (begin >= count) {
618 break;
619 }
620 const auto end = std::min(begin + stride, count);
621 for (auto offset = begin; offset < end; ++offset) {
622 results_[offset] = invoke(context, first + offset);
623 }
624 finished_operations_.fetch_add(end - begin, std::memory_order_release);
625 }
626 }
627
628#if TESS_HAS_EXCEPTIONS
629 void run_catching_job(void* context, detail::PhaseJobInvoke invoke,
630 std::size_t first, std::size_t count,
631 std::size_t stride) const {
632 auto cancelled = false;
633 while (!this->cancelled_.load(std::memory_order_acquire)) {
634 const auto begin =
635 next_offset_.fetch_add(stride, std::memory_order_relaxed);
636 if (begin >= count) {
637 break;
638 }
639 const auto end = std::min(begin + stride, count);
640 auto finished = std::size_t{0};
641 for (auto offset = begin; offset < end; ++offset) {
642 if (this->cancelled_.load(std::memory_order_acquire)) {
643 cancelled = true;
644 break;
645 }
646 try {
647 results_[offset] = invoke(context, first + offset);
648 ++finished;
649 } catch (...) {
650 this->cancelled_.store(true, std::memory_order_release);
651 {
652 const std::scoped_lock exception_lock{mutex_};
653 if (!this->exception_) {
654 this->exception_ = std::current_exception();
655 }
656 }
657 cancelled = true;
658 break;
659 }
660 }
661 // One release-add per run publishes the whole run's results to
662 // the dispatcher's acquire wait.
663 finished_operations_.fetch_add(finished, std::memory_order_release);
664 if (cancelled) {
665 break;
666 }
667 }
668 }
669#endif
670
671 mutable std::mutex mutex_;
672 mutable std::condition_variable work_cv_;
673 mutable std::condition_variable done_cv_;
674 mutable std::vector<PlannedExecutionResult> results_;
675 // Own cache lines: every worker RMWs both counters per claimed run; adjacent
676 // counters ping-pong one line between cores.
677 // 128, not 64: Apple Silicon (where the A/B numbers were measured) has
678 // 128-byte lines and x86 prefetches the adjacent line, while alignas
679 // only fixes spacing relative to the object base -- at 64 the pair
680 // could still share one 128-byte line depending on allocation address.
681 alignas(128) mutable std::atomic<std::size_t> next_offset_ = 0;
682 alignas(128) mutable std::atomic<std::size_t> finished_operations_ = 0;
683 alignas(128) mutable void* job_context_ = nullptr;
684 mutable detail::NoThrowPhaseJobInvoke job_invoke_nothrow_ = nullptr;
685 mutable std::size_t job_first_ = 0;
686 mutable std::size_t job_count_ = 0;
687 mutable std::size_t job_stride_ = 1;
688 mutable std::uint64_t job_epoch_ = 0;
689 mutable std::size_t active_workers_ = 0;
690 mutable bool job_active_ = false;
691 mutable bool dispatch_active_ = false;
692 bool stop_ = false;
693 std::vector<std::thread> workers_;
694};
695
697using WorkerPoolPhaseExecutor = WorkerPoolPhaseExecutorImpl<has_exceptions>;
698
700using NoThrowWorkerPoolPhaseExecutor = WorkerPoolPhaseExecutorImpl<false>;
701// NOLINTEND(clang-analyzer-optin.performance.Padding)
702#if defined(_MSC_VER)
703#pragma warning(pop)
704#endif
705
707template <typename Executor, typename Fn>
708[[nodiscard]] auto execute_operation_index_range(Executor&& executor,
709 ExecutorPhaseRange range,
710 Fn&& fn)
712 return executor.for_each_operation(
713 range.first_operation, range.operation_count, std::forward<Fn>(fn));
714}
715
716} // namespace tess
Definition phase_executor.h:139
Definition phase_executor.h:338
Definition phase_executor.h:88
Definition phase_executor.h:129
Definition phase_executor.h:54
Definition phase_executor.h:65
Definition phase_executor.h:97