Skip to content

perf: decode two HPACK Huffman symbols per table lookup - #927

Draft
ArniDagur wants to merge 5 commits into
hyperium:masterfrom
ArniDagur:huffman-fast-decode
Draft

perf: decode two HPACK Huffman symbols per table lookup#927
ArniDagur wants to merge 5 commits into
hyperium:masterfrom
ArniDagur:huffman-fast-decode

Conversation

@ArniDagur

@ArniDagur ArniDagur commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

This PR is LLM generated, inspired by #926. If there is interest, I could clean this up and get it to a mergable state. Otherwise I think there's value in the benchmark numbers on their own.

Since this PR uses unsafe, I'd want to for example add fuzz testing for added confidence.


Note

The below text is LLM generated

This builds on #926 and includes its commits; only the last commit (6177a6f) is new. Opening as a draft to share the approach, the experiments that were tried, and benchmark numbers.

What this does

#926 replaced the nibble-at-a-time state machine with byte-wide tables, decoding up to one symbol per 8-bit lookup. This PR adds two techniques on top, borrowed from fast DEFLATE/zstd decoders:

  1. A primary 12-bit lookup table that decodes up to two symbols per lookup. The most common HPACK codes are five to seven bits long, so a 12-bit index usually covers two whole codes (5+5, 5+6, 5+7, 6+6). Each u32 entry packs both decoded bytes, a symbol count, and the total bits consumed. The table (16 KB) is built at compile time by a const fn from ENCODE_TABLE. Codes longer than 12 bits — control characters and non-ASCII octets — are rare in header data and complete by walking the byte-wide tables from perf: optimize HPACK decoding with 8-bit tables #926, which also handle the tail/padding logic.
  2. A 64-bit bit buffer refilled seven bytes at a time with a single unaligned load (the idempotent acc |= word >> bits refill), instead of feeding the accumulator one byte per iteration.

Output is written through a raw pointer into capacity reserved up front, with a speculative two-byte store per lookup. The shortest code is five bits, so decoded output is at most ⌊8·len/5⌋ bytes and the reservation of 2 * len always covers the store plus one byte of slack.

Benchmarks

Apple M4 Max, cargo bench with the harness from #926 (same four inputs on all three revisions):

bench master (46bfd62) #926 (dd225e5) this PR vs master vs #926
decode_header_value 196 ns / 225 MB/s 171 ns / 258 MB/s 61 ns / 733 MB/s 3.2× 2.8×
decode_long_ascii 274 ns / 218 MB/s 242 ns / 248 MB/s 91 ns / 659 MB/s 3.0× 2.7×
decode_short_ascii 46 ns / 266 MB/s 47 ns / 255 MB/s 21 ns / 600 MB/s 2.2× 2.2×
decode_all_octets 2712 ns / 214 MB/s 1632 ns / 357 MB/s 1377 ns / 423 MB/s 2.0× 1.2×

decode_all_octets is a synthetic worst case (every byte value, dominated by 13–28-bit codes on the fallback path); real header data is ASCII, where the win is largest.

Experiments that were tried (and where they ended up)

The starting question was whether SIMD could help. It can't, directly: Huffman decoding of a single stream is inherently serial — the position of codeword n+1 is unknown until codeword n is decoded. Decoders that use SIMD (e.g. zstd's Huffman) rely on the encoder splitting output into four interleaved streams, and HPACK's single-stream format is fixed by RFC 7541. So the ceiling is scalar ILP, which is what this PR goes after. Variants measured along the way (same machine/harness):

variant header_value long_ascii short_ascii all_octets notes
12-bit, 2 symbols (this PR, 16 KB) 60.4 ns 90.6 ns 20.5 ns 1397 ns
13-bit, 2 symbols (32 KB) 58.9 81.4 20.6 1333 ~5–10% for 2× table
14-bit, 2 symbols (64 KB) 55.6 78.4 18.8 1327 ~8–13% for 4× table
15-bit, up to 3 symbols (128 KB) 56.2 81.1 18.9 1316 no better than 14-bit
long-code path outlined as #[cold] fn 82.7 115.9 24.7 1720 ~35% regression (hot-loop regalloc)
fully safe variant (put_u8/put_slice, try_into reads) 108.7 150.8 35.1 1503 still ~1.6× over #926

Two takeaways from the sweep:

  • Going 12 → 14 bits buys single-digit percentages; 14 → 15 bits with three symbols per entry buys nothing despite an 8× larger table. The loop is latency-bound on the serial chain (load entry → extract bits → shift accumulator → next index → next load), so wider entries stop paying once refill stops being the bottleneck. Given that the microbenchmarks keep the table hot in cache — which flatters large tables relative to bursty real-world HPACK decoding — 12 bits (16 KB) looked like the right size/speed point. 13 bits is defensible if the extra 16 KB is considered cheap.
  • If unsafe in this path is unwanted, the safe variant keeps a solid chunk of the win (~1.6× over perf: optimize HPACK decoding with 8-bit tables #926 on ASCII benches, table above). The unsafe here is three small blocks (one unaligned read, speculative stores into reserved capacity, and the final set_len), each with a safety comment tied to the capacity analysis at the top of decode.

Correctness

The previous decoder (from #926) is kept as a test-only reference implementation, and differential tests assert byte-identical output and identical errors against it on:

  • round-trips of random inputs of every length 0–599, and
  • 40,000 random/adversarial raw inputs (half biased toward 0xE0-0xFF bytes to exercise long codes, EOS prefixes, and invalid padding).

This caught one real bug during development (the long-code fallback initially rejected valid codes whose final table level lands on a partial byte), so the coverage does bite. All existing tests pass unchanged; MSRV (1.63) verified — the table builder fills prefix ranges per code rather than searching codes per index specifically to stay under older compilers' const-eval step limit.

seanmonstar and others added 3 commits July 28, 2026 14:14
Builds on the byte-wide tables by adding a primary 12-bit lookup table
(16 KB, built at compile time from ENCODE_TABLE). The most common HPACK
codes are five to seven bits, so a single 12-bit index usually covers
two whole symbols, which are emitted from one table entry. Input is
consumed through a 64-bit bit buffer refilled seven bytes at a time
with one unaligned load. Codes longer than twelve bits are rare and
complete by walking the existing byte-wide tables.

Output is written through a raw pointer into capacity reserved up
front, with a speculative two-byte store per lookup; the shortest code
is five bits, so reserving twice the input length always suffices.

The previous decoder is kept as a test-only reference: differential
tests assert identical output, including identical errors, across
valid round-trips and 40,000 random and adversarial inputs.
@seanmonstar

Copy link
Copy Markdown
Member

Cool! I need to look more closely at the general technique, but one question: do the improvements depend on the unsafe code a lot? Or can we do it with all safe code for basically the same performance?

The wide refill is guarded by `pos + 8 <= len`, which lets the
compiler prove the slice index infallible: the emitted instructions
are identical to the unaligned-pointer read, with the bounds check
and unwrap panic path deleted entirely (verified by diffing the
generated assembly on aarch64). Benchmarks are unchanged within
noise, so keep the safe form.
@ArniDagur

Copy link
Copy Markdown
Contributor Author

I was able to delete the unsafe read: 52b646a. The assembly is identical aftewards.

The unsafe writes are somewhat faster, but less than I originally stated once the safe version is done carefully.

Naively replacing the stores with put_u8/put_slice removes half the win. However a fully safe variant that resizes the output to its worst-case length once, and then writes through an indexed slice, truncating at the end, lands at ~72 ns. That's within 10-20% of the unsafe version, and still ~2.4× faster than the 8-bit-table decoder on ASCII header data:

#926 fully safe unsafe
decode_header_value 171 ns 72 ns 60 ns
decode_long_ascii 242 ns 97 ns 89 ns
decode_short_ascii 47 ns 21 ns 20 ns
decode_all_octets 1632 ns 1420 ns 1387 ns

Replace the pointer-based output path with safe code: zero-fill the
output buffer to its worst-case length up front (at most twice the
input, since the shortest code is five bits), write decoded bytes
through a plain indexed slice, and truncate to the decoded length at
the end. The speculative two-byte store per lookup carries a bounds
check the optimizer cannot fully remove, and the up-front zero-fill
is a small memset, which together cost roughly 10-20% on the ASCII
decoding benchmarks relative to the unsafe stores:

test hpack::huffman::bench::decode_all_octets   ... 1,505.17 ns/iter (+/- 83.21) = 387 MB/s
test hpack::huffman::bench::decode_header_value ...    71.02 ns/iter (+/- 2.18) = 619 MB/s
test hpack::huffman::bench::decode_long_ascii   ...   105.56 ns/iter (+/- 1.17) = 571 MB/s
test hpack::huffman::bench::decode_short_ascii  ...    20.20 ns/iter (+/- 0.58) = 600 MB/s

That is still 2.3-2.4x faster than the byte-at-a-time decoder on
header-like input, and the module now contains no unsafe code.
@ArniDagur

Copy link
Copy Markdown
Contributor Author

Commit 2a17286 makes the code safe.

Let me know if you want me to pursue this any further.

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.

2 participants