tess 0.4.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/diagnostics/diagnostics.h>
5
6#include <algorithm>
7#include <atomic>
8#include <concepts>
9#include <condition_variable>
10#include <cstddef>
11#include <cstdint>
12#include <exception>
13#include <mutex>
14#include <thread>
15#include <type_traits>
16#include <utility>
17#include <vector>
18
19// Phase executor contract.
20//
21// Queued-operation planning groups already-validated operations into phases
22// whose members may execute together. A phase executor receives one
23// contiguous planned-operation index range and invokes the per-operation
24// callback for every index in it on normal return, completing or joining all
25// callbacks (and making their writes visible) before returning. A callback
26// exception may suppress work that has not started, but the executor still
27// joins callbacks already in flight before rethrowing. Executors do not plan,
28// do not reorder result reduction, and do not own dirty metadata: callers
29// reduce operation results in plan order and merge caller-owned dirty
30// partitions after the executor returns.
31//
32// Thread contract: `World` fields and `ChunkMeta` are not atomic. Concurrent
33// callbacks are safe only because planning proves disjoint mutable chunk
34// ownership per phase; callbacks write dirty records into per-operation
35// partitions instead of shared metadata. Executors that invoke callbacks
36// concurrently must not declare `serial_execution_tag` (see the
37// SerialExecutor concept below).
38
39namespace tess {
40
42enum class PlannedExecutionStatus : std::uint8_t {
43 Executed,
44 PolicyMismatch,
45 InvalidShape,
46 InvalidChunk,
47 InvalidPhase,
48};
49static_assert(sizeof(PlannedExecutionStatus) == sizeof(std::uint8_t));
50
53 std::size_t first_operation = 0;
54 std::size_t operation_count = 0;
55};
56
64 PlannedExecutionStatus status = PlannedExecutionStatus::Executed;
65 std::size_t chunk_count = 0;
66};
67
68namespace detail {
69
70// Probe callback used only to state the PhaseExecutor concept without
71// evaluating a lambda in an unevaluated context.
72struct PhaseExecutorProbeCallback {
73 auto operator()(std::size_t /*operation_index*/) const
76 }
77};
78
79} // namespace detail
80
85template <typename Executor>
87 requires(const std::remove_cvref_t<Executor>& executor, std::size_t first,
88 std::size_t count, detail::PhaseExecutorProbeCallback callback) {
89 {
90 executor.for_each_operation(first, count, callback)
91 } -> std::same_as<PlannedExecutionResult>;
92 };
93
96 // Serialized-callback promise; see the SerialExecutor concept below.
97 using serial_execution_tag = void;
98
99 template <typename Fn>
100 auto for_each_operation(ExecutorPhaseRange range, Fn&& fn) const
102 return for_each_operation(range.first_operation, range.operation_count,
103 std::forward<Fn>(fn));
104 }
105
106 template <typename Fn>
107 auto for_each_operation(std::size_t first, std::size_t count, Fn&& fn) const
109 auto&& callback = fn;
110 const auto end = first + count;
111 for (std::size_t i = first; i < end; ++i) {
112 auto result = callback(i);
113 if (result.status != PlannedExecutionStatus::Executed) {
114 return result;
115 }
116 }
117 return PlannedExecutionResult{};
118 }
119};
120
125template <typename Executor>
127 requires { typename std::remove_cvref_t<Executor>::serial_execution_tag; };
128
135class ScopedThreadPhaseExecutor {
136 public:
137 explicit ScopedThreadPhaseExecutor(std::size_t worker_count) noexcept
138 : worker_count_(worker_count == 0 ? 1 : worker_count) {}
139
140 ScopedThreadPhaseExecutor() noexcept
141 : ScopedThreadPhaseExecutor(std::thread::hardware_concurrency()) {}
142
143 [[nodiscard]] auto worker_count() const noexcept -> std::size_t {
144 return worker_count_;
145 }
146
147 template <typename Fn>
148 auto for_each_operation(std::size_t first, std::size_t count, Fn&& fn) const
150 if (count == 0) {
151 return PlannedExecutionResult{};
152 }
153
154 const auto thread_count = std::min(worker_count_, count);
155 TESS_DIAG_EVENT_VALUE(queued_scoped_thread_dispatch, thread_count);
156 std::atomic<std::size_t> next_offset = 0;
157 std::atomic<bool> cancelled = false;
158 std::exception_ptr exception;
159 std::mutex exception_mutex;
160 std::vector<PlannedExecutionResult> results(count);
161 std::vector<std::thread> threads;
162 threads.reserve(thread_count);
163 auto&& callback = fn;
164
165 for (std::size_t worker = 0; worker < thread_count; ++worker) {
166 try {
167 threads.emplace_back([&] {
168 while (true) {
169 const auto offset = next_offset.fetch_add(1);
170 if (offset >= count || cancelled.load(std::memory_order_acquire)) {
171 return;
172 }
173 try {
174 results[offset] = callback(first + offset);
175 } catch (...) {
176 {
177 const std::scoped_lock lock{exception_mutex};
178 if (!exception) {
179 exception = std::current_exception();
180 }
181 }
182 cancelled.store(true, std::memory_order_release);
183 return;
184 }
185 }
186 });
187 } catch (...) {
188 // A std::thread constructor threw mid-spawn: join the workers
189 // that did start (they drain the remaining operations, so the
190 // join is bounded) and rethrow instead of letting the vector
191 // unwind over joinable threads, which would std::terminate.
192 for (auto& thread : threads) {
193 thread.join();
194 }
195 throw;
196 }
197 }
198
199 for (auto& thread : threads) {
200 thread.join();
201 }
202
203 if (exception) {
204 std::rethrow_exception(exception);
205 }
206
207 for (const auto result : results) {
208 if (result.status != PlannedExecutionStatus::Executed) {
209 return result;
210 }
211 }
212 return PlannedExecutionResult{};
213 }
214
215 private:
216 std::size_t worker_count_ = 1;
217};
218
219// Prototype persistent worker-pool backend behind the PhaseExecutor
220// contract: workers are created once and reused across phases, so phase
221// dispatch does not create threads. It invokes callbacks concurrently, so
222// like ScopedThreadPhaseExecutor it does not declare serial_execution_tag
223// and pairs only with execute_phase_partitioned_dirty_with. AutoExec uses it
224// as its synchronous parallel backend when a pool is attached; it is not a
225// general asynchronous scheduler. Callback exceptions cancel unclaimed work
226// and are rethrown on the dispatching thread after already-running callbacks
227// finish.
228// After reserve_operations, successful warm for_each_operation calls perform
229// no dynamic allocation.
230//
231// Dispatch contract: at most one for_each_operation may be in flight per
232// executor, and callbacks must not re-enter for_each_operation or call
233// reserve_operations on the same executor. All dispatch state
234// (job_context_ through results_) is shared per executor, so a nested or
235// concurrent dispatch clobbers the active job and deadlocks the outer
236// caller, which waits on done_cv_ while its own worker is parked inside
237// the nested call. Debug builds (TESS_ENABLE_ASSERTS) fail fast on both
238// violations; in release builds the assert compiles out (zero cost, per
239// core/assert.h) and the misuse deadlocks or races exactly as documented
240// here. Distinct executors are independent and may dispatch in parallel.
241// The analyzer's padding complaint is the point: the alignas(128) members
242// below buy false-sharing isolation with those bytes (audit 2026-07-11 M8).
243#if defined(_MSC_VER)
244#pragma warning(push)
245// C4324 reports padding introduced by alignment. The padding in this class is
246// intentional: it isolates contended worker-pool state from false sharing.
247#pragma warning(disable : 4324)
248#endif
249// NOLINTBEGIN(clang-analyzer-optin.performance.Padding)
256class WorkerPoolPhaseExecutor {
257 public:
258 explicit WorkerPoolPhaseExecutor(std::size_t worker_count) {
259 const auto count = worker_count == 0 ? std::size_t{1} : worker_count;
260 workers_.reserve(count);
261 try {
262 for (std::size_t worker = 0; worker < count; ++worker) {
263 workers_.emplace_back([this] { run_worker(); });
264 }
265 } catch (...) {
266 // A std::thread constructor threw mid-pool-construction: stop and
267 // join the workers that did start, then rethrow instead of letting
268 // workers_ unwind over joinable threads, which would
269 // std::terminate.
270 {
271 const std::scoped_lock lock{mutex_};
272 stop_ = true;
273 }
274 work_cv_.notify_all();
275 for (auto& worker : workers_) {
276 worker.join();
277 }
278 throw;
279 }
280 }
281
282 WorkerPoolPhaseExecutor()
283 : WorkerPoolPhaseExecutor(std::thread::hardware_concurrency()) {}
284
285 WorkerPoolPhaseExecutor(const WorkerPoolPhaseExecutor&) = delete;
286 auto operator=(const WorkerPoolPhaseExecutor&)
287 -> WorkerPoolPhaseExecutor& = delete;
288 WorkerPoolPhaseExecutor(WorkerPoolPhaseExecutor&&) = delete;
289 auto operator=(WorkerPoolPhaseExecutor&&)
290 -> WorkerPoolPhaseExecutor& = delete;
291
292 ~WorkerPoolPhaseExecutor() {
293 {
294 const std::scoped_lock lock{mutex_};
295 stop_ = true;
296 }
297 work_cv_.notify_all();
298 for (auto& worker : workers_) {
299 worker.join();
300 }
301 }
302
303 [[nodiscard]] auto worker_count() const noexcept -> std::size_t {
304 return workers_.size();
305 }
306
307 // Pre-sizes the per-operation result buffer so warm phases of up to
308 // `count` operations do not allocate. A larger phase grows the buffer on
309 // that dispatch. Only legal between dispatches: resizing results_ while
310 // workers write into it would relocate their slots (use-after-realloc).
311 void reserve_operations(std::size_t count) const {
312 const std::scoped_lock lock{mutex_};
313 TESS_ASSERT_MSG(!dispatch_active_,
314 "WorkerPoolPhaseExecutor::reserve_operations called "
315 "during an active dispatch");
316 if (results_.size() < count) {
317 results_.resize(count);
318 }
319 }
320
321 template <typename Fn>
322 auto for_each_operation(std::size_t first, std::size_t count, Fn&& fn) const
324 if (count == 0) {
325 return PlannedExecutionResult{};
326 }
327 TESS_DIAG_EVENT_VALUE(queued_worker_pool_dispatch,
328 std::min(workers_.size(), count));
329
330 auto&& callback = fn;
331 using Callback = std::remove_reference_t<decltype(callback)>;
332 std::size_t runs = 0;
333 std::exception_ptr exception;
334 {
335 const std::scoped_lock lock{mutex_};
336 // Single-dispatch guard; see the class comment. The flag is
337 // maintained in release builds too (two stores under an
338 // already-held lock), but only debug builds check it.
339 TESS_ASSERT_MSG(!dispatch_active_,
340 "WorkerPoolPhaseExecutor::for_each_operation "
341 "re-entered during an active dispatch");
342 if (results_.size() < count) {
343 results_.resize(count);
344 }
345 // Set only after the potentially throwing resize so a bad_alloc
346 // cannot leave the flag wedged; the whole block holds mutex_, so
347 // a competing dispatch still observes the flag before touching
348 // any job state.
349 dispatch_active_ = true;
350 job_context_ = &callback;
351 job_invoke_ = [](void* context,
352 std::size_t index) -> PlannedExecutionResult {
353 return (*static_cast<Callback*>(context))(index);
354 };
355 job_first_ = first;
356 job_count_ = count;
357 // Claim short runs instead of single operations: one contended RMW
358 // per run instead of per op, while ~4 runs per worker keep the
359 // tail balanced (audit 2026-07-11 M8).
360 job_stride_ = std::max<std::size_t>(
361 1, count / (std::max<std::size_t>(1, workers_.size()) * 4));
362 next_offset_.store(0, std::memory_order_relaxed);
363 finished_operations_.store(0, std::memory_order_relaxed);
364 job_cancelled_.store(false, std::memory_order_relaxed);
365 job_exception_ = std::exception_ptr{};
366 ++job_epoch_;
367 job_active_ = true;
368 // Derived under the lock so the notify count below never reads
369 // job_stride_ across the unlock (safe today with one dispatcher,
370 // but locally obvious beats derivable).
371 runs = (count + job_stride_ - 1) / job_stride_;
372 }
373 // Wake only as many workers as there are runs to claim; a small phase
374 // on a wide pool otherwise storms every thread awake to find nothing
375 // (audit 2026-07-11 M8). A worker that reaches wait() on its own sees
376 // the live job through the predicate, so under-notification cannot
377 // strand work.
378 if (runs >= workers_.size()) {
379 work_cv_.notify_all();
380 } else {
381 for (std::size_t i = 0; i < runs; ++i) {
382 work_cv_.notify_one();
383 }
384 }
385
386 {
387 std::unique_lock lock{mutex_};
388 done_cv_.wait(lock, [&] {
389 return active_workers_ == 0 &&
390 (job_exception_ ||
391 finished_operations_.load(std::memory_order_acquire) == count);
392 });
393 exception = job_exception_;
394 job_active_ = false;
395 dispatch_active_ = false;
396 }
397
398 if (exception) {
399 std::rethrow_exception(exception);
400 }
401
402 for (std::size_t offset = 0; offset < count; ++offset) {
403 if (results_[offset].status != PlannedExecutionStatus::Executed) {
404 return results_[offset];
405 }
406 }
407 return PlannedExecutionResult{};
408 }
409
410 private:
411 using JobInvoke = auto (*)(void*, std::size_t) -> PlannedExecutionResult;
412
413 void run_worker() {
414 std::uint64_t seen_epoch = 0;
415 while (true) {
416 std::unique_lock lock{mutex_};
417 work_cv_.wait(lock, [&] {
418 return stop_ || (job_active_ && job_epoch_ != seen_epoch);
419 });
420 if (stop_) {
421 return;
422 }
423 seen_epoch = job_epoch_;
424 ++active_workers_;
425 auto* const context = job_context_;
426 const auto invoke = job_invoke_;
427 const auto first = job_first_;
428 const auto count = job_count_;
429 const auto stride = job_stride_;
430 lock.unlock();
431
432 auto cancelled = false;
433 while (!job_cancelled_.load(std::memory_order_acquire)) {
434 const auto begin =
435 next_offset_.fetch_add(stride, std::memory_order_relaxed);
436 if (begin >= count) {
437 break;
438 }
439 const auto end = std::min(begin + stride, count);
440 auto finished = std::size_t{0};
441 for (auto offset = begin; offset < end; ++offset) {
442 if (job_cancelled_.load(std::memory_order_acquire)) {
443 cancelled = true;
444 break;
445 }
446 try {
447 results_[offset] = invoke(context, first + offset);
448 ++finished;
449 } catch (...) {
450 job_cancelled_.store(true, std::memory_order_release);
451 {
452 const std::scoped_lock exception_lock{mutex_};
453 if (!job_exception_) {
454 job_exception_ = std::current_exception();
455 }
456 }
457 cancelled = true;
458 break;
459 }
460 }
461 // One release-add per run publishes the whole run's results to
462 // the dispatcher's acquire wait.
463 finished_operations_.fetch_add(finished, std::memory_order_release);
464 if (cancelled) {
465 break;
466 }
467 }
468
469 lock.lock();
470 --active_workers_;
471 // Only the last worker out can satisfy the dispatcher's predicate
472 // (finished == count AND active_workers_ == 0), so intermediate
473 // notifies were pure wakeup churn. notify_one: the single-dispatch
474 // contract means done_cv_ has at most one waiter.
475 if (active_workers_ == 0) {
476 done_cv_.notify_one();
477 }
478 }
479 }
480
481 mutable std::mutex mutex_;
482 mutable std::condition_variable work_cv_;
483 mutable std::condition_variable done_cv_;
484 mutable std::vector<PlannedExecutionResult> results_;
485 // Own cache lines: every worker RMWs both counters per claimed run;
486 // adjacent they ping-pong one line between cores (audit 2026-07-11 M8).
487 // 128, not 64: Apple Silicon (where the A/B numbers were measured) has
488 // 128-byte lines and x86 prefetches the adjacent line, while alignas
489 // only fixes spacing relative to the object base -- at 64 the pair
490 // could still share one 128-byte line depending on allocation address.
491 alignas(128) mutable std::atomic<std::size_t> next_offset_ = 0;
492 alignas(128) mutable std::atomic<std::size_t> finished_operations_ = 0;
493 mutable std::atomic<bool> job_cancelled_ = false;
494 alignas(128) mutable void* job_context_ = nullptr;
495 mutable JobInvoke job_invoke_ = nullptr;
496 mutable std::size_t job_first_ = 0;
497 mutable std::size_t job_count_ = 0;
498 mutable std::size_t job_stride_ = 1;
499 mutable std::uint64_t job_epoch_ = 0;
500 mutable std::size_t active_workers_ = 0;
501 mutable std::exception_ptr job_exception_;
502 mutable bool job_active_ = false;
503 mutable bool dispatch_active_ = false;
504 bool stop_ = false;
505 std::vector<std::thread> workers_;
506};
507// NOLINTEND(clang-analyzer-optin.performance.Padding)
508#if defined(_MSC_VER)
509#pragma warning(pop)
510#endif
511
513template <typename Executor, typename Fn>
514auto execute_operation_index_range(Executor&& executor,
515 ExecutorPhaseRange range, Fn&& fn)
517 return executor.for_each_operation(
518 range.first_operation, range.operation_count, std::forward<Fn>(fn));
519}
520
521} // namespace tess
Definition phase_executor.h:86
Definition phase_executor.h:126
Definition phase_executor.h:52
Definition phase_executor.h:63
Definition phase_executor.h:95