Files
leonarski_fandClaude Opus 5.5 672e182d6a GPU engines wait for their stream before their buffers go; a lost context fails where it is seen
A pooled CudaDevicePtr frees on the thread's allocation stream, not on the engine's stream, and the
pool may hand the memory to another engine - or, past its release threshold, unmap it - as soon as
that free is reached, which on an idle allocation stream is at once. An engine destroyed with work
still queued (FFTIndexerGPU after SearchCap's last DirectionsChanged upload, a spot finder between
DetectAt and Extract, a shadow accumulator after a pending fold, any engine on an exception path)
thus had kernels or copies writing memory that was someone else's or no longer mapped. Now:
- CudaStream synchronises before cudaStreamDestroy (destructor and move-assignment), which covers
  engines whose own stream is declared after their buffers (FFTIndexerGPU, the gather buffer);
- every engine holding pooled buffers and a stream (shared or own, declared before the buffers)
  synchronises it in its destructor; BraggIntegrationEngineGPU also before EnsureCapacity
  reallocates, where a Run that threw leaves work queued.

A GPU failure that is handled no longer hides a lost context: ShadowFinder, BeamCenterFFT, the
rigid-body pool and model validation call cuda_throw_if_context_lost() before cuda_clear_error(),
as the device-decode fallbacks already did; RotationScaleMergeGPU's Alloc does so before waiting up
to ten minutes for GPU work beside it and then reporting a lost device as out of memory; and a
failed cudaMalloc says why. BeamCenterFFT logs the failure it used to drop silently, the
speculative geometry probe logs the exception it swallowed (its GPU fault was otherwise reported by
the merge beside it, under the merge's name), and RotationScaleMergeGPU's DeviceGuard no longer
throws from its destructor.

Only synchronisation and error paths change: p.hkl md5 and the MTZ data (gemmi) are identical to
the b530c2d full battery on myob_x10sa, cytc_x10sa, 8a1a, 9gdj, 11if, kdp_x10sa_20keV and 6z9g.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-10 10:59:24 +02:00

278 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "BeamCenterFFT.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <stdexcept>
#include "BeamCenterFFTCPU.h"
#include "BeamCenterFFTEngine.h"
#include "../../common/CUDAWrapper.h"
#include <spdlog/spdlog.h>
#ifdef JFJOCH_USE_CUDA
#include "BeamCenterFFTGPU.h"
#include "../../common/JFJochException.h"
#endif
namespace {
// The image prepared for scoring: hot pixels clipped at the 99.9th percentile, the median
// background level removed, negatives clamped, masked pixels zero. The final subtraction of the
// valid-pixel mean changes nothing mathematically - the masked Pearson is exactly invariant under
// a global shift of the image (both D*C - S^2 and D*Q - S^2 cancel the shift) - but it removes
// most of the large-term cancellation that single-precision FFTs would otherwise have to survive.
struct Prepared {
std::vector<float> a; // image, 0 on masked pixels
std::vector<float> m; // 1 on valid pixels, 0 on masked
double variance = 0.0; // of a over the valid pixels, after the whole preparation
};
Prepared PrepareImage(const std::vector<float> &mean) {
Prepared out;
const size_t n = mean.size();
out.a.assign(n, 0.0f);
out.m.assign(n, 0.0f);
std::vector<float> valid;
valid.reserve(n);
for (float v : mean)
if (std::isfinite(v))
valid.push_back(v);
if (valid.size() < 2)
return out;
auto kth = [&valid](size_t k) {
std::nth_element(valid.begin(), valid.begin() + k, valid.end());
return valid[k];
};
const float clip_hi = kth(static_cast<size_t>(0.999 * (valid.size() - 1)));
const float median = kth(valid.size() / 2);
double sum = 0.0;
size_t count = 0;
for (size_t i = 0; i < n; i++) {
if (!std::isfinite(mean[i]))
continue;
const float v = std::max(0.0f, std::min(std::max(mean[i], 0.0f), clip_hi) - median);
out.a[i] = v;
out.m[i] = 1.0f;
sum += v;
count++;
}
const float shift = static_cast<float>(sum / static_cast<double>(count));
double var = 0.0;
for (size_t i = 0; i < n; i++)
if (out.m[i] != 0.0f) {
out.a[i] -= shift;
var += static_cast<double>(out.a[i]) * static_cast<double>(out.a[i]);
}
out.variance = var / static_cast<double>(count);
return out;
}
std::vector<BeamCenterFFTCandidate> Shortlist1D(std::vector<float> &surface, float nms_pxl,
int budget, bool is_x) {
std::vector<BeamCenterFFTCandidate> out;
const int64_t n = static_cast<int64_t>(surface.size());
const int64_t d = std::lround(2.0f * nms_pxl);
for (int k = 0; k < budget; k++) {
int64_t best = -1;
float best_v = -std::numeric_limits<float>::infinity();
for (int64_t i = 0; i < n; i++)
if (surface[i] > best_v) {
best_v = surface[i];
best = i;
}
if (best < 0 || !std::isfinite(best_v))
break;
const float c = static_cast<float>(best) / 2.0f;
const float nan = std::numeric_limits<float>::quiet_NaN();
out.push_back(is_x ? BeamCenterFFTCandidate{c, nan, best_v}
: BeamCenterFFTCandidate{nan, c, best_v});
for (int64_t i = std::max<int64_t>(0, best - d); i <= std::min(n - 1, best + d); i++)
surface[i] = -std::numeric_limits<float>::infinity();
}
return out;
}
float Margin(const std::vector<BeamCenterFFTCandidate> &peaks) {
if (peaks.size() < 2 || peaks[0].score <= 0.0f)
return 0.0f;
return (peaks[0].score - peaks[1].score) / peaks[0].score;
}
// The same score for a 1D line mirror, whose accumulators were summed over the other coordinate.
std::vector<float> PearsonLine(const BeamCenterConvSurfaces1D &conv, float min_pair_fraction,
double global_variance) {
const size_t n = conv.C.size();
double d_max = 0.0;
for (double d : conv.D)
d_max = std::max(d_max, d);
const double lim = min_pair_fraction * d_max;
std::vector<float> r(n, -std::numeric_limits<float>::infinity());
for (size_t i = 0; i < n; i++) {
const double num = conv.D[i] * conv.C[i] - conv.S[i] * conv.S[i];
const double den = conv.D[i] * conv.Q[i] - conv.S[i] * conv.S[i];
// Same no-evidence floor as the 2D surface: see BEAM_CENTER_VARIANCE_FLOOR.
if (conv.D[i] > lim
&& den > conv.D[i] * conv.D[i] * BEAM_CENTER_VARIANCE_FLOOR * global_variance)
r[i] = static_cast<float>(num / den);
}
return r;
}
} // namespace
std::vector<float> BeamCenterPointScore(const BeamCenterConvSurfaces2D &conv,
float min_pair_fraction, double global_variance) {
const size_t n = conv.C.size();
float d_max = 0.0f;
for (size_t i = 0; i < n; i++)
d_max = std::max(d_max, conv.D[i]);
const double lim = min_pair_fraction * d_max;
std::vector<float> r(n, -std::numeric_limits<float>::infinity());
for (size_t i = 0; i < n; i++) {
const double d = conv.D[i];
if (d <= lim)
continue;
const double s = conv.S[i];
const double num = d * conv.C[i] - s * s;
const double den = d * conv.Q[i] - s * s;
if (den > d * d * BEAM_CENTER_VARIANCE_FLOOR * global_variance)
r[i] = static_cast<float>(num / den);
}
return r;
}
std::vector<BeamCenterFFTCandidate> BeamCenterShortlist2D(std::vector<float> &surface, int64_t h,
int64_t w, float nms_pxl, int budget) {
std::vector<BeamCenterFFTCandidate> out;
const int64_t d = std::lround(2.0f * nms_pxl);
// Each round takes the first maximum of the whole surface in index order. Kept per row - its
// maximum and the first index holding it - so a round reads the rows' maxima, and only the rows
// the suppression touched are scanned again. Rows in order and the first index within a row is
// the first index overall, the same pick as one scan of the whole surface.
std::vector<float> row_v(h);
std::vector<int64_t> row_i(h);
const auto scan_row = [&](int64_t y) {
float v = -std::numeric_limits<float>::infinity();
int64_t at = -1;
for (int64_t x = 0; x < w; x++)
if (surface[static_cast<size_t>(y * w + x)] > v) {
v = surface[static_cast<size_t>(y * w + x)];
at = y * w + x;
}
row_v[y] = v;
row_i[y] = at;
};
for (int64_t y = 0; y < h; y++)
scan_row(y);
for (int k = 0; k < budget; k++) {
int64_t best = -1;
float best_v = -std::numeric_limits<float>::infinity();
for (int64_t y = 0; y < h; y++)
if (row_i[y] >= 0 && row_v[y] > best_v) {
best_v = row_v[y];
best = row_i[y];
}
if (best < 0 || !std::isfinite(best_v))
break;
const int64_t iy = best / w, ix = best % w;
out.push_back({static_cast<float>(ix) / 2.0f, static_cast<float>(iy) / 2.0f, best_v});
for (int64_t y = std::max<int64_t>(0, iy - d); y <= std::min(h - 1, iy + d); y++) {
for (int64_t x = std::max<int64_t>(0, ix - d); x <= std::min(w - 1, ix + d); x++)
surface[static_cast<size_t>(y * w + x)] =
-std::numeric_limits<float>::infinity();
scan_row(y);
}
}
return out;
}
// The default route, and the whole of the CPU path: the four convolutions come back, and the
// combination and the search run here.
std::vector<BeamCenterFFTCandidate>
BeamCenterFFTEngine::PointShortlist(const std::vector<float> &a, const std::vector<float> &m,
int64_t h, int64_t w, const BeamCenterFFTSettings &settings,
double global_variance) {
const auto conv = PointSurfaces(a, m, h, w);
auto surface = BeamCenterPointScore(conv, settings.min_pair_fraction, global_variance);
return BeamCenterShortlist2D(surface, 2 * h, 2 * w, settings.nms_radius_pxl,
settings.candidates_point);
}
int64_t BeamCenterFFTPadSize(int64_t n) {
for (int64_t k = n;; ++k) {
int64_t v = k;
for (int p : {2, 3, 5, 7})
while (v % p == 0)
v /= p;
if (v == 1)
return k;
}
}
BeamCenterFFTResult BeamCenterFFTScore(int64_t width, int64_t height,
const std::vector<float> &mean,
const BeamCenterFFTSettings &settings,
BeamCenterFFTEngine &engine) {
if (width <= 0 || height <= 0
|| mean.size() != static_cast<size_t>(width) * static_cast<size_t>(height))
throw std::runtime_error("BeamCenterFFT: image dimensions do not match the projection");
BeamCenterFFTResult result;
const Prepared prep = PrepareImage(mean);
result.point = engine.PointShortlist(prep.a, prep.m, height, width, settings, prep.variance);
{
const auto conv_y = engine.LineSurfaces(prep.a, prep.m, height, width,
BeamCenterMirror::Rows);
auto ry = PearsonLine(conv_y, settings.min_pair_fraction, prep.variance);
result.line_y = Shortlist1D(ry, settings.nms_radius_pxl, settings.candidates_line, false);
const auto conv_x = engine.LineSurfaces(prep.a, prep.m, height, width,
BeamCenterMirror::Columns);
auto rx = PearsonLine(conv_x, settings.min_pair_fraction, prep.variance);
result.line_x = Shortlist1D(rx, settings.nms_radius_pxl, settings.candidates_line, true);
}
result.margin_point = Margin(result.point);
result.margin_line_x = Margin(result.line_x);
result.margin_line_y = Margin(result.line_y);
return result;
}
BeamCenterFFTResult BeamCenterFFTScore(int64_t width, int64_t height,
const std::vector<float> &mean,
const BeamCenterFFTSettings &settings) {
#ifdef JFJOCH_USE_CUDA
if (get_gpu_count() > 0 && BeamCenterFFTGPU::FitsInDeviceMemory(width, height)) {
BeamCenterFFTGPU gpu;
try {
return BeamCenterFFTScore(width, height, mean, settings, gpu);
} catch (const JFJochException &e) {
// The card is shared with this run's analysis workers, so the memory the check above
// saw can be gone by the time it is asked for. A capture is not worth failing a run
// over: score it on the CPU instead - unless the device is gone.
spdlog::warn("Beam centre FFT: GPU scoring failed ({}), scoring on the CPU", e.what());
cuda_throw_if_context_lost();
cuda_clear_error(); // handled - see cuda_clear_error()
}
}
#endif
BeamCenterFFTCPU cpu;
return BeamCenterFFTScore(width, height, mean, settings, cpu);
}
BeamCenterFFTResult FindBeamCenterFFT(const DiffractionExperiment &experiment,
const std::vector<float> &mean,
const BeamCenterFFTSettings &settings) {
// The pre-scan projection is in converted geometry (ShadowFinder's frame).
return BeamCenterFFTScore(experiment.GetXPixelsNumConv(), experiment.GetYPixelsNumConv(),
mean, settings);
}