Files
Jungfraujoch/common/ParallelFor.h
T
leonarski_fandClaude Opus 5.5 c20cd016dc Fixed-partition reductions: sums that do not depend on the thread count
ParallelChunks cuts a range into one chunk per worker, so a floating-point
sum folded per chunk and then added up rounds differently at another -N.
ParallelBlocks cuts it by n alone (n / min_per_block blocks, at most 256);
each block folds into its own slot and the slots are added in block order,
so the sum has the same bits at any thread count.

Converted:
- RotationScaleMerge::ApplyCellSurface: the per-cell cross/ref2 sums of the
  surface fit (modulation, absorption), previously per-thread partials cut by
  ThreadsForWork(idx_all) threads.
- PostRefine: the scale-scan cost grid (per-chunk slots cut by -N).
- PostRefine joint solve: Ceres at a fixed 16 threads (as the rotation
  indexer's chain) instead of -N. Ceres sums cost and gradient in
  4 * num_threads pieces, so the split no longer follows -N. The pieces are
  handed to its threads in scheduling order, which only one thread makes
  exact; one thread was measured at +1.0-1.4 s of post-refinement on myob and
  lyso (0.5 -> 1.9 s, 1.1 -> 2.1 s), so that channel is left.
- IndexAndRefine supercell probe: per-frame probes kept by image number and
  summed in frame order, instead of added under a mutex in completion order;
  the primitive is the highest probed frame's instead of the last finisher's.

Integer reductions and per-item passes are unchanged; the three ParallelSort
callers already break ties on the index.

Validation (myob, cytc, lyso, sparse; GPU and CPU builds; -N 8/16/32):
p.hkl, p.mtz, p_P1.mtz and p_unmerged.mtz are md5-identical across -N, and
identical to the base 351be7de0 at every -N. The base was already -N
invariant on these four sets (GPU -N 1..32, CPU lyso -N 2..32), so no output
moved: the converted sums differ from the old ones only in last bits that
never reached a written float.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-28 18:57:02 +02:00

248 lines
11 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 <cstdint>
#include <condition_variable>
#include <deque>
#include <exception>
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
#include "ThreadAffinity.h"
// 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. A pass whose data is small can
// otherwise spend more on splitting the work than on doing it. 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 {
// The threads both helpers below run on. They are made once and kept, because the analysis code
// repeats some of its passes thousands of times in a run and a thread costs tens of microseconds
// to create and join - more, on a short pass, than the pass itself.
class WorkerPool {
public:
static WorkerPool &Instance() {
static WorkerPool pool;
return pool;
}
// True on a thread the pool owns. A parallel pass reached from inside one runs inline instead
// of queueing: the workers are already occupied by the outer pass, so waiting for one of them
// to pick up the inner work could wait forever.
static bool InWorker() { return InWorkerFlag(); }
size_t WorkerCount() const { return workers.size(); }
void Submit(std::function<void()> job) {
{
std::lock_guard lock(m);
queue.push_back(std::move(job));
}
cv.notify_one();
}
private:
WorkerPool() {
const unsigned hw = std::max(1u, std::thread::hardware_concurrency());
workers.reserve(hw - 1);
for (unsigned i = 0; i + 1 < hw; i++) // the submitting thread takes a share too
workers.emplace_back([this] {
// It inherited whatever CPUs the thread that first used the pool was kept to.
RestoreThreadAffinity();
InWorkerFlag() = true;
Loop();
});
}
~WorkerPool() {
{
std::lock_guard lock(m);
stop = true;
}
cv.notify_all();
for (auto &t: workers) t.join();
}
void Loop() {
for (;;) {
std::function<void()> job;
{
std::unique_lock lock(m);
cv.wait(lock, [this] { return stop || !queue.empty(); });
if (stop) return;
job = std::move(queue.front());
queue.pop_front();
}
job();
}
}
std::mutex m;
std::condition_variable cv;
std::deque<std::function<void()> > queue;
std::vector<std::thread> workers;
bool stop = false;
// A function-local thread_local rather than an inline static member: Apple's linker rejects the
// TLS wrapper clang emits for the latter as a duplicate symbol once two libraries include this.
static bool &InWorkerFlag() {
static thread_local bool in_worker = false;
return in_worker;
}
};
// What the tasks of one pass share: the body to call, how many of them are still outstanding, and
// the first exception any of them threw.
struct RunState {
const std::function<void(int)> *body = nullptr;
std::atomic<int> remaining{0};
std::mutex done_m;
std::condition_variable done_cv;
bool done = false; // guarded by done_m; see RunOneTask
std::mutex err_m;
std::exception_ptr error;
};
inline void RunOneTask(RunState &s, int t) {
try {
(*s.body)(t);
} catch (...) {
std::lock_guard lock(s.err_m);
if (!s.error) s.error = std::current_exception();
}
if (s.remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) {
// The flag the waiter tests is set UNDER done_m, and the counter is not that flag. If the
// waiter watched the counter it could see zero the instant the decrement above lands -
// before this thread has taken the lock - find its predicate already true, never block,
// and return from RunTasks. RunState is a local of that frame, so the lock and the notify
// below would then run on a destroyed mutex and condition variable, writing pthread state
// into a stack frame the submitting thread has already reused. Watching a flag set under
// the lock means completion cannot be observed until this thread has released it.
std::lock_guard lock(s.done_m);
s.done = true;
s.done_cv.notify_all();
}
}
// Call body(t) for every t in [0, ntasks) on the pool and on this thread, and return once they have
// all finished. An exception from any of them is held until then and rethrown here, so the others
// still run to completion - which is what waiting on futures used to give.
inline void RunTasks(int ntasks, const std::function<void(int)> &body) {
if (ntasks <= 0) return;
WorkerPool &pool = WorkerPool::Instance();
if (ntasks == 1 || WorkerPool::InWorker() || pool.WorkerCount() == 0) {
for (int t = 0; t < ntasks; t++) body(t);
return;
}
RunState s;
s.body = &body;
s.remaining.store(ntasks, std::memory_order_relaxed);
RunState *sp = &s;
for (int t = 1; t < ntasks; t++)
pool.Submit([sp, t] { RunOneTask(*sp, t); });
RunOneTask(s, 0);
{
std::unique_lock lock(s.done_m);
s.done_cv.wait(lock, [sp] { return sp->done; });
}
if (s.error) std::rethrow_exception(s.error);
}
}
// 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. A sum folded per chunk does not - use ParallelBlocks.
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;
parallel_detail::RunTasks(nt, [&](int t) {
const int lo = t * chunk, hi = std::min(n, lo + chunk);
if (lo < hi) fn(lo, hi);
});
}
// 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 size_t local = std::min(nthreads, static_cast<size_t>(n));
std::atomic<int> next = 0;
parallel_detail::RunTasks(static_cast<int>(local), [&](int) {
for (int i = next.fetch_add(1); i < n; i = next.fetch_add(1)) fn(i);
});
}
// Fixed blocks, for a floating-point reduction. ParallelChunks cuts the range once per worker, so a sum
// folded per chunk and then added up rounds differently at another thread count - and a result sitting
// on a decision gate can then flip with -N. Here the cut depends on n alone: n / min_per_block blocks,
// at most MAX_REDUCTION_BLOCKS, block b being [n*b/nb, n*(b+1)/nb). Fold each block into its own slot
// (ReductionBlocks(n) of them), add the slots up in block order afterwards, and the sum has the same
// bits at any thread count; nthreads only says how many workers share the blocks.
constexpr int MAX_REDUCTION_BLOCKS = 256;
inline int ReductionBlocks(int n, int min_per_block = 4096) {
if (n <= 0) return 0;
return std::clamp(n / min_per_block, 1, MAX_REDUCTION_BLOCKS);
}
template <typename Fn>
void ParallelBlocks(int n, size_t nthreads, Fn fn, int min_per_block = 4096) {
const int nb = ReductionBlocks(n, min_per_block);
const auto begin = [n, nb](int b) { return static_cast<int>(static_cast<int64_t>(n) * b / nb); };
ParallelFor(nb, nthreads, [&](int b) { fn(b, begin(b), begin(b + 1)); });
}
// std::sort on several workers: the range is cut into one piece per worker, the pieces are sorted
// in parallel and then merged pairwise. Only for a comparator that is a strict TOTAL order on the
// elements present - no two of them equivalent - because then there is exactly one sorted sequence
// and this returns it, the same as the serial sort, bit for bit. (With ties the two could order the
// equivalent elements differently.) Break ties on something unique, such as the element's index.
template <typename It, typename Cmp>
void ParallelSort(It first, It last, size_t nthreads, Cmp cmp) {
const size_t n = static_cast<size_t>(last - first);
const size_t pieces = ThreadsForWork(n, nthreads);
if (pieces <= 1) {
std::sort(first, last, cmp);
return;
}
std::vector<size_t> bound(pieces + 1);
for (size_t p = 0; p <= pieces; p++)
bound[p] = p * n / pieces;
parallel_detail::RunTasks(static_cast<int>(pieces), [&](int p) {
std::sort(first + bound[p], first + bound[p + 1], cmp);
});
for (size_t width = 1; width < pieces; width *= 2) {
const int merges = static_cast<int>((pieces + 2 * width - 1) / (2 * width));
parallel_detail::RunTasks(merges, [&](int m) {
const size_t lo = 2 * width * m, mid = std::min(lo + width, pieces), hi = std::min(lo + 2 * width, pieces);
if (mid < hi)
std::inplace_merge(first + bound[lo], first + bound[mid], first + bound[hi], cmp);
});
}
}