Skip to content

Perf: hot-type-first dispatch in Helpers coercion (-10.5% Money.new) - #529

Draft
alexcwatt wants to merge 1 commit into
Shopify:mainfrom
alexcwatt:perf/helpers-dispatch
Draft

alexcwatt wants to merge 1 commit into
Shopify:mainfrom
alexcwatt:perf/helpers-dispatch

Conversation

@alexcwatt

Copy link
Copy Markdown
Member

What

Reorders the hot type-dispatch in Money::Helpers and removes redundant work per call. No behavior change.

  • value_to_decimal checks the most common input types first (BigDecimal, Integer, String) and returns early from branches that cannot produce a negative zero, skipping the sign check there. Previously every call ran through a when nil, 0, '' triple-comparison and an unconditional sign check.
  • value_to_currency handles all String sub-cases (empty / XXX / ISO code) inside a single String branch instead of running up to four case comparisons per call.
  • The default-currency path no longer recurses through value_to_currency: Config#default_currency= already coerces to a Currency/NullCurrency at set time, so the stored value can be returned directly.
  • Float::DIG / BigDecimal::SIGN_NEGATIVE_ZERO hoisted into private constants.

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 704.8 ns/op -10.5%
allocations 3.80 /op 3.80 /op unchanged (pure dispatch work)

Part of a series of independent Money.new optimizations (currency cache, construction path, Config.current); 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/whitespace strings) produced byte-identical results.
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 }

value_to_decimal now checks the most common input types first
(BigDecimal, Integer, String) and returns early from branches that
cannot produce a negative zero, skipping the sign check there.

value_to_currency handles all String sub-cases (empty, XXX, ISO code)
inside a single String branch instead of running multiple case
comparisons per call, and the default-currency path no longer recurses:
Config#default_currency= already coerces to a Currency at set time.

Benchmark (mixed-input Money.new): 787.6 -> 704.8 ns/op (-10.5%).
Allocations unchanged (this is pure dispatch-order work).
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