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 (GitHub - joekain/bmark: A benchmarking tool for Elixir · GitHub) 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 (Money — Money v1.15.0). 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%.

Last Post!

Paradox

Paradox

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.

Where Next?

Popular in Questions Top

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
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
hariharasudhan94
lets say i have a sample like a = 20; b = 10; if (a &gt; b) do {:ok, "a"} end if (a &lt; b) do {:ok, b} end if (a == b) do {:ok, "equa...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New

Other popular topics Top

KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36654 110
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
dokuzbir
I want to highlight html closing tags when i click a html tag. That works in .html files but doesnt work for html.eex templates. How can...
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
sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New

We're in Beta

About us Mission Statement