dtip

dtip

Excruciatingly slow performance of ex_money Money.to_string()

I’m working on a project using GraphQL (Absinthe) which involves price data. We’re using ex_money.

When Absinthe serialises money data before sending responses to clients it uses Money.to_string(). The performance is terrible!

If we have a query which looks like:

{
    products {
        id
    }
}

it will return 50,000 items in a couple of seconds.

Yet if we have a query like:

{
    products {
        id
        price
    }
}

then Elixir chugs away for 30 seconds before timing out.

I did a little benchmarking using bmark (https://github.com/joekain/bmark) to compare the performance of Money.to_string() to a naive implementation:

defmodule MoneyBmark do
    use Bmark
    @money Money.from_integer({"EUR", 7001, 0, 0})

    bmark :money_to_string do
        Money.to_string(@money)
    end
    
    bmark :fast_money_to_string do
        fast_money_to_string(@money)
    end

    defp fast_money_to_string(money) do
        Atom.to_string(money.currency) <> " " <> Decimal.to_string(money.amount)
    end
end

On my machine the naive implementation is 1400x faster than Money.to_string(). Switching the implementation means our second query returns its data in a couple of seconds.

I know that ex_money does a lot of great stuff like localised formatting and rounding yet this is almost certainly the cause of its slow serialisation.

I feel like I’m missing something obvious. Surely it’s not that uncommon to send reasonably large amounts of price data over the wire with Elixir (e.g. for exporting data), yet it seems like it’s impossible if you use ex_money.

Whilst typing this out I ran some benchmarking on money (https://hexdocs.pm/money/Money.html). It seems to be 200-250x faster than ex_money for serialisation. Perhaps we can fix our performance issue by switching package.

Can anyone weigh in on the pros and cons of using ex_money vs money?

Marked As Solved

kip

kip

ex_cldr Core Team

As a note for posterity: there is a material cost to processing options for Cldr.Numbers.to_string/3 which is ultimately what Money.to_string/2 calls. It is possible to pre-validate these options for exactly the case that the original post is motivated by: tight loops.

With pre-processing the options the time is further improved.

  • Original case (bug in Cldr.validate_number_system/1: 2.99 ms
  • Bug fixed in Cldr version 2.7.1: 111.34 μs
  • Using pre-validated options: 57.51 μs

A speed up of 50x over the original case.

Performance comparisons

Version   Name                ips        average  deviation         median         99th %

Original with bug:
2.7.0     to_string        334.14        2.99 ms    ±24.62%        2.79 ms        5.41 ms

Bug fixed:
2.7.1     to_string        8.98 K      111.34 μs    ±29.45%         102 μs         224 μs

Pre-validated options:
2.7.1     to_string       17.39 K       57.51 μs    ±35.82%          50 μs         125 μs  

I published ex_money version 3.4.4 to surface this optimisation which can be used as follows:

Using pre-validated options

 iex> money = Money.new(:USD, 100)
    
 # Apply any options required as a keyword list
 # Money will take care of managing the `:currency` option
 iex> options = []
    
 iex> {:ok, options} = Cldr.Number.Format.Options.validate_options(0, backend, options)
 iex> Money.to_string(money, options)

The 0 in validate_options is used to determine the sign of the amount because that can influence formatting - for example the accounting format often uses (1234) as its format. If you know your amounts are always positive, just use 0.

If the use case may have both positive and negative amounts, generate two option sets (one with the positive number and one with the negative). Then use the appropriate option set. For example:

iex> money = Money.new(:USD, 1234)
iex> options = []
iex> {:ok, positive_options} = Cldr.Number.Format.Options.validate_options(0, backend, options)
iex> {:ok, negative_options} = Cldr.Number.Format.Options.validate_options(-1, backend, options)

iex> if Money.cmp(money, Money.zero(:USD)) == :gt do
...>   Money.to_string(money, positive_options)
...> else
...>   Money.to_string(money, negative_options)
...> end

Updating dependencies

Simple as:

mix deps.update ex_cldr ex_money
12
Post #8

Also Liked

kip

kip

ex_cldr Core Team

@dtip, apologies for the inconvenience. @rodrigues, @LostKobrakai, thanks for helping diagnose such an egregious error.

I have published an update to hex based upon this commit which does nothing more than change the call from Config.known_number_systems/0 to known_number_systems/0 which already has the valid list of number systems built at compile time.

Performance of Money.to_string/2 is now improved from an average of 2.99ms to 111μs

Version   Name                ips        average  deviation         median         99th %
2.7.0     to_string        334.14        2.99 ms    ±24.62%        2.79 ms        5.41 ms
2.7.1     to_string        8.98 K      111.34 μs    ±29.45%         102 μs         224 μs

Please update with mix deps.update ex_cldr and you will be good to go.

14
Post #7
LostKobrakai

LostKobrakai

I’m wondering why you’re serializing money values to a string at all. I’d much rather expect money to be returned as decimal amount and currency separately. A string handling both doesn’t seems like a proper transfer format. Given that you probably want to transfer raw data and not formatted data to_string seems especially like the wrong function to use, as it’s doing quite a bit of work in terms for formatting data to the current locale, which you already seem to know. I’m not sure how aware your graphql endpoint is of locales of the user in the first place.

money in comparison to ex_money is quite a bit more dumb when serializing to a string. It doesn’t know anything about locale/currency specific formatting or rounding and requires the user to handle all of that. money provides basically just a struct for money and a bit of simple math with money, some integration for common libraries and a hardcoded list of currency metadata. ex_money handles way more especially locale specific data (given it get’s it data from the CLDR database), which is to a big degree related to formatting, but also related to rounding rules present in different countries/situations. E.g. there are countries where final retail prices are rounded in 0.05 steps, while everything else is rounded to 0.01 steps.

kip

kip

ex_cldr Core Team

Comparing the “fast” version with the current best optimisation (pre-validated options) and non-pre-validated keyword options the results are:

Name                            ips        average  deviation         median         99th %
fast format               1088.87 K        0.92 μs  ±5244.23%           1 μs           2 μs
pre-validated options       18.55 K       53.90 μs    ±33.25%          49 μs         137 μs
keyword options              9.48 K      105.43 μs    ±74.56%          95 μs         217 μs

The equivalent on yesterdays version was 2.99 ms average. So a long way off a simple string catenation but 50x better than yesterday.

UPDATE I’ve just published ex_cldr_numbers version 2.6.1 which improves performance of Money.to_string/2 and Cldr.Number.to_string/3 a further 10%.

dtip

dtip

I think the code is the way it is because originally we were only sending (small amounts of) data to the front-end for display. We weren’t doing any numerical calculations with the prices so it made sense for them to be formatted to the user’s locale.

Only now we’re looking to export larger amounts of data. I think you’re right and we probably want to send currency and decimals separately and format them on the front-end as needed :slight_smile: But that only solves this specific case.

I can see a situation where you might want to have a front-end page which displays a lot of locale-formatted price data and it seems like ex_money would have a hard time with that. What would you suggest in this case - format all the prices client-side? It feels like if a client-side library would be able to format a few thousand prices in a reasonable time then ex_money should be able to as well.

rodrigues

rodrigues

Hi @dtip, I did some profiling of this call through exprof, something that caught my attention is how much JSON decoding happens on each call, maybe something that could be memorized?

defmodule TestMoney do
  import ExProf.Macro

  defmodule CLDRBackend do
    use Cldr,
      locales: ~w(en fr es),
      default_locale: "en"
  end

  def run do
    money = Money.from_integer({"EUR", 7001, 0, 0})

    profile do
      for i <- 1..1000 do
        to_string(money)
      end
    end
  end
end


➜ iex -S mix
…
iex(1)> TestMoney.run
FUNCTION                                                                             CALLS        %     TIME  [uS / CALLS]
--------                                                                             -----  -------     ----  [----------]
# ommited some lines because the forum wouldn't allow posting everything
'Elixir.Jason':format_decode_opts/1                                                   4000     0.08     7921  [      1.98]
'Elixir.Enum':reject/2                                                               24000     0.08     7931  [      0.33]
'Elixir.TestMoney.CLDRBackend':known_gettext_locale_names/0                          24000     0.08     8031  [      0.33]
erlang:atom_to_binary/2                                                              30000     0.09     8552  [      0.29]
lists:mergel/2                                                                       40000     0.09     8600  [      0.21]
'Elixir.Cldr.Locale':'-gettext_locale_name/2-fun-0-'/2                               24000     0.09     8714  [      0.36]
erlang:send/3                                                                         4000     0.09     8737  [      2.18]
'Elixir.Module':concat/2                                                             14000     0.09     8898  [      0.64]
'Elixir.Enum':find/2                                                                 28000     0.09     9217  [      0.33]
'Elixir.Cldr.Locale':locale_name_from/4                                              24000     0.09     9267  [      0.39]
elixir_aliases:do_concat/1                                                           28000     0.10     9801  [      0.35]
maps:keys/1                                                                           4000     0.10    10076  [      2.52]
erlang:demonitor/2                                                                    8004     0.11    10348  [      1.29]
elixir_aliases:to_partial/1                                                          14000     0.12    12147  [      0.87]
lists:merge3_12_3/6                                                                  72000     0.13    12868  [      0.18]
'Elixir.Enum':'-join/2-fun-0-'/3                                                     48000     0.16    15707  [      0.33]
erlang:monitor/2                                                                      8004     0.16    15935  [      1.99]
'Elixir.Enum':'-join/2-lists^foldl/2-0-'/3                                           72000     0.17    16342  [      0.23]
erlang:iolist_to_binary/1                                                            36001     0.17    16413  [      0.46]
lists:rmerge3_2/6                                                                    92000     0.18    17417  [      0.19]
lists:split_2_1/6                                                                    96000     0.19    18818  [      0.20]
lists:reverse/2                                                                      59001     0.20    19433  [      0.33]
'Elixir.Enum':into/2                                                                  4000     0.20    19683  [      4.92]
lists:rmerge3_1/6                                                                   116000     0.24    23076  [      0.20]
lists:merge3_21_3/6                                                                 140000     0.25    24560  [      0.18]
'Elixir.Enum':reject_list/2                                                         120000     0.28    27798  [      0.23]
lists:merge3_1/6                                                                    120000     0.29    28614  [      0.24]
'Elixir.Cldr.Locale':'-locale_name_from/4-fun-0-'/1                                  96000     0.33    31843  [      0.33]
lists:split_2/5                                                                     232000     0.44    42975  [      0.19]
lists:merge3_2/6                                                                    248000     0.48    46807  [      0.19]
'Elixir.Map':new/1                                                                  342000     0.59    57423  [      0.17]
lists:reverse/1                                                                     343001     0.62    60111  [      0.18]
'Elixir.Cldr.Map':identity/1                                                        664000     1.04   101953  [      0.15]
erts_internal:map_next/3                                                            349000     1.13   110128  [      0.32]
maps:iterator/1                                                                     341000     1.15   112324  [      0.33]
maps:fold/3                                                                         341000     1.18   115087  [      0.34]
'Elixir.Cldr.Config':'-number_systems/0-fun-0-'/1                                   332000     1.22   118862  [      0.36]
'Elixir.Cldr.Map':deep_map/3                                                        336000     1.24   121471  [      0.36]
'Elixir.Enum':map/2                                                                 343000     1.39   135531  [      0.40]
'Elixir.Cldr.Map':atomize_element/2                                                 996000     1.75   170677  [      0.17]
'Elixir.Jason.Decoder':key/6                                                        996000     1.93   188448  [      0.19]
'Elixir.Jason.Decoder':key/7                                                        996000     1.97   192193  [      0.19]
lists:keyfind/3                                                                    1006000     1.99   194393  [      0.19]
'Elixir.Jason.Decoder':value/6                                                     1000000     1.99   194543  [      0.19]
'Elixir.Jason.Decoder':object/7                                                     996000     2.18   212761  [      0.21]
'Elixir.Cldr.Map':'-atomize_keys/2-fun-0-'/1                                        664000     2.19   213967  [      0.32]
'Elixir.Enum':'-map/2-fun-0-'/3                                                    1328000     2.37   231468  [      0.17]
maps:fold_1/3                                                                      1680000     2.84   276856  [      0.16]
maps:from_list/1                                                                    681000     3.12   304282  [      0.45]
'Elixir.Cldr.Map':'-atomize_keys/2-fun-1-'/2                                        996000     3.41   332555  [      0.33]
'Elixir.Cldr.Map':'-deep_map/3-fun-0-'/3                                            996000     3.42   334198  [      0.34]
'Elixir.Jason.Decoder':'-key_decode_function/1-fun-0-'/1                            996000     3.44   335305  [      0.34]
'Elixir.Access':get/3                                                              1022000     3.45   337156  [      0.33]
erlang:binary_to_atom/2                                                            1342000     4.13   403269  [      0.30]
'Elixir.Enum':'-map/2-anonymous-2-'/4                                              1328000     4.59   447762  [      0.34]
'Elixir.Jason.Decoder':'-string_decode_function/1-fun-0-'/1                        1660000     5.64   550263  [      0.33]
maps:next/1                                                                        1680000     5.72   558449  [      0.33]
'Elixir.Jason.Decoder':string/7                                                   13476000    25.11  2451106  [      0.18]
--------------------------------------------------------------------------------  --------  -------  -------  [----------]
Total:                                                                            40110046  100.00%  9760614  [      0.24]

Where Next?

Popular in Questions Top

dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
romenigld
I am trying to run a deploy with docker and I successfully runned with this command: docker build -t romenigld/blog-prod . but when I t...
New
dokuzbir
Hello, I am trying to convert my lists to string without losing brackets.For start i have 3 map. They look like these buyer = %{ id: ...
New
makeitrein
Hey all, just started picking up Elixir last week and am writing a scraper as a learning project. Baby step #1 is extracting the number ...
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
gonzofish
I’m currently trying to understand how to join three tables using Ecto. All the examples I’ve seen use 2, so maybe I’m just missing somet...
New
wernerlaude
In AR this is so simple @articles = current_user.articles How to do in Ecto? def index(conn, _params) do current_user = conn.assig...
New
jc00ke
Expanding on this topic: https://forum.elixirforum.net/t/map-typespec-question/19217 Let’s say I have a map with required and optional k...
New
idi527
I’ve been re-reading swift book again and noticed that multiline strings there don’t have a trailing line break, unlike in elixir iex(2)...
New

Other popular topics Top

rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list....
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
AstonJ
You’re a programmer, so you don’t need spoon feeding with the conventional drivel about “this is an integer.” No. You need to know what’s...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
lucidguppy
I have a super simple question about elixir - how would I take a file like this foo bar baz and output a new file that enumerates th...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New

We're in Beta

About us Mission Statement