perf: decode two HPACK Huffman symbols per table lookup - #927
Conversation
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.
6177a6f to
e737bed
Compare
|
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.
|
I was able to delete the The Naively replacing the stores with
|
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.
|
Commit 2a17286 makes the code safe. Let me know if you want me to pursue this any further. |
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:
u32entry packs both decoded bytes, a symbol count, and the total bits consumed. The table (16 KB) is built at compile time by aconst fnfromENCODE_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.acc |= word >> bitsrefill), 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 * lenalways covers the store plus one byte of slack.Benchmarks
Apple M4 Max,
cargo benchwith the harness from #926 (same four inputs on all three revisions):46bfd62)dd225e5)decode_header_valuedecode_long_asciidecode_short_asciidecode_all_octetsdecode_all_octetsis 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):
header_valuelong_asciishort_asciiall_octets#[cold]fnput_u8/put_slice,try_intoreads)Two takeaways from the sweep:
unsafein 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). Theunsafehere is three small blocks (one unaligned read, speculative stores into reserved capacity, and the finalset_len), each with a safety comment tied to the capacity analysis at the top ofdecode.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:
0xE0-0xFFbytes 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.