Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[
inputs: ["{bench,mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["*.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## Unreleased

### Enhancements

* Reduce the cost of every operation that goes through the context: results
that signal nothing no longer copy the context or write it back to the
process dictionary, the coefficient's digit count is computed once per
operation instead of up to four times, and digit counting no longer walks a
ladder of bignum comparisons before falling back to its estimate. Division
is ~1.9x faster, `add`/`sub`/`mult`/`round`/`normalize` ~1.3x, comparison of
same-scale values ~1.4x, parsing ~1.2x, all with 20-30% less allocation.

* Make `Decimal.to_float/1` scale the operand with a computed shift instead of
one bit at a time: ~1.6x faster for typical values and ~11x for exponents
near the ends of the double range, with 96% fewer collections.

### Bug fixes

* Fix `Decimal.div/2` rounding the wrong way on inexact results. The long
Expand Down
62 changes: 53 additions & 9 deletions bench.exs
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
Mix.install([
{:decimal, path: ".", override: true},
{:benchee, "~> 1.0"},
{:benchee_html, "~> 1.0"}
])

{head, 0} = System.cmd("git", ["symbolic-ref", "--short", "HEAD"])
{hash, 0} = System.cmd("git", ["rev-parse", "--short", "HEAD"])
Code.require_file("bench_helper.exs", __DIR__)

# Measure production-compiled code:
#
# MIX_ENV=prod elixir bench.exs
#
# To compare two checkouts, measure one and load its results into the run of
# the other, which makes Benchee print both per job:
#
# DECIMAL_PATH=../decimal-main MIX_ENV=prod elixir bench.exs
# LOAD=benchmarks/HEAD-0c0f72c.benchee MIX_ENV=prod elixir bench.exs
#
decimal_path = BenchHelper.install!([{:benchee, "~> 1.0"}, {:benchee_html, "~> 1.0"}])

# The tag names the code under test, which is not necessarily this checkout.
# `--abbrev-ref` reports `HEAD` rather than failing when that checkout has no
# branch, as a worktree at a bare commit does.
{head, 0} = System.cmd("git", ["-C", decimal_path, "rev-parse", "--abbrev-ref", "HEAD"])
{hash, 0} = System.cmd("git", ["-C", decimal_path, "rev-parse", "--short", "HEAD"])

tag = "#{String.trim(head)}-#{String.trim(hash)}"

Expand Down Expand Up @@ -73,6 +84,20 @@ each = fn decimals, fun ->
fn -> Enum.each(decimals, fun) end
end

money_strings = BenchHelper.money_strings()
money = Enum.map(money_strings, &Decimal.new/1)
money_pairs = Enum.zip(money, Enum.reverse(money))

floats = for i <- 1..200, do: i * 1.37

# Conversions to float scale the operand into the significand of a double, so
# operands near the ends of the exponent range do the most work. Exponents stay
# inside the double range, which `to_float/1` requires.
float_range_decimals =
for coef <- [1, 15, 1_234_567_890_123_456], exp <- [-290, -30, -1, 0, 1, 30, 290] do
struct(Decimal, %{sign: 1, coef: coef, exp: exp})
end

jobs = %{
"compare" => each_pair.(decimal_pairs, &Decimal.compare/2),
"compare same scale" => each_pair.(same_scale_pairs, &Decimal.compare/2),
Expand All @@ -87,12 +112,31 @@ jobs = %{
"normalize" => each.(decimals, &Decimal.normalize/1),
"sqrt" => each.(positive_decimals, &Decimal.sqrt/1),
"to_string scientific" => each.(decimals, &Decimal.to_string(&1, :scientific)),
"to_string normal" => each.(decimals, &Decimal.to_string(&1, :normal))
"to_string normal" => each.(decimals, &Decimal.to_string(&1, :normal)),
"new from string" => each.(money_strings, &Decimal.new/1),
"from_float" => each.(floats, &Decimal.from_float/1),
"to_float" => each.(float_range_decimals, &Decimal.to_float/1),
"inspect" => each.(money, &inspect/1),
"money add" => each_pair.(money_pairs, &Decimal.add/2),
"money mult" => each_pair.(money_pairs, &Decimal.mult/2),
"money div" => each_pair.(money_pairs, &Decimal.div/2),
"money compare" => each_pair.(money_pairs, &Decimal.compare/2),
"money round" => each.(money, &Decimal.round(&1, 2))
}

load = System.get_env("LOAD", "") |> String.split(",", trim: true)

# Benchee resolves load paths with Path.wildcard, which turns a mistyped path
# into an empty list and a comparison run into a plain one, with no error.
for path <- load, Path.wildcard(path) == [] do
IO.puts(:stderr, "LOAD path #{path} matches no saved benchmark")
System.halt(1)
end

Benchee.run(jobs,
time: 10,
memory_time: 2,
save: [path: "benchmarks/#{tag}.benchee", tag: tag],
load: load,
formatters: [Benchee.Formatters.Console]
)
27 changes: 27 additions & 0 deletions bench_helper.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule BenchHelper do
@moduledoc false

# Both bench scripts must measure the same checkout the same way, so the
# install preamble lives here. Returns the path of the code under test.
def install!(extra_deps \\ []) do
decimal_path = System.get_env("DECIMAL_PATH", ".")

Mix.install([{:decimal, path: decimal_path, override: true} | extra_deps])

if Mix.env() != :prod do
IO.puts(:stderr, "refusing to benchmark a #{Mix.env()} build; rerun with MIX_ENV=prod")
System.halt(1)
end

decimal_path
end

# Values at a fixed scale with small coefficients: the shape of monetary
# amounts. The cents are zero-padded so every value has exponent -2 and the
# same-exponent paths of `add/2` and `compare/2` are what run.
def money_strings do
for i <- 1..200 do
"#{i * 37}.#{String.pad_leading("#{Integer.mod(i * 13, 100)}", 2, "0")}"
end
end
end
Loading