From 4e15fba98a9e4d9e8a9d401d14327a51573226f5 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sun, 23 Aug 2026 12:51:53 +0200 Subject: [PATCH] Pin what the beam-stop parallelisation must not change The mask rewrite replaces a BFS dilation with a separable box-max, a full-frame border flood with a bounded one, and three median passes with a single bin-and-rank - four separate equivalence arguments, none of them obvious by inspection. The reference count in the first test was taken from the serial implementation before any of it was picked, and is unchanged by all three commits. Two properties the reference alone cannot cover: the mask must not depend on how many threads split the per-pixel passes, and it must not depend on which shard a frame was accumulated into - including the maximum, which lives in a single shard when the reflection is on one frame. A four-armed scene is invariant under a quarter turn and so must its mask be, which is the sharpest probe available for the x and y passes being written differently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfYvJT5Nb71suJCowRBn5z --- docs/CHANGELOG.md | 3 +- tests/ShadowFinderTest.cpp | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce12b0d1..bb8ec3d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,7 +25,8 @@ This is an UNSTABLE release. It includes many experimental features, as well as * The image stream can carry the sample transformation chain (`transformations`, in the END message) in mounting order; a producer that does not send it gets the same chain built by the writer. * The writer refuses a stream whose start message declares a different pixel format than its images carry. * `images_per_file` is chosen from the acquisition when it is not given: a rotation sweep of at most 20000 images goes into a single data file, a grid scan splits on whole fast-axis rows, and stills and serial keep 1000. -* rugnux: the beam-stop pre-scan is faster - it reads and accumulates its frames on several threads. The shadow it finds is unchanged. +* rugnux: beam-stop detection is substantially faster - the pre-scan reads and accumulates its frames on several threads, and the mask is built in one pass over the image rather than by repeated searches. The shadow it finds is unchanged. +* rugnux: the `_process.h5` file is written on a thread of its own, so processing no longer waits on it. * CUDA 12 builds now contain device code for Volta, so the RHEL 8 packages and the portable Linux `.tgz` run on a V100; the CUDA 13 artefacts (RHEL 9, Ubuntu, Windows) remain Turing and newer. * Documentation: a security page, and the supported GPU generations and minimum NVIDIA driver version of every released artefact. diff --git a/tests/ShadowFinderTest.cpp b/tests/ShadowFinderTest.cpp index d4d35f79..92f36f74 100644 --- a/tests/ShadowFinderTest.cpp +++ b/tests/ShadowFinderTest.cpp @@ -105,3 +105,73 @@ TEST_CASE("ShadowFinder_FindsAnInjectedBeamStop", "[ShadowFinder]") { // several stages downstream. CHECK(std::count(mask.begin(), mask.end(), 1u) == 2669); } + +// The per-pixel passes are split across threads, so where the split falls must not be visible in the +// answer. The x pass and the y pass of the dilation and of the pooled sum are written differently - +// one a plain scan, the other blocked by column - so an x/y asymmetry is the plausible regression. +TEST_CASE("ShadowFinder_MaskDoesNotDependOnTheThreadCount", "[ShadowFinder]") { + const DiffractionExperiment x = TestExperiment(); + const PixelMask pixel_mask(x); + ShadowFinder finder(x, pixel_mask); + + std::vector> frames; + Feed(finder, frames, /*cross=*/false, /*reflection_on_first=*/true); + + const auto one = finder.GetMask(1); + CHECK(finder.GetMask(3) == one); + CHECK(finder.GetMask(8) == one); +} + +// Workers accumulate into shards of their own and the shards are summed when the projection is read, +// so which worker saw which frame must not reach the answer - including the maximum, which only one +// shard holds when the reflection is on a single frame. +TEST_CASE("ShadowFinder_ShardingDoesNotChangeTheProjection", "[ShadowFinder]") { + const DiffractionExperiment x = TestExperiment(); + const PixelMask pixel_mask(x); + + ShadowFinder serial(x, pixel_mask); + ShadowFinder sharded(x, pixel_mask); + sharded.SetShardCount(4); + + std::vector> frames; + std::vector buffer; + for (int f = 0; f < NFRAMES; f++) { + frames.push_back(Scene(/*cross=*/false, /*reflection=*/f == 0)); + DataMessage msg{}; + msg.image = CompressedImage(frames.back(), W, H); + serial.AddImage(msg, buffer, 0); + sharded.AddImage(msg, buffer, static_cast(f) % 4); + } + + CHECK(serial.GetFrameCount() == sharded.GetFrameCount()); + + const auto a = serial.GetMeanProjection(); + const auto b = sharded.GetMeanProjection(); + REQUIRE(a.size() == b.size()); + // NAN marks a pixel nothing counted, and NAN != NAN, so compare the bits rather than the values. + CHECK(memcmp(a.data(), b.data(), a.size() * sizeof(float)) == 0); + + // The reflection is on one frame, so its maximum lives in a single shard. If the fold lost it, + // the mask would swallow the reflection instead of giving it back. + CHECK(serial.GetMask(1) == sharded.GetMask(1)); + CHECK(sharded.GetMask(1)[I(C - 14, C - 2)] == 0); +} + +// Four opaque arms and a centred disk: the scene is invariant under a quarter turn, so the mask must +// be too, whatever the thread count. +TEST_CASE("ShadowFinder_ASymmetricSceneGivesASymmetricMask", "[ShadowFinder]") { + const DiffractionExperiment x = TestExperiment(); + const PixelMask pixel_mask(x); + ShadowFinder finder(x, pixel_mask); + + std::vector> frames; + Feed(finder, frames, /*cross=*/true, /*reflection_on_first=*/false); + + const auto mask = finder.GetMask(8); + for (int y = 0; y < H; y++) { + for (int xi = 0; xi < W; xi++) { + REQUIRE(mask[I(xi, y)] == mask[I(y, xi)]); // transpose + REQUIRE(mask[I(xi, y)] == mask[I(W - 1 - y, xi)]); // quarter turn + } + } +}