Files
Jungfraujoch/image_analysis/beam_stop/ShadowFinder.h
T
jungfrauandClaude Opus 5 6368c00173 Decode and accumulate the beam-stop projection on the GPU
The pre-scan decompressed its frames on the host and folded them into a
per-pixel projection there. On a 16M-pixel detector that is 60 frames of 72 MB
to decompress and 20 bytes per pixel to read and write back per frame - about
40 GB of memory traffic - and it was the whole cost of the phase once the mask
was no longer the bottleneck.

Only the compressed chunk crosses PCIe now. BSLZ4DecoderGPU already exposes the
raw decoded bytes (Decode(), the path its own tests use), which is what this
needs: the projection is defined on the RAW STORED COUNTS with the pixel type's
sentinel skipped, not on the preprocessed image, so nothing here goes through
the preprocessor. Sums, maxima and counts are integers, so the device result is
identical to the host's rather than merely close.

Frames are folded in batches of four. The fold reads and writes the whole
accumulator whatever the batch holds, so per frame it was spending most of the
bandwidth on the accumulator rather than on the data; four is where that stops
mattering, and every frame beyond it is another full frame of device memory,
which costs more in cudaMalloc - device-synchronizing - than it saves.

The accumulator is built on a thread of its own. It allocates and clears
several hundred megabytes, and doing that in the constructor stalled the caller
before it had read its first frame.

Frames the device cannot take - anything but bitshuffle+LZ4 - still go to a host
shard, so a run mixing compressions needs no second code path, and a build
without CUDA is unchanged.

RotationScaleMergeGPU set the CUDA device in its constructor and never put it
back. CUDA's current device is per-thread, so that silently re-pinned the
calling thread for the rest of its life, and the destructor freed several
gigabytes against whatever device happened to be current by then - CudaDevicePtr
records no device of its own. Every entry point now sets the device on entry and
restores it on exit.

ParallelFor/ParallelChunks moved to common/ParallelFor.h; two files had copies
and a third wants them.

Measured on a 16M-pixel rotation dataset: pre-scan 4.78 s -> 2.37 s -> ~2.0 s,
shadow unchanged at 139126 pixels (22143 on a 2M-pixel dataset). Full 24-crystal
battery: same space group on all 24, none failed, 15m32s -> 14m49s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:38:26 -04:00

110 lines
5.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <future>
#include <memory>
#include <mutex>
#include <vector>
#include "../../common/CompressedImage.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/JFJochMessages.h"
#include "../../common/PixelMask.h"
#ifdef JFJOCH_USE_CUDA
#include "ShadowAccumulatorGPU.h"
#endif
// Finds the beam-stop shadow - the central disk and the holder arm - from a set of images,
// mirroring the accumulate-then-finalize shape of DarkMaskAnalysis: feed frames with
// AddImage(), then read the mask once with GetMask(). The mask is in converted geometry
// and is 1 where the beam stop shadows the detector.
//
// The shadow is a place where the background is missing, so it is found by comparing each
// pixel's mean against the typical background at the same radius - the median over its ring,
// taken over the pixels not already known to be shadowed. That comparison holds wherever the
// ring still has unshadowed pixels to measure. Where it does not - a ring lying wholly inside
// the stop - there is nothing to compare against, and such a ring is shadow in its entirety.
//
// The background belongs to the beam and the shadow to the stop, and the two are not concentric:
// the stop sits off the beam by a sizeable fraction of its own radius. Only the per-ring
// comparison is used, so nothing here assumes they share a centre.
//
// Frames are chosen by the caller; the detection needs enough of them that the background
// is counted rather than guessed (see MIN_EXPECTED_COUNTS in the .cpp).
// Thread-safe: workers call AddImage concurrently, each naming a shard of its own (see
// SetShardCount) - so no two threads touch the same accumulator and nothing is locked while
// an image is added. The shards are summed when the projection is read.
class ShadowFinder {
mutable std::mutex m;
const int width;
const int height;
const float beam_x;
const float beam_y;
std::vector<uint32_t> pixel_mask; // pixels already masked carry no background to test
// Per-pixel projection over the frames added so far (converted geometry). One set per shard:
// the sums and counts are integers, so summing the shards is exact and the result does not
// depend on how the frames were spread over them.
struct Projection {
std::vector<int64_t> max_value;
std::vector<int64_t> sum_value;
std::vector<uint32_t> valid_count;
uint32_t frames = 0;
};
std::vector<Projection> shards;
#ifdef JFJOCH_USE_CUDA
// Present when a GPU is available. Frames it can decode are accumulated there instead of on the
// host - only the compressed chunk crosses PCIe - and its projection is folded in with the
// shards when the mask is read. Frames it cannot take (anything but bitshuffle+LZ4) still go to
// a host shard, so a run mixing compressions is handled without a second code path.
// Built on a thread of its own: it allocates and clears several hundred megabytes of device
// memory, and cudaMalloc synchronises the whole device, so doing it in the constructor would
// stall the caller before it has read its first frame. The first AddImage waits for it, by
// which time the reads have been running for a while.
mutable std::future<std::unique_ptr<ShadowAccumulatorGPU>> gpu_pending;
mutable std::unique_ptr<ShadowAccumulatorGPU> gpu;
mutable std::mutex gpu_mutex;
// The accumulator once its construction has finished, or null if there is none.
[[nodiscard]] ShadowAccumulatorGPU *Gpu() const;
#endif
template<class T> void Add(const T *ptr, Projection &p);
// Sum the shards into one projection. max_value is only taken from a shard that actually
// counted the pixel - a shard that never saw it holds 0, which would beat a genuinely
// negative maximum.
[[nodiscard]] Projection Reduce() const;
public:
ShadowFinder(const DiffractionExperiment &experiment, const PixelMask &mask);
// Give each worker a shard to accumulate into. Must be called before the first AddImage,
// and costs 20 bytes per pixel per shard.
void SetShardCount(size_t n);
// Accumulate one full converted-geometry image into shard `shard`. Gap / masked pixels
// (the pixel type's sentinel extreme) are skipped. `buffer` is scratch space for
// decompression, reused across the calls of one worker.
void AddImage(const DataMessage &data, std::vector<uint8_t> &buffer, size_t shard = 0);
// Compute the shadow mask (1 = shadow, 0 = keep), of the converted pixel count.
// Recomputed from the accumulators on each call - meant to be called once at the end.
// nthreads = 0 asks for all hardware threads. The per-pixel passes over a 16M-pixel detector
// dominate this, and they are all exactly parallel.
[[nodiscard]] std::vector<uint32_t> GetMask(size_t nthreads = 0) const;
// Mean counts per pixel over the frames added, NAN where nothing was counted. This is the
// projection GetMask() tests, so anything else that wants the background before indexing
// gets it without reading the frames a second time.
[[nodiscard]] std::vector<float> GetMeanProjection() const;
[[nodiscard]] uint32_t GetFrameCount() const;
};