Skip to content

Eliminate sub-binary allocations and backtracking in Plug.Conn.Cookies.decode/1 - #1334

Merged
josevalim merged 1 commit into
elixir-plug:mainfrom
preciz:perf/cookie-5
Oct 21, 2026
Merged

josevalim merged 1 commit into
elixir-plug:mainfrom
preciz:perf/cookie-5

Conversation

@preciz

@preciz preciz commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Assisted-by: Antigravity CLI:Gemini 4

Faster and uses less memory.

Bench:

Mix.install([:benchee])

defmodule OldCookies do
  def decode(cookie) when is_binary(cookie) do
    Map.new(decode_kv(cookie, []))
  end

  defp decode_kv("", acc), do: acc
  defp decode_kv(<<h, t::binary>>, acc) when h in [?\s, ?\t], do: decode_kv(t, acc)
  defp decode_kv(kv, acc) when is_binary(kv), do: decode_key(kv, kv, 0, acc)

  defp decode_key(<<h, t::binary>>, _key_rest, _len, acc)
       when h in [?\s, ?\t, ?\r, ?\n, ?\v, ?\f],
       do: skip_until_cc(t, acc)

  defp decode_key(<<?;, t::binary>>, _key_rest, _len, acc), do: decode_kv(t, acc)
  defp decode_key(<<?=, t::binary>>, _key_rest, 0, acc), do: skip_until_cc(t, acc)

  defp decode_key(<<?=, t::binary>>, key_rest, len, acc) do
    key = binary_part(key_rest, 0, len)
    decode_value(t, t, 0, 0, key, acc)
  end

  defp decode_key(<<_, t::binary>>, key_rest, len, acc), do: decode_key(t, key_rest, len + 1, acc)
  defp decode_key(<<>>, _key_rest, _len, acc), do: acc

  defp decode_value(<<?;, t::binary>>, val_rest, len, spaces, key, acc) do
    value = binary_part(val_rest, 0, len - spaces)
    decode_kv(t, [{key, value} | acc])
  end

  defp decode_value(<<?\s, t::binary>>, val_rest, len, spaces, key, acc) do
    decode_value(t, val_rest, len + 1, spaces + 1, key, acc)
  end

  defp decode_value(<<h, t::binary>>, _val_rest, _len, _spaces, _key, acc)
       when h in [?\t, ?\r, ?\n, ?\v, ?\f],
       do: skip_until_cc(t, acc)

  defp decode_value(<<_, t::binary>>, val_rest, len, _spaces, key, acc) do
    decode_value(t, val_rest, len + 1, 0, key, acc)
  end

  defp decode_value(<<>>, val_rest, len, spaces, key, acc) do
    value = binary_part(val_rest, 0, len - spaces)
    [{key, value} | acc]
  end

  defp skip_until_cc(<<?;, t::binary>>, acc), do: decode_kv(t, acc)
  defp skip_until_cc(<<_, t::binary>>, acc), do: skip_until_cc(t, acc)
  defp skip_until_cc(<<>>, acc), do: acc
end

defmodule NewCookies do
  def decode(cookie) when is_binary(cookie) do
    Map.new(decode_kv(cookie, cookie, 0, []))
  end

  defp decode_kv(<<h, t::binary>>, cookie, pos, acc) when h in [?\s, ?\t, ?;],
    do: decode_kv(t, cookie, pos + 1, acc)

  defp decode_kv(<<h, t::binary>>, cookie, pos, acc) when h in [?\r, ?\n, ?\v, ?\f, ?=],
    do: skip_until_cc(t, cookie, pos + 1, acc)

  defp decode_kv(<<_, t::binary>>, cookie, pos, acc), do: decode_key(t, cookie, pos, 1, acc)
  defp decode_kv(<<>>, _cookie, _pos, acc), do: acc

  defp decode_key(<<h, t::binary>>, cookie, pos, len, acc)
       when h in [?\s, ?\t, ?\r, ?\n, ?\v, ?\f],
       do: skip_until_cc(t, cookie, pos + len + 1, acc)

  defp decode_key(<<?;, t::binary>>, cookie, pos, len, acc),
    do: decode_kv(t, cookie, pos + len + 1, acc)

  defp decode_key(<<?=, t::binary>>, cookie, pos, len, acc) do
    key = binary_part(cookie, pos, len)
    decode_value(t, cookie, pos + len + 1, 0, 0, key, acc)
  end

  defp decode_key(<<_, t::binary>>, cookie, pos, len, acc),
    do: decode_key(t, cookie, pos, len + 1, acc)

  defp decode_key(<<>>, _cookie, _pos, _len, acc), do: acc

  defp decode_value(<<?;, t::binary>>, cookie, pos, len, spaces, key, acc) do
    value = binary_part(cookie, pos, len - spaces)
    decode_kv(t, cookie, pos + len + 1, [{key, value} | acc])
  end

  defp decode_value(<<?\s, t::binary>>, cookie, pos, len, spaces, key, acc) do
    decode_value(t, cookie, pos, len + 1, spaces + 1, key, acc)
  end

  defp decode_value(<<h, t::binary>>, cookie, pos, len, _spaces, _key, acc)
       when h in [?\t, ?\r, ?\n, ?\v, ?\f],
       do: skip_until_cc(t, cookie, pos + len + 1, acc)

  defp decode_value(<<_, t::binary>>, cookie, pos, len, _spaces, key, acc) do
    decode_value(t, cookie, pos, len + 1, 0, key, acc)
  end

  defp decode_value(<<>>, cookie, pos, len, spaces, key, acc) do
    value = binary_part(cookie, pos, len - spaces)
    [{key, value} | acc]
  end

  defp skip_until_cc(<<?;, t::binary>>, cookie, pos, acc), do: decode_kv(t, cookie, pos + 1, acc)

  defp skip_until_cc(<<_, t::binary>>, cookie, pos, acc),
    do: skip_until_cc(t, cookie, pos + 1, acc)

  defp skip_until_cc(<<>>, _cookie, _pos, acc), do: acc
end

inputs = %{
  "1. empty" => "",
  "2. single short cookie" => "theme=dark",
  "3. single Phoenix session cookie (~250B)" =>
    "_my_app_key=" <> String.duplicate("SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAY", 5),
  "4. typical browser (5 cookies)" =>
    "_ga=GA1.2.123456789.1234567890; _gid=GA1.2.987654321.1234567890; " <>
      "locale=en_US; theme=dark; _app_session=" <> String.duplicate("a1b2c3d4", 20),
  "5. heavy browser (20 cookies)" =>
    Enum.map_join(1..20, "; ", fn i ->
      "cookie_key_#{i}=#{String.duplicate("val#{i}_", 10)}"
    end),
  "6. many small cookies (50 cookies)" =>
    Enum.map_join(1..50, "; ", fn i -> "k#{i}=v#{i}" end),
  "7. edge: lots of whitespace and trailing spaces" =>
    Enum.map_join(1..15, ";", fn i -> "   \t  key_#{i}=value_#{i}     " end),
  "8. edge: malformed / invalid pairs mixed with valid" =>
    ";;; =only_val; bad key=123; key_without_eq; bad_val=abc\tdef; valid1=ok1; =; \r\ninvalid=2; valid2=ok2;;;"
}

Benchee.run(
  %{
    "old" => fn input -> OldCookies.decode(input) end,
    "new" => fn input -> NewCookies.decode(input) end
  },
  inputs: inputs,
  pre_check: :all_same,
  warmup: 1,
  time: 3,
  memory_time: 2
)

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.1.1
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 1 s
time: 3 s
memory time: 2 s
reduction time: 0 ns
parallel: 1
inputs: 1. empty, 2. single short cookie, 3. single Phoenix session cookie (~250B), 4. typical browser (5 cookies), 5. heavy browser (20 cookies), 6. many small cookies (50 cookies), 7. edge: lots of whitespace and trailing spaces, 8. edge: malformed / invalid pairs mixed with valid
Estimated total run time: 1 min 36 s
Excluding outliers: false

##### With input 1. empty #####
Name           ips        average  deviation         median         99th %
old        25.13 M       39.79 ns  ±1080.57%          40 ns          50 ns
new        25.00 M       40.00 ns  ±1197.08%          40 ns          51 ns

Comparison: 
old        25.13 M
new        25.00 M - 1.01x slower +0.21 ns

Memory usage statistics:

Name    Memory usage
old             64 B
new             64 B - 1.00x memory usage +0 B

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

##### With input 2. single short cookie #####
Name           ips        average  deviation         median         99th %
new         9.85 M      101.49 ns  ±4066.47%          80 ns         191 ns
old         8.84 M      113.18 ns  ±2382.45%         100 ns         190 ns

Comparison: 
new         9.85 M
old         8.84 M - 1.12x slower +11.69 ns

Memory usage statistics:

Name    Memory usage
new            128 B
old            264 B - 2.06x memory usage +136 B

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

##### With input 3. single Phoenix session cookie (~250B) #####
Name           ips        average  deviation         median         99th %
new         1.97 M      508.78 ns  ±1279.14%         470 ns         671 ns
old         1.73 M      579.36 ns   ±879.54%         541 ns         721 ns

Comparison: 
new         1.97 M
old         1.73 M - 1.14x slower +70.57 ns

Memory usage statistics:

Name    Memory usage
new            128 B
old            288 B - 2.25x memory usage +160 B

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

##### With input 4. typical browser (5 cookies) #####
Name           ips        average  deviation         median         99th %
new         1.32 M      757.18 ns   ±719.72%         711 ns         972 ns
old         1.00 M      995.41 ns   ±555.93%         942 ns        1282 ns

Comparison: 
new         1.32 M
old         1.00 M - 1.31x slower +238.23 ns

Memory usage statistics:

Name    Memory usage
new          0.34 KB
old          1.13 KB - 3.27x memory usage +0.78 KB

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

##### With input 5. heavy browser (20 cookies) #####
Name           ips        average  deviation         median         99th %
new       243.02 K        4.11 μs   ±109.15%        3.99 μs        5.67 μs
old       203.05 K        4.92 μs    ±65.95%        4.79 μs        7.78 μs

Comparison: 
new       243.02 K
old       203.05 K - 1.20x slower +0.81 μs

Memory usage statistics:

Name    Memory usage
new          1.16 KB
old          4.33 KB - 3.72x memory usage +3.16 KB

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

##### With input 6. many small cookies (50 cookies) #####
Name           ips        average  deviation         median         99th %
new       220.33 K        4.54 μs   ±113.50%        4.21 μs        7.20 μs
old       172.45 K        5.80 μs    ±99.43%        5.42 μs        9.43 μs

Comparison: 
new       220.33 K
old       172.45 K - 1.28x slower +1.26 μs

Memory usage statistics:

Name    Memory usage
new          2.63 KB
old         11.38 KB - 4.32x memory usage +8.75 KB

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

##### With input 7. edge: lots of whitespace and trailing spaces #####
Name           ips        average  deviation         median         99th %
new       579.84 K        1.72 μs   ±357.98%        1.64 μs        2.24 μs
old       465.29 K        2.15 μs   ±340.11%        2.04 μs        3.12 μs

Comparison: 
new       579.84 K
old       465.29 K - 1.25x slower +0.42 μs

Memory usage statistics:

Name    Memory usage
new          0.89 KB
old          3.27 KB - 3.67x memory usage +2.38 KB

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

##### With input 8. edge: malformed / invalid pairs mixed with valid #####
Name           ips        average  deviation         median         99th %
new         3.01 M      332.41 ns  ±1413.78%         301 ns         461 ns
old         2.08 M      481.36 ns   ±815.83%         450 ns         662 ns

Comparison: 
new         3.01 M
old         2.08 M - 1.45x slower +148.95 ns

Memory usage statistics:

Name    Memory usage
new         0.180 KB
old          1.50 KB - 8.35x memory usage +1.32 KB

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

@josevalim
josevalim merged commit 6955eb3 into elixir-plug:main Oct 21, 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