Skip to content

perf: escape token values with a single binary scan - #77

Merged
josevalim merged 1 commit into
elixir-makeup:masterfrom
javiergarea:perf/scan-escape
Oct 1, 2026
Merged

josevalim merged 1 commit into
elixir-makeup:masterfrom
javiergarea:perf/scan-escape

Conversation

@javiergarea

Copy link
Copy Markdown
Contributor

Current code converts each token value to a charlist and maps every character. This allocates a list of the same length as the input, even for text with nothing to escape.

The new version scans the binary and returns the unchanged runs as sub-binaries, which avoids copying them.

Benchmark

# escape_bench.exs
defmodule Old do
  def escape_for(?&), do: "&"
  def escape_for(?<), do: "&lt;"
  def escape_for(?>), do: "&gt;"
  def escape_for(?"), do: "&quot;"
  def escape_for(?'), do: "&#39;"
  def escape_for(c) when is_integer(c) and c <= 127, do: c
  def escape_for(c) when is_integer(c) and c >= 128, do: <<c::utf8>>

  def escape(string) when is_binary(string) do
    string
    |> to_charlist()
    |> Enum.map(&escape_for/1)
  end
end

defmodule New do
  def escape(string) when is_binary(string) do
    escape_binary(string, string, 0, 0, [])
  end

  for {char, replacement} <- [
        {?&, "&amp;"},
        {?<, "&lt;"},
        {?>, "&gt;"},
        {?", "&quot;"},
        {?', "&#39;"}
      ] do
    defp escape_binary(<<unquote(char), rest::binary>>, original, skip, len, acc) do
      part = binary_part(original, skip, len)
      escape_binary(rest, original, skip + len + 1, 0, [acc, part, unquote(replacement)])
    end
  end

  defp escape_binary(<<_char, rest::binary>>, original, skip, len, acc) do
    escape_binary(rest, original, skip, len + 1, acc)
  end

  defp escape_binary(<<>>, original, skip, len, acc) do
    [acc | binary_part(original, skip, len)]
  end
end

input = String.duplicate("identifier a < b & c \"d\" ", 20_000)
runs = 100

IO.puts("same output: #{IO.iodata_to_binary(Old.escape(input)) == IO.iodata_to_binary(New.escape(input))}")

for {name, escape} <- [{"Old", &Old.escape/1}, {"New", &New.escape/1}] do
  samples = Enum.map(1..runs, fn _ -> elem(:timer.tc(fn -> escape.(input) end), 0) / 1000 end)
  mean = Enum.sum(samples) / runs
  deviation = :math.sqrt(Enum.sum(Enum.map(samples, &:math.pow(&1 - mean, 2))) / (runs - 1))

  IO.puts("#{name}: #{Float.round(mean, 2)} ms +- #{Float.round(deviation, 2)} ms")
end
❯ elixir escape_bench.exs
same output: true
Old: 35.68 ms +- 9.64 ms
New: 7.68 ms +- 2.87 ms

@josevalim
josevalim merged commit 6773349 into elixir-makeup:master Oct 1, 2026
2 checks passed
@josevalim

Copy link
Copy Markdown
Collaborator

💚 💙 💜 💛 ❤️

@josevalim

Copy link
Copy Markdown
Collaborator

Thank you! Btw, did you see this comment? elixir-makeup/makeup_html#11

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