Files
Jungfraujoch/tests/ParallelForTest.cpp
T
leonarski_fandClaude Opus 5 b74d8f8545 Give ParallelFor a home and a test
The two shapes - a fixed contiguous split, and work stealing off an atomic - had been copied into
whichever file wanted them: an anonymous namespace in RotationScaleMerge.cpp, and another, byte for
byte the same, in ShadowFinder.cpp, whose comment claimed it was "the only other file that wants
it". The header is taken from the beam-stop GPU commit on the performance branch, which is not
otherwise being picked.

ShadowFinder now uses it, so the construct is exercised rather than shipped unused, and its own copy
is gone. The beam-stop mask is unchanged, which its reference count already pins.

Tested for the properties the callers rely on and which are easy to lose in a rewrite: the chunked
slices tile the range in order with no empty one, work stealing visits every item exactly once, one
thread means the caller's loop in order, an empty or negative count does nothing, an exception in a
worker reaches the caller, and - the point of the whole thing - the answer is the serial answer bit
for bit at every thread count.

RotationScaleMerge.cpp keeps its own copy for now; consolidating it belongs with the scaling work,
which is not being touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfYvJT5Nb71suJCowRBn5z
2026-08-23 13:05:31 +02:00

128 lines
5.0 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <vector>
#include "../common/ParallelFor.h"
namespace {
const std::vector<size_t> THREAD_COUNTS = {0, 1, 2, 3, 8, 64};
const std::vector<int> SIZES = {0, 1, 2, 7, 64, 1000};
}
// The split is fixed and contiguous, which is what lets a pass whose per-element work is independent
// give the serial answer bit for bit. Every element has to land in exactly one slice, the slices have
// to tile [0, n) in order, and there must never be an empty one.
TEST_CASE("ParallelChunks_SlicesTileTheRange", "[ParallelFor]") {
for (int n: SIZES) {
for (size_t nthreads: THREAD_COUNTS) {
CAPTURE(n, nthreads);
std::mutex m;
std::vector<std::pair<int, int>> slices;
std::vector<int> visits(static_cast<size_t>(std::max(n, 1)), 0);
ParallelChunks(n, nthreads, [&](int lo, int hi) {
{
std::scoped_lock lock(m);
slices.emplace_back(lo, hi);
}
for (int i = lo; i < hi; i++)
visits[static_cast<size_t>(i)]++; // slices are disjoint, so no lock needed
});
std::sort(slices.begin(), slices.end());
int expected_lo = 0;
for (const auto &[lo, hi]: slices) {
CHECK(lo == expected_lo);
CHECK(hi > lo); // never an empty slice
expected_lo = hi;
}
CHECK(expected_lo == n); // and they reach the end
CHECK(slices.size() <= static_cast<size_t>(std::max(n, 0)));
for (int i = 0; i < n; i++)
CHECK(visits[static_cast<size_t>(i)] == 1);
}
}
}
// Work stealing, so a worker takes whatever is next rather than a fixed slice - but every item still
// has to be done exactly once, whatever order they come out in.
TEST_CASE("ParallelFor_VisitsEveryItemOnce", "[ParallelFor]") {
for (int n: SIZES) {
for (size_t nthreads: THREAD_COUNTS) {
CAPTURE(n, nthreads);
std::vector<std::atomic<int>> visits(static_cast<size_t>(std::max(n, 1)));
for (auto &v: visits)
v.store(0);
ParallelFor(n, nthreads, [&](int i) { visits[static_cast<size_t>(i)].fetch_add(1); });
for (int i = 0; i < n; i++)
CHECK(visits[static_cast<size_t>(i)].load() == 1);
}
}
}
// One thread means the caller's loop, in order - the property the serial fallbacks rely on.
TEST_CASE("ParallelFor_IsSerialAndInOrderForOneThread", "[ParallelFor]") {
for (size_t nthreads: {size_t{0}, size_t{1}}) {
CAPTURE(nthreads);
std::vector<int> order;
ParallelFor(16, nthreads, [&](int i) { order.push_back(i); });
std::vector<int> expected(16);
std::iota(expected.begin(), expected.end(), 0);
CHECK(order == expected);
}
}
// Splitting the work must not change the answer. Each element is written by exactly one worker, so
// the result has to match the serial loop element for element, at every thread count.
TEST_CASE("ParallelFor_SplitDoesNotChangeTheResult", "[ParallelFor]") {
constexpr int N = 1000;
std::vector<double> serial(N);
for (int i = 0; i < N; i++)
serial[static_cast<size_t>(i)] = std::sin(i * 0.001) * 1e6 + i;
for (size_t nthreads: THREAD_COUNTS) {
CAPTURE(nthreads);
std::vector<double> chunked(N, 0.0), stolen(N, 0.0);
ParallelChunks(N, nthreads, [&](int lo, int hi) {
for (int i = lo; i < hi; i++)
chunked[static_cast<size_t>(i)] = std::sin(i * 0.001) * 1e6 + i;
});
ParallelFor(N, nthreads, [&](int i) {
stolen[static_cast<size_t>(i)] = std::sin(i * 0.001) * 1e6 + i;
});
CHECK(chunked == serial); // bit for bit, not approximately
CHECK(stolen == serial);
}
}
// A negative or zero count is a no-op rather than an error - callers pass a computed size.
TEST_CASE("ParallelFor_DoesNothingForAnEmptyRange", "[ParallelFor]") {
int calls = 0;
for (int n: {0, -1, -1000}) {
ParallelChunks(n, 8, [&](int, int) { calls++; });
ParallelFor(n, 8, [&](int) { calls++; });
}
CHECK(calls == 0);
}
// An exception thrown in a worker reaches the caller rather than terminating: the futures are waited
// on, so the other workers finish first and only then does it propagate.
TEST_CASE("ParallelFor_PropagatesAnException", "[ParallelFor]") {
CHECK_THROWS_AS(ParallelChunks(64, 4, [](int lo, int) {
if (lo == 0) throw std::runtime_error("from a chunk");
}), std::runtime_error);
CHECK_THROWS_AS(ParallelFor(64, 4, [](int i) {
if (i == 0) throw std::runtime_error("from an item");
}), std::runtime_error);
}