Money.round drastic performance regression
Closed this issue · 10 comments
Hi, we've recently upgraded ex_money
and noticed a pretty drastic slow-down that I've traced to Money.round
.
Going from:
{:ex_cldr, "2.2.0"},
{:ex_cldr_currencies, "2.0.0"},
{:ex_cldr_numbers, "2.1.0"},
{:ex_money, "3.1.0"},
...to:
{:ex_cldr, "2.2.4"},
{:ex_cldr_currencies, "2.1.1"},
{:ex_cldr_numbers, "2.1.1"},
{:ex_money, "3.2.3"},
...affected the performance of Money.round
making it about 20x slower. Here are some benchee benchmarks from BEFORE the upgrade:
Operating System: macOS
CPU Information: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
Number of Available Cores: 12
Available memory: 16 GB
Elixir 1.8.0
Erlang 21.2.2
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 28 s
Benchmarking add...
Benchmarking mult...
Benchmarking round...
Benchmarking sum...
Name ips average deviation median 99th %
sum 19.14 52.24 ms ±5.87% 51.29 ms 68.89 ms
add 11.87 84.23 ms ±14.23% 80.10 ms 123.34 ms
mult 10.97 91.20 ms ±16.01% 86.69 ms 137.41 ms
round 2.20 455.37 ms ±5.61% 447.31 ms 519.11 ms
Comparison:
sum 19.14
add 11.87 - 1.61x slower
mult 10.97 - 1.75x slower
round 2.20 - 8.72x slower
And here are the same benchmarks AFTER the upgrade:
Operating System: macOS
CPU Information: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
Number of Available Cores: 12
Available memory: 16 GB
Elixir 1.8.0
Erlang 21.2.2
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 28 s
Benchmarking add...
Benchmarking mult...
Benchmarking round...
Benchmarking sum...
Name ips average deviation median 99th %
sum 19.82 50.44 ms ±4.23% 50.11 ms 67.87 ms
add 11.38 87.87 ms ±13.55% 82.95 ms 129.83 ms
mult 10.88 91.94 ms ±12.81% 86.87 ms 133.62 ms
round 0.107 9385.01 ms ±0.00% 9385.01 ms 9385.01 ms
Comparison:
sum 19.82
add 11.38 - 1.74x slower
mult 10.88 - 1.82x slower
round 0.107 - 186.06x slower
You can see the average time for my round
function went from ~455ms to ~9385ms.
I've since also upgraded to the very latest versions at the time of this writing, with similar results.
I'm not exactly sure where in the ecosystem this slow-down is coming from, wether or not it's purely from ex_money
or maybe from ex_cldr
or ex_cldr_numbers
, which is why I've included all the relevant version numbers.
The repo I used to perform the benchmarks is available here: https://github.com/peek-travel/money_bench
The relevant benchmark code is:
monies = 1..100_000 |> Enum.map(fn i -> Money.new(:USD, i) end)
mult = fn monies -> Enum.map(monies, fn money -> Money.mult!(money, 2) end) end
add = fn monies -> Enum.map(monies, fn money -> Money.add!(money, money) end) end
round = fn monies -> Enum.map(monies, fn money -> Money.round(money) end) end
sum = fn monies -> Enum.reduce(monies, Money.zero(:USD), &Money.add!/2) end
Benchee.run(%{
"mult" => fn -> mult.(monies) end,
"add" => fn -> add.(monies) end,
"round" => fn -> round.(monies) end,
"sum" => fn -> sum.(monies) end
})
Hoping we can get to the bottom of this, as we use ex_money
in a critical code-path that is performance sensitive.
Thanks for your time and your amazing work on this and all related libraries!
@doughsay No doubt, thats not a good thing. Thanks for the detailed analysis. I will review in the next few hours.
Given the examples you show its quite surprising since the math operations and rounding delegate to to the Decimal
package.
I did think at first it may have been a regression in Decimal
, but the version of that package didn't change, so it pointed me towards ex_money instead. I looked through the diffs of ex_money, ex_cldr, ex_cldr_numbers and ex_cldr_currencies, but I couldn't find anything obvious to me...
Thanks for the clarification. Its very weird indeed and I haven't been able to dedicate enough time to working on this today, but I will get to the bottom of it over the weekend if not before.
Current theory (based upon just changing dependency versions one-by-one) is that its ex_cldr_currencies thats the issue. May I ask you to try to set your dependency for this to:
{:ex_cldr_currencies, "2.1.0"}
Using your benchmarking script, this is the version that delivers the original performance and 2.1.1
has the much slower performance.
As to why? Currently I have no idea but if setting this version gets you some performance back then its a start. I'll keep working on this and will build performance regression into the release process (when I work out how to automate that!)
I think i have it. It's a regression in Cldr.Currency.currency_for_code/2
. Now to work out what the regression is and how to fix it. This function should be fast because all of the work is done at compile time. I'm assuming I've screwed that up in a commit between 2.1.0
and 2.1.1
.
Hmmm, that's very interesting. I added profiling to my money_bench
repo and it doesn't point me towards currency_for_code
. The new versions seem to be making a lot of extra calls to Elixir.Cldr.Locale.known_gettext_locale_name/2
and Elixir.Cldr.Currency.currency_filter/2
. See my profiling results below to see if they help you:
Before upgrade:
FUNCTION CALLS % TIME [uS / CALLS]
-------- ----- ------- ---- [----------]
erlang:send/2 1 0.00 0 [ 0.00]
code_server:call/1 5 0.00 2 [ 0.40]
code:ensure_loaded/1 5 0.00 3 [ 0.60]
code:call/1 5 0.00 3 [ 0.60]
error_handler:undefined_function/3 5 0.00 4 [ 0.80]
'Elixir.MoneyBench.Profile':'-do_analyze/0-fun-1-'/0 1 0.00 5 [ 5.00]
erlang:function_exported/3 5 0.00 6 [ 1.20]
erlang:module_loaded/1 5 0.00 6 [ 1.20]
erlang:whereis/1 5 0.00 7 [ 1.40]
erlang:demonitor/2 5 0.00 9 [ 1.80]
error_handler:ensure_loaded/1 5 0.00 12 [ 2.40]
erlang:monitor/2 5 0.00 22 [ 4.40]
lists:reverse/2 10000 0.05 1797 [ 0.18]
'Elixir.Money':do_round_to_nearest/4 100000 0.39 13936 [ 0.14]
'Elixir.Money':round/1 100000 0.41 14742 [ 0.15]
'Elixir.Decimal':context/1 100000 0.43 15488 [ 0.15]
'Elixir.Money':round/2 100000 0.44 16093 [ 0.16]
lists:reverse/1 100000 0.48 17441 [ 0.17]
erlang:list_to_integer/1 100000 0.51 18285 [ 0.18]
'Elixir.Money':round_to_nearest/2 100000 0.52 18752 [ 0.19]
erlang:'++'/2 100000 0.52 18826 [ 0.19]
'Elixir.Enum':find/3 200000 0.78 28313 [ 0.14]
'Elixir.Enum':find_list/3 200000 0.81 29173 [ 0.15]
'Elixir.Cldr.Currency':currency_for_code/2 200000 0.81 29235 [ 0.15]
'Elixir.Decimal':context/2 200000 0.81 29419 [ 0.15]
application:get_env/3 200000 0.82 29618 [ 0.15]
'Elixir.Money':increment_from_opts/2 100000 0.83 30041 [ 0.30]
elixir_aliases:concat/1 200000 0.83 30176 [ 0.15]
'Elixir.Enum':'-map/2-lists^map/1-0-'/2 100001 0.85 30630 [ 0.31]
'Elixir.MoneyBench.Profile':'-do_analyze/0-fun-0-'/1 100000 0.86 31217 [ 0.31]
'Elixir.Money':round_to_decimal_digits/2 100000 0.88 32006 [ 0.32]
'Elixir.Process':get/2 200000 0.89 32274 [ 0.16]
'Elixir.Application':get_env/3 200000 0.90 32674 [ 0.16]
'Elixir.Decimal':digits_to_integer/1 100000 0.91 32914 [ 0.33]
'Elixir.Keyword':get/3 100000 0.93 33819 [ 0.34]
application_controller:get_env/2 200000 0.94 34144 [ 0.17]
'Elixir.MoneyBench.Cldr':validate_locale/1 200000 0.95 34402 [ 0.17]
'Elixir.Decimal':round/3 100000 0.96 34669 [ 0.35]
'Elixir.Decimal':do_round/5 100000 0.98 35574 [ 0.36]
'Elixir.Decimal':reduce/1 100000 1.01 36476 [ 0.36]
'Elixir.MoneyBench.Cldr.Currency':currencies_for_locale/1 200000 1.04 37458 [ 0.19]
'Elixir.Enum':map/2 100001 1.05 38057 [ 0.38]
'Elixir.Map':get/3 200000 1.06 38265 [ 0.19]
'Elixir.Cldr.Currency':currency_for_code/3 200000 1.08 38910 [ 0.19]
erlang:put/2 200000 1.08 38917 [ 0.19]
'Elixir.Range':new/2 100000 1.09 39593 [ 0.40]
'Elixir.Enum':'-map/2-fun-0-'/3 211111 1.50 54333 [ 0.26]
'Elixir.Enum':reduce_range_inc/4 211111 1.56 56503 [ 0.27]
'Elixir.Enum':'-reduce/3-lists^foldl/2-0-'/3 400000 1.60 57837 [ 0.14]
'Elixir.Cldr.Currency':get_currency_metadata/2 200000 1.61 58197 [ 0.29]
'Elixir.Decimal':'-do_round/5-fun-0-'/1 211111 1.62 58462 [ 0.28]
application:get_env/2 200000 1.65 59720 [ 0.30]
'Elixir.Map':get/2 200000 1.67 60273 [ 0.30]
erlang:binary_to_atom/2 200000 1.67 60283 [ 0.30]
'Elixir.Process':put/2 200000 1.68 60880 [ 0.30]
'Elixir.Application':get_env/2 200000 1.69 60987 [ 0.30]
'Elixir.Money':default_backend/0 200000 1.70 61449 [ 0.31]
'Elixir.Keyword':merge/2 200000 1.70 61643 [ 0.31]
elixir_aliases:do_concat/2 400000 1.72 62088 [ 0.16]
'Elixir.Enum':find/2 200000 1.72 62335 [ 0.31]
'Elixir.Decimal':handle_error/4 200000 1.74 62962 [ 0.31]
'Elixir.Money':digits_from_opts/2 200000 1.74 63122 [ 0.32]
'Elixir.MoneyBench.Cldr':default_locale/0 200000 1.75 63248 [ 0.32]
'Elixir.Cldr':validate_currency/1 200000 1.82 65994 [ 0.33]
'Elixir.Cldr.Currency':currencies_for_locale/2 200000 1.82 66025 [ 0.33]
erlang:integer_to_list/1 300000 1.87 67548 [ 0.23]
'Elixir.Decimal':get_context/0 200000 1.87 67701 [ 0.34]
'Elixir.Money.Currency':currency_for_code/1 200000 1.88 67964 [ 0.34]
'Elixir.Decimal':set_context/1 200000 1.89 68445 [ 0.34]
'Elixir.Cldr':validate_locale/2 200000 1.90 68748 [ 0.34]
'Elixir.Decimal':put_uniq/2 200000 1.96 71069 [ 0.36]
'Elixir.Decimal':do_reduce/2 111111 2.00 72222 [ 0.65]
'Elixir.Decimal':precision/3 200000 2.01 72821 [ 0.36]
'Elixir.List':wrap/1 200000 2.12 76720 [ 0.38]
erlang:atom_to_binary/2 400000 2.14 77413 [ 0.19]
'Elixir.Module':concat/2 200000 2.18 79061 [ 0.40]
ets:lookup/2 200000 2.44 88273 [ 0.44]
lists:keyfind/3 600000 2.45 88615 [ 0.15]
'Elixir.Enum':reduce/3 400000 2.74 99042 [ 0.25]
elixir_aliases:do_concat/1 400000 2.93 106171 [ 0.27]
elixir_aliases:to_partial/1 200000 4.32 156479 [ 0.78]
'Elixir.Access':get/3 500000 4.44 160543 [ 0.32]
--------------------------------------------------------- -------- ------- ------- [----------]
Total: 13654498 100.00% 3618589 [ 0.27]
After upgrade:
FUNCTION CALLS % TIME [uS / CALLS]
-------- ----- ------- ---- [----------]
erlang:send/2 1 0.00 0 [ 0.00]
'Elixir.MoneyBench.Profile':'-do_analyze/0-fun-1-'/0 1 0.00 6 [ 6.00]
'Elixir.Money':do_round_to_nearest/4 100000 0.02 14784 [ 0.15]
'Elixir.Decimal':context/1 100000 0.02 15885 [ 0.16]
'Elixir.Money':round/1 100000 0.02 16046 [ 0.16]
'Elixir.Money':round/2 100000 0.02 18701 [ 0.19]
'Elixir.Money':round_to_nearest/2 100000 0.02 21083 [ 0.21]
erlang:list_to_integer/1 100000 0.02 21336 [ 0.21]
erlang:'++'/2 100000 0.02 21927 [ 0.22]
'Elixir.Cldr.Currency':currency_for_code/2 200000 0.03 30493 [ 0.15]
'Elixir.Cldr.Config':locale_name_to_posix/1 200000 0.03 30568 [ 0.15]
elixir_aliases:concat/1 200000 0.03 32514 [ 0.16]
application:get_env/3 200000 0.03 32591 [ 0.16]
'Elixir.Decimal':context/2 200000 0.03 33341 [ 0.17]
'Elixir.Money':increment_from_opts/2 100000 0.04 35456 [ 0.35]
'Elixir.Cldr.Locale':locale_name_to_posix/1 200000 0.04 35972 [ 0.18]
'Elixir.Process':get/2 200000 0.04 36038 [ 0.18]
'Elixir.Decimal':digits_to_integer/1 100000 0.04 36384 [ 0.36]
'Elixir.MoneyBench.Profile':'-do_analyze/0-fun-0-'/1 100000 0.04 36731 [ 0.37]
'Elixir.Money':round_to_decimal_digits/2 100000 0.04 36982 [ 0.37]
erlang:put/2 200000 0.04 37672 [ 0.19]
'Elixir.Range':new/2 100000 0.04 39334 [ 0.39]
'Elixir.Cldr.Locale':put_gettext_locale_name/2 200000 0.04 39344 [ 0.20]
'Elixir.Application':get_env/3 200000 0.04 39659 [ 0.20]
'Elixir.Decimal':round/3 100000 0.04 39827 [ 0.40]
'Elixir.Enum':'-map/2-lists^map/1-0-'/2 100001 0.04 40230 [ 0.40]
'Elixir.MoneyBench.Cldr':validate_locale/1 200000 0.04 40279 [ 0.20]
'Elixir.Map':get/3 200000 0.04 40592 [ 0.20]
'Elixir.Decimal':reduce/1 100000 0.04 42354 [ 0.42]
'Elixir.Keyword':get/3 100000 0.04 42735 [ 0.43]
'Elixir.Cldr.Currency':currencies_for_locale/3 200000 0.04 42952 [ 0.21]
application_controller:get_env/2 200000 0.04 43549 [ 0.22]
'Elixir.Decimal':do_round/5 100000 0.05 44309 [ 0.44]
'Elixir.Map':new/1 200000 0.05 45794 [ 0.23]
'Elixir.MoneyBench.Cldr.Currency':currencies_for_locale/2 200000 0.05 46189 [ 0.23]
'Elixir.Enum':map/2 100001 0.05 47510 [ 0.48]
'Elixir.Enum':'-map/2-fun-0-'/3 211111 0.05 53026 [ 0.25]
'Elixir.Enum':reduce_range_inc/4 211111 0.06 55246 [ 0.26]
'Elixir.Cldr.Currency':currency_for_code/3 200000 0.06 56650 [ 0.28]
'Elixir.Application':get_env/2 200000 0.06 57800 [ 0.29]
'Elixir.Cldr.Currency':get_currency_metadata/2 200000 0.06 57965 [ 0.29]
'Elixir.Decimal':do_reduce/2 111111 0.06 59209 [ 0.53]
'Elixir.Decimal':'-do_round/5-fun-0-'/1 211111 0.06 60170 [ 0.29]
'Elixir.Enum':'-reduce/3-lists^foldl/2-0-'/3 400000 0.06 61726 [ 0.15]
maps:iterator/1 200000 0.07 64163 [ 0.32]
lists:reverse/1 300000 0.07 64704 [ 0.22]
'Elixir.Process':put/2 200000 0.07 65752 [ 0.33]
'Elixir.List':wrap/1 200000 0.07 66714 [ 0.33]
application:get_env/2 200000 0.07 66882 [ 0.33]
elixir_aliases:do_concat/2 400000 0.07 68010 [ 0.17]
'Elixir.Keyword':merge/2 200000 0.07 68944 [ 0.34]
'Elixir.Decimal':handle_error/4 200000 0.07 69962 [ 0.35]
'Elixir.Decimal':put_uniq/2 200000 0.07 70513 [ 0.35]
'Elixir.Map':get/2 200000 0.07 71099 [ 0.36]
'Elixir.Money':default_backend/0 200000 0.08 73285 [ 0.37]
'Elixir.Decimal':set_context/1 200000 0.08 73794 [ 0.37]
'Elixir.Money':digits_from_opts/2 200000 0.08 74049 [ 0.37]
erlang:binary_to_atom/2 200000 0.08 74325 [ 0.37]
'Elixir.Cldr.Currency':currencies_for_locale/2 200000 0.08 75463 [ 0.38]
'Elixir.Decimal':get_context/0 200000 0.08 75731 [ 0.38]
'Elixir.Cldr.Locale':first_match/2 200000 0.08 76193 [ 0.38]
erlang:integer_to_list/1 300000 0.08 76277 [ 0.25]
'Elixir.Decimal':precision/3 200000 0.08 78714 [ 0.39]
'Elixir.MoneyBench.Cldr':default_locale/0 200000 0.08 79542 [ 0.40]
maps:fold/3 200000 0.08 80171 [ 0.40]
'Elixir.Cldr':validate_locale/2 200000 0.08 80805 [ 0.40]
'Elixir.Cldr':validate_currency/1 200000 0.08 81006 [ 0.41]
'Elixir.Cldr.Locale':gettext_locale_name/2 200000 0.09 84568 [ 0.42]
'Elixir.Money.Currency':currency_for_code/1 200000 0.09 85348 [ 0.43]
erlang:atom_to_binary/2 400000 0.10 93322 [ 0.23]
'Elixir.Enum':filter/2 200000 0.10 95103 [ 0.48]
'Elixir.Module':concat/2 200000 0.10 98280 [ 0.49]
'Elixir.Enum':reduce/3 400000 0.10 100090 [ 0.25]
lists:keyfind/3 600000 0.11 103100 [ 0.17]
ets:lookup/2 200000 0.11 111306 [ 0.56]
elixir_aliases:do_concat/1 400000 0.13 121291 [ 0.30]
elixir_aliases:to_partial/1 200000 0.16 159003 [ 0.80]
'Elixir.Access':get/3 500000 0.18 176856 [ 0.35]
'Elixir.Enum':find/3 1800000 0.26 254028 [ 0.14]
'Elixir.Enum':find_list/3 1800000 0.27 260656 [ 0.14]
'Elixir.Enum':join/2 1600000 0.27 266012 [ 0.17]
'Elixir.Cldr.Locale':known_gettext_locale_name/2 1600000 0.30 287755 [ 0.18]
lists:reverse/2 210006 0.34 333858 [ 1.59]
erlang:iolist_to_binary/1 1619259 0.37 353435 [ 0.22]
'Elixir.Enum':reject/2 1600000 0.46 445729 [ 0.28]
'Elixir.Cldr.Locale':'-gettext_locale_name/2-fun-0-'/2 1600000 0.48 463148 [ 0.29]
'Elixir.MoneyBench.Cldr':known_gettext_locale_names/0 1600000 0.48 464147 [ 0.29]
'Elixir.Enum':find/2 1800000 0.53 514298 [ 0.29]
erts_internal:map_next/3 457410 0.81 780689 [ 1.71]
'Elixir.Cldr.Locale':locale_name_from/4 3200000 0.83 803288 [ 0.25]
'Elixir.Enum':'-join/2-fun-0-'/3 3200000 0.93 897753 [ 0.28]
'Elixir.Enum':'-join/2-lists^foldl/2-0-'/3 4800000 0.99 962824 [ 0.20]
'Elixir.Enum':reject_list/2 8000000 1.67 1621065 [ 0.20]
'Elixir.Cldr.Locale':'-locale_name_from/4-fun-0-'/1 6400000 1.85 1794245 [ 0.28]
maps:from_list/1 200000 3.38 3275099 [ 16.38]
maps:fold_1/3 60800000 9.20 8907204 [ 0.15]
'Elixir.Enum':'-filter/2-fun-0-'/3 60600000 9.45 9146954 [ 0.15]
'Elixir.Cldr.Currency':currency_filter/2 60800000 9.47 9171169 [ 0.15]
'Elixir.Cldr.Currency':'-currency_filter/2-fun-1-'/2 60600000 17.31 16762081 [ 0.28]
maps:next/1 60800000 17.70 17133889 [ 0.28]
'Elixir.Enum':'-filter/2-anonymous-1-'/4 60600000 17.95 17378499 [ 0.29]
--------------------------------------------------------- --------- ------- -------- [----------]
Total: 420931123 100.00% 96823149 [ 0.23]
The code I ran:
defmodule MoneyBench.Profile do
import ExProf.Macro
@monies 1..100_000 |> Enum.map(fn i -> Money.new(:USD, i) end)
def do_analyze do
profile do
Enum.map(@monies, fn money -> Money.round(money) end)
end
end
@doc "get analysis records and sum them up"
def run do
{records, _block_result} = do_analyze()
total_percent = Enum.reduce(records, 0.0, &(&1.percent + &2))
IO.inspect("total = #{total_percent}")
end
end
MoneyBench.Profile.run()
Yes, the root cause is the call to currency_filter
which is doing a lot of unnecessary work for the default case of all currencies. I have a fix in and I'm testing now. Cldr.Currency.currencies_for_locale/2
calls currency_filter
and currency_filter
isn't behaving nicely.
New release pushed and a new version 2.1.4 published. There is a marginal difference in performance from the original version and I'll keep working on that but I believe this release should get your performance back. Please let me know if you see differently.
@doughsay I can confirm that with the following upgrade, our performance is back where we were at previously! Thanks @kipcole9 for the quick fix... and any other performance improvements are always welcome as, like @doughsay said, we have a critical path with many-a-money..
Upgraded:
ex_cldr 2.2.0 => 2.2.5
ex_cldr_currencies 2.0.0 => 2.1.4
ex_cldr_numbers 2.1.0 => 2.1.1
ex_money 3.1.0 => 3.2.4
Very glad to hear its back to where it was. Sorry for the inconvenience. Ive posted a question on the Elixir Forum seeking feedback on good ways to build performance regression testing into a CI and release cycle.
One of the things I noticed in the profiling above is a lot of time spend in resolving locale names (ie en
) and transforming them to language tags (ie %Cldr.LanguageTag{...}
). Most function in cldr_*
work by first resolving the locale name into a language tag. This is done in the call to Cldr.validate_locale/2
.
For configured locale names this is quick since the language tag is pre-generated at compile time. But I can't know in advance the gettext
configuration so gettext
locale name resolution occurs each call to Cldr.validate_locale/2
.
Maybe there is a way in your code to resolve the locale name into a %Cldr.LanguageTag{}
early in your request cycle and always pass that around instead of the locale name binary.
I don't think it will rock your world, but I suspect i may help.
I'm going to close this issue now, but feel free to open any others regarding areas of performance improvement you can see - or are getting in your way.