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
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
Cldrversion 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
Also Liked
kip
@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.
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
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
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #hex









