Skip to content

Perf: trim allocations and checks on the Money.new construction path (-10% time, -25% allocs) - #530

Draft
alexcwatt wants to merge 1 commit into
Shopify:mainfrom
alexcwatt:perf/money-new-micro
Draft

alexcwatt wants to merge 1 commit into
Shopify:mainfrom
alexcwatt:perf/money-new-micro

Conversation

@alexcwatt

Copy link
Copy Markdown
Member

What

Three micro-optimizations on the Money.new construction path. No behavior change.

  • Zero-money cache as a constant: ZERO_MONEY (private constant) replaces the @@zero_money ||= {} re-test that ran on every zero-valued call.
  • initialize: a single finite? check replaces separate nan? + infinite? calls, and rounding is skipped entirely when value.scale <= currency.minor_units — the value is already within precision, so round would just allocate an identical BigDecimal. (When rounding does happen, the BigDecimal() wrap stays: BigDecimal#round(0) returns an Integer, which would break zero-minor-unit currencies like JPY.)
  • new_from_money: passing a money's own ISO code string (a common no-op conversion, e.g. Money.new(money, "USD")) returns the existing instance without any currency lookup.

Performance

Measured on Ruby 4.0.1 (arm64-darwin), median of 7 trials, mixed-input Money.new workload:

main this PR Δ
wall time 787.6 ns/op 706.0 ns/op -10.4%
allocations 3.80 /op 2.87 /op -24.6%

The Money-passthrough case alone improves ~65%. Part of a series of independent Money.new optimizations; combined they reach ~-38% wall time and -45% allocations on this workload.

Correctness

  • Full spec suite, rubocop, and steep green; 100% line coverage maintained.
  • A 585-case differential test vs main (all value types × currency args, incl. NaN/Inf/±0/bignums/Money passthrough/zero-cache identity) produced byte-identical results. Notably, Money#value can be a rounding-produced -0.0; the scale fast path preserves that behavior.
Benchmark script (save as bench.rb, run ruby -Ilib bench.rb on each ref)
# frozen_string_literal: true

# Standalone benchmark for shopify-money hot paths. No external dependencies.
#
# Usage (from the repo root, on each ref you want to compare):
#   ruby -Ilib money_pr_bench.rb
#
# Section 1 measures Money.new across a representative mix of input types
# (median of 7 trials, plus deterministic object allocations per call).
# Section 2 measures a few hot Money instance methods.

require "money"
require "bigdecimal"

Money.configure do |config|
  config.default_currency = "USD"
end

usd = Money::Currency.find!("USD")
big = BigDecimal("12.34")
existing = Money.new(5, "USD")

# Representative input mix (value, currency) — weighted roughly by
# real-world usage: integers/strings/bigdecimals with ISO strings dominate.
INPUTS = [
  [1, "USD"],
  [100, "USD"],
  [42, "CAD"],
  [12.34, "USD"],
  [99.99, "EUR"],
  ["12.34", "USD"],
  ["0.99", "CAD"],
  [big, "USD"],
  [big, usd],
  [1500, "JPY"],
  [0, "USD"],          # zero-cache path
  [existing, "USD"],   # Money passthrough
  [7, nil],            # default currency
  [Rational(1, 3), "USD"],
  ["5", "GBP"],
].freeze

N = 20_000 # iterations of the whole mix per trial

def run_mix
  inputs = INPUTS
  i = 0
  while i < N
    inputs.each { |v, c| Money.new(v, c) }
    i += 1
  end
end

# Warmup (also populates currency cache, zero-money cache, etc.)
2.times { run_mix }

# --- Allocations (deterministic) ---
GC.start
GC.disable
before = GC.stat(:total_allocated_objects)
run_mix
allocs = GC.stat(:total_allocated_objects) - before
GC.enable

total_ops = N * INPUTS.size
allocs_per_op = allocs.to_f / total_ops

# --- Wall time: median of 7 trials ---
times = 7.times.map do
  GC.start
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  run_mix
  Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
end
median = times.sort[times.size / 2]
ns_per_op = (median / total_ops) * 1_000_000_000

puts "== Money.new (mixed input types) =="
puts format("  %-18s %8.1f ns/op", "wall time:", ns_per_op)
puts format("  %-18s %8.3f objects/op", "allocations:", allocs_per_op)
puts format("  %-18s %s", "trials (ms):", times.map { |t| (t * 1000).round(1) }.inspect)

# --- Section 2: hot instance methods ---
M = 2_000_000
money = Money.new("12.34", "USD")
neg = Money.new("-12.34", "USD")

def bench_method(label, m)
  best = 3.times.map do
    GC.start
    t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    yield
    Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
  end.min
  puts format("  %-12s %8.1f ns/op", label, (best / m) * 1_000_000_000)
end

puts
puts "== Money instance methods (#{M} calls each, best of 3) =="
bench_method("zero?", M) { i = 0; while i < M; money.zero?; i += 1; end }
bench_method("to_i", M) { i = 0; while i < M; money.to_i; i += 1; end }
bench_method("hash", M) { i = 0; while i < M; money.hash; i += 1; end }
bench_method("positive?", M) { i = 0; while i < M; money.positive?; i += 1; end }
bench_method("negative?", M) { i = 0; while i < M; neg.negative?; i += 1; end }

Three micro-optimizations on the hot construction path:

- Cache zero Money instances in a private ZERO_MONEY constant instead
  of re-testing @@zero_money ||= {} on every zero-valued call.
- initialize: a single finite? check replaces separate nan? and
  infinite? calls, and rounding is skipped entirely when the value is
  already within the currency's minor units (value.scale check), which
  avoids one BigDecimal allocation per call.
- new_from_money: passing the money's own ISO code string (a common
  no-op conversion) returns the existing instance without any currency
  lookup.

Benchmark (mixed-input Money.new): 787.6 -> 706.0 ns/op (-10.4%),
allocations 3.80 -> 2.87 per call (-24.6%).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant