Give each FFT direction a block and its histogram shared memory

The de-novo indexer projects every spot onto each of 16384 search directions and
bins the projections; the peak of each direction's spectrum is a reciprocal
lattice row spacing. One thread owned a whole direction, so neighbouring lanes
wrote 12.6 kB apart and every warp instruction touched 32 separate sectors of a
206 MB buffer with no chance of staying in a 4 MB L2. 160 million scattered
global read-modify-writes, at about 14% of the card's bandwidth.

One block per direction now, with the bins in shared memory. They are counts, so
they are held as integers: an integer atomicAdd is a real shared-memory
instruction where the float one compiles to a compare-and-swap retry loop, and a
count below 2^24 converts to float exactly, so the output is bit for bit what the
repeated += 1.0 produced. Above 48 kB of bins the old kernel still runs.

245.75 ms per launch -> 4.08 ms, so 0.98 s of the run -> 0.016 s. This machine
runs two of them at once on two cards, so it is worth about half a second here
and about a second on the single-GPU machines the viewer and the broker run on.
The FFT it feeds takes 3.3 ms; preparing its input took 70x longer than
transforming it.

Alongside it, the per-frame scale fit divided by k^2 once per observation per
IRLS iteration, and a loop-invariant divisor does not get hoisted out of a double
division - ptxas emits the whole Newton refinement of the reciprocal every time.
Hoisted, as 1/sigma already is a few lines above; the same expression in the
three CPU scale paths went with it so the two stay algebraically identical.
54.92 ms per launch -> 44.56 ms, 1.65 s -> 1.34 s.

That one is not bit-identical - a multiply by a rounded reciprocal differs from a
correctly rounded quotient in the last place - so it can move a frame that sits
on the convergence tolerance. Battery: 21/24 space groups, no failures, and 17 of
24 crystals identical to the previous run, against a floor of 13 of 24 for the
same binary run twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jungfrau
2026-08-16 06:29:48 -04:00
co-authored by Claude Opus 5
parent 8ea3076f96
commit 057fff98b2
5 changed files with 73 additions and 14 deletions
+62 -6
View File
@@ -91,6 +91,53 @@ __global__ void histogram_kernel(const float *__restrict__ coord_x,
}
}
// The same histogram, one block per direction with the bins in shared memory.
//
// The kernel above gives a whole direction to a single thread, so neighbouring lanes write
// histogram_size floats apart - 12.6 kB at the default sizing. Every warp instruction then touches 32
// separate sectors of a buffer that is hundreds of megabytes (16384 directions x 3142 bins), with no
// hope of staying in a 4 MB L2, and the projection loop becomes 160 million scattered global
// read-modify-writes.
//
// The bins are counts, so here they are integers in shared memory. That matters twice: an integer
// atomicAdd is a real shared-memory instruction on Turing and Ada, where the float one compiles to a
// compare-and-swap retry loop; and a count below 2^24 converts to float exactly, so the output is bit
// for bit what the repeated `+= 1.0` above produces. The division by histogram_spacing stays a
// division - turning it into a multiply by the reciprocal would move a spot across a bin edge.
__global__ void histogram_shared_kernel(const float *__restrict__ coord_x,
const float *__restrict__ coord_y,
const float *__restrict__ coord_z,
const float *__restrict__ dir_x,
const float *__restrict__ dir_y,
const float *__restrict__ dir_z,
float histogram_spacing,
int histogram_size,
int coord_size,
int direction_vectors_size,
float *__restrict__ output) {
extern __shared__ unsigned int bins[];
const int direction_idx = blockIdx.x;
if (direction_idx >= direction_vectors_size)
return;
for (int i = threadIdx.x; i < histogram_size; i += blockDim.x)
bins[i] = 0;
__syncthreads();
const float dx = dir_x[direction_idx], dy = dir_y[direction_idx], dz = dir_z[direction_idx];
for (int i = threadIdx.x; i < coord_size; i += blockDim.x) {
const float dot = fabsf(dx * coord_x[i] + dy * coord_y[i] + dz * coord_z[i]);
const int64_t bin = static_cast<int64_t>(dot / histogram_spacing);
if (bin >= 0 && bin < histogram_size)
atomicAdd(&bins[bin], 1u);
}
__syncthreads();
float *out = output + static_cast<size_t>(direction_idx) * static_cast<size_t>(histogram_size);
for (int i = threadIdx.x; i < histogram_size; i += blockDim.x)
out[i] = static_cast<float>(bins[i]);
}
inline void cuda_err(cudaError_t val) {
if (val != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
@@ -155,12 +202,21 @@ void FFTIndexerGPU::ExecuteFFT(const std::vector<Coord> &coord, size_t nspots) {
cudaMemcpyAsync(d_spot_y, spot_y, nspots * sizeof(float), cudaMemcpyHostToDevice, stream);
cudaMemcpyAsync(d_spot_z, spot_z, nspots * sizeof(float), cudaMemcpyHostToDevice, stream);
histogram_kernel<<<l_gridDim, l_blockDim, 0, stream>>>(d_spot_x, d_spot_y, d_spot_z,
d_dir_x, d_dir_y, d_dir_z,
histogram_spacing, histogram_size,
nspots,
direction_vectors.size(),
d_input_fft);
// Shared-memory bins where they fit (they do at any sane sizing - 12.6 kB at the defaults), the
// thread-per-direction kernel where they do not.
const size_t hist_shared_bytes = static_cast<size_t>(histogram_size) * sizeof(unsigned int);
if (hist_shared_bytes <= 48 * 1024) {
histogram_shared_kernel<<<direction_vectors.size(), 256, hist_shared_bytes, stream>>>(
d_spot_x, d_spot_y, d_spot_z, d_dir_x, d_dir_y, d_dir_z,
histogram_spacing, histogram_size, nspots, direction_vectors.size(), d_input_fft);
} else {
histogram_kernel<<<l_gridDim, l_blockDim, 0, stream>>>(d_spot_x, d_spot_y, d_spot_z,
d_dir_x, d_dir_y, d_dir_z,
histogram_spacing, histogram_size,
nspots,
direction_vectors.size(),
d_input_fft);
}
cuda_err(cufftExecR2C(plan, d_input_fft, d_output_fft));
@@ -124,12 +124,12 @@ namespace {
return 1.0;
G = std::max(0.0, G);
const double k2 = robust_k * robust_k;
const double inv_k2 = 1.0 / (robust_k * robust_k); // divisor hoisted; see the GPU kernel
for (int iter = 0; iter < 30; ++iter) {
const double G_prev = G;
const double G_next = weighted_scale([&](const ScaleObs &o) {
const double res = o.weight * (G * o.coeff - o.Iobs);
return 1.0 / (1.0 + res * res / k2);
return 1.0 / (1.0 + res * res * inv_k2);
});
if (!std::isfinite(G_next))
break;
@@ -118,7 +118,10 @@ namespace {
__syncthreads();
if (s_cnt < MIN_REFLECTIONS) return; // leave g[f]/scaled[f] as-is
const double k2 = robust_k * robust_k;
// 1/k^2, not k^2: the weight below divides by it once per observation per iteration, and the
// compiler will not hoist a loop-invariant divisor out of a double division - it emits the whole
// Newton refinement of the reciprocal every time. Same reason 1/sigma is precomputed above.
const double inv_k2 = 1.0 / (robust_k * robust_k);
// seed: plain weighted-LS ratio (robust weight = 1)
double num = 0.0, den = 0.0;
@@ -151,7 +154,7 @@ namespace {
const double w = inv_sigma[a];
const double w2 = w * w;
const double res = w * (G * coeff - double(I[a]));
const double rw = 1.0 / (1.0 + res * res / k2);
const double rw = 1.0 / (1.0 + res * res * inv_k2);
num += rw * w2 * coeff * double(I[a]);
den += rw * w2 * coeff * coeff;
}
+2 -2
View File
@@ -57,12 +57,12 @@ namespace {
return 1.0;
G = std::max(0.0, G);
const double k2 = robust_k * robust_k;
const double inv_k2 = 1.0 / (robust_k * robust_k); // divisor hoisted; see the GPU kernel
for (int iter = 0; iter < 30; ++iter) {
const double G_prev = G;
const double G_next = weighted_scale([&](const ScaleObs &o) {
const double res = o.weight * (G * o.coeff - o.Iobs);
return 1.0 / (1.0 + res * res / k2);
return 1.0 / (1.0 + res * res * inv_k2);
});
if (!std::isfinite(G_next))
break;
@@ -83,12 +83,12 @@ namespace {
return 1.0;
G = std::max(0.0, G);
const double k2 = robust_k * robust_k;
const double inv_k2 = 1.0 / (robust_k * robust_k); // divisor hoisted; see the GPU kernel
for (int iter = 0; iter < 30; ++iter) {
const double G_prev = G;
const double G_next = weighted_scale([&](size_t i) {
const double res = weight[i] * (G * coeff[i] - Iobs[i]);
return 1.0 / (1.0 + res * res / k2);
return 1.0 / (1.0 + res * res * inv_k2);
});
if (!std::isfinite(G_next))
break;