Powered by AppSignal & Oban Pro

Skip the copy: a fast path for unescape_chars/2

unescape_fast_path.livemd

Skip the copy: a fast path for unescape_chars/2

Mix.install([
  {:benchee, "~> 1.3"},
  {:kino, "~> 0.14"},
  {:kino_vega_lite, "~> 0.1"}
])

alias VegaLite, as: Vl

# one variant = one color, everywhere
palette = [
  {"A current (copy all)", "#8a8984"},
  {"B skip if no \\", "#2a78d6"},
  {"C prefix, sub-binary seed", "#eb6834"},
  {"C' prefix, writable seed", "#9b59d0"},
  {"D C' + compiled pattern", "#1baf7a"}
]

color = [
  field: "variant",
  type: :nominal,
  scale: [domain: Enum.map(palette, &elem(&1, 0)), range: Enum.map(palette, &elem(&1, 1))],
  title: nil
]

The change


# pick the Elixir checkout to measure.
repo_candidates =
  ["~/Projects/elixir", File.cwd!()]
  |> Enum.flat_map(&Path.wildcard(Path.expand(&1)))
  |> Enum.filter(&File.exists?(Path.join(&1, "lib/elixir/src/elixir_interpolation.erl")))
  |> Enum.uniq()
  |> case do
    [] -> [File.cwd!()]
    found -> found
  end

repo_input = Kino.Input.select("Elixir repo", Enum.map(repo_candidates, &{&1, &1}))

elixir_interpolation:unescape_chars/2 copies every string literal in every compiled file byte by byte, whether or not it contains an escape. The proposal: look for a backslash first, return the input untouched when there is none.

unescape_chars(String, Map) ->
  case binary:match(String, <<$\\>>) of
    nomatch -> String;
    _ -> unescape_chars(String, Map, <<>>)
  end.

The pattern is a single byte, so binary:match/2 compiles it cheaply and scans memchr-style — none of the multi-pattern caveats apply.

The proposal started from URI.decode_www_form/1. unescape_chars/2 is what gets benchmarked here because the compiler supplies a measurable real-world corpus — every string literal in every compiled file — whereas www-form inputs vary by application. For URI.decode/1 the fast path is the same one-liner with % in place of \; decode_www_form/1 also rewrites +, which changes the picture — see the appendix at the end.

1. What the compiler actually unescapes

Tokenizing with unescape: false collects exactly the raw binaries handed to unescape_chars/2 — one pass over the Elixir repo itself.

defmodule Corpus do
  def from_repo(root) do
    Path.wildcard(Path.join(root, "lib/**/*.{ex,exs}"))
    |> Enum.filter(&File.regular?/1)
    |> Enum.flat_map(fn file ->
      case :elixir_tokenizer.tokenize(String.to_charlist(File.read!(file)), 1, 1, unescape: false) do
        {:ok, _, _, _, tokens} -> collect(tokens)
        {:ok, _, _, _, tokens, _} -> collect(tokens)
        _ -> []
      end
    end)
  end

  defp collect(tokens), do: walk(tokens, [])

  defp walk(list, acc) when is_list(list), do: Enum.reduce(list, acc, &walk/2)
  defp walk({:bin_string, _, parts}, acc), do: parts_of(parts, acc)
  defp walk({:list_string, _, parts}, acc), do: parts_of(parts, acc)
  defp walk({:bin_heredoc, _, _indentation, parts}, acc), do: parts_of(parts, acc)
  defp walk({:list_heredoc, _, _indentation, parts}, acc), do: parts_of(parts, acc)
  defp walk({:sigil, _, _, parts, _, _, _}, acc), do: parts_of(parts, acc)
  defp walk(tuple, acc) when is_tuple(tuple), do: walk(Tuple.to_list(tuple), acc)
  defp walk(_, acc), do: acc

  defp parts_of(parts, acc) do
    Enum.reduce(parts, acc, fn
      bin, acc when is_binary(bin) -> [bin | acc]
      {_pos, inner}, acc -> walk(inner, acc)
      other, acc -> walk(other, acc)
    end)
  end
end

repo = Kino.Input.read(repo_input)
corpus = Corpus.from_repo(repo)

sizes = Enum.map(corpus, &byte_size/1)
n = length(corpus)
total_bytes = Enum.sum(sizes)
no_bs = Enum.count(corpus, &(:binary.match(&1, "\\") == :nomatch))
sorted = Enum.sort(sizes)
p = fn q -> Enum.at(sorted, min(n - 1, trunc(n * q))) end

IO.puts("""
literals:                 #{n}
no backslash (fast path): #{no_bs} (#{Float.round(no_bs * 100 / n, 1)}%)
bytes copied per pass:    #{total_bytes} (#{div(total_bytes, 1024)} kB)
size p50/p90/p99/max:     #{p.(0.5)} / #{p.(0.9)} / #{p.(0.99)} / #{List.last(sorted)} B
""")
buckets = [{0, 16}, {16, 64}, {64, 256}, {256, 1024}, {1024, 4096}, {4096, 1_000_000}]

bucket_rows =
  for {lo, hi} <- buckets do
    in_b = Enum.filter(corpus, &(byte_size(&1) >= lo and byte_size(&1) < hi))
    label = if hi == 1_000_000, do: "≥#{lo}", else: "#{lo}#{hi}"
    escaped = Enum.count(in_b, &(:binary.match(&1, "\\") != :nomatch))

    %{
      bucket: label,
      literals: length(in_b),
      kB: Float.round(in_b |> Enum.map(&byte_size/1) |> Enum.sum() |> Kernel./(1024), 1),
      escaped_pct: Float.round(escaped * 100 / max(length(in_b), 1), 1)
    }
  end

order = Enum.map(bucket_rows, & &1.bucket)

bar = fn field, title ->
  Vl.new(width: 300, height: 180, title: title)
  |> Vl.data_from_values(bucket_rows)
  |> Vl.mark(:bar, color: "#2a78d6", corner_radius_end: 4)
  |> Vl.encode_field(:x, "bucket", type: :nominal, sort: order, title: "literal size [B]")
  |> Vl.encode_field(:y, field, type: :quantitative, title: nil)
  |> Vl.encode(:tooltip, [[field: "bucket"], [field: field]])
end

Kino.Layout.grid(
  [Kino.VegaLite.new(bar.("literals", "how many literals")),
   Kino.VegaLite.new(bar.("kB", "where the bytes are")),
   Kino.VegaLite.new(bar.("escaped_pct", "% containing a backslash"))],
  columns: 3
)

Most literals are tiny, but the bytes live in the tail — the big buckets are doc heredocs. The third chart answers the obvious objection up front: short strings are rarely escaped. Around the 10 B median only ~5% of literals contain a backslash, so the most popular sizes are also where the guard misses least; the rate climbs with size, yet even the heredoc tail stays ~90% escape-free — and that tail is exactly where skipping the copy pays 10–400×.

2. Variants — the real file, patched at one clause

Nothing is transcribed here. The cell below reads lib/elixir/src/elixir_interpolation.erl out of the checkout selected above, replaces only the unescape_chars/2 entry clause, renames the module, and compiles it. The byte loop, the escape clauses, unescape_map/1 — all of it is the upstream file, whatever revision the checkout is on. (Replacing the entry clause rather than editing it also means the notebook gives the same baseline on a pristine checkout and on one that already has the patch applied.)

Each variant gets its own module, which is not cosmetic — see 2b.

%% A: baseline, exactly upstream
unescape_chars(String, Map) -> unescape_chars(String, Map, <<>>).

%% B: the proposed patch
unescape_chars(String, Map) ->
  case binary:match(String, <<$\\>>) of
    nomatch -> String;
    _ -> unescape_chars(String, Map, <<>>)
  end.

%% C: the match already says where the first escape is — seed the accumulator
%%    with the prefix instead of copying it byte by byte ("sub-binary seed")
unescape_chars(String, Map) ->
  case binary:match(String, <<$\\>>) of
    nomatch -> String;
    {Pos, _} ->
      <<Prefix:Pos/binary, Rest/binary>> = String,
      unescape_chars(Rest, Map, Prefix)
  end.

%% C': same prefix, but seeded through <<>> so the accumulator stays a
%%     *writable* binary ("writable seed") — see 2b, this one expression is
%%     worth 1.4x on the miss path and ~1.7x over the whole repo
      unescape_chars(Rest, Map, <<<<>>/binary, Prefix/binary>>)

%% D: C' with the pattern compiled once by the caller (the tokenizer would
%%    thread it through per file); binary:compile_pattern/1 returns a
%%    reference, so it cannot be a module constant
src_path = Path.join(repo, "lib/elixir/src/elixir_interpolation.erl")
original = File.read!(src_path)

# split the file at the first loop clause, then throw away the old entry clause
# (and any comment attached to it) — everything after the split is untouched
[head, loop_tail] = String.split(original, "unescape_chars(<<$\\\\, $x,", parts: 2)
loop = "unescape_chars(<<$\\\\, $x," <> loop_tail

[before_entry, _old_entry] =
  String.split(head, ~r/(?=(%[^\n]*\n)*unescape_chars\(String, Map\) ->)/, parts: 2)

entries = %{
  bench_a: """
  unescape_chars(String, Map) ->
    unescape_chars(String, Map, <<>>).
  """,
  bench_b: """
  unescape_chars(String, Map) ->
    case binary:match(String, <<$\\\\>>) of
      nomatch -> String;
      _ -> unescape_chars(String, Map, <<>>)
    end.
  """,
  bench_c: """
  unescape_chars(String, Map) ->
    case binary:match(String, <<$\\\\>>) of
      nomatch -> String;
      {Pos, _} ->
        <<Prefix:Pos/binary, Rest/binary>> = String,
        unescape_chars(Rest, Map, Prefix)
    end.
  """,
  bench_c_writable: """
  unescape_chars(String, Map) ->
    case binary:match(String, <<$\\\\>>) of
      nomatch -> String;
      {Pos, _} ->
        <<Prefix:Pos/binary, Rest/binary>> = String,
        unescape_chars(Rest, Map, <<<<>>/binary, Prefix/binary>>)
    end.
  """,
  bench_d: """
  unescape_chars(String, Map) ->
    unescape_chars_pat(String, Map, binary:compile_pattern(<<$\\\\>>)).

  unescape_chars_pat(String, Map, Pattern) ->
    case binary:match(String, Pattern) of
      nomatch -> String;
      {Pos, _} ->
        <<Prefix:Pos/binary, Rest/binary>> = String,
        unescape_chars(Rest, Map, <<<<>>/binary, Prefix/binary>>)
    end.
  """
}

# benchmark entry points, appended to each module. Every job is a plain compiled
# capture taking {Pattern, String} — an anonymous fn built in a Livebook cell
# runs through the evaluator and adds ~400 ns per call, which would drown the
# fast scans. Only D reads the pattern; the others ignore it.
bench_entries = %{
  bench_d: """
  bench({P, S}) -> unescape_chars_pat(S, fun unescape_map/1, P).
  bench_pass(L) ->
    P = binary:compile_pattern(<<$\\\\>>),
    lists:foreach(fun(S) -> unescape_chars_pat(S, fun unescape_map/1, P) end, L).
  """
}

default_bench = """
bench({_P, S}) -> unescape_chars(S, fun unescape_map/1).
bench_pass(L) -> lists:foreach(fun(S) -> unescape_chars(S, fun unescape_map/1) end, L).
"""

beams =
  for {mod, entry} <- entries, into: %{} do
    src =
      (before_entry <> entry <> "\n" <> loop <> "\n" <> Map.get(bench_entries, mod, default_bench))
      # renamed so the running compiler's own elixir_interpolation is never clobbered
      |> String.replace(
        "-module(elixir_interpolation).",
        "-module(#{mod}).\n-export([bench/1, bench_pass/1])."
      )

    path = Path.join(System.tmp_dir!(), "#{mod}.erl")
    File.write!(path, src)

    {:ok, ^mod, beam} =
      :compile.file(String.to_charlist(path), [
        :binary,
        :return_errors,
        {:i, String.to_charlist(Path.join(repo, "lib/elixir/src"))}
      ])

    {:module, ^mod} = :code.load_binary(mod, String.to_charlist(path), beam)
    {mod, beam}
  end

variants = [
  {"A current (copy all)", :bench_a},
  {"B skip if no \\", :bench_b},
  {"C prefix, sub-binary seed", :bench_c},
  {"C' prefix, writable seed", :bench_c_writable},
  {"D C' + compiled pattern", :bench_d}
]

pattern = :binary.compile_pattern("\\")

# sigils carry their own maps (~r has hex: false), so a handful of literals are
# invalid under the default map — drop them (~0.02%)
{bench_corpus, skipped} =
  Enum.split_with(corpus, fn s ->
    try do
      :bench_a.bench({pattern, s})
      true
    catch
      _, _ -> false
    end
  end)

for s <- bench_corpus, {name, mod} <- variants, mod.bench({pattern, s}) != :bench_a.bench({pattern, s}) do
  raise "#{name} disagrees on #{inspect(s)}"
end

"#{length(bench_corpus)} literals (#{length(skipped)} skipped), all variants agree"

2b. One module per variant — and why the C variants differ by one line

<<Acc/binary, Char>> compiles to bs_create_bin, which comes in two flavours. private_append mutates a uniquely-owned writable binary in place: the loop allocates one accumulator and grows it. Plain append cannot assume ownership, so every iteration produces a new binary term — a ~5-word header on the process heap, instantly garbage. Same source, ~40 bytes of heap churn per input byte instead of a flat ~105 bytes per literal, and ~2–3× the wall clock.

The compiler picks per module, and it only needs one caller it cannot prove to downgrade the loop for everybody. Variant C hands the loop Prefix — a sub-binary of the input, not a writable binary — so C's loop compiles to append. Two consequences:

  • for the benchmark: with all variants in one module (an earlier version of this notebook), C would drag the baseline down with it, inflating A's time ~3× and its allocations ~40× — and every reported speedup with it. Hence one module per variant, asserted below.
  • for the proposal: this is not a benchmark artifact but a constraint on the patch. Landing C as written would deoptimize the real loop in elixir_interpolation, penalizing every literal that does contain an escape. Seeding through <<>> instead — <<<<>>/binary, Prefix/binary>>, variant C' — keeps private_append and costs nothing.

The assertion below is the guard rail: it fails loudly if a variant's loop compiles differently from upstream's.

append_kind = fn beam ->
  {:beam_file, _, _, _, _, code} = :beam_disasm.file(beam)

  for {:function, :unescape_chars, 3, _, instructions} <- code,
      i <- instructions,
      is_tuple(i),
      elem(i, 0) == :bs_create_bin,
      {:list, args} = i |> Tuple.to_list() |> List.last(),
      {:atom, kind} <- args,
      kind in [:append, :private_append] do
    kind
  end
  |> Enum.frequencies()
end

upstream_kind = append_kind.(:code.which(:elixir_interpolation))

kind_rows =
  [%{variant: "upstream elixir_interpolation.beam", loop: inspect(upstream_kind)}] ++
    for {label, mod} <- variants do
      %{variant: "#{label} (#{mod})", loop: inspect(append_kind.(beams[mod]))}
    end

# A and B must compile exactly like upstream, or the baseline is not a baseline
for {label, mod} <- variants, label in ["A current (copy all)", "B skip if no \\"] do
  ^upstream_kind = append_kind.(beams[mod])
end

Kino.DataTable.new(kind_rows, keys: [:variant, :loop])

3. One pass over the whole repo

The closest thing to "what a compile actually pays".

repo_suite =
  Benchee.run(
    Map.new(variants, fn {label, mod} -> {label, &mod.bench_pass/1} end),
    inputs: %{"whole repo" => bench_corpus},
    time: 2,
    warmup: 1,
    memory_time: 1,
    print: [benchmarking: false, configuration: false, fast_warning: false]
  )

repo_rows =
  for s <- repo_suite.scenarios do
    %{
      variant: s.job_name,
      ms: Float.round(s.run_time_data.statistics.average / 1_000_000, 2),
      mb: Float.round((s.memory_usage_data.statistics.average || 0) / 1_048_576, 2)
    }
  end

base = Enum.find(repo_rows, &(&1.variant == "A current (copy all)"))

repo_rows =
  Enum.map(repo_rows, fn r ->
    Map.merge(r, %{
      speedup: "#{Float.round(base.ms / r.ms, 2)}×",
      # >1 means fewer allocations than today; <1 means MORE
      mem: "#{Float.round(base.mb / max(r.mb, 0.001), 1)}×"
    })
  end)

hbar = fn field, title ->
  Vl.new(width: 300, height: 140, title: title)
  |> Vl.data_from_values(repo_rows)
  |> Vl.mark(:bar, corner_radius_end: 4)
  |> Vl.encode_field(:y, "variant", type: :nominal, sort: Enum.map(palette, &elem(&1, 0)), title: nil)
  |> Vl.encode_field(:x, field, type: :quantitative, title: nil)
  |> Vl.encode(:color, color ++ [legend: nil])
  |> Vl.encode(:tooltip, [[field: "variant"], [field: field], [field: "speedup"]])
end

Kino.Layout.grid(
  [Kino.VegaLite.new(hbar.("ms", "time per pass [ms]")),
   Kino.VegaLite.new(hbar.("mb", "allocations per pass [MB]"))],
  columns: 2
)
Kino.DataTable.new(repo_rows, keys: [:variant, :ms, :speedup, :mb, :mem])

Measured on this machine (OTP 29, Elixir 1.20): 22.0 ms / 5.02 MB for the current code, 6.4 ms / 0.37 MB for the one-line guard (3.4× faster, 14× fewer allocations), 5.3 ms for C′, and 2.8 ms for D — 7.7×.

Variant C is the cautionary tale: 2.4×, slower than the plain guard, and it allocates more than today's code (6.78 MB vs 5.02 MB). Nothing about its algorithm is worse than C′ — the single difference is the deoptimized loop from 2b, paid on the 5.7% of literals that do contain an escape. A one-line change in how the accumulator is seeded is worth more here than the entire prefix optimization.

4. Scaling with literal size

Escape-free strings — the ~95% case. Log–log: the current code is a straight line, the guarded variants flatten to the cost of one scan.

sweep_sizes = [4, 16, 64, 256, 1024, 4096, 16384]

# per-literal jobs: compiled entry points taking {pattern, string}
bm_jobs = Map.new(variants, fn {label, mod} -> {label, &mod.bench/1} end)

sweep_suite =
  Benchee.run(bm_jobs,
    inputs: Map.new(sweep_sizes, fn n -> {"#{n}", {pattern, :binary.copy("a", n)}} end),
    time: 0.5,
    warmup: 0.2,
    print: [benchmarking: false, configuration: false, fast_warning: false]
  )

sweep_rows =
  for s <- sweep_suite.scenarios do
    %{
      variant: s.job_name,
      bytes: String.to_integer(s.input_name),
      ns: Float.round(s.run_time_data.statistics.average, 1),
      med: Float.round(s.run_time_data.statistics.median * 1.0, 1)
    }
  end

Vl.new(width: 560, height: 300, title: "escape-free literal: avg ns per call")
|> Vl.data_from_values(sweep_rows)
|> Vl.mark(:line, point: true, stroke_width: 2)
|> Vl.encode_field(:x, "bytes", type: :quantitative, scale: [type: :log], title: "literal size [B]")
|> Vl.encode_field(:y, "ns", type: :quantitative, scale: [type: :log], title: "ns per call")
|> Vl.encode(:color, color)
|> Vl.encode(:tooltip, [[field: "variant"], [field: "bytes"], [field: "ns"]])

5. When the guard misses

A literal that does contain an escape — the guard is pure overhead when the escape sits at byte 0, and prefix seeding starts paying the further in it sits. The payload is editable (prefilled to ~100 B): \n is inserted at the start, the middle, and the end, so any string of your own can be tested. Type it as it appears in source, before unescaping — a \ in the box is a literal backslash byte, exactly what unescape_chars/2 receives from the tokenizer.

miss_payload_input = Kino.Input.textarea("payload to test", default: String.duplicate("a", 98))
payload = Kino.Input.read(miss_payload_input)
mid = div(byte_size(payload), 2)
<<front::binary-size(^mid), back::binary>> = payload

case :binary.match(payload, "\\") do
  :nomatch ->
    IO.puts("payload: #{byte_size(payload)} B, no backslash — on its own it would take the fast path")

  {pos, _} ->
    IO.puts("payload: #{byte_size(payload)} B, has a backslash of its own at byte #{pos}")
end

unescaped =
  try do
    :bench_a.bench({pattern, "\\n" <> payload})
  catch
    {:error, msg, tok} ->
      raise "payload contains an invalid escape (#{msg} #{tok}) — fix it before benchmarking"
  end

IO.puts("start-escaped variant unescapes to: #{inspect(unescaped)}")

miss_inputs = %{
  "escape at start" => {pattern, "\\n" <> payload},
  "escape at middle" => {pattern, front <> "\\n" <> back},
  "escape at end" => {pattern, payload <> "\\n"}
}

miss_suite =
  Benchee.run(bm_jobs,
    inputs: miss_inputs,
    time: 0.5,
    warmup: 0.2,
    print: [benchmarking: false, configuration: false, fast_warning: false]
  )

miss_rows =
  for s <- miss_suite.scenarios do
    %{variant: s.job_name, input: s.input_name, ns: Float.round(s.run_time_data.statistics.median * 1.0, 1)}
  end

Vl.new(width: 460, height: 240,
  title: "#{byte_size(payload) + 2} B literal WITH an escape: median ns per call")
|> Vl.data_from_values(miss_rows)
|> Vl.mark(:bar, corner_radius_end: 4)
|> Vl.encode_field(:x, "input", type: :nominal,
    sort: ["escape at start", "escape at middle", "escape at end"], title: nil)
|> Vl.encode_field(:x_offset, "variant", type: :nominal)
|> Vl.encode_field(:y, "ns", type: :quantitative, title: "ns per call")
|> Vl.encode(:color, color)
|> Vl.encode(:tooltip, [[field: "variant"], [field: "input"], [field: "ns"]])

The same question across sizes, read exactly like the chart in section 4: the gray line is the current code, and every input here does contain an escape. Left panel — escape at the very first byte, the honest worst case: the guarded lines sit slightly above gray at small sizes (the flat ~99 ns guard cost) and converge onto it as the copy dominates — except orange (C), which stays ~1.4× above gray at every size, because its loop is the deoptimized one. Right panel — escape at the very last byte: B still tracks gray, but C, C′ and D drop far below it (~200× at 16 kB), because the prefix copy replaces almost the whole byte loop. The middle panel is yours: the slider sets how far into the literal the escape sits — re-run the cell after dragging.

escape_pos_input =
  Kino.Input.range("escape position, % into the literal", min: 0, max: 100, step: 5, default: 50)
pct = round(Kino.Input.read(escape_pos_input))

miss_sweep_inputs =
  for n <- sweep_sizes,
      {scen, s} <- [
        {"escape at first byte", "\\n" <> :binary.copy("a", max(n - 2, 0))},
        {"escape at #{pct}%",
         :binary.copy("a", max(div(n * pct, 100) - 2, 0)) <>
           "\\n" <> :binary.copy("a", max(n - div(n * pct, 100), 0))},
        {"escape at last byte", :binary.copy("a", max(n - 2, 0)) <> "\\n"}
      ],
      into: %{} do
    {"#{scen}|#{n}", {pattern, s}}
  end

miss_sweep_suite =
  Benchee.run(bm_jobs,
    inputs: miss_sweep_inputs,
    time: 0.3,
    warmup: 0.1,
    print: [benchmarking: false, configuration: false, fast_warning: false]
  )

miss_sweep_rows =
  for s <- miss_sweep_suite.scenarios do
    [scen, bytes] = String.split(s.input_name, "|")

    %{
      variant: s.job_name,
      scenario: scen,
      bytes: String.to_integer(bytes),
      ns: s.run_time_data.statistics.average,
      med: s.run_time_data.statistics.median * 1.0
    }
  end

miss_sweep_rows =
  Enum.map(miss_sweep_rows, &Map.update!(&1, :ns, fn ns -> Float.round(ns * 1.0, 1) end))

Vl.new(columns: 3, title: "literal WITH an escape: avg ns per call (gray = current)")
|> Vl.data_from_values(miss_sweep_rows)
|> Vl.facet(
  [field: "scenario", title: nil,
   sort: ["escape at first byte", "escape at #{pct}%", "escape at last byte"]],
  Vl.new(width: 300, height: 220)
  |> Vl.mark(:line, point: true, stroke_width: 2)
  |> Vl.encode_field(:x, "bytes", type: :quantitative, scale: [type: :log], title: "literal size [B]")
  |> Vl.encode_field(:y, "ns", type: :quantitative, scale: [type: :log], title: "ns per call")
  |> Vl.encode(:color, color)
  |> Vl.encode(:tooltip, [[field: "variant"], [field: "bytes"], [field: "ns"]])
)

6. Where the time goes — flame view

:tprof (OTP stdlib) traces both variants on a 16 kB literal, with and without an escape in the middle. All four bars share one scale, so width is directly comparable. Both functions are tail-recursive, so a full flamegraph has exactly these frames; tprof's per-call trace overhead inflates the recursive loop, so read proportions, not absolute µs.

profile = fn fun ->
  {_result, {:call_time, data}} = :tprof.profile(fun, %{type: :call_time, report: :return})

  for {mod, f, a, samples} <- data,
      {mod, f, a} in [{:bench_a, :unescape_chars, 3}, {:bench_d, :unescape_chars, 3}, {:binary, :match, 2}],
      {_pid, count, us} <- samples do
    %{fun: "#{mod}:#{f}/#{a}", count: count, us: us}
  end
  |> Enum.sort_by(& &1.fun, :desc)
end

clean16k = :binary.copy("a", 16384)
esc_mid = :binary.copy("a", 8191) <> "\\n" <> :binary.copy("a", 8192)

flames =
  for {label, fun} <- [
        {"A current (copy all) — clean", fn -> :bench_a.bench({pattern, clean16k}) end},
        {"D fast path — clean", fn -> :bench_d.bench({pattern, clean16k}) end},
        {"A current (copy all) — escape in the middle", fn -> :bench_a.bench({pattern, esc_mid}) end},
        {"D fast path — escape in the middle", fn -> :bench_d.bench({pattern, esc_mid}) end}
      ] do
    {label, profile.(fun)}
  end

max_total =
  flames |> Enum.map(fn {_, rows} -> rows |> Enum.map(& &1.us) |> Enum.sum() end) |> Enum.max()

# blue = the scan, orange = the byte loop (whichever module it lives in)
fun_color = fn "binary:match/2" -> "#2a78d6"; _ -> "#eb6834" end

blocks =
  for {label, rows} <- flames do
    total = rows |> Enum.map(& &1.us) |> Enum.sum()

    segs =
      Enum.map_join(rows, fn r ->
        w = max(Float.round(r.us * 100 / max_total, 2), 0.4)

        ~s(<div title="#{r.fun}#{r.us} µs, #{r.count} calls" ) <>
          ~s(style="width:#{w}%;background:#{fun_color.(r.fun)};height:26px;margin-right:2px;) <>
          ~s(border-radius:3px;color:#fff;font:11px monospace;overflow:hidden;) <>
          ~s(white-space:nowrap;padding:5px 4px 0;box-sizing:border-box">#{r.fun} · #{r.us} µs · #{r.count} calls</div>)
      end)

    ~s(<div style="margin:14px 0 4px;font:12px sans-serif">#{label}#{total} µs traced</div>) <>
      ~s(<div style="display:flex">#{segs}</div>)
  end

Kino.HTML.new(
  ~s(<div style="max-width:760px">#{Enum.join(blocks)}</div>) <>
    ~s(<div style="margin-top:10px;font:11px sans-serif;color:#888">) <>
    ~s(blue = binary:match/2 · orange = unescape_chars/3 · shared scale · hover for details</div>)
)

The picture the reader should take away: on a clean literal the current code is one wide orange bar of 16 385 loop iterations; the fast path is a blue sliver — one binary:match/2 call. With an escape in the middle, the fast path pays the sliver and half the orange bar.

7. When does it pay off — and would a byte_size cutoff help?

A two-parameter model from the measured sweep: copying costs ~a ns/byte, the guarded scan ~s ns/byte, and a missed guard a flat ~g ns.

# the model uses medians: these jobs run in tens of ns, where Benchee averages
# are dominated by GC/scheduler outliers — differences of averages come out garbage
sw = Map.new(sweep_rows, fn r -> {{r.variant, r.bytes}, r.med} end)
miss = Map.new(miss_sweep_rows, fn r -> {{r.variant, r.scenario, r.bytes}, r.med} end)

a_slope = (sw[{"A current (copy all)", 16384}] - sw[{"A current (copy all)", 256}]) / (16384 - 256)
s_slope = (sw[{"B skip if no \\", 16384}] - sw[{"B skip if no \\", 256}]) / (16384 - 256)
# the flat miss cost (~tens of ns) must be measured on SMALL inputs — at 4 kB
# it would be a difference of two ~40 µs numbers and drown in benchmark noise
g_flat =
  [4, 16, 64]
  |> Enum.map(fn b ->
    miss[{"B skip if no \\", "escape at first byte", b}] -
      miss[{"A current (copy all)", "escape at first byte", b}]
  end)
  |> Enum.sort()
  |> Enum.at(1)
  |> max(0.0)

crossover = Enum.find(sweep_sizes, fn n -> sw[{"B skip if no \\", n}] < sw[{"A current (copy all)", n}] end)

hit_rate = no_bs / n

expected_saving = fn bytes ->
  hit_rate * (a_slope - s_slope) * bytes - (1 - hit_rate) * g_flat
end

IO.puts("""
copying one byte in the loop:      a ≈ #{Float.round(a_slope, 2)} ns per byte
scanning one byte (binary:match):  s ≈ #{Float.round(s_slope, 4)} ns per byte
one missed guard (flat):           g ≈ #{Float.round(g_flat, 0)} ns per literal
measured crossover on clean literals: #{crossover} B
break-even for a literal of unknown content: #{Float.round(g_flat * (1 - hit_rate) / (hit_rate * (a_slope - s_slope)), 2)} B
expected saving at the median (#{p.(0.5)} B): #{Float.round(expected_saving.(p.(0.5)), 0)} ns/literal
expected saving at p99 (#{p.(0.99)} B): #{Float.round(expected_saving.(p.(0.99)), 0)} ns/literal
""")

With a 94.3% hit rate the break-even lands around 1 byte (a ~99 ns miss against ~6.2 ns/byte of copying), so a byte_size(String) > X cutoff buys almost nothing on this corpus. It is worth one sentence in the proposal, though: for a codebase where most literals do contain escapes, exempting literals below the break-even size (a few dozen bytes) would cap the only regression this change has — a suggestion to mention, not necessarily to implement.

Conclusions

Measured on OTP 29 / Elixir 1.20, corpus = every string literal in the Elixir repo (50 447 literals, 2.65 MB, 94.3% escape-free).

  • ~94% of real literals contain no escape, and the bytes concentrate in large, escape-free doc heredocs — the fast path hits almost always, exactly where it pays the most.
  • Whole-repo pass: the one-line guard (B) is 3.4× faster with 14× fewer allocations (22.0 → 6.4 ms, 5.02 → 0.37 MB). Prefix seeding done right (C′) gives 4.1×, and the precompiled pattern (D) 7.7× — the C′→D delta (~50–60 ns per literal) is exactly the per-call pattern compile, the part the recent OTP PR precompiling literal patterns at load time removes for free.
  • How the accumulator is seeded matters more than the prefix optimization. Handing the loop the prefix sub-binary directly (C) makes the compiler drop private_append in favour of append for the whole module: 2.4× instead of 4.1×, worse than the plain guard, and more allocations than today's code (6.78 MB vs 5.02 MB). Seeding through <<>> (C′) avoids it. Any patch that touches the accumulator should assert on the emitted bs_create_bin flavour — see 2b.
  • Clean literals scale flat instead of linearly: today ~6.2 ns/byte, guarded a flat ~100 ns (~40–50 ns with a precompiled pattern), i.e. ~780× on a 16 kB escape-free literal.
  • Worst case — an escape at the first byte — costs a flat ~99 ns: about 2× on a 4-byte literal, within noise from a few hundred bytes up. Only ~5.7% of real literals miss at all, and prefix seeding turns a late escape into a win instead (~200× on a 16 kB literal ending in one).
  • The returned binary may be a sub-binary referencing a larger one; tokens arrive via characters_to_binary and module literals are copied into beam chunks, so this does not extend lifetimes in practice.

Appendix: decode_www_form needs two characters

URI.decode/1 only rewrites %, so its guard is the same single-byte one-liner as above. URI.decode_www_form/1 also rewrites + — and a two-pattern binary:match is a different beast: with more than one pattern the scan switches from a memchr-style single-byte search to Aho–Corasick, which walks the input byte by byte. Precompiling the pattern does not rescue the scan — the compile is not where the time goes. The guard that stays fast is two sequential single-byte scans: return the input as-is only when both miss (and on a hit, the earlier of the two positions still seeds the prefix). Today's URI.decode_www_form/1 is included as the baseline — on a clean input it pays the full byte-by-byte copy that every guard above replaces.

A note on input sizes: decode_www_form runs per value (bodies are split on &/= first), so 100 B is the typical case. Values containing spaces miss the guard by construction — spaces arrive as + or %20 — which means the fast path's territory is machine-generated values: ids, hashes, hex digests, base64url tokens. That is also exactly where large clean values come from (JWTs in OAuth form posts run 1–8 kB), hence the 2 kB input; 16 kB is there to expose how the scans scale, not as a typical payload.

www_src = ~S"""
-module(bench_www).
-export([current/1, single_compile/1, multi_compile/1, single_pre/1, multi_pre/1, two_scans/1]).

%% today's stdlib code: unpercent copies byte by byte even when clean
current({_Ps, S}) -> 'Elixir.URI':decode_www_form(S).

%% every entry point takes {Patterns, String} so each Benchee job is a plain
%% compiled capture — a closure over the patterns would run through Livebook's
%% evaluator and add ~400 ns per call, drowning the fast scans
single_compile({_Ps, S}) -> binary:match(S, <<$%>>).
multi_compile({_Ps, S}) -> binary:match(S, [<<$%>>, <<$+>>]).
single_pre({{Pct, _Plus, _Both}, S}) -> binary:match(S, Pct).
multi_pre({{_Pct, _Plus, Both}, S}) -> binary:match(S, Both).
two_scans({{Pct, Plus, _Both}, S}) ->
  case binary:match(S, Pct) of
    nomatch -> binary:match(S, Plus);
    Found -> Found
  end.
"""

www_path = Path.join(System.tmp_dir!(), "bench_www.erl")
File.write!(www_path, www_src)
{:ok, www_mod, www_beam} = :compile.file(String.to_charlist(www_path), [:binary, :return_errors])
{:module, ^www_mod} = :code.load_binary(www_mod, String.to_charlist(www_path), www_beam)

www_patterns = {
  :binary.compile_pattern("%"),
  :binary.compile_pattern("+"),
  :binary.compile_pattern(["%", "+"])
}

www_suite =
  Benchee.run(
    %{
      "current URI.decode_www_form" => &:bench_www.current/1,
      "single %, compile per call" => &:bench_www.single_compile/1,
      "multi [%,+], compile per call" => &:bench_www.multi_compile/1,
      "single %, precompiled" => &:bench_www.single_pre/1,
      "multi [%,+], precompiled" => &:bench_www.multi_pre/1,
      "two single scans, precompiled" => &:bench_www.two_scans/1
    },
    inputs: %{
      "1: 100 B clean" => {www_patterns, :binary.copy("a", 100)},
      "2: 2 kB clean (JWT-sized)" => {www_patterns, :binary.copy("a", 2048)},
      "3: 16 kB clean" => {www_patterns, :binary.copy("a", 16384)}
    },
    time: 0.5,
    warmup: 0.2,
    print: [benchmarking: false, configuration: false, fast_warning: false]
  )

www_rows =
  for s <- www_suite.scenarios do
    %{input: s.input_name, guard: s.job_name, median_ns: Float.round(s.run_time_data.statistics.median * 1.0, 1)}
  end

Kino.DataTable.new(Enum.sort_by(www_rows, &{&1.input, &1.median_ns}), keys: [:input, :guard, :median_ns])

Two consequences for the proposals:

  • the decode_www_form fast path must be written as two single-byte scans, never as binary:match(S, ["%", "+"]) — the multi-pattern scan is ~200× slower per byte and would eat the entire gain on long clean inputs;
  • precompilation (proposal 2) still matters for short inputs — the per-call compile is a meaningful share of a ~30 ns scan — but it cannot make the multi-pattern path competitive, so it complements the two-scan shape rather than replacing it.