ParallelFor and ParallelChunks started fresh OS threads on every call, one per chunk, through std::async. There are 57 call sites and several sit inside iterative fits, so one run of the heaviest crystal created 13 497 threads and a 900-frame dataset 7 320. Both now run on a persistent pool. The contracts are unchanged: ParallelChunks keeps the same worker count and the same fixed split, so a reduction sums term for term as before, and ParallelFor keeps stealing per item. Two things in the pool are worth knowing. The caller is one of the hands - it claims its own region's tasks and then waits only on tasks already running - so a region entered from inside another region cannot deadlock at any depth, which a shared-queue pool would. And a task wakes one worker rather than the whole pool: on a large machine notify_all wakes every idle thread to find nothing, once per region, tens of thousands of times a run. Two hand-rolled copies of the same pattern now use it, in FrenchWilson and in the two histogram passes of ComputeAsuGroups. Be clear about what this buys today: nothing measurable. Thread creations drop 13 497 -> 770 and entering a parallel region goes from 1.2-2.2 ms to 112 us, an 11-20x cut, but wall clock on 48 threads is level with before, inside the +-5 % this machine's run-to-run placement is worth. What it removes is a cost that grows with the thread count - measured, entry is linear in it - and the machine this is heading for has four times the threads of the one it was measured on, where the same 335 regions a run would cost about 1.8 s of pure thread creation. ComputeAsuGroups' histogram also changes. It is an nthreads x n_groups table, 936 MB at -N 48 on the heaviest crystal and allocated five times a run, and the prefix over it walked DOWN a column - a 19.5 MB stride, so a cache and TLB miss per step, 234 M of them, serially. Both passes now walk rows and split over group ranges. The counts are integers, so the result is bit-identical. This one is reasoning, not measurement: at 48 threads it sits under this machine's noise and could not be shown either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
7.9 KiB
C++
200 lines
7.9 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <condition_variable>
|
|
#include <exception>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
// Two shapes of "run this over a range on several threads", used by the analysis code. Both take the
|
|
// worker count from the caller rather than asking the hardware, so a run that was told how many
|
|
// threads to use keeps to it.
|
|
|
|
// How many workers a pass over `n` cheap items should use: enough that each gets at least
|
|
// `min_per_thread` of them, and never more than the caller was given. Entering a parallel region below
|
|
// costs a condition-variable wakeup rather than a thread, but a pass over a few thousand items still
|
|
// finishes before the last worker has woken up, so the floor stays. That is not a large machine's
|
|
// problem: it is what makes the same code right on an 8-core laptop, a 16-core desktop and a
|
|
// two-socket node, none of which should be handed 48 chunks of a few thousand items.
|
|
inline size_t ThreadsForWork(size_t n, size_t nthreads, size_t min_per_thread = 32768) {
|
|
if (nthreads <= 1 || n == 0) return 1;
|
|
return std::clamp<size_t>(n / min_per_thread, 1, nthreads);
|
|
}
|
|
|
|
namespace parallel_detail {
|
|
|
|
// One parallel region: `ntasks` invocations of one body, each claimed and run by exactly one thread.
|
|
struct Region {
|
|
const std::function<void(int)> *body;
|
|
int ntasks;
|
|
int next = 0; // next unclaimed task, guarded by the pool's mutex
|
|
std::atomic<int> done{0};
|
|
std::exception_ptr error; // the first task to throw, guarded by the pool's mutex
|
|
};
|
|
|
|
// The threads both helpers below run on: started once, parked on a condition variable between regions.
|
|
// The analysis code enters tens of thousands of parallel regions in a run - several from inside
|
|
// iterative fits - and a thread per chunk per call was tens of microseconds of clone and first-touch
|
|
// each time, paid again on every iteration.
|
|
class Pool {
|
|
public:
|
|
static Pool &Instance() {
|
|
static Pool pool;
|
|
return pool;
|
|
}
|
|
|
|
// Run body(0) ... body(ntasks-1), each exactly once, on the pool's threads and on this one, and
|
|
// return when they have all finished. An exception from a task is rethrown here, as it was when
|
|
// these were futures.
|
|
void Run(int ntasks, const std::function<void(int)> &body) {
|
|
Region region{&body, ntasks};
|
|
{
|
|
std::lock_guard lock(mutex_);
|
|
regions_.push_back(®ion);
|
|
}
|
|
// One wakeup per task the caller is not going to run itself. notify_all instead would wake every
|
|
// parked thread for a region of two chunks - on a node with 192 of them that is 190 threads woken
|
|
// to find nothing and go back to sleep, per region, tens of thousands of times in a run.
|
|
for (int i = 1; i < ntasks; i++)
|
|
work_.notify_one();
|
|
// The calling thread is one of the hands and keeps claiming until nothing of this region is
|
|
// left unclaimed. That is what makes a region entered from inside a region safe: a region never
|
|
// waits for a pool thread to come free, only for tasks that are already running.
|
|
for (int i = Claim(region); i >= 0; i = Claim(region))
|
|
Execute(region, i);
|
|
std::unique_lock lock(mutex_);
|
|
done_.wait(lock, [&] { return region.done.load() == ntasks; });
|
|
if (region.error)
|
|
std::rethrow_exception(region.error);
|
|
}
|
|
|
|
private:
|
|
Pool() {
|
|
const unsigned n = std::max(1u, std::thread::hardware_concurrency());
|
|
threads_.reserve(n);
|
|
for (unsigned i = 0; i < n; i++)
|
|
threads_.emplace_back([this] { Work(); });
|
|
}
|
|
|
|
~Pool() {
|
|
{
|
|
std::lock_guard lock(mutex_);
|
|
stop_ = true;
|
|
}
|
|
work_.notify_all();
|
|
for (auto &t : threads_)
|
|
t.join();
|
|
}
|
|
|
|
// Claim one of `region`'s tasks, or -1 if it has none left. A region leaves the list the moment its
|
|
// last task is claimed, so no pool thread can look at it after Run returns and it goes out of scope.
|
|
int Claim(Region ®ion) {
|
|
std::lock_guard lock(mutex_);
|
|
if (region.next == region.ntasks)
|
|
return -1;
|
|
const int i = region.next++;
|
|
if (region.next == region.ntasks)
|
|
regions_.erase(std::find(regions_.begin(), regions_.end(), ®ion));
|
|
return i;
|
|
}
|
|
|
|
// Claim one task of the newest region that still has one, called with the mutex held. Newest first,
|
|
// so an inner region is finished before more hands go to the outer one waiting on it.
|
|
Region *ClaimAny(int &i) {
|
|
for (size_t at = regions_.size(); at-- > 0;) {
|
|
Region *region = regions_[at];
|
|
if (region->next < region->ntasks) {
|
|
i = region->next++;
|
|
if (region->next == region->ntasks)
|
|
regions_.erase(regions_.begin() + at);
|
|
return region;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Run one task and count it. The last one to finish wakes whoever is waiting in Run; it reads ntasks
|
|
// before the count, because after the count the region may already be gone.
|
|
void Execute(Region ®ion, int i) {
|
|
try {
|
|
(*region.body)(i);
|
|
} catch (...) {
|
|
std::lock_guard lock(mutex_);
|
|
if (!region.error)
|
|
region.error = std::current_exception();
|
|
}
|
|
const int ntasks = region.ntasks;
|
|
if (region.done.fetch_add(1) + 1 == ntasks) {
|
|
std::lock_guard lock(mutex_);
|
|
done_.notify_all();
|
|
}
|
|
}
|
|
|
|
void Work() {
|
|
std::unique_lock lock(mutex_);
|
|
for (;;) {
|
|
int i = 0;
|
|
Region *region = ClaimAny(i);
|
|
if (!region) {
|
|
if (stop_)
|
|
return;
|
|
work_.wait(lock);
|
|
continue;
|
|
}
|
|
lock.unlock();
|
|
Execute(*region, i);
|
|
lock.lock();
|
|
}
|
|
}
|
|
|
|
std::mutex mutex_;
|
|
std::condition_variable work_, done_;
|
|
std::vector<Region *> regions_;
|
|
std::vector<std::thread> threads_;
|
|
bool stop_ = false;
|
|
};
|
|
|
|
} // namespace parallel_detail
|
|
|
|
// Chunked: each worker gets one contiguous [lo, hi) range and there is no per-item synchronisation.
|
|
// Right for millions of cheap uniform items - the CPU stand-in for a flat CUDA grid-stride kernel.
|
|
// The split is fixed and deterministic, so a pass whose per-element work is independent gives the
|
|
// same answer as the serial loop, bit for bit.
|
|
template <typename Fn>
|
|
void ParallelChunks(int n, size_t nthreads, Fn fn) {
|
|
if (n <= 0) return;
|
|
const int nt = static_cast<int>(std::max<size_t>(1, std::min(nthreads, static_cast<size_t>(n))));
|
|
if (nt == 1) { fn(0, n); return; }
|
|
const int chunk = (n + nt - 1) / nt;
|
|
const std::function<void(int)> body = [&fn, n, chunk](int t) {
|
|
const int lo = t * chunk, hi = std::min(n, lo + chunk);
|
|
if (lo < hi) fn(lo, hi);
|
|
};
|
|
parallel_detail::Pool::Instance().Run(nt, body);
|
|
}
|
|
|
|
// Work-stealing per item, off a shared atomic counter: one atomic per item, so use it only where the
|
|
// per-item work is heavy and uneven (per-frame fits, per-ring selections) and the atomic amortises.
|
|
// For millions of tiny uniform items a per-item atomic is pure contention - use ParallelChunks.
|
|
template <typename Fn>
|
|
void ParallelFor(int n, size_t nthreads, Fn fn) {
|
|
if (n <= 0) return;
|
|
if (nthreads <= 1 || n == 1) {
|
|
for (int i = 0; i < n; i++) fn(i);
|
|
return;
|
|
}
|
|
const int nt = static_cast<int>(std::min(nthreads, static_cast<size_t>(n)));
|
|
std::atomic<int> next = 0;
|
|
const std::function<void(int)> body = [&fn, &next, n](int) {
|
|
for (int i = next.fetch_add(1); i < n; i = next.fetch_add(1))
|
|
fn(i);
|
|
};
|
|
parallel_detail::Pool::Instance().Run(nt, body);
|
|
}
|