Skip to content

Conversation

@jgaskins
Copy link

@jgaskins jgaskins commented Aug 1, 2025

IEEE754 floats often have rounding errors when doing math with them. Using BigDecimal avoids these rounding errors.

To reproduce:

[1] pry(main)> Unit.new("3.5g").convert_to("mg").scalar
=> 3500.0000000000005

With this PR, we get precisely 3500:

[1] pry(main)> Unit.new("3.5g").convert_to("mg").scalar
=> 0.35e4

IEEE754 floats often have rounding errors when doing math with them. We
want to use BigDecimal as a way to correct this.
@olbrich
Copy link
Owner

olbrich commented Dec 29, 2025

@jgaskins I'm looking into adding this as a configuration option as making this a default behavior may have negative performance impacts.

FYI, it is currently possible to do this...

irb(main):012> Unit.new(BigDecimal(3.5), 'g').convert_to('mg').scalar
=> 0.35e4

@jgaskins
Copy link
Author

BigDecimal is indeed slower than Float, but the performance difference seems small enough to be negligible in the context of this gem. I tweaked spec/benchmarks/bigdecimal.rb to compare Float vs BigDecimal on instantiation, arithmetic, and unit-conversion operations and only saw a difference of 1-2%:

Click for benchmark code
# frozen_string_literal: true

$LOAD_PATH << "lib"

require "ruby-units"
require "bigdecimal"
require "bigdecimal/util"
require "benchmark/ips" # Also had to install this gem
a = [
  [2.025, "gal"],
  [5.575, "gal"],
  [8.975, "gal"],
  [1.5, "gal"],
  [9, "gal"],
  [1.85, "gal"],
  [2.25, "gal"],
  [1.05, "gal"],
  [4.725, "gal"],
  [3.55, "gal"],
  [4.725, "gal"],
  [3.75, "gal"],
  [6.275, "gal"],
  [0.525, "gal"],
  [3.475, "gal"],
  [0.85, "gal"]
]

puts "Instantiation"
Benchmark.ips do |x|
  ns, nu = a.first
  x.report("Float") { Unit.new(ns, nu) }
  x.report("BigDecimal") { Unit.new(ns.to_d, nu) }
  x.compare!
end

puts
puts "Arithmetic"
Benchmark.ips do |x|
  ns1, nu1 = a[1]
  ns2, nu2 = a[2]

  float1 = Unit.new(ns1, nu1)
  float2 = Unit.new(ns2, nu2)
  bigdecimal1 = Unit.new(ns1.to_d, nu1)
  bigdecimal2 = Unit.new(ns2.to_d, nu2)

  x.report("Float") { float1 + float2 }
  x.report("BigDecimal") { bigdecimal1 + bigdecimal2 }
  x.compare!
end

puts
puts "Conversion"
Benchmark.ips do |x|
  ns, nu = a.first
  float = Unit.new(ns, nu)
  bigdecimal = Unit.new(ns.to_d, nu)

  x.report("Float") { float.convert_to("l") }
  x.report("BigDecimal") { bigdecimal.convert_to("l") }
  x.compare!
end
➜  ruby-units git:(master) ✗ ruby --yjit spec/benchmarks/bigdecimal.rb
Instantiation
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
               Float   809.000 i/100ms
          BigDecimal   794.000 i/100ms
Calculating -------------------------------------
               Float      7.991k (± 0.8%) i/s  (125.13 μs/i) -     40.450k in   5.062056s
          BigDecimal      7.834k (± 0.8%) i/s  (127.64 μs/i) -     39.700k in   5.067816s

Comparison:
               Float:     7991.4 i/s
          BigDecimal:     7834.3 i/s - 1.02x  slower


Arithmetic
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
               Float   738.000 i/100ms
          BigDecimal   723.000 i/100ms
Calculating -------------------------------------
               Float      7.391k (± 0.8%) i/s  (135.30 μs/i) -     37.638k in   5.092602s
          BigDecimal      7.219k (± 0.6%) i/s  (138.52 μs/i) -     36.150k in   5.007772s

Comparison:
               Float:     7391.2 i/s
          BigDecimal:     7219.0 i/s - 1.02x  slower


Conversion
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
               Float   943.000 i/100ms
          BigDecimal   934.000 i/100ms
Calculating -------------------------------------
               Float      9.416k (± 0.8%) i/s  (106.20 μs/i) -     47.150k in   5.007628s
          BigDecimal      9.274k (± 1.1%) i/s  (107.83 μs/i) -     46.700k in   5.036275s

Comparison:
               Float:     9416.3 i/s
          BigDecimal:     9273.9 i/s - same-ish: difference falls within error

@jgaskins
Copy link
Author

The other reason I think this PR makes sense, which I forgot to mention above, is that directly multiplying 3.5 * 1000.0 doesn't result in the floating-point error we're seeing.

[1] pry(main)> 3.5*1000.0
=> 3500.0
[2] pry(main)> Unit.new(3.5, "g").convert_to("mg").scalar
=> 3500.0000000000005

This gem is very powerful and abstracts away a lot of math for us but, in doing so, it appears to be introducing opportunities for floating-point error that may not otherwise occur.

@olbrich
Copy link
Owner

olbrich commented Dec 30, 2025

@jgaskins Please give #385 a try and see if that does what you need.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants