image_preprocessing: decode bitshuffle+LZ4 on the GPU
Build Packages / build:viewer-tgz:cpu (push) Successful in 20m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 20m40s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 22m24s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m8s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 27m31s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 27m38s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 29m7s
Build Packages / XDS test (durin plugin) (push) Successful in 11m12s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 22m49s
Build Packages / build:rpm (rocky9) (push) Successful in 22m51s
Build Packages / Generate python client (push) Successful in 40s
Build Packages / Build documentation (push) Successful in 1m22s
Build Packages / Create release (push) Skipped
Build Packages / DIALS test (push) Successful in 20m21s
Build Packages / build:rpm (rocky8) (push) Successful in 27m26s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m59s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m52s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 9m41s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m41s
Build Packages / Unit tests (push) Successful in 1h17m41s
Build Packages / build:windows:nocuda (push) Successful in 13m24s
Build Packages / build:windows:cuda (push) Successful in 17m0s

The pipeline decompressed each image on the host and uploaded the result. On
an 18 Mpx rotation dataset that made the host-to-device copy the bottleneck of
the whole per-image loop: nsys puts the copies at 78% of the loop against 39%
for every kernel combined - 3600 transfers of 72.4 MB - and they ran at only
12.5 GB/s of an available 27-28 because the host-side decompression was itself
saturating host memory bandwidth. The GPU was mostly waiting.

So the compressed chunk goes across instead, about 4 MB rather than 72 MB, and
is decoded on the device. That removes the transfer and the host decompression
that was throttling it, in one change. Measured on an idle machine, a run goes
from 45.11 s to 24.97 s - 1.81x - with the merged output unchanged.

THE APPROACH IS JON WRIGHT'S (ESRF): "Experiences with GPU decompression for
bitshuffle + LZ4 data", HDF5 User Group 2021, and github.com/jonwright/
bslz4decoders. The kernels here are ours, but the idea and the demonstration
that it is worth doing are his. Cited in docs/ACKNOWLEDGEMENT.md and in the new
section 0 of docs/CPU_DATA_ANALYSIS.md.

Two kernels mirror the CPU decoder. LZ4 runs one WARP per bitshuffle block:
every lane parses the same sequence stream (a broadcast read, no divergence)
and the literal and match copies are split across the 32 lanes so the stores
coalesce; an overlapping match is treated as a pattern of period offset sourced
from bytes that already precede the write position, which keeps it parallel
rather than a serial byte loop. One thread per block instead measured 13x
slower. The bitshuffle inverse then un-transposes each byte-plane through
shared memory and interleaves the planes back into elements.

Only BSHUF_LZ4 is decoded on the device. The zstd variants have no device
decoder, and neither has an uncompressed or float image; Supports() returns
false for those and the caller decompresses on the host exactly as before. The
fallback is explicit, so a format we cannot decode on the device is a slower
path and never a wrong answer.

Tests hold the device decoder against the CPU one byte for byte, on data from
the production compressor, for every element size the detectors emit -
including the 8-bit DECTRIS modes, which take bitshuf_decode_block's separate
elem_size == 1 branch - plus a many-block frame, the formats it must decline,
and malformed containers, which must throw rather than run off a buffer.

Battery: 37 crystals, no failures, identical to the host-decode run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:16:36 +02:00
co-authored by Claude Opus 5
parent 59702b0123
commit 6e4c0ce202
11 changed files with 578 additions and 10 deletions
@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include "BSLZ4DecoderGPU.h"
#include "../../common/JFJochException.h"
#include "../../compression/JFJochDecompress.h" // BSHUF_BLOCKED_MULT and the container layout
namespace {
void cuda_err(cudaError_t val) {
if (val != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
}
// One WARP per LZ4 block. Every lane runs the same sequence parser over the same bytes - a
// broadcast read, so no divergence - and the literal and match copies are split across the 32
// lanes so the stores coalesce. One thread per block instead has each thread streaming its own
// 8 kB region, which coalesces not at all and measured 13x slower.
__global__ void lz4_decode_blocks(const uint8_t *__restrict__ src,
const BSLZ4BlockDesc *__restrict__ desc,
uint8_t *__restrict__ dst,
int nblocks, uint32_t elem_size) {
const int lane = threadIdx.x & 31;
const int b = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
if (b >= nblocks) return;
const uint8_t *ip = src + desc[b].in_off;
const uint8_t *const iend = ip + desc[b].in_len;
uint8_t *op = dst + desc[b].out_off;
uint8_t *const oend = op + desc[b].nelem * elem_size;
while (ip < iend) {
const uint32_t token = *ip++;
uint32_t litlen = token >> 4;
if (litlen == 15) {
uint32_t s;
do { s = *ip++; litlen += s; } while (s == 255 && ip < iend);
}
if (litlen) {
const uint32_t n = min(litlen, (uint32_t)(oend - op));
for (uint32_t i = lane; i < n; i += 32) op[i] = ip[i];
op += n; ip += litlen;
}
if (ip >= iend) break; // last sequence carries literals only
const uint32_t offset = (uint32_t)ip[0] | ((uint32_t)ip[1] << 8);
ip += 2;
uint32_t matchlen = token & 0x0F;
if (matchlen == 15) {
uint32_t s;
do { s = *ip++; matchlen += s; } while (s == 255 && ip < iend);
}
matchlen += 4; // minmatch
if (offset == 0 || offset > (uint32_t)(op - (dst + desc[b].out_off))) break; // malformed
const uint8_t *mp = op - offset;
const uint32_t n = min(matchlen, (uint32_t)(oend - op));
if (offset >= matchlen) {
for (uint32_t i = lane; i < n; i += 32) op[i] = mp[i];
} else {
// An overlapping match is a pattern of period `offset`. mp[0..offset-1] all lie
// before op and are already final, so each output byte can be sourced from them
// independently - which keeps this parallel rather than a serial byte loop. Long
// zero runs in sparse detector data arrive here with offset == 1.
for (uint32_t i = lane; i < n; i += 32) op[i] = mp[i % offset];
}
op += n;
}
}
__device__ __forceinline__ uint64_t transpose8(uint64_t x) {
uint64_t t;
t = (x ^ (x >> 7)) & 0x00aa00aa00aa00aaULL; x = x ^ t ^ (t << 7);
t = (x ^ (x >> 14)) & 0x0000cccc0000ccccULL; x = x ^ t ^ (t << 14);
t = (x ^ (x >> 28)) & 0x00000000f0f0f0f0ULL; x = x ^ t ^ (t << 28);
return x;
}
// The bitshuffle inverse, one CUDA block per bitshuffle block, mirroring bitshuf_decode_block:
// un-transpose the bits of each byte-plane, then interleave the planes back into elements. The
// planes are staged in shared memory so the final store is coalesced.
extern __shared__ uint8_t smem[];
__global__ void bitshuffle_untranspose(const uint8_t *__restrict__ shuffled,
const BSLZ4BlockDesc *__restrict__ desc,
uint8_t *__restrict__ out,
int nblocks, uint32_t elem_size) {
const int b = blockIdx.x;
if (b >= nblocks) return;
const uint32_t size = desc[b].nelem; // bytes per plane
const uint8_t *in = shuffled + desc[b].out_off;
uint8_t *dst = out + desc[b].out_off;
const uint32_t n = size / 8;
for (uint32_t p = 0; p < elem_size; p++) {
const uint8_t *pin = in + p * size;
uint8_t *pout = smem + p * size;
for (uint32_t i = threadIdx.x; i < n; i += blockDim.x) {
uint64_t a = 0;
#pragma unroll
for (int k = 0; k < 8; k++) a |= (uint64_t)pin[k * n + i] << (8 * k);
const uint64_t x = transpose8(a);
#pragma unroll
for (int k = 0; k < 8; k++) pout[i * 8 + k] = (uint8_t)(x >> (8 * k));
}
}
__syncthreads();
for (uint32_t i = threadIdx.x; i < size; i += blockDim.x)
for (uint32_t j = 0; j < elem_size; j++)
dst[i * elem_size + j] = smem[j * size + i];
}
uint64_t be64(const uint8_t *p) { uint64_t v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | p[i]; return v; }
uint32_t be32(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; }
size_t elem_size_of(CompressedImageMode mode) {
switch (mode) {
// 8-bit is a real DECTRIS mode. bitshuf_decode_block takes a separate branch for
// elem_size == 1 (bit un-transpose only, no byte interleave), and the kernel here
// reproduces that for free: with one plane the interleave step degenerates to a copy.
case CompressedImageMode::Int8:
case CompressedImageMode::Uint8: return 1;
case CompressedImageMode::Int16:
case CompressedImageMode::Uint16: return 2;
case CompressedImageMode::Int32:
case CompressedImageMode::Uint32: return 4;
default: return 0; // float and RGB modes are never bitshuffled by this pipeline
}
}
}
bool BSLZ4DecoderGPU::Supports(const CompressedImage &image) {
return image.GetCompressionAlgorithm() == CompressionAlgorithm::BSHUF_LZ4
&& elem_size_of(image.GetMode()) != 0;
}
BSLZ4DecoderGPU::BSLZ4DecoderGPU(size_t in_max_uncompressed_bytes, std::shared_ptr<CudaStream> in_stream)
: stream(std::move(in_stream)),
max_uncompressed_bytes(in_max_uncompressed_bytes) {
// The compressed chunk is smaller than the image in every case worth having, but a pathological
// frame can expand slightly, so allow headroom rather than risk a per-frame reallocation.
max_compressed_bytes = max_uncompressed_bytes + max_uncompressed_bytes / 16 + 4096;
// Enough descriptors for the block size the compressor actually uses (8 kB); Decode() grows
// these if a stream turns up with smaller blocks. Sizing for the format's theoretical minimum
// block instead would allocate megabytes of pinned memory that no real file needs.
max_blocks = max_uncompressed_bytes / 8192 + 2;
gpu_compressed = CudaDevicePtr<uint8_t>(max_compressed_bytes);
gpu_shuffled = CudaDevicePtr<uint8_t>(max_uncompressed_bytes);
gpu_desc = CudaDevicePtr<BSLZ4BlockDesc>(max_blocks);
host_desc = CudaHostPtr<BSLZ4BlockDesc>(max_blocks);
}
void BSLZ4DecoderGPU::Decode(const CompressedImage &image, uint8_t *gpu_out) {
const uint8_t *src = image.GetCompressed();
const size_t clen = image.GetCompressedSize();
const size_t elem_size = elem_size_of(image.GetMode());
const size_t total_bytes = image.GetUncompressedSize();
if (clen < 12)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 chunk shorter than its header");
if (total_bytes > max_uncompressed_bytes || clen > max_compressed_bytes)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 image larger than the decoder was sized for");
if (be64(src) != total_bytes)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 header size does not match the image");
const uint32_t block_bytes = be32(src + 8);
if (block_bytes == 0 || block_bytes % elem_size != 0)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 block size invalid");
const size_t block_elems = block_bytes / elem_size;
const size_t nelements = total_bytes / elem_size;
const size_t nfull = nelements / block_elems;
const size_t rem = nelements - nfull * block_elems;
const size_t last = rem - rem % BSHUF_BLOCKED_MULT;
const size_t leftover_bytes = (rem % BSHUF_BLOCKED_MULT) * elem_size;
// Walk the container to locate the blocks. Lengths are only knowable in order, so this scan is
// inherent to the format rather than an implementation choice.
const size_t nblocks_needed = nfull + (last > 0 ? 1 : 0);
if (nblocks_needed > max_blocks) { // a stream with smaller blocks than the compressor emits
max_blocks = nblocks_needed;
gpu_desc = CudaDevicePtr<BSLZ4BlockDesc>(max_blocks);
host_desc = CudaHostPtr<BSLZ4BlockDesc>(max_blocks);
}
size_t nblk = 0, off = 12, out_off = 0;
for (size_t i = 0; i < nfull + (last > 0 ? 1 : 0); i++) {
if (off + 4 > clen)
throw JFJochException(JFJochExceptionCategory::Compression, "truncated bslz4 block header");
const uint32_t block_clen = be32(src + off);
off += 4;
if (block_clen == 0 || off + block_clen > clen)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 block extends past the chunk");
if (nblk >= max_blocks)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 chunk has more blocks than expected");
const uint32_t ne = (i < nfull) ? static_cast<uint32_t>(block_elems) : static_cast<uint32_t>(last);
host_desc.get()[nblk++] = {static_cast<uint32_t>(off), block_clen, static_cast<uint32_t>(out_off), ne};
off += block_clen;
out_off += static_cast<size_t>(ne) * elem_size;
}
if (nblk == 0)
throw JFJochException(JFJochExceptionCategory::Compression, "bslz4 chunk contains no blocks");
cuda_err(cudaMemcpyAsync(gpu_compressed.get(), src, clen, cudaMemcpyHostToDevice, *stream));
cuda_err(cudaMemcpyAsync(gpu_desc.get(), host_desc.get(), nblk * sizeof(BSLZ4BlockDesc),
cudaMemcpyHostToDevice, *stream));
const int nb = static_cast<int>(nblk);
lz4_decode_blocks<<<(nb * 32 + 255) / 256, 256, 0, *stream>>>(
gpu_compressed.get(), gpu_desc.get(),
gpu_shuffled.get(), nb, static_cast<uint32_t>(elem_size));
bitshuffle_untranspose<<<nb, 256, block_bytes, *stream>>>(
gpu_shuffled.get(), gpu_desc.get(),
gpu_out, nb, static_cast<uint32_t>(elem_size));
cuda_err(cudaGetLastError());
// The tail that bitshuffle leaves uncompressed and copies verbatim.
if (leftover_bytes > 0) {
if (off + leftover_bytes > clen)
throw JFJochException(JFJochExceptionCategory::Compression, "truncated bslz4 leftover bytes");
cuda_err(cudaMemcpyAsync(gpu_out + out_off, src + off, leftover_bytes,
cudaMemcpyHostToDevice, *stream));
}
}