image_preprocessing: fuse the bitshuffle inverse with preprocessing, and verify the decode

The device decoder was byte-exact on every valid input - 994 production-compressed
images, 927 hand-built LZ4 blocks covering engineered (offset, matchlen) pairs across
the overlap branch boundary, 18000 repeat decodes, sanitizer-clean - and an audit
against LZ4_decompress_generic could not construct a valid block it mis-decodes. What
it did not do was notice when the input was NOT valid, and that mattered more than it
looks: the decode buffers are reused frame to frame, so a block that stopped early left
the PREVIOUS image in place, and in the bitshuffled layout the untouched tail is the
most significant byte-plane. A corrupt chunk therefore did not look like a missing
corner. It looked like thousands of real pixels several powers of two too bright, fed
to spot finding with no diagnostic, where the host decoder had raised an error.

So the kernel now flags a block that fails to reach its declared length while consuming
exactly its payload, and the host turns that into an exception once the caller has
synchronised. Reads are clamped against the end of the payload as well as the output,
both length chains are bounded exactly as read_variable_length bounds them, the two
offset bytes are bounded, and LZ4's parsing restrictions are enforced. On the host side
a block size that is not a multiple of 8 elements is rejected (it made the un-transpose
read uninitialised shared memory), the block count is bounded by what the chunk could
hold before it becomes an allocation (twelve header bytes could demand hundreds of MB
of pinned memory, permanently, per worker), trailing bytes are rejected, and the stream
is synchronised before any throw that happens after work is queued. An image of fewer
than 8 elements is all verbatim tail and now decodes rather than throwing. When the
device route fails for any reason the host decoder gets its turn, so it costs speed
rather than the acquisition.

The lanes cooperate on the copies and a later match can read bytes another lane wrote,
which since Volta needs an explicit __syncwarp(); it worked only because ptxas happened
to reconverge at the post-dominator. The prototype's offset == 1 and power-of-two fast
paths are also restored - the shipped kernel ran a runtime modulo, an emulated 32-bit
division per output byte, on the path its own comment calls the common case.

The un-transpose is now fused with preprocessing. One thread owns one group of 8
elements across every byte-plane, so once it has transposed its 8 bytes out of each
plane it holds 8 complete elements and emits 8 finished int32 pixels with the mask, the
error marker, the saturation cap and the statistics applied. The decompressed image is
never materialised: 0.623 -> 0.411 ms/frame at 18 Mpx, 0.523 -> 0.340 with 8 concurrent
workers. Staging nothing in shared memory also drops the 48 kB ceiling, which had made
any file whose bitshuffle blocks exceed it a hard failure; 64 kB blocks now decode.
gpu_compressed is sized from the chunk with grow-on-demand instead of from the
uncompressed size - it was reserving ~73 MB per worker to hold ~4 MB. Measured on a
1630x1553 uint32 rotation set at -N 32, peak GPU memory falls 3756 -> 3084 MiB; the
same model gives ~144 MB per worker on an 18 Mpx frame.

Decoding on the device also stopped reporting a decompression time, which blanked the
broker's compression plot trace and filled /entry/profiling/compressionTime with NaN.
The decoder brackets the decode with CUDA events and reports it again.

Tests: a differential fuzz suite against the CPU decoder - incompressible and highly
compressible data, engineered offsets, a size sweep hitting every rem%8 value twice,
all six element sizes, an 18 Mpx frame, decoder reuse, concurrency, hand-built LZ4
blocks across the overlap boundary, 26 foreign bitshuffle block sizes from 128 B to
64 kB, corrupt payloads and malformed containers, with a coverage report that proves
which LZ4 paths were reached rather than assuming it. Plus the fused path held byte for
byte against ImagePreprocessorCPU, statistics included, and against the host-upload
path on the same frame.

Battery: 37 crystals, every merged number 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 14:13:55 +02:00
co-authored by Claude Opus 5
parent 47277674fa
commit bec7e2e922
12 changed files with 2023 additions and 92 deletions
+28 -5
View File
@@ -40,16 +40,25 @@ the GPU when one is present. Instead of decompressing on the host and uploading
compressed chunk is uploaded — a few MB rather than tens of MB — and decoded on the device. The
approach follows Jon Wright (ESRF); the kernels are Jungfraujoch's own.
Two kernels mirror the CPU decoder:
Two kernels do the work:
1. **LZ4, one warp per bitshuffle block.** Blocks are independent, so the parallelism is across
them; within a warp every lane runs the same sequence parser over the same bytes, 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.
2. **The bitshuffle inverse**, one CUDA block per bitshuffle block: bit un-transpose of each
byte-plane into shared memory, then interleave the planes back into elements so the final store
is coalesced. For 8-bit images there is a single plane and the interleave degenerates to a copy.
write position, which keeps it parallel rather than a serial byte loop; `offset == 1` (a run of
one repeated byte, the common case in sparse detector data) and power-of-two offsets avoid the
modulo altogether. Because the lanes cooperate on the copies, each one is followed by
`__syncwarp()` — a later match can read bytes another lane wrote, and since Volta that ordering
is not implicit.
2. **The bitshuffle inverse fused with preprocessing.** One thread owns one group of 8 elements
across every byte-plane, so once it has transposed its 8 bytes out of each plane it holds 8
complete elements — and it applies the pixel mask, the error marker and the saturation cap and
emits 8 finished `int32` pixels directly. The decompressed image is therefore never materialised
in device memory at all, which removes a frame-sized buffer per worker and a full-frame write
plus read from the pipeline. Staging nothing in shared memory also means the kernel has no
dynamic-shared-memory request, so it is indifferent to the bitshuffle block size the file
declares. For 8-bit images there is a single plane and the assembly degenerates to a copy.
The block offsets inside the container can only be discovered by reading the block lengths in
order, so that scan stays on the host.
@@ -58,6 +67,20 @@ Only `BSHUF_LZ4` is decoded on the device. For the zstd variants (`BSHUF_ZSTD`,
`BSHUF_ZSTD_RLE_HUFF`), and for uncompressed or float images, `BSLZ4DecoderGPU::Supports()` returns
false and the pipeline decompresses on the host and uploads as before.
The container arrives off the network or off disk and is not trusted. Everything checkable on the
host — declared sizes, the block scan, a block size that is not a multiple of 8 elements, a block
count the chunk could not hold, trailing bytes — is rejected before any work is queued. What only
the kernel can see is that a block failed to decode to exactly its declared length while consuming
exactly its payload; that raises a device-side flag which becomes an exception once the caller has
synchronised. This matters because the decode buffers are reused frame to frame: a block that
stopped early would otherwise leave the *previous* image's bytes in place, and in the bitshuffled
layout those are the most significant byte-plane, so the result would not look like a missing
corner but like real pixels several powers of two too bright. The kernel reproduces the reference
decoder's length, bounds and parsing-restriction checks; it does not reproduce its exact
fast-loop/safe-loop selection, so a small tail of corrupt streams still decodes here that
`LZ4_decompress_safe` would refuse — as a *complete* decode differing in a few bytes, never as a
partial one.
## 1. Geometry, reciprocal-space mapping, and basic quantities
### 1.1 Coordinate conventions