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' — keepsprivate_appendand 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_appendin favour ofappendfor 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 emittedbs_create_binflavour — 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_binaryand 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_formfast path must be written as two single-byte scans, never asbinary: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.