Metal LZ4 bounds - 2026-10-11#
Question#
The CUDA LZ4 decoder bounds its reads and writes and raises ValueError for
damaged LZ4 bytes inside intact framing
(record). The Metal decoders
bounded neither. What does a damaged block do on Metal, and what do the same
bounds cost there?
Setup#
One Apple silicon laptop (M5 Max, 128 GB), macOS 26.4, Python 3.12, PyTorch 2.14.1, pyobjc-framework-Metal 12.2.2, h5py 3.16.0, hdf5plugin 7.1.0.
Revisions: base
6e14725; boundedd75c476.Metal has two LZ4 decoders on the Python path. Whole-block uint16 frames (192 x 192) take the fused kernel
h5lz4dc_unshuffle_u16_single_block_packed_h5inqh5idx.metal, which decodes one 8 KiB block into threadgroup memory and unshuffles it into the output. Every other layout (uint8, uint32, a partial final block) takesh5lz4dc_batchedinbslz4.msl, which decodes into a device scratch buffer that the unshuffle kernels then read.Data: one Arina acquisition, 512 x 512 scan, 192 x 192 detector, uint16 (27 files; fused kernel). No acquisition for the other kernel was at hand, so three synthetic bitshuffle+LZ4 stacks were written with hdf5plugin from the first 8192 frames of that acquisition: the same counts as uint32 (4096 frames), the detector binned 2 x 2 to 96 x 96 uint16 (8192 frames, a partial final block), and the counts clipped to uint8 (8192 frames).
Timing: the decode modules of both revisions are loaded side by side in one process, each compiling its own kernel files. One prepared batch (the loader’s batch: 8192 frames, 4096 for uint32) is copied into a decoder of each, and the decoders are launched in rotation, 300 launches each, the order rotating every round. GPU time is
GPUEndTime - GPUStartTimeof the command buffer. A second decoder of the base revision runs in the same rotation as a control. The two base decoders agree within 0.05% where one kernel is timed (the fused kernel, the LZ4 kernel alone) and differ by up to 0.9% for the whole decode command of the scratch layouts, constantly within one process, so those rows are given against both.
Before the change#
Damage was written into the LZ4 bytes of one block after the host had walked the chunk, so every length field stayed intact: the block zeroed, replaced by random bytes, its second half zeroed, and its first 45 bytes replaced by one literal and a match of 10219 bytes that repeats it (every read valid, the block 2 KiB too long). Four layouts, the first block of frame 2 and the last block of the batch: 32 cases, in one throwaway process.
All 32 decoded without an error. The Metal command completed without an error status, the damaged block held wrong counts (672 to 7825 wrong values per block), and a valid batch decoded exactly afterwards.
The over-long match wrote outside its block in
h5lz4dc_batched: 2254 to 8885 bytes of the scratch changed in two neighbouring blocks, whose decoded counts were wrong as well, and 10005 to 12427 bytes changed past the last frame of the batch when the damaged block was the last one (inside the scratch here, because the probe allocated it twice as long as the batch).In the fused kernel the same stream runs past the 8192-byte threadgroup array. No other block’s counts changed; what it overwrote is not visible from the host.
A block whose length field was halved after the walk (a block that stops early) decoded without an error as well, with wrong counts in its frame.
Through the public API, a master with one zeroed block on disk loaded without an error, as encoded counts and as scaled precision storage.
The change#
bslz4.msl decompressStream and a new
bslz4DecompressStreamDirectBoundedToThreadgroup in qh5idx.metal return
false for a stream that does not decode to exactly its block, with the bounds
of CUDA’s decompressStream: literals lie inside the compressed stream, two
bytes remain before a match offset is read, a length extension that runs off
the stream is refused, a match copies from bytes the block already wrote
(0 < offset <= decoded), and writes stay inside the block. Both kernels take
an error buffer and record the lowest damaged frame of the batch and one past
the highest. MPSDecompressor.load_prepared_frames raises
ValueError("Frames a to b: a compressed HDF5 chunk is truncated or corrupt ..."); the frame numbers come from a new frame_indices entry of the plan
that FrameReader.prepare returns.
The bounds are not a line-for-line port. A refused sequence is clamped to the
room it has (min of its length and the bytes left in the stream and in the
block; no room at all for an offset outside 1..decoded), so it copies only
bytes that are safe to copy, and decoding runs on to the end of the stream.
The loop has no early exit, and the result is valid && decoded == block.
The native Swift entry points of qh5idx.metal keep their decoders unchanged.
Numbers#
Damaged input after the change#
The 32 cases above raise
ValueErrornaming the frame; no byte of the scratch outside the damaged block changes; the next valid batch is exact.tests/hardware/mps/test_streamed_h5_ans.py::test_damaged_lz4_bytes_raise_and_the_gpu_stays_usable(the four damages and the block that stops early, on a 192 x 192 and a 100 x 100 uint16 master) fails on the base withzeroed: damaged LZ4 bytes decoded without an errorand passes after.io.loadof a master with one zeroed block on disk raisesFrames 37 to 37: ...for both representations, and the undamaged copy loads exactly afterwards.240,000 randomly mutated blocks (byte flips, zero, 0xFF and random ranges, 0xFF to the end of the stream, changed last bytes, crafted tokens; frames from empty to incompressible; three seeds, four layouts) were decoded one per batch and compared with a plain Python LZ4 decoder that has CUDA’s bounds. Metal refused exactly the blocks the reference refuses (88,193) and named their frame, the accepted blocks (151,807) equal the reference’s bytes, and no byte outside the mutated block changed. Each of the nine bounds refused blocks in every layout (3 to 14,910 blocks per bound and layout).
Valid input#
Existing suites on MPS (
testswithout the CUDA, Swift, WebGPU and repository-layout folders; MPS, regional and real-acquisition gates on): base 682 passed and 138 skipped, bounded 683 passed and 138 skipped (the new test).SHA-256 of all 262,144 decoded frames of the acquisition: identical before and after. The first 2048 frames equal h5py’s own decode. The three synthetic stacks: identical digests, and every frame equals h5py’s decode.
Cost#
GPU time of one batch, ms, minimum / median of 300 launches. “Early return” is the line-for-line port of the CUDA bounds, measured in the same rotation. Percentages compare minima with the base; where the two base decoders differ, with both.
Fused kernel (the acquisition; 8192 frames, 604 MB decoded):
Measure |
base |
base, second decoder |
bounded |
early return |
|---|---|---|---|---|
decode command |
5.834 / 5.844 |
5.835 / 5.844 |
6.024 / 6.032 (+3.2%) |
7.106 / 7.114 (+21.8%) |
h5lz4dc_batched (synthetic stacks):
Stack |
Measure |
base |
base, second decoder |
bounded |
early return |
|---|---|---|---|---|---|
uint32 192 x 192, 4096 frames, 604 MB |
LZ4 kernel alone |
4.059 / 4.079 |
4.057 / 4.078 |
4.173 / 4.198 (+2.8%) |
4.490 / 4.512 (+10.6%) |
decode command |
7.277 / 7.301 |
7.245 / 7.268 |
7.362 / 7.389 (+1.2 to +1.6%) |
7.674 / 7.702 (+5.5 to +5.9%) |
|
uint16 96 x 96, 8192 frames, 151 MB |
LZ4 kernel alone |
1.459 / 1.464 |
1.459 / 1.464 |
1.520 / 1.526 (+4.2%) |
1.719 / 1.724 (+17.8%) |
decode command |
2.335 / 2.340 |
2.315 / 2.319 |
2.384 / 2.391 (+2.1 to +3.0%) |
2.574 / 2.581 (+10.3 to +11.2%) |
|
uint8 192 x 192, 8192 frames, 302 MB |
LZ4 kernel alone |
4.176 / 4.183 |
4.176 / 4.183 |
4.403 / 4.408 (+5.4%) |
5.095 / 5.101 (+22.0%) |
decode command |
7.023 / 7.033 |
7.013 / 7.021 |
7.237 / 7.247 (+3.0 to +3.2%) |
7.931 / 7.940 (+12.9 to +13.1%) |
The decode command is the LZ4 kernel followed by the unshuffle kernels. An earlier process gave the same picture: +3.2% for the fused kernel, +2.9%, +4.2% and +5.4% for the LZ4 kernel alone, +1.7%, +2.7% and +3.2% for the three decode commands, with the two base decoders within 0.1% of each other in every row.
Whole calls, wall time, minimum / median:
Call |
base |
bounded |
|---|---|---|
|
15.52 / 16.86 |
16.05 / 17.20 |
|
1.574 / 1.582 |
1.565 / 1.585 |
The kernel difference predicts 6 ms per load (32 batches of 0.19 ms), inside the 30 ms spread of the warm loads.
Where the cost of bounds comes from#
Fused kernel, the acquisition batch, minimum GPU time of 200 launches against the base (5.836 ms):
Formulation |
ms |
vs base |
|---|---|---|
final length check and error report only |
5.816 |
-0.3% |
write bounds only, early return |
6.251 |
+7.1% |
read bounds only, early return |
6.951 |
+19.1% |
all bounds, early return |
7.101 |
+21.7% |
all bounds, early return, extension loops unchecked |
6.674 |
+14.4% |
extension loops stop by their own condition, checks return |
6.770 |
+16.0% |
the same, checks leave by |
6.841 |
+17.2% |
no exit: refused sequence skipped in an |
6.463 |
+10.7% |
no exit, no branch: lengths zeroed by select, read position moved to the stream end |
6.181 |
+5.9% |
the same, decoding continues instead of moving the read position |
6.112 |
+4.7% |
lengths clamped by |
6.101 |
+4.5% |
the same, a match length that runs off the stream set in the extension branch (shipped) |
6.019 |
+3.2% |
the same, refusals counted in an integer instead of a bool |
6.052 |
+3.7% |
select form with unbounded extension loops (unsafe, for attribution) |
6.027 |
+3.3% |
The comparisons are nearly free; the exits are not. Two early returns in the main loop cost 7%. With every bound in place, the forms that leave the loop cost 16.0 to 21.7% and the forms that do not cost 3.2 to 10.7%. The stream bound inside the length-extension loops is 1.4% of the kernel.
Conclusion#
Before the change, damaged LZ4 bytes inside intact framing loaded on Metal without any error, with wrong counts in the damaged block, and a stream that decodes to more than its block overwrote neighbouring blocks and bytes past the batch. Now both Metal decoders refuse the same streams as CUDA, the load raises
ValueErrornaming the frames, and the process keeps decoding.Valid data decodes to the same bytes.
The bounds cost 3.2% of the fused kernel (0.19 ms per 8192 frames) and 2.8 to 5.4% of
h5lz4dc_batched(1.2 to 3.2% of its decode command). A whole load does not change beyond its run-to-run spread.On this Apple GPU the cost of a bound is its control flow. The CUDA form (return at the first refused sequence) costs 22% of the fused kernel, against 11% of the CUDA kernel for the same bounds; the clamped form costs 3%.
A bound detects damage that breaks the LZ4 structure. In the randomized run 63% of the mutated blocks were still valid LZ4 streams of the right length (the test frames are rich in literals) and decoded to changed counts without an error, on Metal as in the reference. These files carry no checksum, so no decoder can see that.
Rejected#
The line-for-line port of the CUDA bounds: 22% of the fused kernel and 11 to 22% of
h5lz4dc_batched.Leaving the loop by
break, skipping a refused sequence withif/else, and counting refusals in an integer: all slower than the clamped form (table above).Moving the read position to the stream end at the first refused sequence, so a damaged stream stops early: 1.2% slower on valid streams than decoding on. A damaged stream is at most its own length of extra work.
A zero byte appended to the compressed buffer so that the length-extension loops need no bound (not built). A run of 0xFF bytes, as an erased region reads, would then send every block’s loop to the end of the batch buffer. The bound costs 1.4%.
A
boundedtemplate flag on the shared direct decoder instead of a second function. The clamped form differs from the original in its loop structure, so one body would test the flag at every bound, and the native entry points would no longer compile from untouched text.Reporting failure the CUDA way, one flag: two atomic updates on the failure path give the frame range at no cost on valid streams (the form with only the final check and the report measured -0.3%).
Not done#
The native Swift entry points of
qh5idx.metalstill decode without bounds, except the family built onbslz4DecompressFullSerialCheckedToDevice, which validates and reports failure already: the kernels that callbslz4DecompressStreamToThreadgroup,bslz4DecompressStreamDirectToThreadgroup, the prefix decoders andbslz4DecompressFullSerialFastToDevice. They have no error buffer, and the native application was not built or measured here. The four kernels on the direct decoder have the topology of the fused kernel measured above.MPSDecompressor.load_prepared_frameswaits for its Metal command without reading the command’s error. No command reported one in any run here.The CUDA test has three damages and a separate test for the block that stops early; the over-long match was added for Metal only.
The cost was measured on one Apple GPU. The exit cost may differ on other Apple GPU generations; the clamped form has no exit to pay for.
No real uint32, uint8 or partial-block acquisition was timed; the three stacks are re-encodings of real frames.