Excruciatingly slow performance of ex_money Money.to_string()

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