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?

1 Like

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.

6 Likes

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.

2 Likes

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]
2 Likes

This is surely something to look at, as ex_cldr and therefore ex_money should only read json data at compile time. All the relevant data should be compiled into proper functions on it’s backend module.

2 Likes
2 Likes

@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 Likes

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 Likes

Thanks @rodrigues, @LostKobrakai, and especially @kip for your help. Rapid fix!

1 Like

Without prevalidated options I now get this for doing to_string on 50k random money values:

Operating System: macOS
CPU Information: Intel(R) Core(TM) i7-5557U CPU @ 3.10GHz
Number of Available Cores: 4
Available memory: 16 GB
Elixir 1.8.0
Erlang 21.0

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: 14 s

Benchmarking Money.to_string...
Benchmarking fast...

Name                      ips        average  deviation         median         99th %
fast                    34.21       0.0292 s    ±25.57%       0.0273 s       0.0729 s
Money.to_string          0.21         4.71 s    ±24.06%         4.71 s         5.51 s

Comparison:
fast                    34.21
Money.to_string          0.21 - 161.15x slower +4.68 s

With the same fast method as in the initial post of the topic.

I don’t have yesterdays results anymore, but they were around 3000% slower and iirc an average of 1 1/2 minute.

1 Like

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%.

5 Likes

Often, when implementing a GraphQL API for a wide variety of consumers, one will return a formatted string along with any other money data, so that clients don’t have to reimplement Money objects/structs and handle the formatting.

For example, an app I’m working on has a price object that we use in a wide variety of places. A price is defined basically as:

  object :price do
    field(:formatted, :string)
    field(:usd_cents, :integer)
  end

We don’t return an actual Money to the consumer, because it is of no use to them. The formatted price reflects the proper formatting for the user’s selected locale, and the usd_cents field is sortable and used for consistency in analytics.

2 Likes