Files
Jungfraujoch/tests/StoredImageDecodeGPUTest.cpp
T
leonarski_fandClaude Opus 5.5 34092a510f marCCD upload: stage the stored pixels instead of page-locking the frame in place
A UINT16_WIDENED frame (marCCD) was page-locked where it lay with PinInputRegion, which keeps the
registration until the engine moves on to another frame. The pre-scan hands its kept frames to the
spot measurement and to the defective-pixel search at once, each with its own engines, so one
engine could unregister a frame while another engine's copy from it was still in flight. That
takes the context down ("unspecified launch failure"), seen as a rare crash at the end of the
pre-scan on a 3072x3072 marCCD sweep (1 run in 30 reproduced it).

The stored bytes now go through the engine's own page-locked staging buffer, as the CBF and BSLZ4
decoders already do. The pixels decoded are unchanged.

Test: ImagePreprocessorGPU_SharedStoredFramesManyEngines - four engines upload four shared marCCD
frames concurrently; it aborted with the launch failure before the change and passes after it.

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

244 lines
11 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../common/CUDAWrapper.h"
#ifdef JFJOCH_USE_CUDA
#include <cstring>
#include <atomic>
#include <random>
#include <thread>
#include <vector>
#include "../common/PixelMask.h"
#include "../compression/CBFByteOffset.h"
#include "../image_analysis/image_preprocessing/CBFDecoderGPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorCPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorGPU.h"
#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h"
// The miniCBF and marCCD frames reach the GPU as their files store them, and the device has to turn
// them into exactly the int32 image the host decoder would have: the same pixels, the same refusal of
// a stream that ends early.
namespace {
// x-CBF_BYTE_OFFSET with the smallest container for each delta; force64 puts a delta in the 15-byte
// form whatever its size, which no real writer does but the format allows.
void AppendDelta(std::vector<uint8_t> &out, int64_t delta, bool force64) {
auto put = [&out](int64_t v, int bytes) {
for (int i = 0; i < bytes; i++) out.push_back(static_cast<uint8_t>(v >> (8 * i)));
};
if (!force64 && delta > -128 && delta <= 127) { put(delta, 1); return; }
put(-128, 1);
if (!force64 && delta > -32768 && delta <= 32767) { put(delta, 2); return; }
put(-32768, 2);
if (!force64 && delta > INT32_MIN && delta <= INT32_MAX) { put(delta, 4); return; }
put(INT32_MIN, 4);
put(delta, 8);
}
std::vector<uint8_t> EncodeByteOffset(const std::vector<int32_t> &pixels, std::mt19937 &rng) {
std::vector<uint8_t> out;
int64_t prev = 0;
for (const int32_t p : pixels) {
AppendDelta(out, p - prev, rng() % 5000 == 0);
prev = p;
}
return out;
}
// Mostly small deltas, with runs of every escape: int16 and int32 deltas whose payload bytes include
// 0x80 (the escape byte, which must not be taken for one inside a payload), and negative pixels.
std::vector<int32_t> MakePixels(size_t n, uint32_t seed) {
std::mt19937 rng(seed);
std::vector<int32_t> p(n);
for (size_t i = 0; i < n; i++) {
const uint32_t r = rng() % 100;
if (r < 80) p[i] = static_cast<int32_t>(rng() % 40);
else if (r < 90) p[i] = 128 + static_cast<int32_t>(rng() % 1000) * 256; // payload bytes 0x80
else if (r < 95) p[i] = static_cast<int32_t>(rng() % 2000000) - 1000000;
else if (r < 98) p[i] = -1;
else p[i] = static_cast<int32_t>(rng());
}
// A long run of escapes back to back, so whole tiles hold nothing but multi-byte tokens.
for (size_t i = n / 3; i < n / 3 + 5000 && i < n; i++)
p[i] = (i % 2) ? 1000000 : -1000000;
return p;
}
std::vector<int32_t> DecodeOnDevice(const std::vector<uint8_t> &stream_bytes, size_t nx, size_t ny,
CBFDecoderGPU &decoder, CudaStream &stream) {
const CompressedImage image(stream_bytes.data(), stream_bytes.size(), nx, ny, CompressedImageMode::Int32,
CompressionAlgorithm::CBF_BYTE_OFFSET);
CudaDevicePtr<int32_t> out(nx * ny);
decoder.Decode(image, out);
std::vector<int32_t> ret(nx * ny);
REQUIRE(cudaMemcpyAsync(ret.data(), out.get(), ret.size() * sizeof(int32_t), cudaMemcpyDeviceToHost, stream)
== cudaSuccess);
REQUIRE(cudaStreamSynchronize(stream) == cudaSuccess);
decoder.ThrowIfDecodeFailed();
return ret;
}
bool SameStats(const ImageStatistics &a, const ImageStatistics &b) {
return a.max_value == b.max_value && a.min_value == b.min_value
&& a.masked_pixel_count == b.masked_pixel_count
&& a.error_pixel_count == b.error_pixel_count
&& a.saturated_pixel_count == b.saturated_pixel_count;
}
} // namespace
TEST_CASE("CBFDecoderGPU_MatchesHost", "[ImagePreprocessorGPU]") {
if (get_gpu_count() == 0)
SKIP("No CUDA GPU present");
auto stream = std::make_shared<CudaStream>();
CBFDecoderGPU decoder(stream);
std::mt19937 rng(11);
for (const auto &[nx, ny] : std::vector<std::pair<size_t, size_t>>{{1, 1}, {7, 3}, {1000, 1013}, {2463, 1000}}) {
const auto pixels = MakePixels(nx * ny, static_cast<uint32_t>(nx * 31 + ny));
auto bytes = EncodeByteOffset(pixels, rng);
// A real file carries the CIF trailer after the stream; nothing past the last pixel is read.
for (const char c : std::string("\r\n--CIF-BINARY-FORMAT-SECTION----\r\n;\r\n\x80\x80\x80"))
bytes.push_back(static_cast<uint8_t>(c));
std::vector<int32_t> host(nx * ny);
DecodeCBFByteOffset(bytes.data(), bytes.size(), host.data(), host.size());
REQUIRE(host == pixels);
INFO(nx << " x " << ny);
CHECK(DecodeOnDevice(bytes, nx, ny, decoder, *stream) == host);
}
}
TEST_CASE("CBFDecoderGPU_ShortStreamIsRefused", "[ImagePreprocessorGPU]") {
if (get_gpu_count() == 0)
SKIP("No CUDA GPU present");
auto stream = std::make_shared<CudaStream>();
CBFDecoderGPU decoder(stream);
std::mt19937 rng(5);
const size_t nx = 300, ny = 200;
auto pixels = MakePixels(nx * ny, 3);
pixels.back() = pixels[pixels.size() - 2] + 1000000; // the last delta takes the 7-byte form
const auto bytes = EncodeByteOffset(pixels, rng);
// One token short, and cut inside the last (multi-byte) token: both decoders refuse both.
const std::vector<uint8_t> short_stream(bytes.begin(), bytes.end() - 2);
for (const auto &s : {std::vector<uint8_t>(bytes.begin(), bytes.end() - 7), short_stream}) {
std::vector<int32_t> host(nx * ny);
CHECK_THROWS(DecodeCBFByteOffset(s.data(), s.size(), host.data(), host.size()));
CHECK_THROWS(DecodeOnDevice(s, nx, ny, decoder, *stream));
}
}
// Through the preprocessor, as the image loop calls it: the stored encodings against the CPU
// preprocessor fed what the host decodes them to. The marCCD frame carries 65535, its saturation
// marker, which a Uint16 image would call an error pixel - widened to int32 it is saturated.
TEST_CASE("ImagePreprocessorGPU_StoredEncodingsMatchCPU", "[ImagePreprocessorGPU]") {
if (get_gpu_count() == 0)
SKIP("No CUDA GPU present");
DetectorSetup detector = DetJF4M();
detector.SaturationLimit(65535);
DiffractionExperiment x(detector);
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
const size_t npixels = x.GetPixelsNum();
PixelMask mask(x);
std::vector<uint32_t> user_mask(mask.GetMask().size(), 0);
for (size_t i = 0; i < npixels; i += 997) user_mask[i] = 1;
mask.LoadUserMask(x, user_mask);
std::mt19937 rng(17);
const auto pixels = MakePixels(npixels, 9);
const auto cbf = EncodeByteOffset(pixels, rng);
std::vector<uint16_t> marccd(npixels);
for (size_t i = 0; i < npixels; i++)
marccd[i] = (i % 1500 == 0) ? 65535 : static_cast<uint16_t>(rng() % 70000);
const std::vector<CompressedImage> images = {
CompressedImage(cbf.data(), cbf.size(), x.GetXPixelsNum(), x.GetYPixelsNum(), CompressedImageMode::Int32,
CompressionAlgorithm::CBF_BYTE_OFFSET),
CompressedImage(marccd.data(), marccd.size() * sizeof(uint16_t), x.GetXPixelsNum(), x.GetYPixelsNum(),
CompressedImageMode::Int32, CompressionAlgorithm::UINT16_WIDENED)
};
auto stream = std::make_shared<CudaStream>();
ImagePreprocessorGPU gpu_pre(x, mask, stream, /*copy_image_to_host=*/true);
ImagePreprocessorCPU cpu_pre(x, mask);
for (const auto &image : images) {
std::vector<uint8_t> decoded;
const uint8_t *raw = image.GetUncompressedPtr(decoded);
ImagePreprocessorBuffer cpu_buf(npixels);
const ImageStatistics cpu_stats = cpu_pre.Analyze(cpu_buf, raw, CompressedImageMode::Int32);
ImagePreprocessorBufferGPU gpu_buf(npixels);
ImageStatistics gpu_stats{};
REQUIRE(gpu_pre.AnalyzeCompressed(gpu_buf, image, gpu_stats));
INFO("algorithm " << static_cast<int>(image.GetCompressionAlgorithm()));
CHECK(SameStats(cpu_stats, gpu_stats));
CHECK(cpu_stats.saturated_pixel_count > 0);
size_t ndiff = 0;
for (size_t i = 0; i < npixels; i++)
ndiff += gpu_buf[i] != cpu_buf[i];
CHECK(ndiff == 0);
}
}
// The pre-scan hands the frames it kept to two consumers at once - the spot measurement and the
// defective-pixel search - each with its own engines, so one marCCD frame is uploaded by several
// preprocessors concurrently while each of them is also moving on to other frames.
TEST_CASE("ImagePreprocessorGPU_SharedStoredFramesManyEngines", "[ImagePreprocessorGPU]") {
if (get_gpu_count() == 0)
SKIP("No CUDA GPU present");
DetectorSetup detector = DetJF4M();
detector.SaturationLimit(65535);
DiffractionExperiment x(detector);
x.DetectorDistance_mm(80).BeamX_pxl(1030).BeamY_pxl(1080);
const size_t npixels = x.GetPixelsNum();
PixelMask mask(x);
std::mt19937 rng(23);
std::vector<std::vector<uint16_t>> frames(4, std::vector<uint16_t>(npixels));
for (auto &f : frames)
for (auto &v : f)
v = static_cast<uint16_t>(rng() % 60000);
std::vector<ImageStatistics> reference(frames.size());
{
ImagePreprocessorCPU cpu_pre(x, mask);
ImagePreprocessorBuffer cpu_buf(npixels);
for (size_t f = 0; f < frames.size(); f++) {
std::vector<int32_t> widened(frames[f].begin(), frames[f].end());
reference[f] = cpu_pre.Analyze(cpu_buf, reinterpret_cast<const uint8_t *>(widened.data()),
CompressedImageMode::Int32);
}
}
std::atomic<size_t> mismatches{0};
std::vector<std::thread> threads;
for (int t = 0; t < 4; t++)
threads.emplace_back([&, t] {
auto stream = std::make_shared<CudaStream>();
ImagePreprocessorGPU gpu_pre(x, mask, stream, /*copy_image_to_host=*/false);
ImagePreprocessorBufferGPU gpu_buf(npixels, /*host_mirror=*/false);
for (int i = 0; i < 200; i++) {
const size_t f = (i + t) % frames.size();
const CompressedImage image(frames[f].data(), npixels * sizeof(uint16_t), x.GetXPixelsNum(),
x.GetYPixelsNum(), CompressedImageMode::Int32,
CompressionAlgorithm::UINT16_WIDENED);
ImageStatistics stats{};
gpu_pre.AnalyzeCompressed(gpu_buf, image, stats);
if (!SameStats(stats, reference[f]))
mismatches++;
}
});
for (auto &t : threads)
t.join();
CHECK(mismatches == 0);
}
#endif