Skip to content

Optimize Huffman decoding - #29

Merged
whatyouhide merged 1 commit into
elixir-mint:mainfrom
preciz:optimize-huffman-decoding
Sep 1, 2026
Merged

Optimize Huffman decoding#29
whatyouhide merged 1 commit into
elixir-mint:mainfrom
preciz:optimize-huffman-decoding

Conversation

@preciz

@preciz preciz commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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:

Mix.install([:benchee])

defmodule OldHuffman do
  @moduledoc false

  import Bitwise, only: [>>>: 2]

  table_file = Path.join([__DIR__, "lib", "hpax", "huffman_table"])
  @external_resource table_file

  entries =
    Enum.map(File.stream!(table_file), fn line ->
      [byte_value, bits, _hex, bit_count] =
        line
        |> case do
          <<?', _, ?', ?\s, rest::binary>> -> rest
          "EOS " <> rest -> rest
          _other -> line
        end
        |> String.replace(["|", "(", ")", "[", "]"], "")
        |> String.split()

      byte_value = String.to_integer(byte_value)
      bits = String.to_integer(bits, 2)
      bit_count = String.to_integer(bit_count)

      {byte_value, bits, bit_count}
    end)

  {regular_entries, [eos_entry]} = Enum.split(entries, -1)
  {_eos_byte_value, eos_bits, eos_bit_count} = eos_entry

  ## Encoding

  @spec encode(binary()) :: binary()
  def encode(binary) do
    encode(binary, _acc = <<>>)
  end

  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
    overflowing_bits = rem(bit_size(acc), 8)

    if overflowing_bits == 0 do
      acc
    else
      bits_to_add = 8 - overflowing_bits

      value_of_bits_to_add =
        take_significant_bits(unquote(eos_bits), unquote(eos_bit_count), bits_to_add)

      <<acc::bitstring, value_of_bits_to_add::size(bits_to_add)>>
    end
  end

  ## Decoding

  @spec decode(binary()) :: binary()
  def decode(binary)

  for {byte_value, bits, bit_count} <- regular_entries do
    def decode(<<unquote(bits)::size(unquote(bit_count)), rest::bitstring>>) do
      <<unquote(byte_value), decode(rest)::binary>>
    end
  end

  def decode(<<>>) do
    <<>>
  end

  def decode(<<padding::bitstring>>) when bit_size(padding) in 1..7 do
    padding_size = bit_size(padding)
    <<padding::size(^padding_size)>> = padding

    if take_significant_bits(unquote(eos_bits), unquote(eos_bit_count), padding_size) == padding do
      <<>>
    else
      throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
    end
  end

  def decode(<<_rest::bitstring>>) do
    throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
  end

  ## Helpers

  @compile {:inline, take_significant_bits: 3}
  defp take_significant_bits(value, bit_count, bits_to_take) do
    value >>> (bit_count - bits_to_take)
  end
end

defmodule NewHuffman do
  @moduledoc false

  import Bitwise, only: [>>>: 2]

  table_file = Path.join([__DIR__, "lib", "hpax", "huffman_table"])
  @external_resource table_file

  entries =
    Enum.map(File.stream!(table_file), fn line ->
      [byte_value, bits, _hex, bit_count] =
        line
        |> case do
          <<?', _, ?', ?\s, rest::binary>> -> rest
          "EOS " <> rest -> rest
          _other -> line
        end
        |> String.replace(["|", "(", ")", "[", "]"], "")
        |> String.split()

      byte_value = String.to_integer(byte_value)
      bits = String.to_integer(bits, 2)
      bit_count = String.to_integer(bit_count)

      {byte_value, bits, bit_count}
    end)

  {regular_entries, [eos_entry]} = Enum.split(entries, -1)
  {_eos_byte_value, eos_bits, eos_bit_count} = eos_entry

  ## Encoding

  @spec encode(binary()) :: binary()
  def encode(binary) do
    encode(binary, _acc = <<>>)
  end

  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
    overflowing_bits = rem(bit_size(acc), 8)

    if overflowing_bits == 0 do
      acc
    else
      bits_to_add = 8 - overflowing_bits

      value_of_bits_to_add =
        take_significant_bits(unquote(eos_bits), unquote(eos_bit_count), bits_to_add)

      <<acc::bitstring, value_of_bits_to_add::size(bits_to_add)>>
    end
  end

  ## Decoding

  @spec decode(binary()) :: binary()
  def decode(binary) when is_bitstring(binary) do
    decode(binary, <<>>)
  end

  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
  end

  defp decode(<<padding::bitstring>>, acc) when bit_size(padding) in 1..7 do
    padding_size = bit_size(padding)
    <<padding::size(^padding_size)>> = padding

    if take_significant_bits(unquote(eos_bits), unquote(eos_bit_count), padding_size) == padding do
      acc
    else
      throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
    end
  end

  defp decode(<<_rest::bitstring>>, _acc) do
    throw({:hpax, {:protocol_error, :invalid_huffman_encoding}})
  end

  ## Helpers

  @compile {:inline, take_significant_bits: 3}
  defp take_significant_bits(value, bit_count, bits_to_take) do
    value >>> (bit_count - bits_to_take)
  end
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)

real_world_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
}

inputs =
  Map.new(real_world_values, fn {name, value} ->
    label = "#{name} (#{byte_size(value)} bytes decoded)"
    {label, NewHuffman.encode(value)}
  end)

Benchee.run(
  %{
    "old recursive decoder" => &OldHuffman.decode/1,
    "new accumulator decoder" => &NewHuffman.decode/1
  },
  inputs: inputs,
  pre_check: :all_same,
  warmup: 1,
  time: 2,
  memory_time: 1
)

Results:

Operating System: Linux
CPU Information: AMD Ryzen 7 8845HS w
Number of Available Cores: 16
Available memory: 54.72 GB
Elixir 1.20.4
Erlang 29.0.5
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 1 s
time: 2 s
memory time: 1 s
reduction time: 0 ns
parallel: 1
inputs: :authority — small (15 bytes decoded), baggage — very large (2381 bytes decoded), content-security-policy — large (349 bytes decoded), cookie — large (989 bytes decoded), user-agent — medium (117 bytes decoded)
Estimated total run time: 40 s
Excluding outliers: false

##### With input :authority — small (15 bytes decoded) #####
Name                              ips        average  deviation         median         99th %
new accumulator decoder        1.35 M      739.67 ns    ±72.05%         722 ns        1052 ns
old recursive decoder          1.27 M      788.55 ns   ±792.59%         752 ns         992 ns

Comparison: 
new accumulator decoder        1.35 M
old recursive decoder          1.27 M - 1.07x slower +48.89 ns

Memory usage statistics:

Name                       Memory usage
new accumulator decoder           104 B
old recursive decoder             456 B - 4.38x memory usage +352 B

**All measurements for memory usage were the same**

##### With input baggage — very large (2381 bytes decoded) #####
Name                              ips        average  deviation         median         99th %
new accumulator decoder       10.43 K       95.91 μs    ±13.48%       95.03 μs      101.96 μs
old recursive decoder          2.77 K      360.56 μs    ±18.30%      356.64 μs      393.89 μs

Comparison: 
new accumulator decoder       10.43 K
old recursive decoder          2.77 K - 3.76x slower +264.64 μs

Memory usage statistics:

Name                       Memory usage
new accumulator decoder        0.102 KB
old recursive decoder         148.10 KB - 1458.23x memory usage +148 KB

**All measurements for memory usage were the same**

##### With input content-security-policy — large (349 bytes decoded) #####
Name                              ips        average  deviation         median         99th %
new accumulator decoder       78.37 K       12.76 μs    ±14.92%       12.59 μs       15.76 μs
old recursive decoder         28.39 K       35.22 μs    ±37.96%       29.91 μs       80.23 μs

Comparison: 
new accumulator decoder       78.37 K
old recursive decoder         28.39 K - 2.76x slower +22.46 μs

Memory usage statistics:

Name                       Memory usage
new accumulator decoder        0.102 KB
old recursive decoder          21.10 KB - 207.77x memory usage +21 KB

**All measurements for memory usage were the same**

##### With input cookie — large (989 bytes decoded) #####
Name                              ips        average  deviation         median         99th %
new accumulator decoder       32.68 K       30.60 μs    ±12.66%       30.30 μs       34.49 μs
old recursive decoder          8.00 K      124.96 μs    ±13.57%      122.68 μs      160.56 μs

Comparison: 
new accumulator decoder       32.68 K
old recursive decoder          8.00 K - 4.08x slower +94.36 μs

Memory usage statistics:

Name                       Memory usage
new accumulator decoder        0.102 KB
old recursive decoder          61.10 KB - 601.62x memory usage +61 KB

**All measurements for memory usage were the same**

##### With input user-agent — medium (117 bytes decoded) #####
Name                              ips        average  deviation         median         99th %
new accumulator decoder      294.86 K        3.39 μs    ±21.25%        3.34 μs        4.78 μs
old recursive decoder        128.92 K        7.76 μs   ±127.27%        6.15 μs       51.97 μs

Comparison: 
new accumulator decoder      294.86 K
old recursive decoder        128.92 K - 2.29x slower +4.37 μs

Memory usage statistics:

Name                       Memory usage
new accumulator decoder        0.102 KB
old recursive decoder           6.60 KB - 65.00x memory usage +6.50 KB

**All measurements for memory usage were the same**

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.
@whatyouhide

Copy link
Copy Markdown
Contributor

Have you run this with erlc +bin_opt_info and have you measured the difference with an iolist as well here?

@preciz

preciz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

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

@whatyouhide

Copy link
Copy Markdown
Contributor

@preciz can you paste the results and code to reproduce them locally for me? There's no info about iolists above.

@preciz

preciz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

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 accumulator
Mix.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 /tmp, not from an HPAX checkout:

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
done

CPU 15 was the pinned core on this 16-logical-CPU machine; use any valid core locally. TRIAL changes both job order and input order. The script also uses pre_check: :all_same, so Benchee verifies that both decoders return identical values for every input before measuring them.

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: warmup: 1, time: 2, memory_time: 1, and no outlier exclusion.

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):

input decoded binary runs 1–5 binary mean iolist runs 1–5 iolist mean result
:authority 15 B 0.758, 0.766, 0.767, 0.791, 0.776 0.772 0.746, 0.733, 0.744, 0.763, 0.756 0.748 iolist 3.0% lower
user-agent 117 B 3.384, 3.438, 3.418, 3.455, 3.500 3.439 4.798, 4.790, 4.832, 4.836, 4.787 4.809 binary 28.5% lower
content-security-policy 349 B 13.179, 13.431, 13.261, 13.527, 13.603 13.400 13.298, 13.177, 13.421, 13.623, 13.662 13.436 binary 0.3% lower
cookie 989 B 30.828, 31.236, 31.111, 31.610, 31.766 31.310 31.456, 31.016, 31.593, 31.404, 31.928 31.479 binary 0.5% lower
baggage 2381 B 97.995, 99.397, 98.364, 100.333, 99.396 99.097 101.045, 99.591, 101.655, 102.150, 103.457 101.580 binary 2.4% lower

Benchee memory usage per decode (all five runs returned the same value):

input decoded binary accumulator iolist accumulator iolist / binary
:authority 15 B 104 B 552 B 5.31×
user-agent 117 B 104 B 3,384 B 32.54×
content-security-policy 349 B 104 B 8,232 B 79.15×
cookie 989 B 104 B 22,040 B 211.92×
baggage 2381 B 104 B 58,552 B 563.00×

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 :binary.referenced_byte_size/1:

decoded result binary accumulator backing iolist result backing
15 B 256 B 15 B
117 B 256 B 117 B
349 B 514 B 349 B
989 B 1,030 B 989 B
2381 B 2,857 B 2,381 B

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 29

On OTP 29, I prefer the binary accumulator because bs_init_writable/private_append keeps its transient process-heap garbage effectively constant, while the reversed-list iolist allocates, reverses, and flattens data proportional to decoded length. Throughput is comparable or better in these runs, except for the 15-byte case where the iolist is 3.0% faster. +bin_opt_info does not distinguish them because input matching is identical; the difference is output construction. The iolist does return an exactly sized binary while the writable binary may retain spare backing, so this is an OTP 29 transient-allocation/GC tradeoff—not a claim that binary always uses less memory or that the conclusion applies unchanged to OTP 25.

@whatyouhide

Copy link
Copy Markdown
Contributor

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?

@preciz

preciz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@whatyouhide yes last one was AI, but the conclusion is the same, binary accumulator uses less memory.
I also don't like to speak with AI through people, but these are benchmark results only. I also updated the comment so it's terser. (I don't have time to run all these benchmarks on different OTP versions, so I just make sure the AI goes in the right direction and I tell it to average out the results because my system is noisy)

@whatyouhide
whatyouhide merged commit 1c589ca into elixir-mint:main Sep 1, 2026
2 checks passed
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