Optimize Map JSON encoding using :maps.to_list - #15474
Closed
preciz wants to merge 1 commit into
Closed
Conversation
Member
|
The Erlang version uses a map comprehension, can you try writing an Erlang function in elixir_utils that does the same and measure against that too? |
Contributor
Author
|
New bench: Mix.install([
{:benchee, "~> 1.3"}
])
# 1. Dynamically compile the Erlang Map Comprehension implementation
erl_code = ~S"""
-module(erl_map_encoder).
-export([encode_map/2]).
encode_map(Map, Encode) when is_map(Map) ->
do_encode_map(Map, Encode).
do_encode_map(Map, Encode) when is_function(Encode, 2) ->
encode_object([[$,, key(Key, Encode), $: | Encode(Value, Encode)] || Key := Value <- Map]).
key(Key, Encode) when is_atom(Key) ->
Encode(atom_to_binary(Key, utf8), Encode);
key(Key, Encode) when is_binary(Key) ->
Encode(Key, Encode);
key(Key, Encode) ->
Encode('Elixir.String.Chars':to_string(Key), Encode).
encode_object([]) ->
<<"{}">>;
encode_object([[$, | First] | Rest]) ->
[${, First, Rest | "}"].
"""
File.write!("erl_map_encoder.erl", erl_code)
{:ok, module, bin} = :compile.file(~c"erl_map_encoder.erl", [:binary])
File.rm!("erl_map_encoder.erl")
:code.load_binary(module, ~c"erl_map_encoder.erl", bin)
# 2. Old Map Encoder using :maps.iterator and :maps.next
defmodule OldMapEncoder do
def encode(value, encoder) do
case :maps.next(:maps.iterator(value)) do
:none ->
"{}"
{key, value, iterator} ->
[?{, key(key, encoder), ?:, encoder.(value, encoder) | next(iterator, encoder)]
end
end
defp next(iterator, encoder) do
case :maps.next(iterator) do
:none ->
"}"
{key, value, iterator} ->
[?,, key(key, encoder), ?:, encoder.(value, encoder) | next(iterator, encoder)]
end
end
defp key(key, encoder) when is_atom(key), do: encoder.(Atom.to_string(key), encoder)
defp key(key, encoder) when is_binary(key), do: encoder.(key, encoder)
defp key(key, encoder), do: encoder.(String.Chars.to_string(key), encoder)
end
# 3. New Map Encoder using :maps.to_list
defmodule NewMapEncoder do
def encode(value, encoder) do
case :maps.to_list(value) do
[] ->
"{}"
[{key, value} | rest] ->
[?{, key(key, encoder), ?:, encoder.(value, encoder) | next(rest, encoder)]
end
end
defp next([], _encoder) do
"}"
end
defp next([{key, value} | rest], encoder) do
[?,, key(key, encoder), ?:, encoder.(value, encoder) | next(rest, encoder)]
end
defp key(key, encoder) when is_atom(key), do: encoder.(Atom.to_string(key), encoder)
defp key(key, encoder) when is_binary(key), do: encoder.(key, encoder)
defp key(key, encoder), do: encoder.(String.Chars.to_string(key), encoder)
end
# Old full encoder dispatcher
defmodule OldEncoder do
def encode(value, encoder) when is_atom(value) do
case value do
nil -> "null"
true -> "true"
false -> "false"
_ -> encoder.(Atom.to_string(value), encoder)
end
end
def encode(value, _encoder) when is_binary(value),
do: :json.encode_binary(value)
def encode(value, _encoder) when is_integer(value),
do: :json.encode_integer(value)
def encode(value, _encoder) when is_float(value),
do: :json.encode_float(value)
def encode(value, encoder) when is_list(value),
do: :json.encode_list(value, encoder)
def encode(%{} = value, encoder) when not is_map_key(value, :__struct__),
do: OldMapEncoder.encode(value, encoder)
def encode(value, encoder),
do: JSON.Encoder.encode(value, encoder)
end
# New full encoder dispatcher
defmodule NewEncoder do
def encode(value, encoder) when is_atom(value) do
case value do
nil -> "null"
true -> "true"
false -> "false"
_ -> encoder.(Atom.to_string(value), encoder)
end
end
def encode(value, _encoder) when is_binary(value),
do: :json.encode_binary(value)
def encode(value, _encoder) when is_integer(value),
do: :json.encode_integer(value)
def encode(value, _encoder) when is_float(value),
do: :json.encode_float(value)
def encode(value, encoder) when is_list(value),
do: :json.encode_list(value, encoder)
def encode(%{} = value, encoder) when not is_map_key(value, :__struct__),
do: NewMapEncoder.encode(value, encoder)
def encode(value, encoder),
do: JSON.Encoder.encode(value, encoder)
end
# Erlang Map Comprehension full encoder dispatcher
defmodule ErlangComprehensionEncoder do
def encode(value, encoder) when is_atom(value) do
case value do
nil -> "null"
true -> "true"
false -> "false"
_ -> encoder.(Atom.to_string(value), encoder)
end
end
def encode(value, _encoder) when is_binary(value),
do: :json.encode_binary(value)
def encode(value, _encoder) when is_integer(value),
do: :json.encode_integer(value)
def encode(value, _encoder) when is_float(value),
do: :json.encode_float(value)
def encode(value, encoder) when is_list(value),
do: :json.encode_list(value, encoder)
def encode(%{} = value, encoder) when not is_map_key(value, :__struct__),
do: :erl_map_encoder.encode_map(value, encoder)
def encode(value, encoder),
do: JSON.Encoder.encode(value, encoder)
end
# Benchmark inputs
inputs = %{
"Small Map (5 Atom Keys)" => Map.new(1..5, fn i -> {String.to_atom("key_#{i}"), i} end),
"Medium Map (100 Atom Keys)" => Map.new(1..100, fn i -> {String.to_atom("key_#{i}"), i} end),
"Large Map (10,000 Atom Keys)" => Map.new(1..10000, fn i -> {String.to_atom("key_#{i}"), i} end),
"Small Map (5 String Keys)" => Map.new(1..5, fn i -> {"key_#{i}", i} end),
"Medium Map (100 String Keys)" => Map.new(1..100, fn i -> {"key_#{i}", i} end),
"Large Map (10,000 String Keys)" => Map.new(1..10000, fn i -> {"key_#{i}", i} end),
"Nested Map" => %{a: 1, b: %{c: 2, d: %{e: 3, f: %{g: 4}}}}
}
# Verification step to ensure correctness of all implementations
for {name, input} <- inputs do
old_out = IO.iodata_to_binary(OldEncoder.encode(input, &OldEncoder.encode/2))
new_out = IO.iodata_to_binary(NewEncoder.encode(input, &NewEncoder.encode/2))
erl_out = IO.iodata_to_binary(ErlangComprehensionEncoder.encode(input, &ErlangComprehensionEncoder.encode/2))
if old_out != new_out or new_out != erl_out do
raise "Mismatch for input #{name}:\nOld: #{inspect(old_out)}\nNew: #{inspect(new_out)}\nErl: #{inspect(erl_out)}"
end
end
IO.puts("All implementations verified successfully! Running benchmark...\n")
Benchee.run(
%{
"Old Elixir (:maps.iterator)" => fn input ->
IO.iodata_to_binary(OldEncoder.encode(input, &OldEncoder.encode/2))
end,
"New Elixir (:maps.to_list)" => fn input ->
IO.iodata_to_binary(NewEncoder.encode(input, &NewEncoder.encode/2))
end,
"Erlang (Map Comprehension)" => fn input ->
IO.iodata_to_binary(ErlangComprehensionEncoder.encode(input, &ErlangComprehensionEncoder.encode/2))
end
},
inputs: inputs,
time: 2,
memory_time: 2
)Results on my noisy heat throttling machine: All implementations verified successfully! Running benchmark...
Operating System: Linux
CPU Information: AMD Ryzen 7 8845HS w
Number of Available Cores: 16
Available memory: 54.72 GB
Elixir 1.20.0
Erlang 29.0.1
JIT enabled: true
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 2 s
memory time: 2 s
reduction time: 0 ns
parallel: 1
inputs: Large Map (10,000 Atom Keys), Large Map (10,000 String Keys), Medium Map (100 Atom Keys), Medium Map (100 String Keys), Nested Map, Small Map (5 Atom Keys), Small Map (5 String Keys)
Estimated total run time: 2 min 6 s
Excluding outliers: false
##### With input Large Map (10,000 Atom Keys) #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 793.94 1.26 ms ±27.60% 1.14 ms 2.89 ms
Old Elixir (:maps.iterator) 747.66 1.34 ms ±16.42% 1.29 ms 2.52 ms
Erlang (Map Comprehension) 731.36 1.37 ms ±18.67% 1.30 ms 2.81 ms
Comparison:
New Elixir (:maps.to_list) 793.94
Old Elixir (:maps.iterator) 747.66 - 1.06x slower +0.0780 ms
Erlang (Map Comprehension) 731.36 - 1.09x slower +0.108 ms
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 1.83 MB
Old Elixir (:maps.iterator) 1.78 MB - 0.97x memory usage -0.05125 MB
Erlang (Map Comprehension) 1.80 MB - 0.98x memory usage -0.02953 MB
**All measurements for memory usage were the same**
##### With input Large Map (10,000 String Keys) #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 1146.88 0.87 ms ±13.31% 0.85 ms 1.47 ms
Old Elixir (:maps.iterator) 946.18 1.06 ms ±12.81% 1.04 ms 1.58 ms
Erlang (Map Comprehension) 919.07 1.09 ms ±11.07% 1.07 ms 1.73 ms
Comparison:
New Elixir (:maps.to_list) 1146.88
Old Elixir (:maps.iterator) 946.18 - 1.21x slower +0.185 ms
Erlang (Map Comprehension) 919.07 - 1.25x slower +0.22 ms
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 1.83 MB
Old Elixir (:maps.iterator) 1.81 MB - 0.99x memory usage -0.02109 MB
Erlang (Map Comprehension) 1.79 MB - 0.98x memory usage -0.03703 MB
**All measurements for memory usage were the same**
##### With input Medium Map (100 Atom Keys) #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 116.71 K 8.57 μs ±31.95% 8.15 μs 14.49 μs
Old Elixir (:maps.iterator) 101.52 K 9.85 μs ±25.13% 9.52 μs 13.86 μs
Erlang (Map Comprehension) 96.50 K 10.36 μs ±42.69% 9.96 μs 15.33 μs
Comparison:
New Elixir (:maps.to_list) 116.71 K
Old Elixir (:maps.iterator) 101.52 K - 1.15x slower +1.28 μs
Erlang (Map Comprehension) 96.50 K - 1.21x slower +1.80 μs
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 16.95 KB
Old Elixir (:maps.iterator) 16.72 KB - 0.99x memory usage -0.22656 KB
Erlang (Map Comprehension) 16.75 KB - 0.99x memory usage -0.19531 KB
**All measurements for memory usage were the same**
##### With input Medium Map (100 String Keys) #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 132.44 K 7.55 μs ±49.79% 7.21 μs 13.03 μs
Old Elixir (:maps.iterator) 113.07 K 8.84 μs ±28.65% 8.55 μs 12.56 μs
Erlang (Map Comprehension) 103.94 K 9.62 μs ±64.37% 9.12 μs 15.47 μs
Comparison:
New Elixir (:maps.to_list) 132.44 K
Old Elixir (:maps.iterator) 113.07 K - 1.17x slower +1.29 μs
Erlang (Map Comprehension) 103.94 K - 1.27x slower +2.07 μs
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 16.97 KB
Old Elixir (:maps.iterator) 18.82 KB - 1.11x memory usage +1.85 KB
Erlang (Map Comprehension) 18.85 KB - 1.11x memory usage +1.88 KB
**All measurements for memory usage were the same**
##### With input Nested Map #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 1.83 M 546.34 ns ±1071.97% 501 ns 962 ns
Old Elixir (:maps.iterator) 1.50 M 665.79 ns ±836.30% 611 ns 992 ns
Erlang (Map Comprehension) 1.41 M 709.49 ns ±661.54% 671 ns 1172 ns
Comparison:
New Elixir (:maps.to_list) 1.83 M
Old Elixir (:maps.iterator) 1.50 M - 1.22x slower +119.45 ns
Erlang (Map Comprehension) 1.41 M - 1.30x slower +163.15 ns
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 1.36 KB
Old Elixir (:maps.iterator) 1.37 KB - 1.01x memory usage +0.00781 KB
Erlang (Map Comprehension) 1.49 KB - 1.10x memory usage +0.133 KB
**All measurements for memory usage were the same**
##### With input Small Map (5 Atom Keys) #####
Name ips average deviation median 99th %
Erlang (Map Comprehension) 1.91 M 522.59 ns ±1111.75% 481 ns 862 ns
Old Elixir (:maps.iterator) 1.88 M 532.44 ns ±1789.13% 461 ns 802 ns
New Elixir (:maps.to_list) 1.23 M 813.15 ns ±1821.37% 671 ns 2134 ns
Comparison:
Erlang (Map Comprehension) 1.91 M
Old Elixir (:maps.iterator) 1.88 M - 1.02x slower +9.85 ns
New Elixir (:maps.to_list) 1.23 M - 1.56x slower +290.56 ns
Memory usage statistics:
Name Memory usage
Erlang (Map Comprehension) 1.05 KB
Old Elixir (:maps.iterator) 1.02 KB - 0.97x memory usage -0.03125 KB
New Elixir (:maps.to_list) 1.05 KB - 0.99x memory usage -0.00781 KB
**All measurements for memory usage were the same**
##### With input Small Map (5 String Keys) #####
Name ips average deviation median 99th %
New Elixir (:maps.to_list) 2.27 M 440.94 ns ±1990.13% 391 ns 631 ns
Old Elixir (:maps.iterator) 1.99 M 501.32 ns ±1854.81% 431 ns 782 ns
Erlang (Map Comprehension) 1.99 M 501.55 ns ±1499.63% 451 ns 822 ns
Comparison:
New Elixir (:maps.to_list) 2.27 M
Old Elixir (:maps.iterator) 1.99 M - 1.14x slower +60.38 ns
Erlang (Map Comprehension) 1.99 M - 1.14x slower +60.61 ns
Memory usage statistics:
Name Memory usage
New Elixir (:maps.to_list) 1.05 KB
Old Elixir (:maps.iterator) 1.02 KB - 0.98x memory usage -0.02344 KB
Erlang (Map Comprehension) 1.05 KB - 1.01x memory usage +0.00781 KB
**All measurements for memory usage were the same** |
Member
|
Any thoughts @michalmuskala? I would expect the other ones to be faster. :) |
Contributor
Author
|
Yes this has to be measured by others. My PC can't be trusted, however for me it's consistently faster. |
Contributor
|
Results on my machine |
Member
|
Yeah, those are too close to show we should go one way or the other. So we may err on the side of keeping things as is. |
Contributor
Author
|
Thank you all for the feedback |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Assisted-by: Antigravity CLI Gemini Flash 3.5
It seems like the upfront
:maps.to_listversion is ~10% faster than the:maps.iteratorone.Bench:
Bench results (noisy system, heat throttling mini pc):