Build the detector's lookup tables once, not once per worker

The image loop gives every worker its own analysis engine, so a run builds ninety-six of them. Each
one derived, from scratch, tables that are the same in all of them: the byte-per-pixel mask, the
resolution mask, the radial kernel, and the checksum that names the shared device tables.

The checksum was the worst of it, because it is part of the cache KEY and so is computed before the
lookup - a hit still hashed the whole table. On a 16 Mpx detector that is the bin table, the
corrections and the mask, 126 MB an engine, about twelve gigabytes over a run, to answer a question
whose answer had not changed. The header said it cost nothing measurable; a profile says otherwise,
and says it is worst exactly during the ramp when the machine has nothing else to do.

It cannot simply be remembered against the address, which is what it exists to catch: a buffer can
be freed and another allocated where it was, and the cache would then hand back a device copy of
something else. So the owner of the bytes computes it instead. The azimuthal mapping writes its two
tables in its constructor and never again. The pixel mask re-derives its binary form and its
checksum on every path that changes the mask, and all of those paths are now private to the class.
The key therefore still describes the bytes as they are at the moment of the lookup.

The resolution mask was two passes over every pixel - a float comparison into a vector<bool>, then a
bit-by-bit repack - in each of the ninety-six. It is one pass now, writing the packed form directly,
built once for the limits asked for and handed out as a shared pointer so a worker keeps the mask it
was given. The radial kernel is cached on the six numbers it is derived from.

Nothing computes a different value; only who computes it changes. Byte-identical merged output on a
16 Mpx set and on a small one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n8riB6X59oRjkrSHzNPAU
This commit is contained in:
jungfrau
2026-08-23 12:59:58 -04:00
co-authored by Claude Opus 5
parent 27020d27e9
commit 5ee0f22a61
17 changed files with 248 additions and 93 deletions
@@ -235,17 +235,12 @@ ImagePreprocessorGPU::ImagePreprocessorGPU(const DiffractionExperiment &experime
gpu_stats(1),
cpu_stats(1),
cpu_stats_reg(cpu_stats) {
// Setup mask. The same for every worker, so it is uploaded once per GPU and shared; keyed on the
// PixelMask's own vector, which the derived table is a pure function of.
// Hoist the accessor and index without the bounds check: this runs once per worker over every
// pixel of the detector - 18 million times per engine on a 16 Mpx one, and an engine is built per
// worker per pass - and .at() on each of them stops the loop vectorising for a bound the loop
// itself already respects.
const std::vector<uint32_t> &mask_raw = mask.GetMask();
std::vector<uint8_t> mask_vec(npixels);
for (int i = 0; i < npixels; i++)
mask_vec[i] = (mask_raw[i] != 0);
gpu_mask = SharedDeviceTable(mask.GetMask().data(), npixels, mask_vec.data(), *stream);
// Setup mask. The byte-per-pixel form and its checksum come from the PixelMask, which derives them
// whenever the mask changes: they are the same for every worker, and deriving them walks every
// pixel of the detector - 18 million of them on a 16 Mpx one, per engine, with an engine built per
// worker per pass. The table is then uploaded once per GPU and shared by the engines on it.
gpu_mask = SharedDeviceTable(mask.GetBinaryMask().data(), npixels, mask.GetBinaryMask().data(),
mask.GetBinaryMaskChecksum(), *stream);
// Setup GPU settings. The current device, not device 0: workers are pinned round-robin across GPUs,
// so device 0's SM count can belong to a different card than the one these kernels launch on.