Skip to content

igzip: speed up aarch64 deflate - #440

Closed
ptweezy wants to merge 11 commits into
intel:masterfrom
ptweezy:igzip-aarch64-speedups
Closed

ptweezy wants to merge 11 commits into
intel:masterfrom
ptweezy:igzip-aarch64-speedups

Conversation

@ptweezy

@ptweezy ptweezy commented Oct 18, 2026 •

Copy link
Copy Markdown

This PR speeds up aarch64 deflate at levels 0 to 3. The ICF encoder and the level 0 body keep their bit buffers in registers, and the level 0 to 2 bodies check each match candidate with one data-dependent branch and compare long matches 16 bytes at a time. At level 3, gen_icf_map_h1_aarch64 is rewritten to match gen_icf_map_h1_base() and to handle four positions per iteration with NEON, and set_long_icf_fg_aarch64 checks eight entries at a time. The first commit for review clears the hash table with memset() instead of wmemset(), which is slow on macOS.

Where it runs

The memset() commit changes igzip/igzip.c on every architecture. The rest is aarch64 code. As on master, the dispatcher selects the level 0 to 2 bodies and the level 3 match map generator on CPUs with CRC32, and the ICF encoder (encode_df.S) and set_long_icf_fg_aarch64 on every aarch64 CPU. The one dispatcher change makes level 3 insert preset dictionaries with isal_deflate_hash_base() on every aarch64 CPU, since the match map generator hashes with compute_hash().

Output

Levels 0 to 2 compress to the same bytes as master with #439 at every commit. On CPUs with CRC32, master's level 3 match map hashes with CRC32C. From "speed up aarch64 level 3 match map generation" on, it hashes with compute_hash(), so level 3 output matches the C implementation (arch=noarch), and no later commit changes it. Stateless level 3 output of the Silesia files is 0.04% larger than master's (75,636,323 against 75,603,510 bytes).

Dependencies

Stacked on #439 (the level 1 and 2 rewrite builds on #439's ICF buffer fix): the first three commits are #439's. Review starts at "igzip: reset the deflate hash table with memset". Through #439's tests, this PR conflicts with #448 in igzip/igzip_rand_test.c.

Results

Neoverse N2 runners, master → this PR in MB/s (run, harness): in-process A/B of the two libraries, median of 9 runs on three runners of the best of 11 rounds, on the Silesia files and 256 slices of the Silesia tar per size.

Deflate Whole files 1 KB slices 4 KB slices 16 KB slices 64 KB slices
Level 0 431 → 572 411 → 519 420 → 543 441 → 549 437 → 560
Level 1 266 → 424 81 → 97 143 → 198 206 → 312 249 → 392
Level 2 262 → 417 81 → 97 143 → 198 204 → 305 251 → 387
Level 3 146 → 213 76 → 86 112 → 144 132 → 185 142 → 207

Against its parent, "speed up aarch64 level 3 match map generation" makes level 3 14% faster on the whole files (150 → 171 MB/s) and 2%, 8%, 12% and 14% faster on 1 KB, 4 KB, 16 KB and 64 KB slices.

Testing

  • tools/check_format.sh passes on every commit. Under qemu-user (GCC 13.3), make -f Makefile.unx host_cpu=aarch64 arch=aarch64 check passes at every commit.
  • make -f Makefile.unx check passes at the tip on x86-64 Linux and with arch=noarch. tools/test_extended.sh passes on a Neoverse N2 runner (run).
  • Under qemu-user, a differential test (levels 0 to 3, stateless and stateful, all flushes, wrappers, hist_bits, dictionaries, level buffer sizes and random buffer sizes; every input buffer ends at a PROT_NONE page) compared each commit with master with igzip: fix full flush corruption and two other bugs #439 on 15,000 cases and the tip on 60,000: levels 0 to 2 are byte-identical at every commit, and level 3 until "speed up aarch64 level 3 match map generation". From that commit on, level 3 is byte-identical to the arch=noarch build. zlib inflates every finished raw deflate, gzip and zlib stream without a preset dictionary, and nothing is read past an input buffer.
  • On a Neoverse N2 runner, stateless deflate of 1 KB to 64 KB slices of the Silesia tar starting every 4,093 bytes gives the same output as master with igzip: fix full flush corruption and two other bugs #439 at levels 0 to 2 (scan). Against master, 71 of the 51,770 64 KB slices differ at level 1 and 59 at level 2, through igzip: fix full flush corruption and two other bugs #439's ICF buffer fix, level 3 differs in 77% to 100% of the slices of each size, and zlib inflates both outputs of every slice that differs.

@ptweezy
ptweezy force-pushed the igzip-aarch64-speedups branch from 8bb8958 to 806d0db Compare October 19, 2026 00:20
@ptweezy
ptweezy force-pushed the igzip-aarch64-speedups branch from bef0470 to eb907c3 Compare October 20, 2026 21:14
ptweezy added a commit to ptweezy/isa-l that referenced this pull request Oct 20, 2026
Signed-off-by: Parker Loflin <parker@cronstable.dev>
ptweezy added a commit to ptweezy/isa-l that referenced this pull request Oct 20, 2026
Signed-off-by: Parker Loflin <parker@cronstable.dev>
ptweezy added a commit to ptweezy/isa-l that referenced this pull request Oct 20, 2026
… every level

Signed-off-by: Parker Loflin <parker@cronstable.dev>
@ptweezy
ptweezy force-pushed the igzip-aarch64-speedups branch from eb907c3 to 259af37 Compare October 21, 2026 21:27
@ptweezy ptweezy changed the title igzip: speed up aarch64 inflate and deflate igzip: speed up aarch64 deflate Oct 21, 2026
@ptweezy
ptweezy marked this pull request as draft October 21, 2026 21:54
When a call compresses all input from the caller's buffer and the flush
type is FULL_FLUSH, get_hist_size() returns 0, so isal_deflate() keeps
no history. The match history is reset when sync_flush() writes the sync
marker: it sets has_hist to IGZIP_NO_HIST, and the next call resets the
hash table.

If the output buffer fills before the end of the block is written, the
flush is still pending when isal_deflate() returns, and has_hist is
not IGZIP_NO_HIST. If the caller supplies more input at that point,
the next call compresses that input before it writes the sync marker,
with the old hash table but without the history it refers to. At every
level, match candidates can then point up to 32 KB before the start of
the caller's input buffer, so the compressor reads memory outside the
input. When those bytes happen to match, it emits a valid deflate
stream that inflates to different data.

When the call that leaves the flush pending compresses from the internal
buffer, which holds the history of earlier input compressed with
NO_FLUSH or SYNC_FLUSH, or of a dictionary, the history stays there. If
the next call's input does not fit next to it, isal_deflate() clears the
flush type while it compresses, so it writes a pending sync marker
without resetting the match history, and the input after the marker is
compressed against the data before it. A full flush is meant to make the
following blocks independent of earlier data.

When a call compresses all input while a full flush is pending, set
has_hist to IGZIP_NO_HIST, so that the next call resets the match
history before it compresses new input, as it does after the sync
marker. Stored blocks are left alone: write_stored_block() resets the
match history when it completes a full flush. Callers that keep the
flush type until each flush completes, and supply more input only
after it completes, get the same output as before.

Add two tests to igzip_rand_test. One compresses with FULL_FLUSH and
small output buffers at each level, and supplies more input while a
flush is pending; without the fix, it fails for most seeds, including
the default one. The other leaves the sync marker of a full flush
pending at the start of a stream, after a SYNC_FLUSH, and after a
dictionary, then supplies IGZIP_HIST_SIZE + 8 KB more input, and
checks that the data after the marker decompresses on its own. Without
the fix, it fails after the SYNC_FLUSH or the dictionary for most
seeds.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
level_buf->icf_buf_avail_out holds the free space in the ICF buffer in
bytes. init_new_icf_block() and compress_icf_map_g() store bytes, every
reader treats the value as bytes, and the x86 level 1 and 2 functions
subtract the bytes they write. update_state() in igzip_icf_base.c and
the aarch64 level 1 and 2 body and finish functions store a count of
deflate_icf entries instead, so every call that leaves the block open
cuts the remaining space to a quarter.

The block then ends early. In stateless compression this happens when
the finish function runs after the body. In stateful compression it
happens each time a call runs out of input, so smaller input buffers
give more blocks. On dickens at level 1, 4 KB input buffers give
45.11% instead of the 45.01% of a single call.

Store the space in bytes. Stateful level 1 and 2 compression of
dickens in 4 KB, 16 KB, or 64 KB input buffers then gives the same
size as a single call.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
print_perf_line() picks the label from the deflate strategy, so the
isal stateless, isal stateful, and zlib inflate runs all print the
label of the deflate run before them, for example zlib_inflate for
every inflate run after a zlib level. Use info->inflate_mode, which
the inflate runs already set.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
reset_match_history() fills the hash table with wmemset(). wchar_t is 4
bytes on Linux and macOS, and wmemset() is not vectorized in every C
library: on an Apple M4 it takes 4.0 us to fill a 64 KB level 2 or 3
hash table, where memset() takes 0.33 us. At the start of a stream the
fill value is 0, so clear the table with memset(), and use a plain loop
for other fill values.

The compressed output does not change. On an Apple M4, stateless level 0
compression of 1 KB inputs goes from 686 to 813 MB/s.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
encode_deflate_icf_aarch64 stores the bit buffer to memory after every
symbol and loads it back, which puts store-to-load forwarding on the
loop-carried dependency. Keep the bit buffer in registers and store it
on exit. Combine the length code, distance code, and distance extra
bits before shifting them into the bit buffer, so only one shift
depends on the bit count.

The compressed output does not change. On an Apple M4, deflate on the
Silesia corpus goes from 381 to 460 MB/s at level 1 and from 348 to 409
MB/s at level 2.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
isal_deflate_icf_body_hash_hist_aarch64 writes each deflate_icf entry
with three read-modify-write sequences, branches separately on the
hash distance and on the match length, and compares matches 8 bytes
at a time with each load address depending on the previous
comparison.

Rewrite the body. Write each entry with one 32-bit store. Compare the
first 8 bytes of every candidate inline: when the hash distance is out
of range, compare the input with itself and force the length to 0, so
each position has one data-dependent branch. Compare longer matches 16
bytes per iteration and count the matching bytes once at the end.
Compute the distance code without branches.

The hash function, the hash table updates, and the compressed output do
not change. On an Apple M4, deflate on the Silesia corpus goes from 460
to 578 MB/s at level 1 and from 408 to 585 MB/s at level 2.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
isal_deflate_body_aarch64 stores the bit buffer to the stream after
every symbol, branches separately on the hash distance and on the
match length, and compares matches with compare_258_bytes, whose next
load address depends on the previous comparison.

Rewrite the body like the level 1 and 2 body. Keep the bit buffer in
registers and store it on exit. Compare the first 8 bytes of every
candidate inline, with one data-dependent branch per position, and
compare longer matches 16 bytes per iteration. Compute the distance
code for distances above 2 without branches. Remove compare_258_bytes,
which has no other users.

The hash function, the hash table updates, and the compressed output do
not change. On an Apple M4, level 0 deflate on the Silesia corpus goes
from 575 to 681 MB/s.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
On CPUs with CRC32, the aarch64 level 3 body reaches
gen_icf_map_h1_aarch64 through the gen_icf_map_lh1 dispatcher. The
function hashes with CRC32C instead of the compute_hash() multiplicative
hash of gen_icf_map_h1_base(), and branches on whether each position
starts a match.

Replace gen_icf_map_h1_aarch64 with a version that produces the same
output as gen_icf_map_h1_base(), including the compute_hash() hash. Its
loop has no data-dependent branches: for each position it builds both
the literal and the match entry and selects one. Insert level 3 preset
dictionaries with isal_deflate_hash_base(), which also hashes with
compute_hash(), so that level 3 still finds dictionary matches.

Level 3 output then matches the C implementation.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
The aarch64 level 0, 1, and 2 bodies count the matching bytes among the
first 8 (eor, rbit, clz, and a shift) before the branch that separates
literals from matches. That branch mispredicts often, and it resolves
only after the count is known.

Branch on a 4-byte comparison of the input and the candidate, folded
into the distance range check with ccmp, and count the matching bytes
only on the match path. A match needs 4 equal bytes, so the branch goes
the same way as before.

The compressed output does not change. On an Apple M4, deflate on the
Silesia corpus goes from 693 to 739 MB/s at level 0, from 582 to 609
MB/s at level 1, and from 590 to 616 MB/s at level 2.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
gen_icf_map_h1_aarch64 handles one position per iteration with about 35
scalar instructions, and instruction throughput limits it. On an Apple
M4, each instruction added to the loop slows level 3 by about 1.5%.

Handle four positions per iteration. NEON computes the four
compute_hash() values, the distance codes, and the deflate_icf entries.
The hash table updates, the candidate loads, and the match lengths stay
scalar and in order, so each position sees the hash table update of the
position before it. The scalar loop handles the last positions. Its
compute_hash() uses a 32-bit multiply for the second product, which
gives the same masked hash because the hash mask is less than 1 << 16.

The compressed output does not change.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
set_long_icf_fg_aarch64 checks one match map entry per iteration for an
8-byte match, extends those matches 8 bytes at a time, and copies a
128-byte distance table to the stack with memcpy() on every call.

Find the entries with 8-byte matches 8 entries at a time with NEON,
extend the matches 16 bytes at a time, and read the distance table in
place.

The compressed output does not change.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
@ptweezy
ptweezy force-pushed the igzip-aarch64-speedups branch from 259af37 to 19e32fe Compare October 24, 2026 02:53
@ptweezy ptweezy closed this Oct 24, 2026
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