Skip to content

feat(mutators): add shuffle/unshuffle byte-transposition filter - #1169

Merged
batmac merged 1 commit into
mainfrom
shuffle-mutator
Aug 22, 2026
Merged

batmac merged 1 commit into
mainfrom
shuffle-mutator

Conversation

@batmac

@batmac batmac commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Follow-up to closing #850: rather than adding a dependency on a superseded 2007 float codec, this implements the classic byte-shuffle filter that Blosc, HDF5, Parquet and Zarr all ship — in ~90 lines of pure Go, no dependency.

Why it works

An IEEE-754 double keeps its sign and exponent in the high bytes and its noisy mantissa in the low ones. For a series of similar values the high bytes barely change:

100.1 -> 66 66 66 66 66 06 59 40
100.2 -> cd cc cc cc cc 0c 59 40
100.3 -> 33 33 33 33 33 13 59 40      bytes 6-7 identical, byte 5 gradual,
100.4 -> 9a 99 99 99 99 19 59 40      bytes 0-4 noise

The redundancy is real but it lives in columns, while the file is laid out in rows. LZ77-family compressors (gzip, zstd, lz4) only find repeated sequences, and the stable bytes are stranded one per eight — never adjacent. So they find nothing and add framing overhead, which is why zstd grows float64 data.

Shuffling transposes the byte planes, turning those columns into contiguous runs:

66 cd 33 9a | 66 cc 33 99 | ... | 06 0c 13 19 | 59 59 59 59 | 40 40 40 40

Results

160000-byte inputs:

zstd xz shuffle,zstd shuffle,xz
random doubles 160019 143208 141909 129828
smooth series 160019 124732 126483 107812

Both beat what fpc achieved in #850 (143137 / 111527), with no new dependency.

Design

  • Chained, not standalone: it compresses nothing by itself. -m shuffle,zstd to pack, -m unzstd,unshuffle to unpack.
  • Element size is the usual X:8 argument — so shuffle:4 for float32/int32, shuffle:3 for RGB pixels, any fixed-width record with correlated fields.
  • No header, still streaming: 64KiB blocks, and both directions derive the same block size from the element size, so they stay in step without metadata in the stream. Bytes that do not fill an element pass through untouched.
  • Registered under the filter category, next to the other non-compressing transforms.

Verified

Round-trips byte-exactly through files and through a 4-stage stdin pipeline (-m shuffle,zstd,unzstd,unshuffle). Tests cover empty input, sub-element input, non-multiples of the element size, sizes 1/3/4/8, multi-block data and a partial final block, plus an assertion that shuffle+zstd genuinely beats zstd alone. golangci-lint v2: 0 issues; full suite green; builds on the nohl,fileonly tag set too.

🤖 Generated with Claude Code

Groups fixed-width elements by byte position, so a following compressor
sees runs of similar bytes instead of the interleaving that defeats
LZ77. This is the classic filter shipped by Blosc, HDF5 and Parquet.

It compresses nothing by itself; it is meant to be chained:

    ccat data.bin -m shuffle,zstd
    ccat data.sz  -m unzstd,unshuffle

On 160KB of float64 samples, where general-purpose compressors are
weak or counterproductive:

                  zstd       xz    shuffle,zstd  shuffle,xz
  random doubles  160019   143208     141909       129828
  smooth series   160019   124732     126483       107812

(the input is 160000 bytes: zstd alone grows it)

Element size is the standard X:8 argument, so it also covers float32
and int32 (shuffle:4), RGB pixels (shuffle:3) and so on. Blocks of
64KiB are transposed at a time and both directions derive the same
block size from the element size, so the stream needs no header and
stays streamable. Trailing bytes that do not fill an element pass
through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@batmac
batmac enabled auto-merge (squash) August 22, 2026 16:47
@batmac
batmac merged commit 2669067 into main Aug 22, 2026
24 checks passed
@batmac
batmac deleted the shuffle-mutator branch August 22, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant