Skip to content

igzip: build Huffman trees with fewer branches and less stack - #462

Closed
ptweezy wants to merge 8 commits into
intel:masterfrom
ptweezy:igzip-huff-tree-merge-runs
Closed

ptweezy wants to merge 8 commits into
intel:masterfrom
ptweezy:igzip-huff-tree-merge-runs

Conversation

@ptweezy

@ptweezy ptweezy commented Oct 21, 2026

Copy link
Copy Markdown

This PR speeds up build_huff_tree(), which makes the Huffman tree of each block, and cuts its stack use.

This PR is stacked on #444, which adds this version of build_huff_tree(). The commit for review is the last one, "igzip: build Huffman trees with fewer branches and less stack".

Results

I measured on an AMD Ryzen 7 9800X3D (Zen 5) under WSL2. Each cell is MB/s of input, the median over three runs of the best of 15 rounds, where each run alternates the libraries round by round in one process and compresses 256 slices of each size from silesia.tar:

Level Slice master #444 This PR
1 1 KB 156 289 296
2 1 KB 156 288 295
3 1 KB 140 236 241
1 4 KB 354 515 525
2 4 KB 354 515 526
3 4 KB 280 375 381
1 16 KB 610 718 721
2 16 KB 588 688 695
3 16 KB 417 461 466

Against #444, that is 2-3% for 1 KB and 4 KB slices at levels 1 and 2, and 1.5-2% at level 3. With every library relinked behind 0, 704 and 1408 bytes of padding, which moves all of its code, the gain stays at 1.1-2.2% for 1 KB slices and 1.2-2.9% for 4 KB slices at level 1. Whole Silesia files stay within 2% of #444.

With gcc 13 -O2, the deepest stack of build_huff_tree() goes from 2464 bytes to 816, and the deepest stack of create_hufftables_icf(), through write_dynamic_header() and make_code_lens(), from 14,656 bytes to 13,008.

Other CPUs

I timed #444 and this PR against each other on GitHub-hosted runners, with the workflow and harness in my fork (run, Intel and Zen 4 run, Zen 5, Ice Lake and Emerald Rapids run). The harness loads both builds' libisal.so into one process and alternates them round by round. Each cell is #444 → this PR in MB/s, the median over the runs on that CPU (two per runner) of the best of 11 rounds, and in parentheses the median change of the per-round medians. Slices are 256 pieces of the Silesia tar. The arm64 runners are all Neoverse N2.

Runner CPU 1 KB, L1 1 KB, L3 4 KB, L1 4 KB, L3 16 KB, L1
AMD EPYC 7763 (Zen 3), 5 runners 145 → 145 (-0.1%) 109 → 109 (+0.3%) 267 → 268 (+0.3%) 163 → 163 (+0.3%) 380 → 380 (+0.2%)
AMD EPYC 9V74 (Zen 4), 5 runners 186 → 191 (+2.3%) 152 → 155 (+2.0%) 325 → 329 (+1.6%) 230 → 232 (+1.1%) 451 → 454 (+0.7%)
AMD EPYC 9V45 (Zen 5), 3 runners 241 → 248 (+3.1%) 199 → 204 (+2.7%) 432 → 436 (+1.7%) 311 → 315 (+1.2%) 587 → 591 (+0.7%)
Intel Xeon Platinum 8370C (Ice Lake), 3 runners 155 → 158 (+1.8%) 119 → 120 (+0.5%) 279 → 283 (+1.5%) 176 → 177 (+0.7%) 390 → 393 (+0.9%)
Intel Xeon Platinum 8573C (Emerald Rapids), 8 runners 150 → 155 (+3.7%) 128 → 131 (+1.8%) 279 → 284 (+2.2%) 227 → 228 (+1.3%) 391 → 395 (+0.9%)
Intel Xeon 6973P-C (Granite Rapids), 3 runners 203 → 208 (+2.4%) 160 → 163 (+1.6%) 374 → 381 (+3.5%) 289 → 294 (+1.3%) 528 → 534 (+1.4%)
Arm Neoverse N2, 3 runners 120 → 122 (+1.5%) 108 → 109 (+1.4%) 176 → 177 (+1.0%) 126 → 126 (+0.6%) 224 → 225 (+0.3%)

Master → #444:

Runner CPU 1 KB, L1 1 KB, L3 4 KB, L1 4 KB, L3 16 KB, L1
AMD EPYC 7763 (Zen 3), 5 runners 89 → 145 (+62.2%) 74 → 109 (+46.9%) 198 → 268 (+35.0%) 134 → 163 (+21.8%) 334 → 379 (+13.4%)
AMD EPYC 9V74 (Zen 4), 5 runners 109 → 186 (+71.2%) 96 → 152 (+57.7%) 236 → 325 (+37.4%) 182 → 230 (+26.2%) 395 → 451 (+14.0%)
AMD EPYC 9V45 (Zen 5), 3 runners 129 → 240 (+86.2%) 116 → 197 (+69.8%) 286 → 424 (+47.9%) 230 → 308 (+34.3%) 492 → 581 (+17.9%)
Intel Xeon Platinum 8370C (Ice Lake), 3 runners 92 → 155 (+67.8%) 78 → 119 (+52.7%) 201 → 278 (+38.0%) 142 → 176 (+23.9%) 338 → 386 (+14.3%)
Intel Xeon Platinum 8573C (Emerald Rapids), 8 runners 90 → 150 (+65.7%) 82 → 129 (+56.4%) 203 → 280 (+37.4%) 174 → 227 (+30.1%) 341 → 391 (+14.2%)
Intel Xeon 6973P-C (Granite Rapids), 3 runners 123 → 201 (+62.7%) 103 → 157 (+54.8%) 276 → 375 (+36.1%) 219 → 287 (+31.1%) 459 → 522 (+13.1%)
Arm Neoverse N2, 3 runners 81 → 120 (+47.6%) 75 → 108 (+42.9%) 142 → 176 (+23.4%) 107 → 125 (+16.8%) 206 → 224 (+8.7%)

Changes

build_huff_tree() sorts up to 32 keys by counting the smaller keys of each, which takes n * n steps. When it removes the last new node of a frequency, it searches for the end of the nodes of the next frequency, and it keeps the keys of new nodes in a 2 KB array.

  • sort_keys() sorts by counting when that takes fewer steps than counting the smaller keys. It sorts the leaves stably by frequency, which needs them in order of id when there are more than two, as all callers write them. The comments of build_huff_tree() and sort_keys() state this.
  • When no key has a frequency of 0, which is the case for every deflate block, a new node never joins the nodes of its frequency after one of them has been removed. So the end of the nodes of each frequency can be recorded as they are made, and the key of the next node to remove kept at hand. Keys with a frequency of 0, which only isal_create_hufftables() passes, go through struct node_queue, which searches for the end of each frequency.
  • The keys of new nodes go into the slots of removed leaves. When node k is made, at least k + 2 leaves have been removed, because at most k of the 2 * k + 2 keys removed so far can be nodes.

Testing

  • make -f Makefile.unx check passes on x86-64, with arch=noarch, and for aarch64 under qemu.
  • create_hufftables_icf() gives the same tables, header bits and return value as igzip: build deflate Huffman codes faster #444 for 302,454 histograms, captured from compression of 1 KB, 4 KB, 16 KB and 1 MB Silesia slices, plus random ones.
  • isal_create_hufftables() and isal_create_hufftables_subset(), which pass keys with a frequency of 0, give the same tables as igzip: build deflate Huffman codes faster #444 for 300,000 random histograms.
  • Stateless and stateful compression at every level, over 40,000 random operation sequences on Silesia data, gives the same output as igzip: build deflate Huffman codes faster #444.

build_huff_tree() repeatedly removes the two smallest keys from a binary
heap and inserts the key of their parent. Each removal sifts a key down
the heap with an unpredictable branch at each level. When level 1
compresses a 4 KB input on an Apple M4, build_heap() and
build_huff_tree() take 28% of the time.

Replace the C build_heap() and build_huff_tree() with a construction
that gives the same tree. Sort the leaf keys with a radix sort on the
frequency, and keep the keys of new nodes in the order they are made,
which is ascending order of frequency. New nodes get decreasing ids, so
among new nodes with the same frequency, the newest one has the smallest
key. The smallest remaining key is then either the smallest leaf or the
newest new node with the lowest frequency. The sort uses the tree nodes
as scratch space, before they hold the tree.

aarch64, ppc64le, riscv64, and base builds use the C functions, and x86
keeps its assembly versions. The compressed output does not change: for
900,000 random histograms, many of them with equal frequencies, the new
functions build the same tree node for node. The exception is a
histogram given to isal_create_hufftables() or
isal_create_hufftables_subset() whose counts add up to 2^48 or more.
The keys hold the counts in 48 bits, so the sums of keys wrap around,
and the two constructions then give different code lengths, both of
them valid.

On an Apple M4, with the faster aarch64 deflate bodies of a separate
patch series, stateless level 1 and 2 compression of 1 KB, 4 KB, 16 KB,
and 64 KB inputs gets 35%, 23%, 8%, and 2% faster, and level 3 gets
29%, 16%, 4%, and 2% faster.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
The Huffman code generation for each deflate block has loops with
data-dependent branches: init_heap32() and the other init_heap functions
skip zero frequencies, fix_code_lens() handles leaves and internal
nodes differently, and set_huff_codes() skips unused symbols. The
init_heap functions also clear the whole 6.9 KB heap_tree for each of
the three trees of a block, even for the 19 code length symbols.

Store every key and advance past the nonzero ones, handle both kinds of
tree node with the same stores, and assign codes to unused symbols too,
which get the code 0 they already have. Clear only the tree nodes that
build_huff_tree() fills.

The compressed output does not change on aarch64 or x86. On an Apple
M4, with the faster aarch64 deflate bodies of a separate patch series,
stateless level 1 compression of 1 KB and 4 KB inputs gets 6% and 3%
faster. On an AMD Ryzen 7 9800X3D, stateless level 1 and level 3
compression of 1 KB inputs gets 5% faster.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
On x86, build_huff_tree() comes from proc_heap.asm, which keeps the
keys in a binary heap. Each insertion and removal takes a
data-dependent branch at every level of the heap. The assembly also
writes only the child id of each tree node, so the init_heap
functions clear the tree nodes before each tree.

Use the C build_huff_tree() from proc_heap_base.c on every
architecture. It gives the same tree and writes each node in full, so
remove build_heap() and the clearing of the tree nodes, and remove
proc_heap.asm and heap_macros.asm.

The compressed output does not change, except, as on the other
architectures, for histograms given to isal_create_hufftables() or
isal_create_hufftables_subset() whose counts add up to 2^48 or more.
On an AMD Ryzen 7 9800X3D, stateless level 1 compression of 1 KB,
4 KB, and 16 KB inputs gets 40%, 30%, and 12% faster, and level 3
compression 34%, 21%, and 8%.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
Before it decodes a dynamic block, inflate decodes the code lengths in
the block header and builds the lit/len and distance decode tables. On
an AMD Ryzen 7 9800X3D this takes about 6,100 cycles for the header of
a 1 KB block of Silesia, about half the time of inflating a 1 KB input,
and 14,300 to 15,500 for a 64 KB block, depending on the compressor.
The time goes to:

- decoding the code lengths with the bit buffer in inflate_state,
  which every store to the code lengths can alias, and with a 10 bit
  table for the code length code, whose codes have at most 7 bits,
- a branch on the code length of each of the 256 literals, and loads
  of counts and next codes right after they are stored, while giving
  the literals their codes and sorting them by length,
- finding the long codes that share their first 12 bits by comparing
  every pair of long codes.

Rewrite setup_dynamic_header() and the table builders:

- Decode the code lengths into a byte array with the bit buffer in
  local variables and a 7 bit table, and add each literal to a list
  for its code length as it is decoded.
- Give the literals of each length consecutive codes from those lists,
  then expand the length symbols, which leaves every expanded code
  sorted by length without a separate sort.
- Group the long codes by their first 12 bits in one pass, numbering
  the groups in the short table entries that later point to them.
- Build the code length code table by doubling it once per length,
  with the loop unrolled so the copies need no calls.

The tables decode every input the same as before. The old code could
count long codes of groups it had already handled as members of the
group whose first 12 bits are all ones, which made that group's
subtable bigger than its codes need. The new code sizes each subtable
for its own codes, so the long code table can be laid out differently.

For the headers of 3,035 dynamic blocks of Silesia slices compressed
by zlib and by isa-l levels 1 and 3, 200,000 corrupted and truncated
variants of them, and 200,000 headers with many long codes, the new
code returns the same result, and its tables decode every input the
same. Inflate gives the same output, return value, and remaining input
as before for 40,000 valid and corrupted streams. The tables in
static_inflate.h come out the same.

On an AMD Ryzen 7 9800X3D, setting up the tables takes 38% fewer
cycles for the headers of 1 KB blocks, 33% fewer for 4 KB and 16 KB
blocks, and 37% fewer for 64 KB blocks. Inflate of 1 KB, 4 KB, 16 KB,
and 64 KB slices of Silesia gets 21%, 10%, 5%, and 2% faster.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
create_hufftables_icf() takes about 15,000 cycles for a 1 KB block on
an AMD Ryzen 7 9800X3D, two thirds of the time of compressing a 1 KB
input at level 1. It handles all 286 lit/len symbols at each step: it
gathers them into a heap, writes a length and gives a code to each,
adds up the length of the block over all of them, and run length
encodes the code lengths with a branch on each one. fix_code_lens()
reads 8 byte tree nodes whose depth was just stored as 4 bytes, which
the CPU cannot forward from the store.

Rewrite it around the symbols that occur:

- make_code_lens() gathers them, builds the tree with
  build_huff_tree(), and visits it in the order fix_code_lens() does,
  with the depths in a byte array. It then limits the code lengths
  the same way.
- set_codes_by_sym() gives codes to these symbols only, and the length
  of the block adds up only these symbols.
- rl_encode_lens() finds each run of code lengths with 8 byte compares.
- write_dynamic_header() writes each code length code together with
  its extra bits, with the bit buffer in local variables.

isal_create_hufftables() and isal_create_hufftables_subset() now use
rl_encode_lens() and write_dynamic_header() too, and the functions they
replace are removed. sort_keys() sorts 32 keys or fewer by counting the
smaller keys of each, which takes no branches, instead of with an
insertion sort.

The output does not change. create_hufftables_icf() gives the same
tables, header bits, and return value for 500,000 random histograms,
many with equal counts or counts that need their code lengths limited,
and for the histograms of 2,454 blocks of Silesia.
isal_create_hufftables() and isal_create_hufftables_subset() give the
same tables for 300,000 random histograms, and deflate gives the same
output for 40,000 random combinations of level, flush, wrapper, and
buffer sizes.

On an AMD Ryzen 7 9800X3D, create_hufftables_icf() takes 30%, 25%, 21%,
and 18% fewer cycles for blocks of 1 KB, 4 KB, 16 KB, and 1 MB slices
of Silesia. Level 1 and 2 compression of 1 KB, 4 KB, and 16 KB slices
gets 25%, 12%, and 5% faster, and level 3 compression 22%, 8%, and 2%.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
When inflate decodes the final block of a stream, it leaves pairs and
triples of symbols out of the lit/len table when little input is left,
because they take longer to add than they save: it builds single
symbols for up to 2 KB of input, adds pairs for up to 4 KB, and adds
triples above that.

With the tables faster to build, single symbols are still best for 1 KB
slices of Silesia, which compress to about 500 bytes, but on an AMD
Ryzen 7 9800X3D pairs pay off from about 1 KB of input, and triples
only from about 8 KB. Build single symbols for up to 1 KB of input,
pairs for up to 8 KB, and triples above that.

The decompressed data does not change, but inflate decodes pairs and
triples as a whole. When the output buffer fills up, the input consumed
can differ by a byte or two from before, and when the input is
corrupted or ends too soon, inflate can stop one literal earlier.
Continuing a valid stream gives the same result, and such differences
could already happen between inputs of different sizes.

On the same CPU, inflate of 3 KB slices of Silesia gets 1% to 3%
faster, of 4 KB slices 2% to 3% faster, and of 8 KB slices about 1%
faster, while inflate of 2 KB slices gets up to 2% slower. 1 KB slices
and slices of 16 KB and more stay within about 1%. Single symbols for
up to 1.5 KB of input lost most of the gain at 3 KB and did not
measurably help at 2 KB.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
The inflate decode tables keep codes longer than the short table index
in long_code_lookup, which has 80 entries for the distance code and
1264 for the lit/len code. igzip_lib.h sizes it for the codes, entries
that repeat codes shorter than the longest code of their group, and
entries that no code uses, which only incomplete codes have.
setup_dynamic_header() only rejects oversubscribed codes, so a header
can define an incomplete code, and the bound on unused entries is too
small: it allows 21 for the distance code, but a 15-bit code alone in
the last group leaves 31 entries unused. An incomplete distance code
can then need 86 entries: 30 codes, 25 repeats, and 31 unused entries.
make_inflate_huff_code_dist() writes them without a check: it clears
up to 12 bytes past long_code_lookup, which are the block_state,
dict_length, and bfinal fields of inflate_state. master's
make_inflate_huff_code_dist() has the same problem.

Both table builders add up the space their long codes need before
writing them, so return ISAL_INVALID_BLOCK if it is more than
long_code_lookup holds. Complete codes always fit: a complete distance
code needs at most 48 entries. zlib rejects incomplete codes as
invalid, except a lit/len or distance code of a single code of length 1
and an empty distance code, none of which has long codes. The lit/len
table gets the same check, since its size was worked out the same way,
though no lit/len code was found that needs more than it holds.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
build_huff_tree() sorts up to 32 keys by counting the smaller keys of
each, which takes n * n steps. When it removes the last new node of a
frequency, it searches for the end of the nodes of the next frequency.
It keeps the keys of new nodes in a 2 KB array.

- sort_keys() sorts by counting when that takes fewer steps than
  counting the smaller keys. It sorts the leaves stably by frequency,
  which needs them in order of id when there are more than two, as all
  callers write them.
- When no key has a frequency of 0, which is the case for every deflate
  block, a new node never joins the nodes of its frequency after one of
  them has been removed. So the end of the nodes of each frequency can
  be recorded as they are made, and the key of the next node to remove
  kept at hand. Keys with a frequency of 0, which only
  isal_create_hufftables() passes, go through struct node_queue, which
  searches for the end of each frequency.
- The keys of new nodes go into the slots of removed leaves. When node k
  is made, at least k + 2 leaves have been removed, because at most k of
  the 2 * k + 2 keys removed so far can be nodes. With gcc -O2 the
  deepest stack of build_huff_tree() goes from 2464 to 816 bytes.

The tree does not change. On an AMD Ryzen 7 9800X3D, level 1 to 3
compression of 1 KB and 4 KB slices of Silesia gets 1-2.5% faster.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
@ptweezy

ptweezy commented Oct 21, 2026

Copy link
Copy Markdown
Author

Closing: this change is in #461 as "igzip: build Huffman trees with fewer branches and less stack".

@ptweezy ptweezy closed this Oct 21, 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