Skip to content

Optimize URI form and RFC 3986 query encoding with SWAR - #15733

Closed
preciz wants to merge 1 commit into
elixir-lang:mainfrom
preciz:optimize-uri-unreserved-swar
Closed

Optimize URI form and RFC 3986 query encoding with SWAR#15733
preciz wants to merge 1 commit into
elixir-lang:mainfrom
preciz:optimize-uri-unreserved-swar

Conversation

@preciz

@preciz preciz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Assisted-by: Codex CLI:GPT-5.6 Sol

Use a guarded 56-bit fast path with compact scalar tail handling and an isolated fallback scanner. Fast-path empty inputs before allocating the binary builder, and cover SWAR lane and length boundaries with focused differential tests.

@NelsonVides it would be great if you would review it.

Elixir Forum discussion: https://elixirforum.com/t/help-swar-optimize-uri-encode-www-form-1/76227

Benchmark:

# Run this file from an Elixir checkout on both branches:
#
#     git switch main
#     bin/elixirc lib/elixir/lib/uri.ex -o lib/elixir/ebin
#     bin/elixir --erl "+S 1:1" bench.exs
#     git switch optimize-uri-unreserved-swar
#     bin/elixirc lib/elixir/lib/uri.ex -o lib/elixir/ebin
#     bin/elixir --erl "+S 1:1" bench.exs

Mix.install([{:benchee, "~> 1.5"}])

safe_70 = String.duplicate("abcdefg", 10)
mixed_70 = String.duplicate("abc /?~", 10)
safe_700 = String.duplicate(safe_70, 10)
mixed_700 = String.duplicate(mixed_70, 10)

inputs = %{
  "empty" => "",
  "safe, 6 bytes" => "abcdef",
  "safe, 7 bytes" => "abcdefg",
  "safe, 70 bytes" => safe_70,
  "mixed, 70 bytes" => mixed_70,
  "safe, 700 bytes" => safe_700,
  "mixed, 700 bytes" => mixed_700
}

{revision, 0} = System.cmd("git", ["rev-parse", "--short", "HEAD"])

IO.puts("""
Revision:     #{String.trim(revision)}
Elixir:       #{System.version()}
OTP:          #{:erlang.system_info(:otp_release)}
Architecture: #{:erlang.system_info(:system_architecture)}
Word size:    #{:erlang.system_info(:wordsize) * 8}-bit
""")

options = [
  inputs: inputs,
  time: 2,
  warmup: 1,
  memory_time: 1,
  reduction_time: 1,
  print: [fast_warning: false]
]

Benchee.run(
  %{"URI.encode_www_form/1" => &URI.encode_www_form/1},
  options
)

query_inputs =
  Map.new(inputs, fn {name, value} ->
    {name, [{"key", value}]}
  end)

Benchee.run(
  %{
    "URI.encode_query/2 (:rfc3986)" => fn query -> URI.encode_query(query, :rfc3986) end
  },
  Keyword.put(options, :inputs, query_inputs)
)

Average of medians from two alternating runs:

   Input                         Main     Branch    Speedup
  ━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━  ━━━━━━━━━  ━━━━━━━━━
   WWW empty                    60 ns      30 ns      2.00×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW safe, 6 B               156 ns     100 ns      1.56×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW safe, 7 B               171 ns      80 ns      2.13×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW safe, 70 B             1.24 µs     201 ns      6.17×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW mixed, 70 B            1.44 µs     521 ns      2.75×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW safe, 700 B           12.52 µs    1.52 µs      8.26×
  ────────────────────────  ──────────  ─────────  ─────────
   WWW mixed, 700 B          14.48 µs    4.72 µs      3.07×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query empty             206 ns     120 ns      1.72×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query safe, 6 B         311 ns     211 ns      1.47×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query safe, 7 B         331 ns     191 ns      1.73×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query safe, 70 B       1.41 µs     316 ns      4.47×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query mixed, 70 B      1.47 µs     687 ns      2.14×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query safe, 700 B     12.77 µs    1.72 µs      7.44×
  ────────────────────────  ──────────  ─────────  ─────────
   RFC query mixed, 700 B    13.53 µs    5.44 µs      2.49×

  Allocation and reduction improvements are also substantial:

  - WWW safe 700 B: 16.51 KB → 104 B, 3.51K → 103 reductions.
  - WWW mixed 700 B: 16.51 KB → 104 B, 4.71K → 406 reductions.
  - RFC safe 700 B: 16.74 KB → 312 B, 3.54K → 121 reductions.

Use a guarded 56-bit fast path with compact scalar tail handling and an isolated fallback scanner. Fast-path empty inputs before allocating the binary builder, and cover SWAR lane and length boundaries with focused differential tests.

Assisted-by: Codex:GPT-5
@josevalim

Copy link
Copy Markdown
Member

Thank you but this implementation is much more complex than the other SWAR ones, for a code that is not that sensitive, so I am not sure if it is worth it.

@NelsonVides NelsonVides left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of comments but also, @josevalim mentioned SWAR being too complicated here, also considering normal input for this function is really just a few tens of bytes, unlike JSON which can see hundreds of MBs of input.

I've tried to offer a couple of points where we could simplify the logic, and I could probably think of more, but if it is still too much, perhaps we can still go for one optimisation: the for ... into: loop. As I mentioned in the forum thread, that compiles to a comprehension with a function call, a temporary binary allocation, and a case-do comparison, while we could instead rewrite the loop into a tail-recursive function as in something like

    defguardp unreserved_char?(character)
              when character in ?0..?9 or character in ?a..?z or character in ?A..?Z or
                     character in ~c"~_-."

    # char_unreserved?/1 becomes: unreserved_char?(character)
    # encode_kv_pair/:rfc3986 becomes: encode_unreserved(Kernel.to_string(key), :percent) <> ...

    def encode_www_form(string) when is_binary(string) do
      encode_unreserved(string, "", :www_form)
    end

    defp encode_unreserved(string, mode), do: encode_unreserved(string, "", mode)

    defp encode_unreserved(<<?\s, rest::binary>>, acc, :www_form) do
      encode_unreserved(rest, <<acc::binary, ?+>>, :www_form)
    end

    defp encode_unreserved(<<byte, rest::binary>>, acc, mode) when unreserved_char?(byte) do
      encode_unreserved(rest, <<acc::binary, byte>>, mode)
    end

    defp encode_unreserved(<<byte, rest::binary>>, acc, mode) do
      encode_unreserved(rest, <<acc::binary, ?%, hex(bsr(byte, 4)), hex(band(byte, 15))>>, mode)
    end

    defp encode_unreserved(<<>>, acc, _mode), do: acc

Which in my local benchmarks (macbook M4) gives me 90% of the performance improvements this branch gives (the other 10% being SWAR specifically). Perhaps we can split this PR in two and first of all propose the loop rewrite, which should be uncontroversial, and if that works then we can discuss the SWAR and think how else we could simplify until it's worth it?

Comment thread lib/elixir/lib/uri.ex
Comment on lines +544 to +603
# Consume through the first disallowed byte instead of checking overlapping words.
defp encode_unreserved_fallback(<<byte1, byte2, rest::binary>>, acc, mode)
when byte_size(rest) >= 5 and not unreserved_char?(byte2) do
encoded = encode_unreserved_byte(byte2, mode)
encode_unreserved(rest, <<acc::binary, byte1, encoded::binary>>, mode)
end

defp encode_unreserved_fallback(<<byte1, byte2, byte3, rest::binary>>, acc, mode)
when byte_size(rest) >= 4 and not unreserved_char?(byte3) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, encode_unreserved_byte(byte3, mode)::binary>>,
mode
)
end

defp encode_unreserved_fallback(<<byte1, byte2, byte3, byte4, rest::binary>>, acc, mode)
when byte_size(rest) >= 3 and not unreserved_char?(byte4) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, encode_unreserved_byte(byte4, mode)::binary>>,
mode
)
end

defp encode_unreserved_fallback(<<byte1, byte2, byte3, byte4, byte5, rest::binary>>, acc, mode)
when byte_size(rest) >= 2 and not unreserved_char?(byte5) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, encode_unreserved_byte(byte5, mode)::binary>>,
mode
)
end

defp encode_unreserved_fallback(
<<byte1, byte2, byte3, byte4, byte5, byte6, rest::binary>>,
acc,
mode
)
when byte_size(rest) >= 1 and not unreserved_char?(byte6) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, byte5,
encode_unreserved_byte(byte6, mode)::binary>>,
mode
)
end

defp encode_unreserved_fallback(
<<byte1, byte2, byte3, byte4, byte5, byte6, byte7, rest::binary>>,
acc,
mode
) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, byte5, byte6,
encode_unreserved_byte(byte7, mode)::binary>>,
mode
)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe generate the repetitive clauses.

Suggested change
# Consume through the first disallowed byte instead of checking overlapping words.
defp encode_unreserved_fallback(<<byte1, byte2, rest::binary>>, acc, mode)
when byte_size(rest) >= 5 and not unreserved_char?(byte2) do
encoded = encode_unreserved_byte(byte2, mode)
encode_unreserved(rest, <<acc::binary, byte1, encoded::binary>>, mode)
end
defp encode_unreserved_fallback(<<byte1, byte2, byte3, rest::binary>>, acc, mode)
when byte_size(rest) >= 4 and not unreserved_char?(byte3) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, encode_unreserved_byte(byte3, mode)::binary>>,
mode
)
end
defp encode_unreserved_fallback(<<byte1, byte2, byte3, byte4, rest::binary>>, acc, mode)
when byte_size(rest) >= 3 and not unreserved_char?(byte4) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, encode_unreserved_byte(byte4, mode)::binary>>,
mode
)
end
defp encode_unreserved_fallback(<<byte1, byte2, byte3, byte4, byte5, rest::binary>>, acc, mode)
when byte_size(rest) >= 2 and not unreserved_char?(byte5) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, encode_unreserved_byte(byte5, mode)::binary>>,
mode
)
end
defp encode_unreserved_fallback(
<<byte1, byte2, byte3, byte4, byte5, byte6, rest::binary>>,
acc,
mode
)
when byte_size(rest) >= 1 and not unreserved_char?(byte6) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, byte5,
encode_unreserved_byte(byte6, mode)::binary>>,
mode
)
end
defp encode_unreserved_fallback(
<<byte1, byte2, byte3, byte4, byte5, byte6, byte7, rest::binary>>,
acc,
mode
) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, byte5, byte6,
encode_unreserved_byte(byte7, mode)::binary>>,
mode
)
end
# Consume through the first disallowed byte instead of checking overlapping words.
for n <- 2..6 do
leading = for i <- 1..(n - 1), do: Macro.var(:"byte#{i}", __MODULE__)
defp encode_unreserved_fallback(<<unquote_splicing(leading), byte, rest::binary>>, acc, mode)
when not unreserved_char?(byte) do
encode_unreserved(
rest,
<<acc::binary, unquote_splicing(leading), encode_unreserved_byte(byte, mode)::binary>>,
mode
)
end
end
# Byte 7 is the offender by elimination, so this clause needs no guard.
defp encode_unreserved_fallback(
<<byte1, byte2, byte3, byte4, byte5, byte6, byte7, rest::binary>>,
acc,
mode
) do
encode_unreserved(
rest,
<<acc::binary, byte1, byte2, byte3, byte4, byte5, byte6,
encode_unreserved_byte(byte7, mode)::binary>>,
mode
)
end

Comment thread lib/elixir/lib/uri.ex
Comment on lines +486 to +493
defp encode_unreserved(string, mode) when byte_size(string) < @swar_threshold do
case string do
<<>> -> string
_ -> encode_unreserved_small(string, "", mode)
end
end

defp encode_unreserved(string, mode), do: encode_unreserved(string, "", mode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defp encode_unreserved(string, mode) when byte_size(string) < @swar_threshold do
case string do
<<>> -> string
_ -> encode_unreserved_small(string, "", mode)
end
end
defp encode_unreserved(string, mode), do: encode_unreserved(string, "", mode)
defp encode_unreserved(<<>>, _mode),
do: ""
defp encode_unreserved(string, mode) when byte_size(string) < @swar_threshold,
do: encode_unreserved_small(string, "", mode)
defp encode_unreserved(string, mode),
do: encode_unreserved(string, "", mode)

plus formatting

@preciz

preciz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @josevalim and @NelsonVides .

@NelsonVides based on your feedback my local agent implemented a version that is indeed much simpler and has most of the performance benefits so I think it's best if I close this version.

@NelsonVides do you plan to open a PR with that? Would be great I think.

@preciz preciz closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants