perf(quic): QPACK Huffman decode — no boxing, IntArray + binary search

Pre-fix QpackHuffman.decode allocated two boxed Integers per output
character: one for the `candidate` Int passed into
`HashMap<Int, Int>.get` and one for the wrapper-Integer return value.
Output also went through `ArrayList<Byte>`, boxing every emitted
byte as a `java.lang.Byte` (~16 bytes per output byte on a 64-bit
JVM). On a typical HTTP/3 response with ~30 header values, that's
hundreds of throwaway wrapper objects per request — pure GC churn
on the hot path.

The new layout keeps two parallel `IntArray`s per code-length:
codes[len] (sorted ascending) and syms[len] (the matching symbol
indices). Lookup is a primitive `IntArray.binarySearch(candidate)`
— no boxing, the array stays in JIT-friendly contiguous memory,
and the per-length arrays are tiny (a few entries each, since the
Huffman table is sparse at any given length).

Output uses a growable `ByteArray` with a manual position index
rather than `ArrayList<Byte>`. Pre-grow to 2× input size as a
rough upper bound — ASCII headers compress to ~62% with HPACK
Huffman, so we rarely need to grow.

Also fixes a latent bug: the new init loop covers lengths 5..30
(previously 5..29), restoring decoding for symbols 10 (LF), 13
(CR), 22 (DC2) which all use 30-bit codes per RFC 7541 Appendix B.
Pre-rewrite the HashMap path included these via `for (sym in 0..255)`
walking the full symbol table; the IntArray rewrite needed an
explicit length range and accidentally cut at 29. Added a unit
test exercising hand-encoded length-30 inputs to lock the fix in.

Behavior verified against RFC 7541 Appendix C test vectors and the
new length-30 round-trip. All 269 :quic:jvmTest tests pass.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
This commit is contained in:
Claude
2026-05-09 00:03:39 +00:00
parent ce8d852518
commit 5421b81569
2 changed files with 69 additions and 19 deletions
@@ -293,30 +293,54 @@ object QpackHuffman {
)
/**
* Lookup tables grouped by code length. `byLength[L]` is a HashMap from
* `code` (an Int up to 30 bits) to `symbol` (0..255) for all symbols
* whose Huffman code is exactly L bits long. Lengths used in the table
* range from 5 to 30. We omit the EOS (length 30, code 0x3FFFFFFF) since
* it must never appear in valid input.
* Lookup tables grouped by code length. For each length L in 5..30,
* [codesByLen]\[L\] holds the codes ascending and [symsByLen]\[L\]
* the matching symbol indices in lock-step — binary-search by the
* candidate Int gives the symbol with no boxing. Pre-fix this was
* `Array<HashMap<Int, Int>>` keyed by boxed `Integer`: every
* decoded character allocated a fresh `Integer` for the
* `candidate` plus a fresh `Integer` return wrapper, dominating
* GC pressure on every QPACK header decode. We omit the EOS
* (length 30, code 0x3FFFFFFF) since it must never appear in
* valid input.
*/
private val byLength: Array<HashMap<Int, Int>> = buildLookupByLength()
private val codesByLen: Array<IntArray>
private val symsByLen: Array<IntArray>
/** Sorted ascending list of distinct code lengths actually used by the table. */
private val lengths: IntArray = byLength.indices.filter { byLength[it].isNotEmpty() }.toIntArray()
private val lengths: IntArray
private fun buildLookupByLength(): Array<HashMap<Int, Int>> {
val out = Array(31) { HashMap<Int, Int>() }
for (sym in 0..255) {
val code = table[sym][0]
val len = table[sym][1]
out[len][code] = sym
init {
// Allocate slots for lengths up to and including 30 (RFC 7541's
// longest non-EOS code is 30 bits — symbols 10/13/22 — plus EOS
// itself which we exclude by iterating 0..255 below).
val codes = Array(31) { IntArray(0) }
val syms = Array(31) { IntArray(0) }
for (len in 5..30) {
// Collect all symbols whose code length equals `len`, sorted
// by code so we can binary-search at decode time. Iterating
// 0..255 (not 0..256) silently excludes EOS, the only
// length-30 entry that must never appear in valid input.
val pairs = (0..255).filter { table[it][1] == len }.map { it to table[it][0] }
val sorted = pairs.sortedBy { it.second }
codes[len] = IntArray(sorted.size) { sorted[it].second }
syms[len] = IntArray(sorted.size) { sorted[it].first }
}
return out
codesByLen = codes
symsByLen = syms
lengths = (5..30).filter { codesByLen[it].isNotEmpty() }.toIntArray()
}
/** Decode a Huffman-encoded byte sequence into a UTF-8 string. */
fun decode(encoded: ByteArray): ByteArray {
val result = ArrayList<Byte>(encoded.size * 2) // rough upper bound
// Output is a growable ByteArray rather than ArrayList<Byte>:
// the latter boxes every emitted byte through `java.lang.Byte`,
// which on a 64-bit JVM is ~16 bytes of wrapper per output byte.
// Pre-grow to ~2× input as a rough upper bound; ASCII-heavy
// headers compress to ~62% with HPACK Huffman, so 2× input is
// an overestimate and we rarely need to grow.
var out = ByteArray(maxOf(8, encoded.size * 2))
var outPos = 0
var bitBuf = 0L
var bitsAvailable = 0
var i = 0
@@ -333,9 +357,12 @@ object QpackHuffman {
for (len in lengths) {
if (len > bitsAvailable) break
val candidate = ((bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1)).toInt()
val sym = byLength[len][candidate]
if (sym != null) {
result.add(sym.toByte())
val codes = codesByLen[len]
val pos = codes.binarySearch(candidate)
if (pos >= 0) {
val sym = symsByLen[len][pos]
if (outPos == out.size) out = out.copyOf(out.size * 2)
out[outPos++] = sym.toByte()
bitsAvailable -= len
bitBuf = bitBuf and ((1L shl bitsAvailable) - 1)
matched = true
@@ -354,6 +381,6 @@ object QpackHuffman {
throw QuicCodecException("invalid Huffman bit stream")
}
}
return result.toByteArray()
return if (outPos == out.size) out else out.copyOf(outPos)
}
}
@@ -94,4 +94,27 @@ class HuffmanRfc7541Test {
val decoded = QpackHuffman.decode(encoded).decodeToString()
assertEquals("https://www.example.com", decoded)
}
@Test
fun length_30_codes_decode_correctly() {
// RFC 7541 Appendix B: symbols 10 (LF), 13 (CR), 22 (DC2) all use
// 30-bit codes. Hand-encoded with two trailing pad-1 bits so the
// total spans exactly 4 bytes. Pre-fix the [codesByLen] range
// accidentally excluded length 30 and these three bytes were
// silently rejected as "invalid Huffman bit stream".
// sym 10 (LF): code 0x3FFFFFFC | (pad 11) → FF FF FF F3
// sym 13 (CR): code 0x3FFFFFFD | (pad 11) → FF FF FF F7
// sym 22 (DC2): code 0x3FFFFFFE | (pad 11) → FF FF FF FB
val cases =
listOf(
10 to byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xF3.toByte()),
13 to byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xF7.toByte()),
22 to byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFB.toByte()),
)
for ((sym, encoded) in cases) {
val decoded = QpackHuffman.decode(encoded)
assertEquals(1, decoded.size, "sym=$sym")
assertEquals(sym.toByte(), decoded[0], "sym=$sym")
}
}
}