Skip to content

Add Transpose filter (method 0C) for fixed-size records - #245

Open
plokijuter wants to merge 4 commits into
ip7z:mainfrom
plokijuter:transpose-filter
Open

Add Transpose filter (method 0C) for fixed-size records#245
plokijuter wants to merge 4 commits into
ip7z:mainfrom
plokijuter:transpose-filter

Conversation

@plokijuter

Copy link
Copy Markdown

Adds a byte-transposition filter for data made of fixed-size records: it groups byte i of each R-byte record together, so that homogeneous columns reach the next coder instead of interleaved, unrelated byte streams.

This is the one structure LZ and context models handle poorly today. Sensor logs, struct arrays, PCM audio and numeric tables all have it, and the existing Delta filter only covers the special case R = the stride of a single scalar series.

Measured, with PPMd:o=16 behind the filter

data PPMd alone with filter
int32 ascending array (240 KB) 98450 2442
16-byte struct records (192 KB) 68722 3211
fixed-record .dat file (450 KB) 76019 20975
4x float32 sensor log (480 KB) 399919 215478
4x float32 sensor log (48 MB) 41876772 11721960
random data (200 KB) 205808 205824
text 3862 3878

On the 48 MB sensor log that is also 39% smaller than xz -9e and 66% smaller than zstd -19.

Choosing R

-m0=Transpose detects R automatically; -m0=Transpose:15 forces it.

Detection asks whether transposing helps, not whether the data is periodic. It compares the mean absolute difference between bytes R apart against that of adjacent bytes, and stays at R=1 (identity) unless a column is clearly more homogeneous, so data with no record structure is passed through untouched.

An autocorrelation detector was written first and rejected on measurement: it found periods everywhere (harmonic sidebands clear a 60%-of-peak threshold) and degraded 7 of 10 test files, one from 107607 to 137309 bytes. Periodicity of a signal says nothing about the homogeneity of its columns.

Block size

The transform is applied on fixed-size blocks whose size is deliberately independent of the caller's buffer, since 7-Zip does not use the same buffer sizes when compressing and decompressing. The size is chosen at encoding time from kExpectedDataSize (at most 1/32 of the stream, clamped to 4 KiB..64 KiB) and recorded in the coder properties, so both sides agree whatever their buffers are.

The reason it is not simply a constant: a filter is never told which call is the final one, so the last partial block of a stream is written through unfiltered. With a fixed 64 KiB block that tail reached 12.6% of a 450 KB file and cost 7020 bytes.

Zero-padding the final block through the AES-CBC path in CFilterCoder::Code was considered and rejected: that protocol is only safe because a 16-byte block divides the FilterCoder buffer, so a full buffer never presents a partial block. A block that must be a multiple of R has no such property, and an exactly-full buffer would spin.

Interface

  • Method ID 0C (free slot; 0B RISCV is the last assigned, 21 is LZMA2)
  • Properties: 2 bytes, R-1 then log2 of the block size. A 1-byte property is still accepted and implies a 64 KiB block.
  • C/Transpose.{c,h} plus CPP/7zip/Compress/TransposeFilter.cpp, registered with REGISTER_FILTER_E. No existing file is modified beyond the build hooks and DOC/Methods.txt.

Verification

60 real files of assorted types, forced R from 2 to 256, sizes 0/1/100/4095/4096/65535/65536/200000, a 48 MB stream, and archives written by an earlier 1-byte-property build: all extract byte-identical. Compiles clean under -Werror -Wall -Wextra.

Happy to adjust the method ID, the property layout, or the detection heuristic if you would rather they were shaped differently.

plokijuter and others added 4 commits August 30, 2026 17:28
Groups byte i of each R-byte record together so that homogeneous columns
reach the next coder. Aimed at fixed-size records: sensor logs, struct
arrays, PCM audio, numeric tables -- data where LZ and context models see
interleaved, unrelated byte streams.

Measured with PPMd:o=16 behind the filter:

  int32 ascending array   98450 -> 18411   (+81.2%)
  16-byte struct records  68722 -> 22215   (+67.6%)
  fixed-record .dat file  76019 -> 25319   (+66.6%)
  4x float32 sensor log  399919 -> 221368  (+44.6%)
  random data            205808 -> 205824  (identity)
  text                     3862 -> 3878    (identity)

R is auto-detected by default (-m0=Transpose); -m0=Transpose:15 forces it.

Detection asks whether transposing HELPS, not whether the data is periodic:
it compares the mean absolute difference between bytes R apart against that
of adjacent bytes, and stays at R=1 (identity) unless a column is clearly
more homogeneous. An autocorrelation-based detector was tried first and
rejected -- it found periods everywhere (harmonic sidebands) and degraded 7
of 10 test files, one from 107607 to 137309 bytes.

The block size is a fixed 64 KiB constant, deliberately independent of the
caller's buffer size: 7-Zip does not use the same buffer sizes when
compressing and decompressing, and a buffer-dependent block makes the
transform irreversible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CeqaL4dXjGa9pUc6qRnof
The last partial block of a stream is never transposed: a filter is not told
which call is the final one, so the tail is written through unfiltered. With
a fixed 64 KiB block that tail cost a lot on small and medium files -- on a
450 KB test file, 56509 bytes (12.6%) reached PPMd as raw interleaved data
and cost 7020 bytes, the whole gap against transposing the tail by hand.

The block size is now chosen at encoding time from kExpectedDataSize (aim
for at most 1/32 of the stream, clamped to 4 KiB..64 KiB) and written into
the coder properties, so encoder and decoder agree regardless of their
buffer sizes. Properties grow to 2 bytes; a 1-byte property is still read
and implies the previous 64 KiB block.

With PPMd:o=16 behind the filter:

  16-byte struct records   22215 -> 3211
  int32 ascending array    18411 -> 2442
  fixed-record .dat file   25319 -> 20975
  4x float32 sensor log   221368 -> 215478
  48 MB sensor log           unchanged (large streams keep the 64 KiB block)
  65535-byte file          10305 -> 3055  (the filter now engages from 4 KiB)

Zero-padding the last block through the AES-CBC path was considered and
rejected: that protocol is only safe because a 16-byte block divides the
FilterCoder buffer, so a full buffer never presents a partial block. A block
that must be a multiple of R has no such property, and an exactly-full
buffer would spin in CFilterCoder::Code.

Verified: 60 real files, forced R from 2 to 256, sizes 0/1/100/4095/4096/
65535/65536/200000, a 48 MB stream, and archives written by the previous
1-byte-property build -- all extract byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CeqaL4dXjGa9pUc6qRnof
The heuristic that picks R from the mean absolute difference between bytes R
apart is not safe as a default. Measured over a 40-file corpus it degrades 13
files, the worst from 15026 to 352332 bytes (+2245%): a tiled RGB image whose
tile repeats across the whole file, which a context model exploits directly and
which block-wise transposition destroys.

Adds an opt-in mode that measures instead of guessing: it transposes a sample
with each of the most promising R values, compresses each, and keeps the winner.
R=1 is always in the running, so measuring cannot pick worse than not filtering
-- as far as the sample is representative.

  -m0=Transpose       heuristic, fast
  -m0=Transpose:a=1   measure, probing with PPMd
  -m0=Transpose:a=2   measure, probing with LZMA

The probe must be the coder that actually follows. LZMA and PPMd do not prefer
the same R, and probing with the wrong one produces confident nonsense: on a
1.6 MB stereo sine, an LZMA probe picked R=4 (17374 bytes) where PPMd wanted
R=1 (9420). Three cases fixed by probing correctly:

  1.6 MB stereo sine       17374 -> 9436
  mono sine, R=2 optimal  384966 -> 186871
  256-byte repeating tile   16939 -> 885

The measurement sample also had to grow from 256 KB to 4 MB. The verdict flips
with sample size, because the transposed form costs linearly in the data while
the raw form stays nearly flat when it has long-range redundancy: on a 3.5 MB
sawtooth, R=12 wins on any prefix up to 2 MB (7295 vs 9644) and loses on the
whole file (19520 vs 10170). A short sample concludes backwards, with
confidence.

Known limitation: a filter never sees more than the FilterCoder buffer (2 MB),
so on files a few MB and larger the sample is still not representative and the
measure mode gets 3 of 15 test files wrong. Deciding reliably needs a pass over
the whole input, which does not belong in a stream filter. Extrapolating the
slope between two samples and requiring a margin was tried and rejected: tuned
on 15 files it looked exact, and on the 40-file corpus it left +11.7% on the
table at every threshold.

Also adds mingw-shim/README.md: cross-compiling from Linux needs symlinks for
the headers the source includes with Windows casing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CeqaL4dXjGa9pUc6qRnof
…lter

A stream filter cannot choose R. It never sees more than the FilterCoder
buffer (2 MB), and the verdict flips with how much it sees: on a 3.5 MB
sawtooth, R=12 wins on every prefix up to 2 MB (7295 vs 9644 bytes) and loses
on the whole file (19520 vs 10170). The transposed form costs linearly in the
data while the raw form stays nearly flat when it has long-range redundancy,
so a short sample concludes backwards, with confidence.

Adds -m0=Transpose:a=3, resolved in Update.cpp before the coders are built,
where the input files are known. Four steps, cheapest first:

  1. one reference compression at R=1, needed anyway. If the file already
     compresses more than 10x, stop: measured over 55 files, no gain is ever
     missed above that ratio, and every trap sits above it.
  2. rank by mean absolute difference over all R in 2..256 -- integer
     subtraction, essentially free. Not a grid: the optimum of one test file
     is R=15 and of another R=21, and a grid that skips them costs 27% and
     40x respectively.
  3. rank by a fast LZMA pass over a curated grid, which sees repetitions the
     statistical criterion is blind to.
  4. compress for real at R=1 and at the (at most two) candidates, keep the
     smallest.

Step 4 is what matters: R=1 is always in the race and the result is a minimum
over real compressed sizes, so degrading is impossible by construction, not
merely rare. Every earlier attempt tried to predict well; this one stops
predicting. When R=1 wins, the filter is dropped from the chain entirely, so
it does not even cost the 16-byte coder record.

Because degradation is impossible, the trigger thresholds can be generous: a
candidate too many costs time, never correctness. A tight 0.30 threshold left
85% and 139% of gain on the table on float sensor logs whose ratio is 0.52.

Probing with PPMd, whatever coder follows. The obvious rule -- probe with the
coder that will actually run -- was implemented and then reverted on
measurement: for an LZMA2 chain, an LZMA probe missed 5.8x on struct records
and 11x on an int32 array, while the PPMd probe found the optimum on 4 of 5.
A context model ranks column homogeneity better than a match finder does.

Measured with -m0=Transpose:a=3 -m1=PPMd:o=16, against the best R found by
exhaustive search:

  fixed-record .dat        76019 -> 20975   optimum
  16-byte struct records   68722 ->  3211   optimum
  4x float32 sensor log   399919 -> 215478  optimum
  periodic PCM (85% trap)   9420 ->  9420   correctly abstains
  256-byte tile (19x trap)   869 ->   869   correctly abstains
  RGB tile16 (13.4x trap)   2280 ->  2280   correctly abstains

70 files of assorted types: 0 degradations, 0 round-trip failures. Sizes
0/1/100/1000/100000, multiple files per archive, PPMd and LZMA2 chains all
round-trip byte-identical.

Above TRANSPOSE_FULL_LIMIT (64 MB) the decision is made on a prefix and the
guarantee no longer holds, so a clear margin is required before accepting the
transposition there.

The pass lives in Update.cpp, which compiles into the executables rather than
7z.dll, so UI/Console needs the encoder objects to link -- 7z.exe grows from
576 KB to 814 KB. 7zFM.exe and 7zG.exe are unchanged and therefore still
lack a=3; they keep the older in-filter modes a=1 and a=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CeqaL4dXjGa9pUc6qRnof
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