MINPK asks how MUCH of the expected profile is readable. It does not ask WHERE, and the two are not the same question. The renormalisation argument the rescue rests on - a fit over a subset of a normalised profile is unbiased - needs the pixels to go missing for reasons unrelated to the reflection. A gap, a mask or the edge of the sensor is such a reason: the loss is set by the detector, and the fit renormalises over what is left. A pixel invalidated BY THE FLUX IT SAW is not: it goes missing because the reflection was bright, and it is the peak. Measured on the combined fulls, against the mean of the complete observations of the same reflection, in the innermost resolution shell of the high-multiplicity control and of a weaker crystal: a rescued reflection whose unreadable pixel sits within a pixel of the predicted centre reads |I - <I>|/I of 0.50 and 0.53, against 0.073 and 0.212 for a complete observation - 6.8x and 2.5x - and carries several times the mean intensity of its shell. On the control that is 0.21% of the shell's observations supplying 1.77% of the R_meas numerator; on the weaker crystal 0.52% supplying 6.82%. Rescues that lost only rim pixels are unremarkable by the same measure, 1.19x and 0.88x. Dropping the peak-losers alone takes the shell's R_meas from 7.440% back to 7.315% (unrescued: 7.307%) and from 22.03% to 21.06% (unrescued: 21.28%) - which is the whole of the low-resolution R_meas the rescue cost, and on the second crystal rather more. Raw frames say what they are. The pattern is a dead-centre invalid pixel with 5878, 9875 and 27583 counts around it: the detector's per-frame invalid marker on the brightest reflections. MINPK cannot catch them because it cuts on profile MASS, and the peak of a broad spot is a few percent of the mass. So a second condition, in the loop that already measures the readable fraction: no unreadable pixel may carry more than 0.9 of the profile's own peak value. A fraction of the peak rather than a radius in pixels because the peak is as wide as the spot - for a Gaussian the cut is at sqrt(-2 ln f) sigma, 0.46 sigma here, which is the peak pixel alone where sigma is 0.8 px and the crest of the ridge where the profile is a bandwidth streak. Swept against the alternatives on two crystals: a fixed radius needs 1.0-1.5 px to do the same work and costs 3-9x more observations for it, and 0.5 px does not reach the peak of a sub-pixel-offset prediction at all; tightening the fraction to 0.5 or 0.2 buys nothing beyond 0.9 and costs 7x more. Six crystals, three detectors, against the rescue as it stands: the rule keeps 99.86-99.96% of the recovered observations and returns R_meas to its unrescued value or below (4.6 -> 4.5%, 6.7 -> 6.6%, 25.1 -> 25.0%), R_meas in the innermost shell likewise (2.7 -> 2.6%, 5.9 -> 5.3% against 5.4% unrescued, 16.5 -> 16.4%), <I/sigma> up or level everywhere, and every unique reflection the rescue won is kept. Raising --overlap-minpk to 0.90 instead reaches the same place on two of them and short of it on the third, while discarding 0.8% of the recovered observations rather than 0.05%. An elongated pink-beam profile on a 9M detector and an EIGER2 16M dataset are both untouched at 99.9%, so the crest protection does not over-reject a streak. One crystal is not improved: a dataset whose error model rugnux declines to fit for want of strong reflections, whose <I/sigma> is <= 0 in eight of its ten shells and whose R_meas is undefined in as many. There the rule costs about 3% of <I/sigma> in the one shell that has signal, reproducibly, on top of the 9% the rescue itself costs there - while its overall R_meas moves 1.5 points on nothing but the thread count. The parity test gains four sections. Unreadable pixels were only ever punched into empty sky, so neither the rescue nor this rule had any CPU/GPU coverage at all; they now go into the signal disks - the peak of every fifth reflection, ~1.1 sigma out of every seventh, the disk edge of every eleventh - for both profile modes, a box sum and an elongated stencil, with a check that the clipping actually costs reflections so the coverage cannot go quietly vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Full 38-crystal rotation battery against the rescue without this rule, both on the same base: ISa better 19 / worse 4, +0.73 CC1/2 better 3 / worse 1, +1.3 R_meas_lo better 4 / worse 3, -0.3 space groups unchanged for 17 770 observations, 0.09 % of the run total and under 2 % of what the rescue had won. The two crystals whose peak-loss population was measured beforehand land on their predicted values: a tetragonal reference goes R_meas_lo 2.7 -> 2.6 % and ISa 27.11 -> 27.42, a cubic insulin 5.9 -> 5.3 % and 20.34 -> 20.65. One crystal pays: a cubic case with 2381 unique reflections goes R_meas 8.8 -> 9.6 % and ISa 4.08 -> 3.49. It is the crystal in the battery with the fewest uniques, so its rescued population is small and its shell statistics are coarse, but the loss is real and not noise in the R_meas. The R_meas sum over the battery reads +1.3, of which +3.2 is one crystal whose R_meas moves 1.5 points on thread count alone; without it the sum is negative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
364 lines
20 KiB
C++
364 lines
20 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 <chrono>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
#include "../common/BraggIntegrationSettings.h"
|
|
#include "../common/DetectorSetup.h"
|
|
#include "../common/DiffractionExperiment.h"
|
|
#include "../common/Reflection.h"
|
|
#include "../image_analysis/bragg_integration/BraggIntegrationEngineCPU.h"
|
|
#include "../image_analysis/bragg_integration/BraggIntegrationEngineGPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h"
|
|
|
|
namespace {
|
|
|
|
// A grid of clean Gaussian spots on a flat background, each seeding one predicted reflection.
|
|
struct Scene {
|
|
std::vector<int32_t> image;
|
|
std::vector<Reflection> predicted;
|
|
size_t width = 0, height = 0;
|
|
};
|
|
|
|
Reflection MakeReflection(float x, float y, float d, int hkl) {
|
|
Reflection r{};
|
|
r.h = hkl; r.k = hkl; r.l = hkl;
|
|
r.predicted_x = x;
|
|
r.predicted_y = y;
|
|
r.d = d;
|
|
r.rlp = 1.0f;
|
|
r.partiality = 1.0f;
|
|
return r;
|
|
}
|
|
|
|
// companion_dx > 0 puts a second spot that many pixels beside every grid spot, so their r1 signal
|
|
// disks share pixels while the background rings still see clean sky - which is what a dense pattern
|
|
// actually looks like (crowded along one reciprocal axis, sparse across it).
|
|
// clip_spots punches unreadable pixels into the spots themselves rather than into empty sky: the
|
|
// centre of every 5th, a mid-profile pixel of every 7th and a disk-edge pixel of every 11th. That is
|
|
// the MINPK rescue's own case - a reflection kept and fitted over the pixels it has - and with it the
|
|
// peak-loss rule, which has to fire on the same reflections in both engines.
|
|
Scene BuildScene(size_t width, size_t height, int spacing = 60, float companion_dx = 0.0f,
|
|
bool clip_spots = false) {
|
|
Scene s;
|
|
s.width = width;
|
|
s.height = height;
|
|
s.image.assign(width * height, 12); // flat background
|
|
|
|
// A grid of spots, well separated so background rings do not overlap the neighbours' disks.
|
|
// A spread of intensities (some weak, some very strong) and a spread of d (so several resolution
|
|
// shells are populated) exercises the strong-spot selection, shell learning and the fit.
|
|
const int margin = 45;
|
|
int hkl = 1;
|
|
for (int gy = 0; margin + gy * spacing < static_cast<int>(height) - margin; ++gy) {
|
|
for (int gx = 0; margin + gx * spacing < static_cast<int>(width) - margin; ++gx) {
|
|
const float cx = static_cast<float>(margin + gx * spacing) + 0.3f; // sub-pixel offset
|
|
const float cy = static_cast<float>(margin + gy * spacing) - 0.2f;
|
|
const double amp = 150.0 + 60.0 * ((gx * 7 + gy * 13) % 30); // 150..1890
|
|
const double sigma = 1.3;
|
|
for (int dy = -6; dy <= 6; ++dy)
|
|
for (int dx = -6; dx <= 6; ++dx) {
|
|
const int x = static_cast<int>(std::lround(cx)) + dx;
|
|
const int y = static_cast<int>(std::lround(cy)) + dy;
|
|
if (x < 0 || y < 0 || x >= static_cast<int>(width) || y >= static_cast<int>(height)) continue;
|
|
const double ex = x - cx, ey = y - cy;
|
|
const double g = amp * std::exp(-(ex * ex + ey * ey) / (2.0 * sigma * sigma));
|
|
s.image[y * width + x] += static_cast<int32_t>(std::lround(g));
|
|
}
|
|
const float d = 1.4f + 0.12f * static_cast<float>((gx + gy) % 12); // 1.4..2.72 A
|
|
s.predicted.push_back(MakeReflection(cx, cy, d, hkl++));
|
|
if (companion_dx > 0.0f) {
|
|
const float ccx = cx + companion_dx;
|
|
for (int dy = -6; dy <= 6; ++dy)
|
|
for (int dx = -6; dx <= 6; ++dx) {
|
|
const int x = static_cast<int>(std::lround(ccx)) + dx;
|
|
const int y = static_cast<int>(std::lround(cy)) + dy;
|
|
if (x < 0 || y < 0 || x >= static_cast<int>(width) || y >= static_cast<int>(height)) continue;
|
|
const double ex = x - ccx, ey = y - cy;
|
|
const double g = 0.6 * amp * std::exp(-(ex * ex + ey * ey) / (2.0 * sigma * sigma));
|
|
s.image[y * width + x] += static_cast<int32_t>(std::lround(g));
|
|
}
|
|
s.predicted.push_back(MakeReflection(ccx, cy, d, hkl++));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A few masked (INT32_MIN) and saturated (INT32_MAX) pixels in background gaps to exercise the
|
|
// validity rejection in both engines identically.
|
|
for (int k = 0; k < 20; ++k) {
|
|
const size_t idx = (static_cast<size_t>(k) * 2654435761u) % s.image.size();
|
|
s.image[idx] = (k % 2) ? INT32_MIN : INT32_MAX;
|
|
}
|
|
|
|
if (clip_spots)
|
|
for (size_t n = 0; n < s.predicted.size(); ++n) {
|
|
int dx = 0, dy = 0;
|
|
if (n % 5 == 0) { dx = 0; dy = 0; } // the peak itself: the rule must reject
|
|
else if (n % 7 == 0) { dx = 1; dy = 1; } // ~1.1 sigma out: near the rule's boundary
|
|
else if (n % 11 == 0) { dx = 3; dy = -2; } // disk edge: MINPK keeps it, the rule does not fire
|
|
else continue;
|
|
const int x = static_cast<int>(std::lround(s.predicted[n].predicted_x)) + dx;
|
|
const int y = static_cast<int>(std::lround(s.predicted[n].predicted_y)) + dy;
|
|
if (x < 0 || y < 0 || x >= static_cast<int>(width) || y >= static_cast<int>(height)) continue;
|
|
s.image[y * width + x] = (n % 2) ? INT32_MAX : INT32_MIN;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// clip_nsigma 0 selects the OTHER background-ring estimator, the symmetric trim, so the two branches
|
|
// the CPU and GPU each implement separately are both covered.
|
|
DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
|
|
float clip_nsigma = 4.0f,
|
|
bool radial = false,
|
|
const DetectorSetup &det = DetJF(2),
|
|
float stencil_k = 0.0f,
|
|
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f,
|
|
OverlapMode overlap = OverlapMode::Off) {
|
|
DiffractionExperiment experiment(det); // DetJF(2) (small) keeps the correctness test fast
|
|
experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV)
|
|
.BeamX_pxl(400.0f).BeamY_pxl(400.0f);
|
|
experiment.BandwidthFWHM(bandwidth_fwhm);
|
|
BraggIntegrationSettings settings;
|
|
settings.Integrator(mode);
|
|
if (r1 > 0.0f)
|
|
settings.R1(r1).R2(r2).R3(r3);
|
|
if (clip_nsigma > 0.0f)
|
|
settings.BackgroundClipNSigma(clip_nsigma);
|
|
else
|
|
settings.BackgroundTrimFraction(0.10f);
|
|
settings.BackgroundRadialCorrection(radial);
|
|
settings.StencilKSigma(stencil_k);
|
|
settings.Overlap(overlap);
|
|
experiment.ImportBraggIntegrationSettings(settings);
|
|
return experiment;
|
|
}
|
|
|
|
void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
|
|
float clip_nsigma = 4.0f, bool radial = false, int spacing = 60,
|
|
float stencil_k = 0.0f,
|
|
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f,
|
|
OverlapMode overlap = OverlapMode::Off, float companion_dx = 0.0f,
|
|
bool clip_spots = false) {
|
|
const DiffractionExperiment experiment =
|
|
MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial, DetJF(2), stencil_k, r1, r2, r3,
|
|
overlap);
|
|
const size_t width = experiment.GetXPixelsNum();
|
|
const size_t height = experiment.GetYPixelsNum();
|
|
const size_t npixel = experiment.GetPixelsNum();
|
|
REQUIRE(npixel == width * height);
|
|
|
|
const Scene scene = BuildScene(width, height, spacing, companion_dx, clip_spots);
|
|
REQUIRE(scene.image.size() == npixel);
|
|
REQUIRE(scene.predicted.size() > 60);
|
|
|
|
// CPU reference
|
|
ImagePreprocessorBuffer cpu_image(npixel);
|
|
for (size_t i = 0; i < npixel; ++i)
|
|
cpu_image[i] = scene.image[i];
|
|
BraggIntegrationEngineCPU cpu(experiment);
|
|
const auto out_cpu = cpu.Run(cpu_image, scene.predicted, scene.predicted.size(), 5);
|
|
|
|
// GPU under test, identical input uploaded to the device
|
|
auto stream = std::make_shared<CudaStream>();
|
|
ImagePreprocessorBufferGPU gpu_image(npixel);
|
|
for (size_t i = 0; i < npixel; ++i)
|
|
gpu_image[i] = scene.image[i];
|
|
REQUIRE(cudaMemcpyAsync(gpu_image.getGPUBuffer(), gpu_image.getBuffer().data(),
|
|
npixel * sizeof(int32_t), cudaMemcpyHostToDevice, *stream) == cudaSuccess);
|
|
BraggIntegrationEngineGPU gpu(experiment, stream);
|
|
const auto out_gpu = gpu.Run(gpu_image, scene.predicted, scene.predicted.size(), 5);
|
|
|
|
// The ok/observed decisions are deterministic geometry, so both engines return the same set in
|
|
// the same (predicted-index) order. Intensities differ only by float rounding and the unordered
|
|
// atomic summation of the learned profile, so compare up to a small tolerance.
|
|
REQUIRE(out_gpu.size() == out_cpu.size());
|
|
REQUIRE(out_cpu.size() > 40);
|
|
if (clip_spots) {
|
|
// Guard against the coverage going vacuous: the punched pixels have to actually cost some
|
|
// reflections, or the two engines are being compared on a case neither of them meets.
|
|
const Scene clean_scene = BuildScene(width, height, spacing, companion_dx, false);
|
|
ImagePreprocessorBuffer clean_image(npixel);
|
|
for (size_t i = 0; i < npixel; ++i)
|
|
clean_image[i] = clean_scene.image[i];
|
|
BraggIntegrationEngineCPU clean_cpu(experiment);
|
|
const auto out_clean = clean_cpu.Run(clean_image, clean_scene.predicted,
|
|
clean_scene.predicted.size(), 5);
|
|
CHECK(out_cpu.size() < out_clean.size());
|
|
}
|
|
for (size_t i = 0; i < out_cpu.size(); ++i) {
|
|
INFO("mode " << static_cast<int>(mode) << " reflection " << i << " hkl " << out_cpu[i].h);
|
|
CHECK(out_gpu[i].h == out_cpu[i].h);
|
|
CHECK(out_gpu[i].image_number == out_cpu[i].image_number);
|
|
CHECK(out_gpu[i].bkg == Catch::Approx(out_cpu[i].bkg).epsilon(0.02).margin(0.5));
|
|
CHECK(out_gpu[i].I == Catch::Approx(out_cpu[i].I).epsilon(0.03).margin(2.0));
|
|
CHECK(out_gpu[i].sigma == Catch::Approx(out_cpu[i].sigma).epsilon(0.03).margin(0.5));
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("BraggIntegrationEngineGPU_MatchesCPU") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping BraggIntegrationEngineGPU_MatchesCPU");
|
|
return;
|
|
}
|
|
|
|
SECTION("BoxSum") { CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt); }
|
|
SECTION("ProfileGaussian mono") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt); }
|
|
SECTION("ProfileGaussian broadband") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.03f); }
|
|
// An elongated background ring: the classification, the bounding box, the neighbour mask and the
|
|
// shared-memory radial window all become reflection-dependent, and the two engines have to agree
|
|
// on every one of them. Spots spaced wider so the grown rings stay clear of the neighbours -
|
|
// what is under test is the stencil, not the crowding.
|
|
SECTION("ProfileGaussian stencil broadband") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, false, 120, 3.0f);
|
|
}
|
|
// A monochromatic beam has no streak, so k_sigma changes nothing - the point of the section is
|
|
// that both engines agree that it changes nothing.
|
|
SECTION("ProfileGaussian stencil mono") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 120, 3.0f);
|
|
}
|
|
// Crowded: at the default spacing the grown rings DO overlap their neighbours, so the elongated
|
|
// neighbour mask, the shrinking background-pixel count and the n_bkg acceptance gate are all in
|
|
// play. That is the case the feature meets at high resolution, and the wide-spacing sections
|
|
// above deliberately avoid it.
|
|
SECTION("ProfileGaussian stencil crowded") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.02f, 4.0f, false, 60, 4.0f);
|
|
}
|
|
SECTION("BoxSum stencil") {
|
|
CompareCpuVsGpu(IntegratorMode::BoxSum, 0.005f, 4.0f, false, 120, 3.0f);
|
|
}
|
|
// The trimmed-mean ring is sorted in a fixed-size shared buffer on the GPU; an elongated ring
|
|
// holds more pixels, so both engines have to fall back to the plain mean at the same place.
|
|
SECTION("ProfileGaussian stencil trim") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 0.0f, false, 120, 3.0f);
|
|
}
|
|
// A ring wide enough to overflow the GPU's fixed trimmed-mean buffer, so the fallback to the
|
|
// plain ring mean is exercised - and has to happen in both engines at the same reflection. The
|
|
// growth cap keeps the default 6/10 ring under the buffer at any bandwidth, so this needs the
|
|
// wider stills radii to be reachable at all.
|
|
SECTION("ProfileGaussian stencil trim overflow") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.04f, 0.0f, false, 120, 4.0f, 6.0f, 8.0f, 12.0f);
|
|
}
|
|
SECTION("ProfileEmpirical") { CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt); }
|
|
// Overlap treatment: companions 4 px apart put each reflection's centre inside its neighbour's
|
|
// signal disk, so the owner map, the excluded pixels and the profile fraction the two modes act on
|
|
// all have to come out the same in both engines - the ownership atomic in particular is settled by
|
|
// an atomicMin on the GPU and a serial minimum on the CPU.
|
|
SECTION("ProfileGaussian overlap exclude") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Exclude, 4.0f);
|
|
}
|
|
SECTION("ProfileGaussian overlap reject") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Reject, 4.0f);
|
|
}
|
|
SECTION("BoxSum overlap reject") {
|
|
CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Reject, 4.0f);
|
|
}
|
|
// Nothing shares a pixel at this spacing, so an overlap treatment has to leave the result alone.
|
|
SECTION("ProfileGaussian overlap inert") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Exclude);
|
|
}
|
|
SECTION("ProfileGaussian mono trim") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 0.0f); }
|
|
// Unreadable pixels inside the signal disks themselves: the MINPK rescue keeps the reflection and
|
|
// fits it over what is left, and the peak-loss rule throws back the ones that lost the profile's
|
|
// maximum. Both decisions are per-reflection cuts on a reduction over the profile grid, computed
|
|
// independently in the two engines (serial max vs an atomicMax on the float bit pattern), so they
|
|
// have to reject exactly the same reflections - a mismatch shows up as a size mismatch here.
|
|
SECTION("ProfileGaussian clipped disks") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
|
}
|
|
SECTION("ProfileEmpirical clipped disks") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
|
}
|
|
// The same, with an elongated profile: the peak is then a ridge, so the fraction-of-peak test has
|
|
// to protect a crest rather than one pixel, and the grid it reduces over is reflection-dependent.
|
|
SECTION("ProfileGaussian clipped disks stencil") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, false, 120, 3.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
|
}
|
|
SECTION("BoxSum clipped disks") {
|
|
CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, false, 60, 0.0f,
|
|
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
|
}
|
|
// The radial background curvature correction is computed independently in the two engines
|
|
// (host loop vs radial_correct kernel), so it needs its own parity coverage.
|
|
SECTION("BoxSum radial") { CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, true); }
|
|
SECTION("ProfileGaussian radial") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, true); }
|
|
// With an elongated ring the radial-curvature kernel is a table indexed per reflection, and the
|
|
// shared window boxsum accumulates the curve in is sized from the widest aperture on the
|
|
// detector. Both are computed independently in the two engines.
|
|
SECTION("ProfileGaussian radial stencil") {
|
|
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, true, 120, 3.0f);
|
|
}
|
|
}
|
|
|
|
// Hidden ([.]) benchmark: the raison d'etre of the GPU port is < 2 ms/frame (vs ~142 ms on the CPU
|
|
// for ProfileIntegrate2D). Run explicitly with: ./jfjoch_test "[bragg_bench]"
|
|
TEST_CASE("BraggIntegrationEngineGPU_Benchmark", "[.][bragg_bench]") {
|
|
if (get_gpu_count() == 0) {
|
|
WARN("No CUDA GPU present. Skipping benchmark");
|
|
return;
|
|
}
|
|
// The overlap treatment is priced here too: it adds an owner map over the whole frame plus one
|
|
// atomic per claimed pixel, so what it costs is a property of the frame more than of the crowding.
|
|
for (OverlapMode ovl : {OverlapMode::Off, OverlapMode::Reject, OverlapMode::Exclude}) {
|
|
const DiffractionExperiment experiment = MakeExperiment(IntegratorMode::ProfileGaussian, std::nullopt,
|
|
4.0f, false, DetJF4M(), 0.0f, 0.0f, 0.0f, 0.0f,
|
|
ovl);
|
|
const size_t width = experiment.GetXPixelsNum();
|
|
const size_t height = experiment.GetYPixelsNum();
|
|
const size_t npixel = experiment.GetPixelsNum();
|
|
REQUIRE(npixel == width * height);
|
|
|
|
auto stream = std::make_shared<CudaStream>();
|
|
BraggIntegrationEngineGPU gpu(experiment, stream);
|
|
for (int spacing : {28, 60}) {
|
|
const Scene scene = BuildScene(width, height, spacing);
|
|
const size_t nrefl = scene.predicted.size();
|
|
|
|
ImagePreprocessorBufferGPU gpu_image(npixel);
|
|
for (size_t i = 0; i < npixel; ++i) gpu_image[i] = scene.image[i];
|
|
REQUIRE(cudaMemcpyAsync(gpu_image.getGPUBuffer(), gpu_image.getBuffer().data(),
|
|
npixel * sizeof(int32_t), cudaMemcpyHostToDevice, *stream) == cudaSuccess);
|
|
cudaStreamSynchronize(*stream);
|
|
|
|
auto run = [&] { return gpu.Run(gpu_image, scene.predicted, nrefl, 0); };
|
|
for (int i = 0; i < 5; ++i) run(); // warm-up (allocations, JIT)
|
|
|
|
const int iters = 100;
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
size_t observed = 0;
|
|
for (int i = 0; i < iters; ++i) observed += run().size();
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
const double ms = std::chrono::duration<double, std::milli>(t1 - t0).count() / iters;
|
|
|
|
BraggIntegrationEngineCPU cpu(experiment);
|
|
ImagePreprocessorBuffer cpu_image(npixel);
|
|
for (size_t i = 0; i < npixel; ++i) cpu_image[i] = scene.image[i];
|
|
const auto c0 = std::chrono::steady_clock::now();
|
|
const size_t cpu_observed = cpu.Run(cpu_image, scene.predicted, nrefl, 0).size();
|
|
const auto c1 = std::chrono::steady_clock::now();
|
|
const double cpu_ms = std::chrono::duration<double, std::milli>(c1 - c0).count();
|
|
|
|
WARN((int) ovl << " | " << width << "x" << height << " | " << nrefl << " refl ("
|
|
<< observed / iters << " obs) | GPU " << ms << " ms | CPU " << cpu_ms << " ms ("
|
|
<< cpu_observed << " obs) | speedup " << cpu_ms / ms << "x");
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|