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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user