Files
Jungfraujoch/tests/BeamCenterFFTGPUTest.cpp
T
leonarski_f 6326c8b301 beam centre: the capture's transforms run on the GPU, and the CPU stays the fallback
BeamCenterFFTScore is split the way the FFT indexer is: an engine interface with a cuFFT
implementation and an fftw3f one, chosen by whether CUDA is compiled in, a device is visible
and the card has room for the image. Everything that decides anything - the preparation, the
masked Pearson, the shortlist and the margins - is shared, so the engines can differ only in
how the four convolutions are computed, and the parity test compares two shortlists rather
than two answers.

The whole-detector transform was the entire added cost of the capture (4-15 s a run on CPU,
all of it the transforms). On the device it is milliseconds, so the composition is now
cheaper than the walk it replaced rather than dearer - which is what makes 2x2 binning, the
other way out, unnecessary: full resolution is affordable and the binned capture was measured
to pick a neighbouring peak on one dataset of 51.

Device discipline, because the card is shared with the run's own analysis workers: the four
convolution surfaces are brought back to the host and combined there, so the device holds
only one real buffer and three spectra; the spectrum of the image is reused for its square;
plans and buffers are created inside the call that needs them and freed when it returns; and
an image that would not fit is scored on the CPU instead.

Tests, none of which need a GPU or a dataset: the autoconvolution identity (an exactly
symmetric image is recovered at integer and half-pixel centres, scoring exactly 1), shift
equivariance, the four convolutions against brute force, the no-variance overlap that
VARIANCE_FLOOR exists for, the smooth pad, GPU against CPU, and the composition - a walk
seeded at the capture where the walk alone declines, the fallback to the capture alone at
BEAM_CENTER_CAPTURE_SIGMA_PXL, and the beam stop being blanked out of the scored image.
2026-09-13 07:37:05 +02:00

149 lines
6.6 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 "../common/CUDAWrapper.h"
#ifdef JFJOCH_USE_CUDA
#include <cmath>
#include <cstdint>
#include <random>
#include <vector>
#include "../image_analysis/geom_refinement/BeamCenterFFTCPU.h"
#include "../image_analysis/geom_refinement/BeamCenterFFTGPU.h"
namespace {
// An isotropic scattered background about (cx, cy) with a solvent ring, a module gap and a darker
// sextant - the same shape of image the corpus measurement runs on, at a size a test can afford.
std::vector<float> SynthesiseBackground(int64_t w, int64_t h, float cx, float cy) {
std::vector<float> mean(static_cast<size_t>(w * h), NAN);
std::mt19937 rng(20260913);
std::normal_distribution<float> gauss(0.0f, 1.0f);
for (int64_t y = 0; y < h; y++) {
if (y >= 500 && y < 520)
continue; // module gap
for (int64_t x = 0; x < w; x++) {
const float r = std::hypot(static_cast<float>(x) - cx, static_cast<float>(y) - cy);
const float t = (r - 330.0f) / 40.0f;
float value = 130.0f * std::exp(-r / 260.0f) + 50.0f * std::exp(-0.5f * t * t) + 6.0f;
const float phi = std::atan2(static_cast<float>(y) - cy, static_cast<float>(x) - cx);
if (phi > 0.0f && phi < 1.0f)
value *= 0.8f;
mean[static_cast<size_t>(y * w + x)] = value + gauss(rng) * std::sqrt(value / 60.0f);
}
}
return mean;
}
void CompareShortlist(const std::vector<BeamCenterFFTCandidate> &gpu,
const std::vector<BeamCenterFFTCandidate> &cpu, const char *what) {
INFO(what);
REQUIRE(gpu.size() == cpu.size());
for (size_t i = 0; i < cpu.size(); i++) {
INFO("candidate " << i);
// The positions are what a caller consumes, and they are a grid index: they must be the
// same index, not a nearby one.
CHECK(((gpu[i].beam_x_pxl == cpu[i].beam_x_pxl)
|| (std::isnan(gpu[i].beam_x_pxl) && std::isnan(cpu[i].beam_x_pxl))));
CHECK(((gpu[i].beam_y_pxl == cpu[i].beam_y_pxl)
|| (std::isnan(gpu[i].beam_y_pxl) && std::isnan(cpu[i].beam_y_pxl))));
CHECK(gpu[i].score == Catch::Approx(cpu[i].score).epsilon(1e-5));
}
}
} // namespace
// The GPU engine exists to make the capture affordable, not to answer differently: the two engines
// share the preparation, the masked Pearson, the shortlist and the margins, and differ only in how
// the four convolutions are computed. So the shortlists must be the same grid positions and the
// margins must agree - a capture that depended on which engine ran it would be a capture that could
// not be reasoned about.
TEST_CASE("BeamCenterFFTGPU_MatchesTheCPUEngine", "[BeamCenter][BeamCenterFFTGPU]") {
if (get_gpu_count() == 0) {
WARN("No CUDA GPU present. Skipping BeamCenterFFTGPU_MatchesTheCPUEngine");
return;
}
const int64_t w = 1100, h = 900;
const float cx = 397.25f, cy = 511.5f;
const auto mean = SynthesiseBackground(w, h, cx, cy);
BeamCenterFFTCPU cpu_engine;
BeamCenterFFTGPU gpu_engine;
const BeamCenterFFTSettings settings;
const auto cpu = BeamCenterFFTScore(w, h, mean, settings, cpu_engine);
const auto gpu = BeamCenterFFTScore(w, h, mean, settings, gpu_engine);
REQUIRE(!cpu.point.empty());
CompareShortlist(gpu.point, cpu.point, "point surface");
CompareShortlist(gpu.line_x, cpu.line_x, "x line mirror");
CompareShortlist(gpu.line_y, cpu.line_y, "y line mirror");
// The margin is the surface's self-diagnostic, and it is a difference of two scores that sit
// within a fraction of a percent of each other - the quantity most exposed to a change of
// transform.
CHECK(gpu.margin_point == Catch::Approx(cpu.margin_point).margin(1e-5));
CHECK(gpu.margin_line_x == Catch::Approx(cpu.margin_line_x).margin(1e-5));
CHECK(gpu.margin_line_y == Catch::Approx(cpu.margin_line_y).margin(1e-5));
}
// The same identity test the CPU engine carries, on the device: an exactly centrosymmetric image
// scores exactly 1 at its centre and the capture lands on that grid point. It is the cheapest
// check there is for an indexing or sign error that only the device path has.
TEST_CASE("BeamCenterFFTGPU_RecoversAnExactlySymmetricCentre", "[BeamCenter][BeamCenterFFTGPU]") {
if (get_gpu_count() == 0) {
WARN("No CUDA GPU present. Skipping BeamCenterFFTGPU_RecoversAnExactlySymmetricCentre");
return;
}
// Keyed on the canonical member of the +- pair, so the image is exactly its own mirror about
// (cx, cy) - the same construction the CPU identity test uses.
const int64_t w = 220, h = 180;
const float cx = 83.5f, cy = 61.5f;
std::vector<float> mean(static_cast<size_t>(w * h));
for (int64_t y = 0; y < h; y++)
for (int64_t x = 0; x < w; x++) {
int64_t dx = std::lround(2.0f * (static_cast<float>(x) - cx));
int64_t dy = std::lround(2.0f * (static_cast<float>(y) - cy));
if (dy < 0 || (dy == 0 && dx < 0)) {
dx = -dx;
dy = -dy;
}
uint64_t k = static_cast<uint64_t>(dx) * 0x9e3779b97f4a7c15ULL
+ static_cast<uint64_t>(dy) * 0xc2b2ae3d27d4eb4fULL;
k ^= k >> 31;
k *= 0xbf58476d1ce4e5b9ULL;
k ^= k >> 29;
mean[static_cast<size_t>(y * w + x)] = 100.0f + static_cast<float>(k % 4096u) * 0.25f;
}
BeamCenterFFTGPU engine;
const auto r = BeamCenterFFTScore(w, h, mean, BeamCenterFFTSettings{}, engine);
REQUIRE(!r.point.empty());
CHECK(r.point[0].beam_x_pxl == cx);
CHECK(r.point[0].beam_y_pxl == cy);
CHECK(r.point[0].score == Catch::Approx(1.0f).margin(1e-4));
}
// The card is shared, so the engine has to be able to say that an image will not fit before it
// starts allocating - that is what sends the capture to the CPU instead of taking the run down.
TEST_CASE("BeamCenterFFTGPU_SizesItsOwnFootprint", "[BeamCenter][BeamCenterFFTGPU]") {
if (get_gpu_count() == 0) {
WARN("No CUDA GPU present. Skipping BeamCenterFFTGPU_SizesItsOwnFootprint");
return;
}
// A 16 Mpixel detector is the largest this runs on, and it has to be a bounded cost.
CHECK(BeamCenterFFTGPU::DeviceMemoryNeeded(4148, 4362) < (3ull << 30));
// Bigger images need more.
CHECK(BeamCenterFFTGPU::DeviceMemoryNeeded(4148, 4362)
> BeamCenterFFTGPU::DeviceMemoryNeeded(1030, 1064));
// Nothing on this card can hold a hundred-gigapixel image.
CHECK(!BeamCenterFFTGPU::FitsInDeviceMemory(300000, 300000));
}
#endif