Files
Jungfraujoch/tests/CorrectionSurfaceGPUTest.cpp
T
leonarski_fandClaude Opus 5.5 eb1e6fa410 RotationScaleMerge: correction-surface fit passes on the GPU, same bits
ApplyCellSurface (detector modulation, time x detector, crystal-frame SH and
goniometer-frame absorption) spends most of its time in two passes per round:
the per-group reference sums and the per-(block, cell) fit sums. With a GPU
both now run on the device (RotationScaleMergeGPU::Surface*) over the same
terms in the same order:
- reference: one thread per ASU group, walking a group-order permutation of
  the terms in fulls order;
- fit: each subset cut into the host's reduction blocks with every block's
  terms ordered by cell (stable counting sort, host); a per-term kernel forms
  w*Is, w*Iref, Iref and one thread per (block, cell) runs the two fma chains;
  the block slots are added per cell in block order.
Every rounding is spelled out (__dmul_rn/__dadd_rn/fma) to be the one the host
build makes: GCC at -march=x86-64-v3 fuses swI's multiply-add only in the
parity-filtered copy of the reference loop, and both fit sums.
Host side, exact on both paths: the 19 serial nth_element selections of the
shell edges become one parallel sort (same order statistics), the per-term shell
lookup runs on all threads, and the gate's per-shell CC is one walk over the
groups instead of one per shell.

Exact: p.mtz md5 identical to the oracle on myob/cytc/thau x10sa, GPU build
(all CUDA architectures) and CPU build. CorrectionSurfaceGPU test checks the
device sums bit for bit against an explicitly rounded host loop.
Measured (cytc/thau, two interleaved A/B pairs, box at load 13-25 from sibling
work): ApplyCellSurface host core-seconds -83% on cytc; SG adoption -> writing
reflections 4.58->3.75 and 3.85->2.54 s (cytc), 3.15->2.28 and 2.29->2.03 s
(thau); RSM final merge -0.7..-0.9 s and P1 cross-check -1.5..-1.8 s on cytc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
2026-10-03 13:35:43 +02:00

168 lines
7.1 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 <cstring>
#include <random>
#include <vector>
#include "../common/ParallelFor.h"
#include "../image_analysis/scale_merge/RotationScaleMergeGPU.h"
namespace {
using Term = RotationScaleMergeGPU::SurfaceTerm;
// The roundings RotationScaleMerge::ApplyCellSurface makes on the host (x86-64-v3 build), spelled out:
// a volatile result is rounded on its own and never fused into the next operation, std::fma is fused.
double Mul(double a, double b) { volatile double r = a * b; return r; }
double Add(double a, double b) { volatile double r = a + b; return r; }
constexpr int SURFACE_BLOCK = 32768; // ApplyCellSurface's reduction block
struct Surface {
int n_groups = 0, ncell = 0;
std::vector<Term> term;
std::vector<uint8_t> parity;
std::vector<int32_t> gperm, gstart;
std::vector<int32_t> sel[3]; // even, odd, all - in term order
};
Surface MakeSurface(int n_terms, int n_groups, int ncell, uint32_t seed) {
std::mt19937 rng(seed);
std::uniform_int_distribution<int> group(0, n_groups - 1), cell(0, ncell - 1), bit(0, 1);
std::uniform_real_distribution<float> u(0.0f, 1.0f);
Surface s;
s.n_groups = n_groups;
s.ncell = ncell;
for (int i = 0; i < n_terms; ++i) {
// Negative intensities, and now and then a sigma of zero: both reach the host sums as they are.
const float I = 1000.0f * u(rng) - 100.0f;
const float sigma = (i % 997 == 0) ? 0.0f : 1.0f + 30.0f * u(rng);
// Every 50th group gets no terms at all, so its reference is empty.
int g = group(rng);
if (g % 50 == 0) g = (g + 1) % n_groups;
s.term.push_back({I, sigma, 0.5f + u(rng), 1.0f + 3.0f * u(rng), cell(rng), g});
s.parity.push_back(static_cast<uint8_t>(bit(rng)));
s.sel[s.parity.back()].push_back(i);
s.sel[2].push_back(i);
}
s.gstart.assign(n_groups + 1, 0);
for (const Term &t : s.term) ++s.gstart[t.group + 1];
for (int g = 0; g < n_groups; ++g) s.gstart[g + 1] += s.gstart[g];
s.gperm.resize(n_terms);
std::vector<int32_t> fill(s.gstart.begin(), s.gstart.end() - 1);
for (int i = 0; i < n_terms; ++i) s.gperm[fill[s.term[i].group]++] = i;
return s;
}
void HostReference(const Surface &s, int parity, const std::vector<double> &A,
std::vector<double> &sw, std::vector<double> &swI) {
sw.assign(s.n_groups, 0.0);
swI.assign(s.n_groups, 0.0);
for (int g = 0; g < s.n_groups; ++g) {
double s_w = 0.0, s_wI = 0.0;
for (int k = s.gstart[g]; k < s.gstart[g + 1]; ++k) {
const int i = s.gperm[k];
if (parity >= 0 && s.parity[i] != parity) continue;
const Term &t = s.term[i];
const double a = A[t.cell];
const double Is = Mul(Mul(t.I, t.corr), a), sc = Mul(Mul(t.sigma, t.corr), a);
const double w = 1.0 / Mul(sc, sc);
s_w = Add(s_w, w);
s_wI = parity >= 0 ? std::fma(Is, w, s_wI) : Add(s_wI, Mul(Is, w));
}
sw[g] = s_w;
swI[g] = s_wI;
}
}
void HostFitSums(const Surface &s, const std::vector<int32_t> &sel, const std::vector<double> &A,
const std::vector<double> &sw, const std::vector<double> &swI,
std::vector<double> &cross, std::vector<double> &ref2) {
cross.assign(s.ncell, 0.0);
ref2.assign(s.ncell, 0.0);
const int n = static_cast<int>(sel.size()), nb = ReductionBlocks(n, SURFACE_BLOCK);
for (int b = 0; b < nb; ++b) {
std::vector<double> xcross(s.ncell, 0.0), xref2(s.ncell, 0.0);
const int lo = static_cast<int>(int64_t(n) * b / nb), hi = static_cast<int>(int64_t(n) * (b + 1) / nb);
for (int k = lo; k < hi; ++k) {
const Term &t = s.term[sel[k]];
if (sw[t.group] <= 0.0) continue;
const double Iref = swI[t.group] / sw[t.group], a = A[t.cell];
const double Is = Mul(Mul(t.I, t.corr), a), sc = Mul(Mul(t.sigma, t.corr), a);
if (!std::isfinite(Iref) || Iref <= 0.0 || !(sc > 0.0)) continue;
const double w = 1.0 / Mul(sc, sc);
xcross[t.cell] = std::fma(Mul(w, Is), Iref, xcross[t.cell]);
xref2[t.cell] = std::fma(Mul(w, Iref), Iref, xref2[t.cell]);
}
for (int c = 0; c < s.ncell; ++c) {
cross[c] = Add(cross[c], xcross[c]);
ref2[c] = Add(ref2[c], xref2[c]);
}
}
}
// The device side of one subset: ApplyCellSurface's per-block counting sort by cell.
void UploadSubset(RotationScaleMergeGPU &gpu, const Surface &s, int id, const std::vector<int32_t> &sel) {
const int n = static_cast<int>(sel.size()), nb = ReductionBlocks(n, SURFACE_BLOCK);
std::vector<int32_t> perm(n), seg_start(static_cast<size_t>(nb) * s.ncell + 1, n);
for (int b = 0; b < nb; ++b) {
const int lo = static_cast<int>(int64_t(n) * b / nb), hi = static_cast<int>(int64_t(n) * (b + 1) / nb);
std::vector<int32_t> pos(s.ncell + 1, 0);
for (int k = lo; k < hi; ++k) ++pos[s.term[sel[k]].cell + 1];
for (int c = 0; c < s.ncell; ++c) pos[c + 1] += pos[c];
for (int c = 0; c < s.ncell; ++c) seg_start[size_t(b) * s.ncell + c] = lo + pos[c];
for (int k = lo; k < hi; ++k) perm[lo + pos[s.term[sel[k]].cell]++] = sel[k];
}
gpu.SurfaceSetSubset(id, nb, perm.data(), seg_start.data());
}
bool SameBits(const std::vector<double> &a, const std::vector<double> &b) {
return a.size() == b.size() && std::memcmp(a.data(), b.data(), a.size() * sizeof(double)) == 0;
}
} // namespace
TEST_CASE("CorrectionSurfaceGPU_SumsMatchHostBitForBit", "[RotationScale][gpu]") {
if (get_gpu_count() == 0)
SKIP("No GPU");
// Enough terms for several reduction blocks in every subset.
const Surface s = MakeSurface(250000, 4000, 144, 7);
RotationScaleMergeGPU gpu;
REQUIRE(gpu.Available());
gpu.SurfaceSetTerms(static_cast<int>(s.term.size()), s.term.data(), s.parity.data(), s.n_groups,
s.gperm.data(), s.gstart.data(), s.ncell);
for (int id = 0; id < 3; ++id)
UploadSubset(gpu, s, id, s.sel[id]);
std::mt19937 rng(11);
std::uniform_real_distribution<double> u(0.7, 1.4);
std::vector<double> A(s.ncell);
for (double &a : A) a = u(rng);
for (int parity : {0, 1, -1}) {
const int id = parity < 0 ? 2 : parity;
std::vector<double> sw, swI, cross, ref2;
HostReference(s, parity, A, sw, swI);
HostFitSums(s, s.sel[id], A, sw, swI, cross, ref2);
REQUIRE(ReductionBlocks(static_cast<int>(s.sel[id].size()), SURFACE_BLOCK) > 1);
gpu.SurfaceReference(parity, A.data());
std::vector<double> dsw(s.n_groups), dswI(s.n_groups), dcross(s.ncell), dref2(s.ncell);
gpu.SurfaceGetReference(dsw.data(), dswI.data());
gpu.SurfaceFitSums(id, dcross.data(), dref2.data());
CHECK(SameBits(sw, dsw));
CHECK(SameBits(swI, dswI));
CHECK(SameBits(cross, dcross));
CHECK(SameBits(ref2, dref2));
}
}
#endif