Optimize Huffman decoding - #29
Conversation
Decode into a tail-recursive binary accumulator instead of rebuilding binaries while unwinding recursion. This is up to 4.2x faster on realistic inputs and reduces measured allocations.
|
Have you run this with |
|
Thank you for this interesting question. I did run a diverse set of experiments with AI and the conclusion is that on OTP 26+ the binary accumulator is preferable due to much more efficient memory usage. The experiments did run main VS iolist VS binary accumulator and they also did run on OTP 25..29 |
|
@preciz can you paste the results and code to reproduce them locally for me? There's no info about iolists above. |
|
Disclaimer from Andrea the maintainer, editing this: the below is AI generated. https://dontpastetheai.com/. @whatyouhide Correcting my earlier benchmark: that reproducer still depended on the PR checkout. This version is actually standalone—it installs only Benchee, embeds the RFC 7541 Huffman table, and defines the encoder plus both decoder implementations in place. 1. Self-contained Benchee benchmark: iolist vs binary accumulatorMix.install([{:benchee, "== 1.5.1"}])
defmodule HuffmanBench.Table do
@moduledoc false
# RFC 7541 Appendix B. Each entry is hexadecimal_code/bit_length;
# its position is the decoded byte (the final position is EOS = 256).
@raw """
1ff8/13 7fffd8/23 fffffe2/28 fffffe3/28 fffffe4/28 fffffe5/28 fffffe6/28 fffffe7/28
fffffe8/28 ffffea/24 3ffffffc/30 fffffe9/28 fffffea/28 3ffffffd/30 fffffeb/28 fffffec/28
fffffed/28 fffffee/28 fffffef/28 ffffff0/28 ffffff1/28 ffffff2/28 3ffffffe/30 ffffff3/28
ffffff4/28 ffffff5/28 ffffff6/28 ffffff7/28 ffffff8/28 ffffff9/28 ffffffa/28 ffffffb/28
14/6 3f8/10 3f9/10 ffa/12 1ff9/13 15/6 f8/8 7fa/11
3fa/10 3fb/10 f9/8 7fb/11 fa/8 16/6 17/6 18/6
0/5 1/5 2/5 19/6 1a/6 1b/6 1c/6 1d/6
1e/6 1f/6 5c/7 fb/8 7ffc/15 20/6 ffb/12 3fc/10
1ffa/13 21/6 5d/7 5e/7 5f/7 60/7 61/7 62/7
63/7 64/7 65/7 66/7 67/7 68/7 69/7 6a/7
6b/7 6c/7 6d/7 6e/7 6f/7 70/7 71/7 72/7
fc/8 73/7 fd/8 1ffb/13 7fff0/19 1ffc/13 3ffc/14 22/6
7ffd/15 3/5 23/6 4/5 24/6 5/5 25/6 26/6
27/6 6/5 74/7 75/7 28/6 29/6 2a/6 7/5
2b/6 76/7 2c/6 8/5 9/5 2d/6 77/7 78/7
79/7 7a/7 7b/7 7ffe/15 7fc/11 3ffd/14 1ffd/13 ffffffc/28
fffe6/20 3fffd2/22 fffe7/20 fffe8/20 3fffd3/22 3fffd4/22 3fffd5/22 7fffd9/23
3fffd6/22 7fffda/23 7fffdb/23 7fffdc/23 7fffdd/23 7fffde/23 ffffeb/24 7fffdf/23
ffffec/24 ffffed/24 3fffd7/22 7fffe0/23 ffffee/24 7fffe1/23 7fffe2/23 7fffe3/23
7fffe4/23 1fffdc/21 3fffd8/22 7fffe5/23 3fffd9/22 7fffe6/23 7fffe7/23 ffffef/24
3fffda/22 1fffdd/21 fffe9/20 3fffdb/22 3fffdc/22 7fffe8/23 7fffe9/23 1fffde/21
7fffea/23 3fffdd/22 3fffde/22 fffff0/24 1fffdf/21 3fffdf/22 7fffeb/23 7fffec/23
1fffe0/21 1fffe1/21 3fffe0/22 1fffe2/21 7fffed/23 3fffe1/22 7fffee/23 7fffef/23
fffea/20 3fffe2/22 3fffe3/22 3fffe4/22 7ffff0/23 3fffe5/22 3fffe6/22 7ffff1/23
3ffffe0/26 3ffffe1/26 fffeb/20 7fff1/19 3fffe7/22 7ffff2/23 3fffe8/22 1ffffec/25
3ffffe2/26 3ffffe3/26 3ffffe4/26 7ffffde/27 7ffffdf/27 3ffffe5/26 fffff1/24 1ffffed/25
7fff2/19 1fffe3/21 3ffffe6/26 7ffffe0/27 7ffffe1/27 3ffffe7/26 7ffffe2/27 fffff2/24
1fffe4/21 1fffe5/21 3ffffe8/26 3ffffe9/26 ffffffd/28 7ffffe3/27 7ffffe4/27 7ffffe5/27
fffec/20 fffff3/24 fffed/20 1fffe6/21 3fffe9/22 1fffe7/21 1fffe8/21 7ffff3/23
3fffea/22 3fffeb/22 1ffffee/25 1ffffef/25 fffff4/24 fffff5/24 3ffffea/26 7ffff4/23
3ffffeb/26 7ffffe6/27 3ffffec/26 3ffffed/26 7ffffe7/27 7ffffe8/27 7ffffe9/27 7ffffea/27
7ffffeb/27 ffffffe/28 7ffffec/27 7ffffed/27 7ffffee/27 7ffffef/27 7fffff0/27 3ffffee/26
3fffffff/30
"""
@entries @raw
|> String.split()
|> Enum.with_index(fn token, byte_value ->
[hex, bit_count] = String.split(token, "/")
{byte_value, String.to_integer(hex, 16), String.to_integer(bit_count)}
end)
defmacro entries, do: Macro.escape(@entries)
end
defmodule HuffmanBench.Encoder do
@moduledoc false
import Bitwise, only: [>>>: 2]
require HuffmanBench.Table
entries = HuffmanBench.Table.entries()
{regular_entries, [{256, eos_bits, eos_bit_count}]} = Enum.split(entries, -1)
def encode(binary), do: encode(binary, <<>>)
for {byte_value, bits, bit_count} <- regular_entries do
defp encode(<<unquote(byte_value), rest::binary>>, acc) do
encode(rest, <<acc::bitstring, unquote(bits)::size(unquote(bit_count))>>)
end
end
defp encode(<<>>, acc) do
case rem(bit_size(acc), 8) do
0 ->
acc
overflowing_bits ->
bits_to_add = 8 - overflowing_bits
padding = unquote(eos_bits) >>> (unquote(eos_bit_count) - bits_to_add)
<<acc::bitstring, padding::size(bits_to_add)>>
end
end
end
defmodule HuffmanBench.BinaryAccumulator do
@moduledoc false
import Bitwise, only: [>>>: 2]
require HuffmanBench.Table
entries = HuffmanBench.Table.entries()
{regular_entries, [{256, eos_bits, eos_bit_count}]} = Enum.split(entries, -1)
def decode(binary) when is_bitstring(binary), do: decode(binary, <<>>)
for {byte_value, bits, bit_count} <- regular_entries do
defp decode(<<unquote(bits)::size(unquote(bit_count)), rest::bitstring>>, acc) do
decode(rest, <<acc::binary, unquote(byte_value)>>)
end
end
defp decode(<<>>, acc), do: acc
defp decode(<<padding::bitstring>>, acc) when bit_size(padding) in 1..7 do
padding_size = bit_size(padding)
<<padding::size(^padding_size)>> = padding
expected = unquote(eos_bits) >>> (unquote(eos_bit_count) - padding_size)
if padding == expected, do: acc, else: invalid!()
end
defp decode(<<_rest::bitstring>>, _acc), do: invalid!()
defp invalid!, do: throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
end
defmodule HuffmanBench.IolistAccumulator do
@moduledoc false
import Bitwise, only: [>>>: 2]
require HuffmanBench.Table
entries = HuffmanBench.Table.entries()
{regular_entries, [{256, eos_bits, eos_bit_count}]} = Enum.split(entries, -1)
def decode(binary) when is_bitstring(binary), do: decode(binary, [])
for {byte_value, bits, bit_count} <- regular_entries do
defp decode(<<unquote(bits)::size(unquote(bit_count)), rest::bitstring>>, acc) do
decode(rest, [unquote(byte_value) | acc])
end
end
defp decode(<<>>, acc), do: acc |> :lists.reverse() |> IO.iodata_to_binary()
defp decode(<<padding::bitstring>>, acc) when bit_size(padding) in 1..7 do
padding_size = bit_size(padding)
<<padding::size(^padding_size)>> = padding
expected = unquote(eos_bits) >>> (unquote(eos_bit_count) - padding_size)
if padding == expected do
acc |> :lists.reverse() |> IO.iodata_to_binary()
else
invalid!()
end
end
defp decode(<<_rest::bitstring>>, _acc), do: invalid!()
defp invalid!, do: throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
end
cookie =
1..20
|> Enum.map_join("; ", fn index ->
"session_part_#{index}=#{String.duplicate("abcdef0123456789", 2)}"
end)
content_security_policy =
[
"default-src 'self'",
"script-src 'self' 'nonce-dGVzdC1ub25jZQ' https://cdn.example.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src https://fonts.gstatic.com",
"img-src 'self' data: https:",
"connect-src 'self' https://api.example.com wss://events.example.com",
"frame-ancestors 'none'",
"report-uri https://reports.example.com/csp"
]
|> Enum.join("; ")
baggage =
1..50
|> Enum.map_join(",", fn index ->
"service.context-#{index}=value-#{index};property=production"
end)
values = [
{":authority - small", "www.example.com"},
{"user-agent - medium",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " <>
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"},
{"cookie - large", cookie},
{"content-security-policy - large", content_security_policy},
{"baggage - very large", baggage}
]
trial = System.get_env("TRIAL", "1") |> String.to_integer()
true = trial in 1..5
rotate = fn list, count ->
{left, right} = Enum.split(list, count)
right ++ left
end
ordered_values = rotate.(values, rem(trial - 1, length(values)))
ordered_values = if rem(trial, 2) == 0, do: Enum.reverse(ordered_values), else: ordered_values
inputs =
ordered_values
|> Enum.with_index(1)
|> Enum.map(fn {{name, value}, order} ->
label = "#{order} #{name} (#{byte_size(value)} bytes decoded)"
{label, HuffmanBench.Encoder.encode(value)}
end)
jobs =
if rem(trial, 2) == 1 do
%{
"1 binary accumulator" => &HuffmanBench.BinaryAccumulator.decode/1,
"2 iolist accumulator" => &HuffmanBench.IolistAccumulator.decode/1
}
else
%{
"1 iolist accumulator" => &HuffmanBench.IolistAccumulator.decode/1,
"2 binary accumulator" => &HuffmanBench.BinaryAccumulator.decode/1
}
end
Enum.each(inputs, fn {input_name, encoded} ->
input = String.replace(input_name, ~r/^\d+ /, "")
binary = HuffmanBench.BinaryAccumulator.decode(encoded)
iolist = HuffmanBench.IolistAccumulator.decode(encoded)
IO.puts(
Enum.join(
[
"BACKING",
input,
byte_size(binary),
:binary.referenced_byte_size(binary),
:binary.referenced_byte_size(iolist)
],
","
)
)
end)
suite =
Benchee.run(jobs,
inputs: inputs,
pre_check: :all_same,
warmup: 1,
time: 2,
memory_time: 1
)
# Machine-readable rows make averaging multiple runs unambiguous.
Enum.each(suite.scenarios, fn scenario ->
implementation = String.replace(scenario.job_name, ~r/^\d+ /, "")
input = String.replace(scenario.input_name, ~r/^\d+ /, "")
IO.puts(
Enum.join(
[
"RESULT",
trial,
implementation,
input,
scenario.run_time_data.statistics.average,
scenario.memory_usage_data.statistics.average
],
","
)
)
end)I ran five fresh VMs from for trial in 1 2 3 4 5; do
taskset -c 15 env TRIAL=$trial \
ERL_FLAGS='+S 1:1 +sbwt none +sbwtdcpu none +sbwtdio none' \
elixir huffman_bench.exs
doneCPU 15 was the pinned core on this 16-logical-CPU machine; use any valid core locally. 2. OTP 29 Benchee results (five runs, varied order, arithmetic mean)Environment: Erlang/OTP 29 (ERTS 17.0.5, JIT); Elixir 1.20.4; Benchee 1.5.1; AMD Ryzen 7 8845HS; Linux x86-64. Benchee settings match the PR description: The table contains each independent run's Benchee average, followed by the arithmetic mean of those five averages. Time, μs per decode (lower is better):
Benchee memory usage per decode (all five runs returned the same value):
Benchee's memory collector measures process-heap words reclaimed by GC; it does not include the complete off-heap backing allocation of ref-counted binaries. I therefore measured that separately with
The two memory tables describe different things: the binary accumulator creates dramatically less transient process-heap garbage, while iolist finalization creates an exactly sized returned binary. The binary builder can temporarily retain spare backing capacity. 3. Why I prefer the binary accumulator on OTP 29On OTP 29, I prefer the binary accumulator because |
|
Various tools tell me everything above is written by AI and I really believe in https://dontpastetheai.com/ for human communication. Can you please post a new comment written by you? |
|
@whatyouhide yes last one was AI, but the conclusion is the same, binary accumulator uses less memory. |
Assisted-by: Codex CLI:GPT 5.6 Sol
Decode into a tail-recursive binary accumulator instead of rebuilding binaries while unwinding recursion. This is up to 4.2x faster on realistic inputs and reduces measured allocations.
Bench:
Results: