Files
Jungfraujoch/image_analysis/spot_finding/SpotExtractorGPU.cu
T
leonarski_fandClaude Opus 5 4b1c611bdf image_analysis: query the current device, and upload the resolution mask on the engine stream
Two leftovers from earlier fixes of the same shape. BraggIntegrationEngineGPU still read
device 0's shared-memory size to decide whether its profile grid fits; workers are pinned
round-robin across GPUs, so on a heterogeneous node that check can pass on a different
card than the one the kernel launches on. SpotExtractorGPU still uploaded its default
resolution mask with a pageable copy on the NULL stream, which is not ordered against the
engine stream now that streams are created non-blocking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:14:24 +02:00

352 lines
17 KiB
Plaintext

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
// Sparse connected-component labelling adapted from the design the ACTS/traccc project arrived at
// for sparse silicon-detector clusterization (backward-neighbour graph over a sorted hit list, then
// a parallel union-find), which is itself the GPU counterpart of the SparseCCL that
// StrongPixelSet.cpp uses on the host.
#include <climits>
#include "SpotExtractorGPU.h"
#include "../../common/JFJochException.h"
namespace {
inline void cuda_err(cudaError_t val) {
if (val != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
}
constexpr int THREADS = 256;
constexpr int FINISH_THREADS = 1024;
// --- compaction: packed bit buffer -> strong-pixel list, sorted by flat index -------------------
// Each block owns a CONTIGUOUS range of words. Pass 1 counts its strong bits, a single-block scan
// turns the counts into offsets, and pass 2 walks the same range in increasing order and writes at
// that offset. No atomics anywhere, which is what keeps the output sorted.
__global__ void count_bits(const uint32_t *__restrict__ strong, const uint32_t *__restrict__ res_mask,
uint32_t *__restrict__ block_count, size_t nwords) {
const size_t per_block = (nwords + gridDim.x - 1) / gridDim.x;
const size_t w0 = static_cast<size_t>(blockIdx.x) * per_block;
const size_t w1 = min(w0 + per_block, nwords);
uint32_t local = 0;
for (size_t w = w0 + threadIdx.x; w < w1; w += blockDim.x)
local += __popc(strong[w] & ~res_mask[w]);
__shared__ uint32_t block_total;
if (threadIdx.x == 0) block_total = 0;
__syncthreads();
atomicAdd(&block_total, local);
__syncthreads();
if (threadIdx.x == 0) block_count[blockIdx.x] = block_total;
}
__global__ void scan_block_counts(const uint32_t *__restrict__ in, uint32_t *__restrict__ out,
uint32_t *__restrict__ total, int n) {
__shared__ uint32_t shared[FINISH_THREADS];
const int t = threadIdx.x, nthreads = blockDim.x;
const int chunk = (n + nthreads - 1) / nthreads;
const int lo = min(t * chunk, n), hi = min(lo + chunk, n);
uint32_t sum = 0;
for (int i = lo; i < hi; i++) sum += in[i];
shared[t] = sum;
__syncthreads();
for (int d = 1; d < nthreads; d <<= 1) {
const uint32_t v = (t >= d) ? shared[t - d] : 0u;
__syncthreads();
shared[t] += v;
__syncthreads();
}
uint32_t acc = shared[t] - sum;
for (int i = lo; i < hi; i++) { out[i] = acc; acc += in[i]; }
if (t == nthreads - 1) *total = shared[t];
}
// One thread per block emits its range. Strong pixels are ~1e-4 of the image, so a block's range
// holds a handful of them and serial emission is both trivially ordered and fast; the parallelism
// comes from the block count.
__global__ void scatter_bits(const uint32_t *__restrict__ strong, const uint32_t *__restrict__ res_mask,
const uint32_t *__restrict__ block_offset, const int32_t *__restrict__ image,
uint32_t *__restrict__ out_index, int32_t *__restrict__ out_value,
size_t nwords, uint32_t capacity) {
if (threadIdx.x != 0) return;
const size_t per_block = (nwords + gridDim.x - 1) / gridDim.x;
const size_t w0 = static_cast<size_t>(blockIdx.x) * per_block;
const size_t w1 = min(w0 + per_block, nwords);
uint32_t pos = block_offset[blockIdx.x];
for (size_t w = w0; w < w1; ++w) {
uint32_t word = strong[w] & ~res_mask[w];
while (word != 0) {
const uint32_t flat = static_cast<uint32_t>(w * 32 + (__ffs(word) - 1));
word &= word - 1;
if (pos < capacity) {
out_index[pos] = flat;
out_value[pos] = image[flat];
}
++pos;
}
}
}
// --- connected components ----------------------------------------------------------------------
__device__ __forceinline__ int lower_bound_device(const uint32_t *a, int n, uint32_t key) {
int lo = 0, hi = n;
while (lo < hi) {
const int mid = (lo + hi) >> 1;
if (a[mid] < key) lo = mid + 1; else hi = mid;
}
return lo;
}
// Walk to the root, halving the path on the way. The plain store is safe: a parent only ever
// decreases and the grandparent is always still an ancestor, so a concurrent writer can only make
// the chain shorter. Without halving, a long thin feature - a diffraction ring is exactly one -
// builds a parent chain as long as the feature itself and every later merge walks all of it.
__device__ __forceinline__ uint32_t find_root(uint32_t *parent, uint32_t a) {
uint32_t p = parent[a];
while (p != a) {
const uint32_t gp = parent[p];
if (gp == p) return p;
parent[a] = gp;
a = gp;
p = parent[a];
}
return a;
}
// Lock-free union. Terminates because max(a,b) strictly decreases; converges on the component's
// LOWEST index as its root, which is what the host's make_union does too.
__device__ __forceinline__ void merge_roots(uint32_t *parent, uint32_t a, uint32_t b) {
a = find_root(parent, a);
b = find_root(parent, b);
while (a != b) {
if (a < b) { const uint32_t t = a; a = b; b = t; }
const uint32_t old = atomicMin(&parent[a], b);
if (old == a) return; // a was a root and now points at b: done
a = find_root(parent, old); // someone re-parented a; carry on from its root
b = find_root(parent, b);
}
}
__global__ void init_parent(uint32_t *__restrict__ parent, const uint32_t *__restrict__ nstrong,
uint32_t capacity) {
const int n = static_cast<int>(min(*nstrong, capacity));
for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += blockDim.x * gridDim.x)
parent[i] = i;
}
// The list is sorted by flat index, so a pixel's 8-neighbours that come EARLIER in it are exactly
// four: (line, col-1), (line-1, col-1), (line-1, col) and (line-1, col+1). Each is one binary
// search away, which is what makes the sparse formulation cheap.
__global__ void union_neighbours(const uint32_t *__restrict__ index, uint32_t *__restrict__ parent,
const uint32_t *__restrict__ nstrong, uint32_t capacity, int width) {
const int n = static_cast<int>(min(*nstrong, capacity));
if (static_cast<uint32_t>(n) >= capacity) return;
for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += blockDim.x * gridDim.x) {
const uint32_t flat = index[i];
const uint32_t col = flat % width;
const uint32_t line = flat / width;
uint32_t candidate[4];
int ncandidate = 0;
if (col > 0) candidate[ncandidate++] = flat - 1;
if (line > 0 && col > 0) candidate[ncandidate++] = flat - width - 1;
if (line > 0) candidate[ncandidate++] = flat - width;
if (line > 0 && col + 1 < static_cast<uint32_t>(width)) candidate[ncandidate++] = flat - width + 1;
for (int k = 0; k < ncandidate; k++) {
const int j = lower_bound_device(index, i, candidate[k]);
if (j < i && index[j] == candidate[k]) merge_roots(parent, static_cast<uint32_t>(i), static_cast<uint32_t>(j));
}
}
}
__global__ void resolve_roots(uint32_t *__restrict__ parent, uint32_t *__restrict__ root,
const uint32_t *__restrict__ nstrong, uint32_t capacity) {
const int n = static_cast<int>(min(*nstrong, capacity));
for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += blockDim.x * gridDim.x)
root[i] = find_root(parent, static_cast<uint32_t>(i));
}
// Everything after the labelling in ONE block, so a frame needs a single host synchronisation:
// hand out labels, count the members of each component, sum the surviving ones, filter by max-pix
// and compact - all of it order-preserving.
__global__ void finish_components(const uint32_t *__restrict__ index, const int32_t *__restrict__ value,
const uint32_t *__restrict__ root, uint32_t *__restrict__ label,
int32_t *__restrict__ count, SpotExtractorGPUSpot *__restrict__ scratch,
SpotExtractorGPUSpot *__restrict__ out, uint32_t *__restrict__ nout,
const uint32_t *__restrict__ nstrong, uint32_t capacity,
int width, int max_pix) {
const int n = static_cast<int>(min(*nstrong, capacity));
if (threadIdx.x == 0) *nout = 0;
__syncthreads();
// Same give-up as StrongPixelSet::FindComponentsImage - except that here the count is known
// before a single pixel has been written anywhere, so the frame costs nothing to reject.
if (n == 0 || static_cast<uint32_t>(n) >= capacity) return;
__shared__ uint32_t shared[FINISH_THREADS];
const int t = threadIdx.x, nthreads = blockDim.x;
const int chunk = (n + nthreads - 1) / nthreads;
const int lo = min(t * chunk, n), hi = min(lo + chunk, n);
// 1) labels, by a prefix sum over the roots in ascending order - the order the host's second
// scan hands them out in, which is what makes the spot ORDER identical.
uint32_t nroot = 0;
for (int i = lo; i < hi; i++) nroot += (root[i] == static_cast<uint32_t>(i)) ? 1u : 0u;
shared[t] = nroot;
__syncthreads();
for (int d = 1; d < nthreads; d <<= 1) {
const uint32_t v = (t >= d) ? shared[t - d] : 0u;
__syncthreads();
shared[t] += v;
__syncthreads();
}
uint32_t next_label = shared[t] - nroot;
for (int i = lo; i < hi; i++)
if (root[i] == static_cast<uint32_t>(i)) label[i] = next_label++;
const int nlabel = static_cast<int>(shared[nthreads - 1]);
__syncthreads();
// 2) member counts
for (int i = t; i < nlabel; i += nthreads) count[i] = 0;
__syncthreads();
for (int i = t; i < n; i += nthreads) atomicAdd(&count[label[root[i]]], 1);
__syncthreads();
// 3) sums, one thread per component, walking its members in ascending list order so the
// accumulation matches DiffractionSpot::AddPixel term for term. A component bigger than
// max-pix is thrown away below, so it is not summed - which is also what keeps a whole lit
// module or diffraction ring from turning into one thread walking tens of thousands of
// entries.
for (int i = t; i < n; i += nthreads) {
if (root[i] != static_cast<uint32_t>(i)) continue;
const uint32_t l = label[i];
const int want = count[l];
scratch[l].pixel_count = want;
if (want > max_pix) continue;
long long x = 0, y = 0;
long long photons = 0, max_photons = LLONG_MIN;
int found = 0;
for (int j = i; j < n && found < want; j++) {
if (root[j] != static_cast<uint32_t>(i)) continue;
const long long counts = value[j];
// Integers, exactly as DiffractionSpot::AddPixel does them, so host and device agree by
// construction - no rounding mode to match and nothing for either compiler to contract.
x += static_cast<long long>(index[j] % width) * counts;
y += static_cast<long long>(index[j] / width) * counts;
photons += counts;
max_photons = max(max_photons, counts);
found++;
}
scratch[l].x = x;
scratch[l].y = y;
scratch[l].photons = photons;
scratch[l].max_photons = max_photons;
}
__syncthreads();
// 4) max-pix filter, compacted by another prefix sum so the surviving spots keep their order
const int label_chunk = (nlabel + nthreads - 1) / nthreads;
const int label_lo = min(t * label_chunk, nlabel), label_hi = min(label_lo + label_chunk, nlabel);
uint32_t nkeep = 0;
for (int i = label_lo; i < label_hi; i++) nkeep += (scratch[i].pixel_count <= max_pix) ? 1u : 0u;
shared[t] = nkeep;
__syncthreads();
for (int d = 1; d < nthreads; d <<= 1) {
const uint32_t v = (t >= d) ? shared[t - d] : 0u;
__syncthreads();
shared[t] += v;
__syncthreads();
}
uint32_t pos = shared[t] - nkeep;
for (int i = label_lo; i < label_hi; i++)
if (scratch[i].pixel_count <= max_pix) out[pos++] = scratch[i];
if (t == nthreads - 1) *nout = shared[t];
}
} // namespace
SpotExtractorGPU::SpotExtractorGPU(int32_t in_width, int32_t in_height, std::shared_ptr<CudaStream> in_stream)
: stream(std::move(in_stream)),
width(in_width),
nwords((static_cast<size_t>(in_width) * in_height + 31) / 32),
gpu_res_mask(nwords),
gpu_nstrong(1),
gpu_index(MAX_STRONG),
gpu_value(MAX_STRONG),
gpu_parent(MAX_STRONG),
gpu_root(MAX_STRONG),
gpu_label(MAX_STRONG),
gpu_count(MAX_STRONG),
gpu_spot(MAX_STRONG),
gpu_spot_out(MAX_STRONG),
gpu_nspot(1),
host_nspot(1),
host_spot(SPOT_PREFIX) {
// One block per few hundred words: enough blocks to fill the device, few enough that the serial
// emission inside a block stays short even when a whole detector row lights up.
compact_blocks = static_cast<int>((nwords + 255) / 256);
if (compact_blocks > 4096) compact_blocks = 4096;
if (compact_blocks < 1) compact_blocks = 1;
gpu_block_count = CudaDevicePtr<uint32_t>(compact_blocks);
gpu_block_offset = CudaDevicePtr<uint32_t>(compact_blocks);
// Nothing excluded except the padding bits of the last word - the same starting point as
// ImageSpotFinder's own mask, so the two agree even when no resolution mask is ever set.
std::vector<uint32_t> mask(nwords, 0);
const size_t npixel = static_cast<size_t>(in_width) * in_height;
if (npixel % 32 != 0)
mask.back() = ~((1u << (npixel % 32)) - 1u);
// On this engine's stream, then synchronised - the default stream is non-blocking, so a NULL-stream
// copy is not ordered against the kernels that read this mask.
cuda_err(cudaMemcpyAsync(gpu_res_mask, mask.data(), nwords * sizeof(uint32_t),
cudaMemcpyHostToDevice, *stream));
cuda_err(cudaStreamSynchronize(*stream));
}
void SpotExtractorGPU::SetResolutionMask(const std::vector<uint32_t> &packed_mask) {
if (packed_mask.size() != nwords)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"SpotExtractorGPU::SetResolutionMask: mask size mismatch");
cuda_err(cudaMemcpyAsync(gpu_res_mask, packed_mask.data(), nwords * sizeof(uint32_t),
cudaMemcpyHostToDevice, *stream));
cuda_err(cudaStreamSynchronize(*stream));
}
void SpotExtractorGPU::Extract(const uint32_t *gpu_strong, const int32_t *gpu_image,
const SpotFindingSettings &settings, std::vector<DiffractionSpot> &spots) {
const int max_pix = static_cast<int>(settings.max_pix_per_spot);
count_bits<<<compact_blocks, THREADS, 0, *stream>>>(gpu_strong, gpu_res_mask, gpu_block_count, nwords);
scan_block_counts<<<1, FINISH_THREADS, 0, *stream>>>(gpu_block_count, gpu_block_offset, gpu_nstrong,
compact_blocks);
scatter_bits<<<compact_blocks, 32, 0, *stream>>>(gpu_strong, gpu_res_mask, gpu_block_offset, gpu_image,
gpu_index, gpu_value, nwords, MAX_STRONG);
// Fixed grids reading the strong-pixel count from device memory: the host never learns it, so it
// never has to synchronise in the middle of the frame.
init_parent<<<512, THREADS, 0, *stream>>>(gpu_parent, gpu_nstrong, MAX_STRONG);
union_neighbours<<<512, THREADS, 0, *stream>>>(gpu_index, gpu_parent, gpu_nstrong, MAX_STRONG, width);
resolve_roots<<<512, THREADS, 0, *stream>>>(gpu_parent, gpu_root, gpu_nstrong, MAX_STRONG);
finish_components<<<1, FINISH_THREADS, 0, *stream>>>(gpu_index, gpu_value, gpu_root, gpu_label,
gpu_count, gpu_spot, gpu_spot_out, gpu_nspot,
gpu_nstrong, MAX_STRONG, width, max_pix);
cuda_err(cudaMemcpyAsync(host_nspot, gpu_nspot, sizeof(uint32_t), cudaMemcpyDeviceToHost, *stream));
cuda_err(cudaMemcpyAsync(host_spot, gpu_spot_out, SPOT_PREFIX * sizeof(SpotExtractorGPUSpot),
cudaMemcpyDeviceToHost, *stream));
cuda_err(cudaStreamSynchronize(*stream)); // the only synchronisation in the frame
const uint32_t nspot = *host_nspot.get();
const SpotExtractorGPUSpot *s = host_spot.get();
if (nspot > SPOT_PREFIX) {
overflow_spot.resize(nspot);
cuda_err(cudaMemcpyAsync(overflow_spot.data(), gpu_spot_out, nspot * sizeof(SpotExtractorGPUSpot),
cudaMemcpyDeviceToHost, *stream));
cuda_err(cudaStreamSynchronize(*stream));
s = overflow_spot.data();
}
spots.clear();
spots.reserve(nspot);
for (uint32_t i = 0; i < nspot; i++)
spots.emplace_back(s[i].x, s[i].y, s[i].pixel_count, s[i].photons, s[i].max_photons);
}