Skip to content

Encode long Huffman values without growing-integer shifts - #304

Open
DicksonWu654 wants to merge 1 commit into
python-hyper:masterfrom
DicksonWu654:perf/huffman-long-values
Open

DicksonWu654 wants to merge 1 commit into
python-hyper:masterfrom
DicksonWu654:perf/huffman-long-values

Conversation

@DicksonWu654

Copy link
Copy Markdown

HuffmanEncoder.encode() appends each symbol to a single Python integer (final_num <<= length; final_num |= code). Every step copies the whole integer, so encoding is quadratic in the value length, and long values such as cookies dominate encode time.

For bytes values of 256 bytes or more, this builds a small map from each distinct input byte to its code written as a string of 0/1 digits, joins those strings, pads with EOS (1) bits to a whole byte and converts once with int(bits, 2).to_bytes(...). The map is rebuilt on every call from the encoder's own huffman_code_list / huffman_code_list_lengths, so nothing is cached between calls and changes to those tables are still picked up.

Everything else keeps the existing loop: values shorter than 256 bytes, bytes subclasses, HuffmanEncoder subclasses, and code tables that are not plain lists of ints or contain a code with a length outside 1..30 or a value that does not fit its length (the loop's masking/overlap behaviour for such tables is not reproduced, so they fall back). The output is identical in all cases.

Benchmarks

Each workload runs header blocks through Encoder.encode() and, for *_roundtrip, Decoder.decode(). browser_mixed alternates 16 browser-like requests and 16 responses; repeated_requests sends one request 16 times; long_cookie is 16 requests whose cookie is about 1.2 KB; no_huffman_encode is browser_mixed with huffman=False; fixture_stories_roundtrip replays the first four stories of the hpack-test-case fixtures in tests/.

master and this branch were imported side by side in the same interpreter and timed in alternating ~20 ms CPU-time blocks (40 rounds in each of 6 processes, pinned to one core). The table shows the median per-round speedup with a 95% bootstrap interval. The machine was shared with other work, so the absolute times are only indicative.

Workload CPython 3.12.3 master → branch (µs) CPython 3.12.3 speedup CPython 3.14.8 speedup
browser_mixed_encode 1,780.1 → 1,775.1 1.00x [0.98, 1.03] 1.00x [0.98, 1.03]
browser_mixed_roundtrip 3,755.1 → 3,640.0 1.02x [1.00, 1.05] 1.00x [0.98, 1.01]
repeated_requests_encode 437.5 → 428.4 1.01x [0.99, 1.03] 0.98x [0.96, 1.01]
repeated_requests_roundtrip 987.8 → 1,002.5 0.99x [0.97, 1.01] 1.03x [1.01, 1.05]
responses_decode 685.0 → 686.8 1.01x [0.99, 1.03] 1.01x [0.98, 1.02]
long_cookie_encode 7,502.8 → 2,401.4 3.14x [3.09, 3.20] 3.22x [3.17, 3.28]
long_cookie_roundtrip 13,269.7 → 7,809.5 1.70x [1.67, 1.73] 1.68x [1.66, 1.70]
no_huffman_encode 1,071.6 → 1,041.1 1.01x [1.00, 1.04] 1.00x [0.99, 1.02]
fixture_stories_roundtrip 2,103.3 → 2,123.9 0.98x [0.96, 1.00] 1.00x [0.98, 1.02]

Only values of 256 bytes or more take the new path, so the other workloads are unchanged within noise.

Behaviour

  • New tests: mutating the code tables between calls; fallback for over-wide, zero-length, int-subclass, list-subclass and short tables, with the exact bytes the existing loop produces.
  • pytest passes with 100% statement and branch coverage on CPython 3.11, 3.12, 3.13 and 3.14; ruff check src/ and mypy src/ (the lint tox env) pass.
  • I also compared master and this branch on 1,500 random encoder/decoder sessions per interpreter (CPython 3.11 and 3.14): header lists with values of 0 to 20,000 bytes (text, random bytes and repeated bytes), sensitive headers, str input, table size changes, Huffman on and off, plus direct HuffmanEncoder calls. The encoded bytes, decoded headers, exceptions and, with DEBUG logging enabled, every log record were identical.

🤖 Generated with Claude Code

HuffmanEncoder.encode() appends every symbol to one Python integer with
a shift and an or. Each step copies the whole integer, so encoding is
quadratic in the value length and long values such as cookies are slow.

For bytes values of 256 bytes or more, map each distinct symbol to its
code as a string of '0'/'1' digits, join them, pad with EOS bits, and
convert once with int(bits, 2).to_bytes(). The codes are read from the
encoder's tables on every call. Shorter values, subclasses and code
tables with unusual entries (non-int, zero or over-wide lengths) keep
the existing loop, so output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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